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
182 changes: 180 additions & 2 deletions crates/dockermap-core/src/findings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::snapshot_runtime::is_host_published_docker_port;
use crate::{
collision_resistant_id_component, Finding, FindingRule, FindingSeverity,
RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind,
RuntimeEvidenceProvider, RuntimeMap, RuntimeNodeKind, RuntimeProviderKind,
RuntimeEvidenceProvider, RuntimeMap, RuntimeMode, RuntimeNodeKind, RuntimeProviderKind,
RuntimeRelationshipKind, RuntimeServiceStatus,
};
use std::collections::BTreeMap;
Expand All @@ -29,6 +29,11 @@ const COMPOSE_DECLARED_TARGET_NOT_ACTIVE_SUMMARY: &str =
"A running Docker Compose service declares a dependency whose container is not active.";
const COMPOSE_DECLARED_TARGET_NOT_ACTIVE_RECOMMENDATION: &str =
"Review the declared dependency and the target container state.";
const COMPOSE_DEPENDENCY_EVIDENCE_SUMMARY: &str = "Docker recorded Compose dependency declaration";
const COMPOSE_MUTUAL_DEPENDENCY_SUMMARY: &str =
"Docker recorded mutually declared Compose dependencies between two containers.";
const COMPOSE_MUTUAL_DEPENDENCY_RECOMMENDATION: &str =
"Review the declared dependencies and remove any unintended mutual dependency.";

/// Derive bounded, deterministic advisory findings from the already-public
/// runtime topology. Every rule intentionally fails closed on its own closed
Expand Down Expand Up @@ -182,6 +187,53 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec<Finding> {
evidence_refs: vec![daemon_evidence.clone(), port_evidence.clone()],
});
}
// A mutual declaration is one closed, unordered pair. Iterate only the
// lexicographically first direction so the emitted subject/target and
// evidence order do not depend on collector edge ordering.
for edge in &runtime_map.edges {
let pair = (edge.source.as_str(), edge.target.as_str());
if matches!(runtime_map.source.as_ref(), Some(RuntimeMode::Mock))
|| pair.0 >= pair.1
|| compose_dependency_edge_counts.get(&pair) != Some(&1)
|| compose_dependency_counts.get(&pair) != Some(&1)
|| !is_candidate_compose_dependency(edge, &nodes)
{
continue;
}
let reverse = (pair.1, pair.0);
if compose_dependency_edge_counts.get(&reverse) != Some(&1)
|| compose_dependency_counts.get(&reverse) != Some(&1)
{
continue;
}
let Some(reverse_edge) = runtime_map.edges.iter().find(|candidate| {
candidate.source == edge.target
&& candidate.target == edge.source
&& is_candidate_compose_dependency(candidate, &nodes)
Comment on lines +209 to +212

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 Scope reciprocal dependencies to one Compose project

When separate Compose projects expose complementary unique service names—for example, project A's frontend declares backend while project B's backend declares frontend—the collector retains only the service name and ContainerAliases::resolve_dependency resolves it globally. These lines therefore accept the resulting cross-project edges as reciprocal because they share the same snapshot time and revision, emitting a mutual-dependency advisory even though neither project has a cycle. Preserve the Compose project identity and require both declarations to belong to the same project.

Useful? React with 👍 / 👎.

}) else {
continue;
};
let evidence = &edge.evidence_refs[0];
let reverse_evidence = &reverse_edge.evidence_refs[0];
if evidence.collected_at != reverse_evidence.collected_at
|| evidence.provider_revision != reverse_evidence.provider_revision
{
continue;
}
findings.push(Finding {
id: format!(
"finding_docker_compose_mutual_dependency_{}",
collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target))
),
rule_id: FindingRule::DockerComposeMutualDependency,
severity: FindingSeverity::Advisory,
summary: COMPOSE_MUTUAL_DEPENDENCY_SUMMARY.into(),
recommendation: COMPOSE_MUTUAL_DEPENDENCY_RECOMMENDATION.into(),
subject_ref: edge.source.clone(),
target_ref: edge.target.clone(),
evidence_refs: vec![evidence.clone(), reverse_evidence.clone()],
});
}
for edge in &runtime_map.edges {
let pair = (edge.source.as_str(), edge.target.as_str());
if compose_dependency_edge_counts.get(&pair) != Some(&1)
Expand Down Expand Up @@ -449,6 +501,7 @@ fn is_candidate_compose_dependency<'a>(
(Some(source), Some(target)) if is_docker_container(source) && is_docker_container(target)
)
&& is_fresh_docker_evidence(edge, RuntimeEvidenceKind::DockerComposeDependsOn)
&& matches!(edge.evidence_refs.first(), Some(evidence) if evidence.summary == COMPOSE_DEPENDENCY_EVIDENCE_SUMMARY)
}

#[cfg(test)]
Expand Down Expand Up @@ -597,13 +650,17 @@ mod tests {
}

