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
196 changes: 192 additions & 4 deletions crates/dockermap-core/src/findings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::{
collision_resistant_id_component, Finding, FindingRule, FindingSeverity,
RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind,
RuntimeEvidenceProvider, RuntimeMap, RuntimeNodeKind, RuntimeProviderKind,
RuntimeRelationshipKind,
RuntimeRelationshipKind, RuntimeServiceStatus,
};
use std::collections::BTreeMap;

Expand All @@ -21,11 +21,15 @@ const DOCKER_DAEMON_STATE_RECOMMENDATION: &str =
const DOCKER_DAEMON_STATE_RISK_ID: &str = "host_risk_docker_daemon_state";
const DOCKER_DAEMON_STATE_EVIDENCE_SUMMARY: &str =
"Docker reported a bind mount exposing Docker daemon state";
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.";

/// Derive bounded, deterministic advisory findings from the already-public
/// runtime topology. The rule intentionally fails closed: it acts only on one
/// fresh V2 systemd `Requires=` declaration between uniquely identified
/// systemd services. Raw provider material is never copied into a finding.
/// runtime topology. Every rule intentionally fails closed on its own closed
/// evidence shape and uniquely identified entities. Raw provider material is
/// never copied into a finding.
pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec<Finding> {
let node_counts = runtime_map
.nodes
Expand Down Expand Up @@ -72,6 +76,21 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec<Finding> {
}
}

let mut compose_dependency_counts = BTreeMap::<(&str, &str), usize>::new();
let mut compose_dependency_edge_counts = BTreeMap::<(&str, &str), usize>::new();
for edge in &runtime_map.edges {
if edge.relationship == RuntimeRelationshipKind::DependsOn {
*compose_dependency_edge_counts
.entry((edge.source.as_str(), edge.target.as_str()))
.or_default() += 1;
}
if is_candidate_compose_dependency(edge, &nodes) {
*compose_dependency_counts
.entry((edge.source.as_str(), edge.target.as_str()))
.or_default() += 1;
}
}

let mut findings = Vec::new();
for edge in &runtime_map.edges {
let pair = (edge.source.as_str(), edge.target.as_str());
Expand Down Expand Up @@ -105,6 +124,50 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec<Finding> {
evidence_refs: vec![evidence],
});
}
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)
|| compose_dependency_counts.get(&pair) != Some(&1)
|| !is_candidate_compose_dependency(edge, &nodes)
{
continue;
}
let (Some(source), Some(target)) = (nodes.get(pair.0), nodes.get(pair.1)) else {
continue;
};
if !is_docker_container(source)
|| !is_docker_container(target)
|| !matches!(
source
.status
.as_deref()
.map(RuntimeServiceStatus::from_status_text),
Some(RuntimeServiceStatus::Running)
)
|| !matches!(
target
.status
.as_deref()
.map(RuntimeServiceStatus::from_status_text),
Some(RuntimeServiceStatus::Stopped | RuntimeServiceStatus::Failed)

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 Preserve completion semantics before flagging stopped targets

For a Compose dependency declared with condition: service_completed_successfully, the expected healthy state is that the target exits successfully while the dependent remains running, but this condition is discarded by parse_depends_on_label in crates/dockermap-daemon/src/docker_collector.rs:296-310. Consequently an Exited (0) one-shot migration or initialization service normalizes to Stopped here and generates a misleading inactive-target advisory even though its declared condition was satisfied; preserve and inspect the condition, or otherwise exclude successful completion from this rule.

Useful? React with 👍 / 👎.

)
{
continue;
}
findings.push(Finding {
id: format!(
"finding_docker_compose_declared_target_not_active_{}",
collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target))
),
rule_id: FindingRule::DockerComposeDeclaredTargetNotActive,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept the new finding at the API boundary

When this branch emits DockerComposeDeclaredTargetNotActive, the browser-facing /api/findings route becomes unavailable: hasCoherentFindings in apps/api/src/daemonResponseValidation.ts accepts only the three pre-existing rules and returns false for every other rule at line 285, after which validateDaemonResponse rejects the entire payload and daemonClient.ts converts that rejection to HTTP 502. Add strict coherence validation for this new rule in the same change so a running service with an inactive Compose target does not hide all findings.

