diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 532fea23..ee11ce71 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -67,6 +67,9 @@ const DOCKER_DAEMON_STATE_FINDING_RECOMMENDATION = "Review whether this containe const COMPOSE_DECLARED_TARGET_NOT_ACTIVE_FINDING_RULE = "docker.compose_declared_target_not_active"; const COMPOSE_DECLARED_TARGET_NOT_ACTIVE_FINDING_SUMMARY = "A running Docker Compose service declares a dependency whose container is not active."; const COMPOSE_DECLARED_TARGET_NOT_ACTIVE_FINDING_RECOMMENDATION = "Review the declared dependency and the target container state."; +const COMPOSE_MUTUAL_DEPENDENCY_FINDING_RULE = "docker.compose_mutual_dependency"; +const COMPOSE_MUTUAL_DEPENDENCY_FINDING_SUMMARY = "Docker recorded mutually declared Compose dependencies between two containers."; +const COMPOSE_MUTUAL_DEPENDENCY_FINDING_RECOMMENDATION = "Review the declared dependencies and remove any unintended mutual dependency."; // Version-one evidence is intentionally a discriminated Docker observation, // not a generic provenance bag. JSON Schema owns each field's closed enum; @@ -126,6 +129,10 @@ function composeDeclaredTargetFindingId(subjectRef: string, targetRef: string): return `finding_docker_compose_declared_target_not_active_${collisionResistantIdComponent(`${subjectRef}\u001f${targetRef}`)}`; } +function composeMutualDependencyFindingId(subjectRef: string, targetRef: string): string { + return `finding_docker_compose_mutual_dependency_${collisionResistantIdComponent(`${subjectRef}\u001f${targetRef}`)}`; +} + function hasCompleteProviderStateVector(payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; const providerStates = (payload as { providerStates?: unknown }).providerStates; @@ -336,6 +343,40 @@ function hasCoherentFindings(payload: unknown): boolean { && typeof evidence.providerRevision === "string" && evidence.providerRevision !== String(evidence.collectedAt); })(); + if (finding.ruleId === COMPOSE_MUTUAL_DEPENDENCY_FINDING_RULE) return finding.severity === "advisory" + && finding.summary === COMPOSE_MUTUAL_DEPENDENCY_FINDING_SUMMARY + && finding.recommendation === COMPOSE_MUTUAL_DEPENDENCY_FINDING_RECOMMENDATION + && typeof finding.subjectRef === "string" + && finding.subjectRef.startsWith("docker_container_") + && typeof finding.targetRef === "string" + && finding.targetRef.startsWith("docker_container_") + && finding.subjectRef < finding.targetRef + && finding.id === composeMutualDependencyFindingId(finding.subjectRef, finding.targetRef) + && Array.isArray(finding.evidenceRefs) + && finding.evidenceRefs.length === 2 + && (() => { + const [forwardCandidate, reverseCandidate] = finding.evidenceRefs; + if (!forwardCandidate || typeof forwardCandidate !== "object" || !reverseCandidate || typeof reverseCandidate !== "object") return false; + const forward = forwardCandidate as Record; + const reverse = reverseCandidate as Record; + const isCanonicalComposeEvidence = (evidence: Record, sourceRef: string) => ( + evidence.version === 1 + && evidence.provider === "docker" + && evidence.kind === "docker_compose_depends_on" + && evidence.assertionKind === "observed" + && evidence.summary === "Docker recorded Compose dependency declaration" + && evidence.subjectRef === sourceRef + && (evidence.providerSlot === undefined || evidence.providerSlot === null) + && evidence.freshness === "fresh" + && typeof evidence.collectedAt === "number" + && typeof evidence.providerRevision === "string" + && evidence.providerRevision !== String(evidence.collectedAt) + ); + return isCanonicalComposeEvidence(forward, finding.subjectRef) + && isCanonicalComposeEvidence(reverse, finding.targetRef) + && forward.collectedAt === reverse.collectedAt + && forward.providerRevision === reverse.providerRevision; + })(); if (finding.ruleId !== INTERNAL_NETWORK_PORT_FINDING_RULE) return false; return finding.severity === "advisory" && finding.summary === INTERNAL_NETWORK_PORT_FINDING_SUMMARY diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index cad9ae3a..0253f2d1 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -941,6 +941,10 @@ test("authenticated browser API pass-through responses preserve Rust schemas acr finding && typeof finding === "object" && (finding as { ruleId?: unknown }).ruleId === "docker.compose_declared_target_not_active" )), `${path} must preserve the canonical Compose target finding`); + assert.ok(findingList.some((finding) => ( + finding && typeof finding === "object" + && (finding as { ruleId?: unknown }).ruleId === "docker.compose_mutual_dependency" + )), `${path} must preserve the canonical mutual Compose finding`); } } } @@ -1054,7 +1058,17 @@ test("daemon model responses require non-empty revision and complete provider st ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].evidenceRefs[0].freshness = "stale"; return value; })()], ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].evidenceRefs[0].kind = "docker_network_membership"; return value; })()], ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].evidenceRefs[0].providerSlot = "project_npm"; return value; })()], - ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].unsafe = "DOCKERMAP_TEST_EXTRA"; return value; })()] + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].unsafe = "DOCKERMAP_TEST_EXTRA"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[4].id = "finding_docker_compose_mutual_dependency_forged"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[4].summary = "DOCKERMAP_TEST_FORGED_MUTUAL_COMPOSE_FINDING"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); [value.findings[4].subjectRef, value.findings[4].targetRef] = [value.findings[4].targetRef, value.findings[4].subjectRef]; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); [value.findings[4].evidenceRefs[0], value.findings[4].evidenceRefs[1]] = [value.findings[4].evidenceRefs[1], value.findings[4].evidenceRefs[0]]; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[4].evidenceRefs[0].providerSlot = "project_npm"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[4].evidenceRefs[1].collectedAt += 1; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[4].evidenceRefs[1].providerRevision = "other-revision"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[4].evidenceRefs.push(structuredClone(value.findings[4].evidenceRefs[0])); return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[4].evidenceRefs[1].kind = "docker_network_membership"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[4].evidenceRefs[0].freshness = "stale"; return value; })()] ] as const; for (const [daemonPath, body] of invalidResponses) { const daemon = await startStubDaemon((req, res) => { diff --git a/tests/fixtures/contracts/findings-response.json b/tests/fixtures/contracts/findings-response.json index db3f7007..5a5797f2 100644 --- a/tests/fixtures/contracts/findings-response.json +++ b/tests/fixtures/contracts/findings-response.json @@ -108,6 +108,42 @@ "freshness": "fresh" } ] + }, + { + "id": "finding_docker_compose_mutual_dependency_docker_container_compose_alpha-docker_container_--755a232568a267513e30b0d0a7472d12e01553187169230a1b71755b284790db", + "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_compose_alpha", + "targetRef": "docker_container_compose_beta", + "evidenceRefs": [ + { + "version": 1, + "id": "docker_compose_depends_on:docker_container_compose_alpha:docker_container_compose_beta", + "provider": "docker", + "kind": "docker_compose_depends_on", + "assertionKind": "observed", + "summary": "Docker recorded Compose dependency declaration", + "subjectRef": "docker_container_compose_alpha", + "collectedAt": 1710000000000, + "providerRevision": "fixture-docker-observation", + "providerSlot": null, + "freshness": "fresh" + }, + { + "version": 1, + "id": "docker_compose_depends_on:docker_container_compose_beta:docker_container_compose_alpha", + "provider": "docker", + "kind": "docker_compose_depends_on", + "assertionKind": "observed", + "summary": "Docker recorded Compose dependency declaration", + "subjectRef": "docker_container_compose_beta", + "collectedAt": 1710000000000, + "providerRevision": "fixture-docker-observation", + "freshness": "fresh" + } + ] } ] }