fn docker_evidence(kind: RuntimeEvidenceKind, source: &str) -> RuntimeEvidenceRef {
let summary = match kind {
RuntimeEvidenceKind::DockerComposeDependsOn => COMPOSE_DEPENDENCY_EVIDENCE_SUMMARY,
_ => "Docker reported a bounded runtime fact",
};
RuntimeEvidenceRef {
version: 1,
id: format!("docker_evidence_{source}"),
provider: RuntimeEvidenceProvider::Docker,
kind,
assertion_kind: RuntimeEvidenceAssertionKind::Observed,
summary: "Docker reported a bounded runtime fact".into(),
summary: summary.into(),
subject_ref: source.into(),
collected_at: 1,
provider_revision: "opaque-docker-observation".into(),
Expand Down Expand Up @@ -1096,6 +1153,127 @@ mod tests {
assert!(derive_findings(&wrong_evidence).is_empty());
}

fn mutual_compose_dependency_map() -> RuntimeMap {
let mut map = compose_dependency_map("created", "starting");
let reverse_source = map.edges[0].target.clone();
let reverse_target = map.edges[0].source.clone();
map.edges.push(RuntimeMapEdge {
source: reverse_source.clone(),
target: reverse_target,
relationship: RuntimeRelationshipKind::DependsOn,
metadata: BTreeMap::new(),
evidence_refs: vec![docker_evidence(
RuntimeEvidenceKind::DockerComposeDependsOn,
&reverse_source,
)],
});
map
}

fn mutual_compose_finding(findings: &[Finding]) -> Option<&Finding> {
findings
.iter()
.find(|finding| finding.rule_id == FindingRule::DockerComposeMutualDependency)
}

#[test]
fn compose_mutual_dependency_is_deterministic_and_contains_only_canonical_evidence() {
let input = mutual_compose_dependency_map();
let finding = mutual_compose_finding(&derive_findings(&input))
.expect("fresh, reciprocal Docker Compose declarations produce an advisory")
.clone();
assert_eq!(finding.severity, FindingSeverity::Advisory);
assert_eq!(finding.summary, COMPOSE_MUTUAL_DEPENDENCY_SUMMARY);
assert_eq!(
finding.recommendation,
COMPOSE_MUTUAL_DEPENDENCY_RECOMMENDATION
);
assert_eq!(finding.subject_ref, "docker_container_compose_source");
assert_eq!(finding.target_ref, "docker_container_compose_target");
assert_eq!(
finding.id,
format!(
"finding_docker_compose_mutual_dependency_{}",
collision_resistant_id_component(
"docker_container_compose_source\u{1f}docker_container_compose_target"
)
)
);
assert_eq!(
finding.evidence_refs,
vec![
input.edges[0].evidence_refs[0].clone(),
input.edges[1].evidence_refs[0].clone()
]
);

let mut reversed = input.clone();
reversed.edges.reverse();
assert_eq!(
mutual_compose_finding(&derive_findings(&reversed)),
Some(&finding),
"edge ordering cannot change a canonical finding"
);
}

#[test]
fn compose_mutual_dependency_fails_closed_for_ambiguous_malformed_or_stale_inputs() {
let mut missing_reverse = mutual_compose_dependency_map();
missing_reverse.edges.pop();
assert!(mutual_compose_finding(&derive_findings(&missing_reverse)).is_none());

let mut duplicate_forward = mutual_compose_dependency_map();
duplicate_forward
.edges
.push(duplicate_forward.edges[0].clone());
assert!(mutual_compose_finding(&derive_findings(&duplicate_forward)).is_none());

let mut malformed_reverse = mutual_compose_dependency_map();
let mut malformed = malformed_reverse.edges[1].clone();
malformed.evidence_refs.clear();
malformed_reverse.edges.push(malformed);
assert!(mutual_compose_finding(&derive_findings(&malformed_reverse)).is_none());

for freshness in [
RuntimeEvidenceFreshness::Stale,
RuntimeEvidenceFreshness::TimedOut,
] {
let mut input = mutual_compose_dependency_map();
input.edges[1].evidence_refs[0].freshness = freshness;
assert!(mutual_compose_finding(&derive_findings(&input)).is_none());
}

let mut metadata = mutual_compose_dependency_map();
metadata.edges[0]
.metadata
.insert("unsafe".into(), "value".into());
assert!(mutual_compose_finding(&derive_findings(&metadata)).is_none());

let mut noncanonical_summary = mutual_compose_dependency_map();
noncanonical_summary.edges[0].evidence_refs[0].summary = "untrusted summary".into();
assert!(mutual_compose_finding(&derive_findings(&noncanonical_summary)).is_none());

let mut mismatched_time = mutual_compose_dependency_map();
mismatched_time.edges[1].evidence_refs[0].collected_at = 2;
assert!(mutual_compose_finding(&derive_findings(&mismatched_time)).is_none());

let mut mismatched_revision = mutual_compose_dependency_map();
mismatched_revision.edges[1].evidence_refs[0].provider_revision = "other".into();
assert!(mutual_compose_finding(&derive_findings(&mismatched_revision)).is_none());

let mut wrong_provider = mutual_compose_dependency_map();
wrong_provider.nodes[1].provider = RuntimeProviderKind::Systemd;
assert!(mutual_compose_finding(&derive_findings(&wrong_provider)).is_none());

let mut collision = mutual_compose_dependency_map();
collision.nodes.push(collision.nodes[0].clone());
assert!(mutual_compose_finding(&derive_findings(&collision)).is_none());

let mut mock = mutual_compose_dependency_map();
mock.source = Some(RuntimeMode::Mock);
assert!(mutual_compose_finding(&derive_findings(&mock)).is_none());
}

#[test]
fn host_publication_discriminant_accepts_only_bounded_collector_port_syntax() {
for port in ["8080:80/tcp", "53:53/udp", "443:443/sctp"] {
Expand Down
2 changes: 2 additions & 0 deletions crates/dockermap-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,8 @@ pub enum FindingRule {
DockerDaemonStateBindMountPublishesPort,
#[serde(rename = "docker.compose_declared_target_not_active")]
DockerComposeDeclaredTargetNotActive,
#[serde(rename = "docker.compose_mutual_dependency")]
DockerComposeMutualDependency,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
Expand Down
42 changes: 42 additions & 0 deletions crates/dockermap-daemon/src/cache_refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1947,6 +1947,48 @@ mod scheduler_tests {
assert!(cache.findings.findings.is_empty());
}

#[tokio::test]
async fn compose_mutual_dependency_advisory_is_cached_only_for_docker_source_and_cleared_on_reset(
) {
let mut snapshot = mock_snapshot();
snapshot
.containers
.iter_mut()
.find(|container| container.id == "container_db")
.expect("fixture supplies reciprocal Compose dependency container")
.depends_on
.push("container_api".into());
let mut initial = docker_cache(snapshot);
initial.rebuild_runtime_map();
initial.assign_revision();
assert!(initial.findings.findings.iter().any(|finding| {
finding.rule_id == dockermap_core::FindingRule::DockerComposeMutualDependency
&& finding.evidence_refs.len() == 2
&& finding.evidence_refs.iter().all(|evidence| {
evidence.kind == RuntimeEvidenceKind::DockerComposeDependsOn
&& evidence.provider_revision == finding.evidence_refs[0].provider_revision
&& evidence.collected_at == finding.evidence_refs[0].collected_at
})
}));
let state = AppState {
cache: Arc::new(RwLock::new(initial)),
docker: Arc::new(RwLock::new(None)),
provider_slot_in_flight: Arc::new(ProviderSlotFlights::default()),
};

publish_docker_snapshot_cache(&state, DaemonCache::mock()).await;
let cache = state.cache.read().await;
assert_eq!(cache.health.mode, RuntimeMode::Mock);
assert!(cache
.runtime_map
.edges
.iter()
.all(|edge| edge.evidence_refs.is_empty()));
assert!(cache.findings.findings.iter().all(|finding| {
finding.rule_id != dockermap_core::FindingRule::DockerComposeMutualDependency
}));
}

#[test]
fn revisionless_or_disabled_systemd_collection_cannot_publish_evidence() {
let mut slots = slots();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
"docker.internal_network_member_publishes_port",
"docker.daemon_state_bind_mount",
"docker.daemon_state_bind_mount_publishes_port",
"docker.compose_declared_target_not_active"
"docker.compose_declared_target_not_active",
"docker.compose_mutual_dependency"
],
"type": "string"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/contracts/src/rustModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ export type FindingRule =
| 'docker.internal_network_member_publishes_port'
| 'docker.daemon_state_bind_mount'
| 'docker.daemon_state_bind_mount_publishes_port'
| 'docker.compose_declared_target_not_active';
| 'docker.compose_declared_target_not_active'
| 'docker.compose_mutual_dependency';
/**
* Findings are intentionally a small, closed advisory vocabulary. They do
* not expose provider output or prescribe an automated remediation.
Expand Down
6 changes: 4 additions & 2 deletions packages/contracts/src/rustSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1393,7 +1393,8 @@ export const RUST_RESPONSE_SCHEMAS = {
"docker.internal_network_member_publishes_port",
"docker.daemon_state_bind_mount",
"docker.daemon_state_bind_mount_publishes_port",
"docker.compose_declared_target_not_active"
"docker.compose_declared_target_not_active",
"docker.compose_mutual_dependency"
],
"type": "string"
},
Expand Down Expand Up @@ -3941,7 +3942,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = {
"docker.internal_network_member_publishes_port",
"docker.daemon_state_bind_mount",
"docker.daemon_state_bind_mount_publishes_port",
"docker.compose_declared_target_not_active"
"docker.compose_declared_target_not_active",
"docker.compose_mutual_dependency"
],
"type": "string"
},
Expand Down
Loading