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
41 changes: 41 additions & 0 deletions apps/api/src/daemonResponseValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
const reverse = reverseCandidate as Record<string, unknown>;
const isCanonicalComposeEvidence = (evidence: Record<string, unknown>, 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)
Comment on lines +375 to +376

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 Bind each Compose evidence ID to the opposite endpoint

When a malformed or compromised daemon returns two unrelated Compose observations, this accepts them as proof of a mutual dependency because it validates only each subjectRef; RuntimeEvidenceRef has no target field and its id is schema-checked only as a nonempty string. Production evidence IDs encode both source and target in docker_runtime_evidence (crates/dockermap-core/src/snapshot_runtime.rs), so the forward and reverse IDs must be validated against the expected A→B and B→A directions; otherwise /daemon/findings can publish an unsupported mutual-dependency conclusion.

Useful? React with 👍 / 👎.

&& 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
Expand Down
16 changes: 15 additions & 1 deletion apps/api/test/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
}
}
Expand Down Expand Up @@ -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) => {
Expand Down
36 changes: 36 additions & 0 deletions tests/fixtures/contracts/findings-response.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
}
Loading