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
32 changes: 32 additions & 0 deletions apps/web/src/lib/findingPresentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ const composeFinding = {
evidenceRefs: [{ version: 1, provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", providerSlot: null, freshness: "fresh", subjectRef: "docker_container_source" }]
};

const mutualComposeFinding = {
id: "finding_docker_compose_mutual_dependency_opaque",
ruleId: "docker.compose_mutual_dependency",
severity: "advisory",
summary: "Docker recorded mutually declared Compose dependencies between two containers.",
recommendation: "Review the declared dependencies and remove any unintended mutual dependency.",
subjectRef: "docker_container_alpha", targetRef: "docker_container_beta",
evidenceRefs: [
{ version: 1, id: "opaque-forward", provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", providerSlot: null, freshness: "fresh", subjectRef: "docker_container_alpha", summary: "Docker recorded Compose dependency declaration", collectedAt: 1, providerRevision: "opaque-revision" },
{ version: 1, id: "opaque-reverse", provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", providerSlot: null, freshness: "fresh", subjectRef: "docker_container_beta", summary: "Docker recorded Compose dependency declaration", collectedAt: 1, providerRevision: "opaque-revision" }
]
};

describe("finding presentation boundary", () => {
it("admits only the static closed Compose advisory shape", () => {
expect(presentationForFinding(composeFinding)).toMatchObject({
Expand All @@ -24,4 +37,23 @@ describe("finding presentation boundary", () => {
expect(presentationForFinding({ ...composeFinding, evidenceRefs: [{ ...composeFinding.evidenceRefs[0], freshness: "stale" }] })).toBeNull();
expect(presentationForFinding({ ...composeFinding, subjectRef: "docker_container_same", targetRef: "docker_container_same" })).toBeNull();
});

it("admits only the ordered, contemporaneous mutual Compose pair", () => {
expect(presentationForFinding(mutualComposeFinding)).toMatchObject({
title: "Mutual Compose declarations need review", category: "Docker Compose", inspectChanges: true
});

const omittedSlots = structuredClone(mutualComposeFinding);
delete (omittedSlots.evidenceRefs[0] as { providerSlot?: unknown }).providerSlot;
delete (omittedSlots.evidenceRefs[1] as { providerSlot?: unknown }).providerSlot;
expect(presentationForFinding(omittedSlots)).not.toBeNull();
});

it("suppresses malformed, reordered, stale, or mismatched mutual Compose evidence", () => {
expect(presentationForFinding({ ...mutualComposeFinding, evidenceRefs: [...mutualComposeFinding.evidenceRefs].reverse() })).toBeNull();
expect(presentationForFinding({ ...mutualComposeFinding, subjectRef: "docker_container_beta", targetRef: "docker_container_alpha" })).toBeNull();
expect(presentationForFinding({ ...mutualComposeFinding, evidenceRefs: [{ ...mutualComposeFinding.evidenceRefs[0], providerRevision: "different" }, mutualComposeFinding.evidenceRefs[1]] })).toBeNull();
expect(presentationForFinding({ ...mutualComposeFinding, evidenceRefs: [{ ...mutualComposeFinding.evidenceRefs[0], freshness: "stale" }, mutualComposeFinding.evidenceRefs[1]] })).toBeNull();
expect(presentationForFinding({ ...mutualComposeFinding, evidenceRefs: [mutualComposeFinding.evidenceRefs[0]] })).toBeNull();
});
});
43 changes: 42 additions & 1 deletion apps/web/src/lib/findingPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,19 @@ const SPECS: readonly FindingSpec[] = [
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
},
{
ruleId: "docker.compose_mutual_dependency", severity: "advisory",
summary: "Docker recorded mutually declared Compose dependencies between two containers.",
recommendation: "Review the declared dependencies and remove any unintended mutual dependency.",
idPrefix: "finding_docker_compose_mutual_dependency_", subjectPrefix: "docker_container_", targetPrefix: "docker_container_",
evidenceCount: 2, evidence: { version: 1, provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", providerSlot: null },
title: "Mutual Compose declarations need review", category: "Docker Compose", hint: "Observed Compose declarations", tone: "muted", severityLabel: "Advisory", inspectChanges: true
}
];

const COMPOSE_DECLARATION_EVIDENCE_SUMMARY = "Docker recorded Compose dependency declaration";

function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
Expand All @@ -89,6 +99,10 @@ export function presentationForFinding(value: unknown): FindingPresentation | nu
|| (spec.targetPrefix !== undefined && !finding.targetRef.startsWith(spec.targetPrefix))
|| (spec.targetRef !== undefined && finding.targetRef !== spec.targetRef)
|| finding.subjectRef === finding.targetRef
// Mutual findings are emitted in one canonical direction. This preserves
// the API's ordered forward/reverse evidence meaning without displaying
// either opaque reference.
|| (spec.ruleId === "docker.compose_mutual_dependency" && finding.subjectRef >= finding.targetRef)
|| !Array.isArray(finding.evidenceRefs) || finding.evidenceRefs.length !== spec.evidenceCount) return null;

const evidence = record(finding.evidenceRefs[0]);
Expand All @@ -100,7 +114,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.compose_mutual_dependency")
? evidence.providerSlot !== undefined && evidence.providerSlot !== null
: evidence.providerSlot !== spec.evidence.providerSlot)
|| evidence.freshness !== "fresh"
Expand All @@ -115,5 +129,32 @@ export function presentationForFinding(value: unknown): FindingPresentation | nu
|| port.subjectRef !== finding.subjectRef) return null;
}

// A mutual Compose declaration is a fixed, contemporaneous pair: first
// subject -> target, then target -> subject. Keep the collection token
// opaque, but require both observations to share it and their collection
// instant so a malformed or stitched response cannot produce advice.
if (spec.ruleId === "docker.compose_mutual_dependency") {
const reverse = record(finding.evidenceRefs[1]);
const isFreshComposeEvidence = (candidate: Record<string, unknown>, subjectRef: unknown) => (
candidate.version === 1
&& candidate.provider === "docker"
&& candidate.kind === "docker_compose_depends_on"
&& candidate.assertionKind === "observed"
&& (candidate.providerSlot === undefined || candidate.providerSlot === null)
&& candidate.freshness === "fresh"
&& candidate.subjectRef === subjectRef
&& typeof candidate.id === "string" && candidate.id.length > 0
&& candidate.summary === COMPOSE_DECLARATION_EVIDENCE_SUMMARY
&& typeof candidate.collectedAt === "number" && Number.isSafeInteger(candidate.collectedAt) && candidate.collectedAt >= 0
&& typeof candidate.providerRevision === "string" && candidate.providerRevision.length > 0
&& candidate.providerRevision !== String(candidate.collectedAt)
);
if (!reverse
|| !isFreshComposeEvidence(evidence, finding.subjectRef)
|| !isFreshComposeEvidence(reverse, finding.targetRef)
|| evidence.collectedAt !== reverse.collectedAt
|| evidence.providerRevision !== reverse.providerRevision) return null;
}

return spec;
}
44 changes: 44 additions & 0 deletions apps/web/src/screens/findings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,50 @@ describe("Findings screen", () => {
expect(html).toContain("Review the declared dependency and the target container state.");
});

it("renders a mutual Compose advisory with static copy only", () => {
const mutual = structuredClone(findings);
mutual.findings[0] = {
id: "finding_docker_compose_mutual_dependency_opaque",
ruleId: "docker.compose_mutual_dependency",
severity: "advisory",
summary: "Docker recorded mutually declared Compose dependencies between two containers.",
recommendation: "Review the declared dependencies and remove any unintended mutual dependency.",
subjectRef: "docker_container_alpha_private", targetRef: "docker_container_beta_private",
evidenceRefs: [
{ version: 1, id: "opaque-forward", provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", summary: "Docker recorded Compose dependency declaration", subjectRef: "docker_container_alpha_private", collectedAt: 1, providerRevision: "opaque-revision", providerSlot: null, freshness: "fresh" },
{ version: 1, id: "opaque-reverse", provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", summary: "Docker recorded Compose dependency declaration", subjectRef: "docker_container_beta_private", collectedAt: 1, providerRevision: "opaque-revision", providerSlot: null, freshness: "fresh" }
]
};
const html = render({ findings: mutual });
expect(html).toContain("Mutual Compose declarations need review");
expect(html).toContain("Review the declared dependencies and remove any unintended mutual dependency.");
expect(html).toContain('href="/changes"');
for (const privateValue of ["docker_container_alpha_private", "docker_container_beta_private", "opaque-forward", "opaque-reverse", "opaque-revision"]) {
expect(html).not.toContain(privateValue);
}
expect(html).not.toContain("start-order");
});

it("suppresses malformed mutual Compose findings and fixture-mode mutual advice", () => {
const mutual = structuredClone(findings);
mutual.findings[0] = {
id: "finding_docker_compose_mutual_dependency_opaque", ruleId: "docker.compose_mutual_dependency", severity: "advisory",
summary: "Docker recorded mutually declared Compose dependencies between two containers.",
recommendation: "Review the declared dependencies and remove any unintended mutual dependency.",
subjectRef: "docker_container_alpha", targetRef: "docker_container_beta",
evidenceRefs: [
{ version: 1, id: "opaque-forward", provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", summary: "Docker recorded Compose dependency declaration", subjectRef: "docker_container_alpha", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" },
{ version: 1, id: "opaque-reverse", provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", summary: "Docker recorded Compose dependency declaration", subjectRef: "docker_container_beta", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" }
]
};
expect(render({ findings: mutual })).toContain("Mutual Compose declarations need review");
const malformed = structuredClone(mutual);
malformed.findings[0].evidenceRefs.reverse();
expect(render({ findings: malformed })).not.toContain("Mutual Compose declarations need review");
expect(render({ findings: mutual, evidenceMode: "demo", modelProvenance: "demo" })).not.toContain("Mutual Compose declarations need review");
expect(render({ findings: mutual, evidenceMode: "mock", modelProvenance: "mock" })).not.toContain("Mutual Compose declarations need review");
});

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 },
Expand Down
Loading