Useful? React with 👍 / 👎.

severity: FindingSeverity::Advisory,
summary: COMPOSE_DECLARED_TARGET_NOT_ACTIVE_SUMMARY.into(),
recommendation: COMPOSE_DECLARED_TARGET_NOT_ACTIVE_RECOMMENDATION.into(),
subject_ref: edge.source.clone(),
target_ref: edge.target.clone(),
evidence_refs: vec![edge.evidence_refs[0].clone()],
});
}
for edge in &runtime_map.edges {
let pair = (edge.source.as_str(), edge.target.as_str());
if daemon_state_counts.get(&pair) != Some(&1)
Expand Down Expand Up @@ -277,6 +340,20 @@ fn is_candidate_requires(edge: &crate::RuntimeMapEdge) -> bool {
)
}

fn is_candidate_compose_dependency<'a>(
edge: &crate::RuntimeMapEdge,
nodes: &BTreeMap<&'a str, &'a crate::RuntimeMapNode>,
) -> bool {
edge.metadata.is_empty()
&& edge.relationship == RuntimeRelationshipKind::DependsOn
&& edge.source != edge.target
&& matches!(
(nodes.get(edge.source.as_str()), nodes.get(edge.target.as_str())),
(Some(source), Some(target)) if is_docker_container(source) && is_docker_container(target)
)
&& is_fresh_docker_evidence(edge, RuntimeEvidenceKind::DockerComposeDependsOn)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -640,6 +717,117 @@ mod tests {
assert!(derive_findings(&private_only_port).is_empty());
}

fn compose_dependency_map(source_status: &str, target_status: &str) -> RuntimeMap {
let source = "docker_container_compose_source";
let target = "docker_container_compose_target";
let mut source_node = docker_node(
source,
RuntimeProviderKind::Docker,
RuntimeNodeKind::Container,
BTreeMap::new(),
);
source_node.status = Some(source_status.into());
let mut target_node = docker_node(
target,
RuntimeProviderKind::Docker,
RuntimeNodeKind::Container,
BTreeMap::new(),
);
target_node.status = Some(target_status.into());
RuntimeMap {
nodes: vec![source_node, target_node],
edges: vec![RuntimeMapEdge {
source: source.into(),
target: target.into(),
relationship: RuntimeRelationshipKind::DependsOn,
metadata: BTreeMap::new(),
evidence_refs: vec![docker_evidence(
RuntimeEvidenceKind::DockerComposeDependsOn,
source,
)],
}],
..Default::default()
}
}

#[test]
fn compose_declared_target_rule_is_bounded_and_normalizes_docker_statuses() {
for (source_status, target_status) in [
("Up 3 hours", "Exited (1) 2 seconds ago"),
("running", "stopped"),
("UP", "failed"),
] {
let input = compose_dependency_map(source_status, target_status);
let findings = derive_findings(&input);
assert_eq!(findings.len(), 1, "{source_status} -> {target_status}");
let finding = &findings[0];
assert_eq!(
finding.rule_id,
FindingRule::DockerComposeDeclaredTargetNotActive
);
assert_eq!(finding.severity, FindingSeverity::Advisory);
assert_eq!(finding.summary, COMPOSE_DECLARED_TARGET_NOT_ACTIVE_SUMMARY);
assert_eq!(
finding.recommendation,
COMPOSE_DECLARED_TARGET_NOT_ACTIVE_RECOMMENDATION
);
assert_eq!(finding.subject_ref, "docker_container_compose_source");
assert_eq!(finding.target_ref, "docker_container_compose_target");
assert_eq!(
finding.evidence_refs,
vec![input.edges[0].evidence_refs[0].clone()]
);
assert!(finding
.id
.starts_with("finding_docker_compose_declared_target_not_active_"));
}
}

#[test]
fn compose_declared_target_rule_fails_closed_for_ambiguous_or_non_advisory_inputs() {
for (source, target) in [
("created", "exited"),
("stopping", "exited"),
("up", "up 1 hour"),
("up", "starting"),
("up", "unknown"),
] {
assert!(derive_findings(&compose_dependency_map(source, target)).is_empty());
}
let mut stale = compose_dependency_map("up", "exited");
stale.edges[0].evidence_refs[0].freshness = RuntimeEvidenceFreshness::Stale;
assert!(derive_findings(&stale).is_empty());
let mut timed_out = compose_dependency_map("up", "failed");
timed_out.edges[0].evidence_refs[0].freshness = RuntimeEvidenceFreshness::TimedOut;
assert!(derive_findings(&timed_out).is_empty());
let mut missing = compose_dependency_map("up", "exited");
missing.edges[0].evidence_refs.clear();
assert!(derive_findings(&missing).is_empty());
let mut duplicate = compose_dependency_map("up", "exited");
duplicate.edges.push(duplicate.edges[0].clone());
assert!(derive_findings(&duplicate).is_empty());
let mut malformed_duplicate = compose_dependency_map("up", "exited");
let mut malformed_edge = malformed_duplicate.edges[0].clone();
malformed_edge.evidence_refs.clear();
malformed_duplicate.edges.push(malformed_edge);
assert!(derive_findings(&malformed_duplicate).is_empty());
let mut metadata = compose_dependency_map("up", "exited");
metadata.edges[0]
.metadata
.insert("unsafe".into(), "value".into());
assert!(derive_findings(&metadata).is_empty());
let mut collision = compose_dependency_map("up", "exited");
collision.nodes.push(collision.nodes[0].clone());
assert!(derive_findings(&collision).is_empty());
let mut non_docker = compose_dependency_map("up", "exited");
non_docker.nodes[1].provider = RuntimeProviderKind::Systemd;
assert!(derive_findings(&non_docker).is_empty());
let mut wrong_evidence = compose_dependency_map("up", "exited");
wrong_evidence.edges[0].evidence_refs[0].kind =
RuntimeEvidenceKind::DockerNetworkMembership;
assert!(derive_findings(&wrong_evidence).is_empty());
}

#[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 @@ -1276,6 +1276,8 @@ pub enum FindingRule {
DockerInternalNetworkMemberPublishesPort,
#[serde(rename = "docker.daemon_state_bind_mount")]
DockerDaemonStateBindMount,
#[serde(rename = "docker.compose_declared_target_not_active")]
DockerComposeDeclaredTargetNotActive,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
Expand Down
34 changes: 34 additions & 0 deletions crates/dockermap-daemon/src/cache_refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1863,6 +1863,40 @@ mod scheduler_tests {
}
}

