Skip to content

Commit 7bd5587

Browse files
bb-connorclaude
andauthored
refactor(agent): split api_server/response_actions.rs into execute/, rollback/, ... (#343)
Convert the single 2,838-line response_actions module into a directory of focused sub-modules, all under 600 lines, matching the Phase 2 plan in the swarm architecture synthesis: response_actions/ mod.rs re-exports + glob from parent scope validate.rs actor identity, ttl, reason validation dispatch.rs execute_edr_response_action switch failure.rs sanitize, redact, record-failure persistence.rs ledger append, receipt emit, transition IDs network_extension.rs NE egress policy reload coordination egress_match.rs egress target normalize + active-match lookup broker_revocation.rs broker capability + integration secret revoke process_signal.rs signal target validation + unix kill helpers execute/ per-action live execution collect_evidence.rs restrict_egress.rs quarantine_file.rs disable_persistence.rs revoke_grant.rs suspend_process_tree.rs rollback/ per-action rollback + TTL expiration restrict_egress.rs quarantine_file.rs disable_persistence.rs suspend_process_tree.rs expiration.rs The pub(crate) surface is unchanged: api_server.rs continues to do `pub(crate) use response_actions::*;`, and mod.rs aggregates each sub-module the same way. External callers (api_server.rs, edr/handlers, api_server tests, policy_history, receipts, policy_delta) build without modification. Existing tests pass; the response_actions unit test for validate_response_action_actor continues to resolve through the same glob path. Path-bounded persistence checks are imported from the clawdstrike-fs-policy-paths crate extracted in Phase 1. Co-authored-by: bb-connor <bb-connor@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5cb717a commit 7bd5587

23 files changed

Lines changed: 3008 additions & 2838 deletions

apps/agent/src-tauri/src/api_server/response_actions.rs

Lines changed: 0 additions & 2838 deletions
This file was deleted.

apps/agent/src-tauri/src/api_server/response_actions/broker_revocation.rs

Lines changed: 491 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//! Live response action dispatch.
2+
//!
3+
//! Provides the entry-point switch over `EndpointDecisionAction` that routes
4+
//! each supported live action to its `execute::*` implementation, plus the
5+
//! `supported_*` predicates used by handlers to reject unsupported actions.
6+
7+
use super::*;
8+
9+
pub(crate) fn supported_edr_simulation_action(action: &EndpointDecisionAction) -> bool {
10+
matches!(
11+
action,
12+
EndpointDecisionAction::Block
13+
| EndpointDecisionAction::RestrictEgress
14+
| EndpointDecisionAction::SuspendProcessTree
15+
| EndpointDecisionAction::TerminateProcessTree
16+
| EndpointDecisionAction::QuarantineFile
17+
| EndpointDecisionAction::RevokeGrant
18+
| EndpointDecisionAction::DisablePersistence
19+
)
20+
}
21+
22+
pub(crate) fn supported_edr_response_action(action: &EndpointDecisionAction) -> bool {
23+
matches!(
24+
action,
25+
EndpointDecisionAction::RestrictEgress
26+
| EndpointDecisionAction::SuspendProcessTree
27+
| EndpointDecisionAction::TerminateProcessTree
28+
| EndpointDecisionAction::QuarantineFile
29+
| EndpointDecisionAction::RevokeGrant
30+
| EndpointDecisionAction::DisablePersistence
31+
| EndpointDecisionAction::CollectEvidence
32+
)
33+
}
34+
35+
pub(crate) fn default_response_action_reason(
36+
action: &EndpointDecisionAction,
37+
dry_run: bool,
38+
) -> &'static str {
39+
if dry_run {
40+
return "endpoint response dry run";
41+
}
42+
match action {
43+
EndpointDecisionAction::CollectEvidence => "collect endpoint evidence",
44+
EndpointDecisionAction::RestrictEgress => "restrict endpoint egress",
45+
EndpointDecisionAction::QuarantineFile => "quarantine endpoint file",
46+
EndpointDecisionAction::DisablePersistence => "disable endpoint persistence item",
47+
EndpointDecisionAction::RevokeGrant => "revoke local endpoint grant",
48+
EndpointDecisionAction::SuspendProcessTree => "suspend endpoint process tree",
49+
EndpointDecisionAction::TerminateProcessTree => "terminate endpoint process tree",
50+
_ => "execute endpoint response",
51+
}
52+
}
53+
54+
pub(crate) async fn execute_edr_response_action(
55+
state: &AgentApiState,
56+
plan: &EndpointResponsePlan,
57+
graph: &CausalGraph,
58+
actor: EndpointDecisionActor,
59+
) -> Result<
60+
(
61+
EndpointResponseExecutionReport,
62+
StoredEndpointEvidenceBundle,
63+
SignedReceipt,
64+
SignedReceipt,
65+
),
66+
(StatusCode, String),
67+
> {
68+
match plan.action {
69+
EndpointDecisionAction::CollectEvidence => {
70+
execute_collect_evidence_response(state, plan, graph, actor).await
71+
}
72+
EndpointDecisionAction::RestrictEgress => {
73+
execute_restrict_egress_response(state, plan, graph, actor).await
74+
}
75+
EndpointDecisionAction::QuarantineFile => {
76+
execute_quarantine_file_response(state, plan, graph, actor).await
77+
}
78+
EndpointDecisionAction::DisablePersistence => {
79+
execute_disable_persistence_response(state, plan, graph, actor).await
80+
}
81+
EndpointDecisionAction::RevokeGrant => {
82+
execute_revoke_grant_response(state, plan, graph, actor).await
83+
}
84+
EndpointDecisionAction::SuspendProcessTree => {
85+
execute_suspend_process_tree_response(state, plan, graph, actor).await
86+
}
87+
_ => Err((
88+
StatusCode::BAD_REQUEST,
89+
format!(
90+
"unsupported endpoint response action for live executor: {}",
91+
plan.action.as_str()
92+
),
93+
)),
94+
}
95+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//! Egress target normalization and active-restriction matching.
2+
//!
3+
//! Normalizes `host:port` targets into a canonical form, validates that they
4+
//! are non-local and non-private before being installed as restrictions, and
5+
//! looks up the active restriction (if any) for a policy check input so the
6+
//! engine can fail it closed at the policy layer.
7+
8+
use super::*;
9+
10+
pub(crate) async fn active_egress_restriction_for_policy_check(
11+
state: &AgentApiState,
12+
input: &PolicyCheckInput,
13+
) -> Option<EndpointEgressRestriction> {
14+
let target = normalize_egress_policy_target(&input.action_type, &input.target)?;
15+
let ledger = state.edr_egress_restriction_ledger.lock().await;
16+
ledger.active_match(&target, chrono::Utc::now())
17+
}
18+
19+
pub(crate) fn policy_output_for_active_egress_restriction(
20+
restriction: &EndpointEgressRestriction,
21+
) -> PolicyCheckOutput {
22+
PolicyCheckOutput {
23+
allowed: false,
24+
guard: Some("edr_restrict_egress".to_string()),
25+
severity: Some("high".to_string()),
26+
message: Some(format!(
27+
"Egress to {} denied by active EDR response execution {}",
28+
restriction.target, restriction.execution_id
29+
)),
30+
details: Some(serde_json::json!({
31+
"reason": "active_edr_egress_restriction",
32+
"target": restriction.target.clone(),
33+
"executionId": restriction.execution_id.clone(),
34+
"actionId": restriction.action_id.clone(),
35+
"rollbackRef": restriction.rollback_ref.clone(),
36+
"graphSliceId": restriction.graph_slice_id.clone(),
37+
"expiresAt": restriction.expires_at,
38+
})),
39+
}
40+
}
41+
42+
pub(crate) fn restrict_egress_targets(
43+
plan: &EndpointResponsePlan,
44+
graph: &CausalGraph,
45+
) -> Result<Vec<String>> {
46+
let root = graph
47+
.nodes
48+
.get(&plan.root_node_id)
49+
.ok_or_else(|| anyhow::anyhow!("root node not found: {}", plan.root_node_id))?;
50+
let root_target = if root.kind == CausalNodeKind::Network {
51+
Some(egress_target_from_node(root)?)
52+
} else {
53+
None
54+
};
55+
56+
let mut targets = graph
57+
.nodes
58+
.values()
59+
.filter(|node| node.kind == CausalNodeKind::Network)
60+
.map(egress_target_from_node)
61+
.collect::<Result<Vec<_>>>()?;
62+
targets.sort();
63+
targets.dedup();
64+
if targets.is_empty() {
65+
return Err(anyhow::anyhow!(
66+
"restrict_egress requires at least one network node in the graph slice"
67+
));
68+
}
69+
if let Some(root_target) = root_target {
70+
targets.retain(|target| target != &root_target);
71+
targets.insert(0, root_target);
72+
}
73+
Ok(targets)
74+
}
75+
76+
fn egress_target_from_node(node: &clawdstrike_policy_event::edr::CausalNode) -> Result<String> {
77+
normalize_egress_target(node.label.as_str())
78+
.with_context(|| format!("normalize network node target {}", node.label))
79+
}
80+
81+
fn normalize_egress_policy_target(action_type: &str, target: &str) -> Option<String> {
82+
let action = action_type.trim().to_ascii_lowercase();
83+
if action != "egress" && action != "network" {
84+
return None;
85+
}
86+
let target = target.trim();
87+
let lower = target.to_ascii_lowercase();
88+
let target = if lower.starts_with("http://")
89+
|| lower.starts_with("https://")
90+
|| lower.starts_with("ws://")
91+
|| lower.starts_with("wss://")
92+
{
93+
let url = reqwest::Url::parse(target).ok()?;
94+
let host = url.host_str()?;
95+
let port = url.port_or_known_default()?;
96+
if host.contains(':') {
97+
format!("[{host}]:{port}")
98+
} else {
99+
format!("{host}:{port}")
100+
}
101+
} else {
102+
target.to_string()
103+
};
104+
normalize_egress_target(target.as_str()).ok()
105+
}
106+
107+
pub(crate) fn normalize_egress_target(target: &str) -> Result<String> {
108+
let target = target.trim();
109+
if target.is_empty() {
110+
return Err(anyhow::anyhow!("egress target must not be empty"));
111+
}
112+
if target.len() > 512 {
113+
return Err(anyhow::anyhow!("egress target must be at most 512 bytes"));
114+
}
115+
if target
116+
.chars()
117+
.any(|ch| ch.is_ascii_whitespace() || matches!(ch, '*' | ',' | '/'))
118+
{
119+
return Err(anyhow::anyhow!(
120+
"egress target must be a literal host:port without wildcards or paths"
121+
));
122+
}
123+
let (host, port) = split_egress_host_port(target)?;
124+
let port: u16 = port
125+
.parse()
126+
.with_context(|| format!("parse egress target port {port}"))?;
127+
if port == 0 {
128+
return Err(anyhow::anyhow!("egress target port must be non-zero"));
129+
}
130+
let normalized_host = host.trim_matches(['[', ']']).to_ascii_lowercase();
131+
if normalized_host.is_empty() {
132+
return Err(anyhow::anyhow!("egress target host must not be empty"));
133+
}
134+
if matches!(
135+
normalized_host.as_str(),
136+
"localhost" | "localhost.localdomain"
137+
) {
138+
return Err(anyhow::anyhow!("refusing to restrict local host egress"));
139+
}
140+
if let Ok(ip) = normalized_host.parse::<IpAddr>() {
141+
validate_egress_restriction_ip(ip)?;
142+
}
143+
if normalized_host.contains(':') {
144+
Ok(format!("[{normalized_host}]:{port}"))
145+
} else {
146+
Ok(format!("{normalized_host}:{port}"))
147+
}
148+
}
149+
150+
fn split_egress_host_port(target: &str) -> Result<(&str, &str)> {
151+
if let Some(rest) = target.strip_prefix('[') {
152+
let (host, port) = rest
153+
.split_once("]:")
154+
.ok_or_else(|| anyhow::anyhow!("bracketed egress target must be [host]:port"))?;
155+
return Ok((host, port));
156+
}
157+
target
158+
.rsplit_once(':')
159+
.ok_or_else(|| anyhow::anyhow!("egress target must include a port"))
160+
}
161+
162+
fn validate_egress_restriction_ip(ip: IpAddr) -> Result<()> {
163+
let blocked = match ip {
164+
IpAddr::V4(ip) => {
165+
ip.is_loopback()
166+
|| ip.is_private()
167+
|| ip.is_link_local()
168+
|| ip.is_broadcast()
169+
|| ip.is_documentation()
170+
|| ip.is_unspecified()
171+
}
172+
IpAddr::V6(ip) => {
173+
ip.is_loopback()
174+
|| ip.is_unspecified()
175+
|| ipv6_is_unique_local(&ip)
176+
|| ipv6_is_unicast_link_local(&ip)
177+
|| ip.is_multicast()
178+
}
179+
};
180+
if blocked {
181+
return Err(anyhow::anyhow!(
182+
"refusing to restrict local, private, link-local, multicast, or documentation egress target {ip}"
183+
));
184+
}
185+
Ok(())
186+
}
187+
188+
fn ipv6_is_unique_local(ip: &std::net::Ipv6Addr) -> bool {
189+
(ip.segments()[0] & 0xfe00) == 0xfc00
190+
}
191+
192+
fn ipv6_is_unicast_link_local(ip: &std::net::Ipv6Addr) -> bool {
193+
(ip.segments()[0] & 0xffc0) == 0xfe80
194+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//! Execute the `collect_evidence` response action.
2+
3+
use super::super::*;
4+
5+
pub(crate) async fn execute_collect_evidence_response(
6+
state: &AgentApiState,
7+
plan: &EndpointResponsePlan,
8+
graph: &CausalGraph,
9+
actor: EndpointDecisionActor,
10+
) -> Result<
11+
(
12+
EndpointResponseExecutionReport,
13+
StoredEndpointEvidenceBundle,
14+
SignedReceipt,
15+
SignedReceipt,
16+
),
17+
(StatusCode, String),
18+
> {
19+
let execution =
20+
EndpointResponseExecutionReport::collect_evidence(plan, graph).map_err(internal_error)?;
21+
persist_edr_response_execution(state, execution, graph, actor).await
22+
}

0 commit comments

Comments
 (0)