From 3ebc8041e10c051e750e5b840de1c736a46f9a15 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:03:31 +0800 Subject: [PATCH] feat: present mutual Compose findings safely --- apps/web/src/lib/findingPresentation.test.ts | 32 ++++++++++++++ apps/web/src/lib/findingPresentation.ts | 43 ++++++++++++++++++- apps/web/src/screens/findings.test.tsx | 44 ++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/findingPresentation.test.ts b/apps/web/src/lib/findingPresentation.test.ts index 89940e1c..3f2ea4cd 100644 --- a/apps/web/src/lib/findingPresentation.test.ts +++ b/apps/web/src/lib/findingPresentation.test.ts @@ -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({ @@ -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(); + }); }); diff --git a/apps/web/src/lib/findingPresentation.ts b/apps/web/src/lib/findingPresentation.ts index 663fe955..a42d5e18 100644 --- a/apps/web/src/lib/findingPresentation.ts +++ b/apps/web/src/lib/findingPresentation.ts @@ -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 | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } @@ -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]); @@ -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" @@ -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, 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; } diff --git a/apps/web/src/screens/findings.test.tsx b/apps/web/src/screens/findings.test.tsx index dcdb2815..cc0a92e3 100644 --- a/apps/web/src/screens/findings.test.tsx +++ b/apps/web/src/screens/findings.test.tsx @@ -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 },