#[tokio::test]
async fn compose_target_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_api")
.expect("fixture supplies Compose dependency target")
.status = "Exited (1) 2 seconds ago".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::DockerComposeDeclaredTargetNotActive
&& finding.evidence_refs.len() == 1
&& finding.evidence_refs[0].kind == RuntimeEvidenceKind::DockerComposeDependsOn
}));
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.is_empty());
}

#[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 @@ -57,7 +57,8 @@
"enum": [
"systemd.requires_target_not_active",
"docker.internal_network_member_publishes_port",
"docker.daemon_state_bind_mount"
"docker.daemon_state_bind_mount",
"docker.compose_declared_target_not_active"
],
"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 @@ -157,7 +157,8 @@ export type HealthState = 'ok' | 'degraded';
export type FindingRule =
| 'systemd.requires_target_not_active'
| 'docker.internal_network_member_publishes_port'
| 'docker.daemon_state_bind_mount';
| 'docker.daemon_state_bind_mount'
| 'docker.compose_declared_target_not_active';
/**
* 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 @@ -1391,7 +1391,8 @@ export const RUST_RESPONSE_SCHEMAS = {
"enum": [
"systemd.requires_target_not_active",
"docker.internal_network_member_publishes_port",
"docker.daemon_state_bind_mount"
"docker.daemon_state_bind_mount",
"docker.compose_declared_target_not_active"
],
"type": "string"
},
Expand Down Expand Up @@ -3937,7 +3938,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = {
"enum": [
"systemd.requires_target_not_active",
"docker.internal_network_member_publishes_port",
"docker.daemon_state_bind_mount"
"docker.daemon_state_bind_mount",
"docker.compose_declared_target_not_active"
],
"type": "string"
},
Expand Down
Loading