Skip to main content

assay_core/coverage_next/
analyzer.rs

1use super::{CoverageReport, HighRiskGap, RuleCoverage, ToolCoverage, TraceRecord};
2use std::collections::{HashMap, HashSet};
3
4/// Coverage analyzer
5pub struct CoverageAnalyzer {
6    /// Tools referenced in policy (from allow, deny, sequences)
7    policy_tools: HashSet<String>,
8
9    /// High-risk tools (from deny list, blocklist patterns)
10    high_risk_tools: HashSet<String>,
11
12    /// Rule IDs in policy
13    rule_ids: Vec<String>,
14
15    /// Resolved aliases (alias -> members)
16    aliases: HashMap<String, Vec<String>>,
17}
18
19impl CoverageAnalyzer {
20    /// Create analyzer from a v1.1 policy
21    pub fn from_policy(policy: &crate::model::Policy) -> Self {
22        let mut policy_tools = HashSet::new();
23        let mut high_risk_tools = HashSet::new();
24        let mut rule_ids = Vec::new();
25
26        // Extract tools from policy.tools
27        if let Some(allow) = &policy.tools.allow {
28            for tool in allow {
29                policy_tools.insert(tool.clone());
30            }
31        }
32
33        if let Some(deny) = &policy.tools.deny {
34            for tool in deny {
35                policy_tools.insert(tool.clone());
36                high_risk_tools.insert(tool.clone()); // Denied = high risk
37            }
38        }
39
40        if let Some(require_args) = &policy.tools.require_args {
41            for tool in require_args.keys() {
42                policy_tools.insert(tool.clone());
43            }
44        }
45
46        // Extract tools from sequences
47        for (idx, rule) in policy.sequences.iter().enumerate() {
48            let rule_id = Self::rule_id(rule, idx);
49            rule_ids.push(rule_id);
50
51            match rule {
52                crate::model::SequenceRule::Require { tool } => {
53                    policy_tools.insert(tool.tool().to_string());
54                }
55                crate::model::SequenceRule::Eventually { tool, .. } => {
56                    policy_tools.insert(tool.tool().to_string());
57                }
58                crate::model::SequenceRule::MaxCalls { tool, .. } => {
59                    policy_tools.insert(tool.tool().to_string());
60                }
61                crate::model::SequenceRule::Before { first, then } => {
62                    policy_tools.insert(first.tool().to_string());
63                    policy_tools.insert(then.tool().to_string());
64                }
65                crate::model::SequenceRule::After { trigger, then, .. } => {
66                    policy_tools.insert(trigger.tool().to_string());
67                    policy_tools.insert(then.tool().to_string());
68                }
69                crate::model::SequenceRule::NeverAfter { trigger, forbidden } => {
70                    policy_tools.insert(trigger.tool().to_string());
71                    policy_tools.insert(forbidden.tool().to_string());
72                    high_risk_tools.insert(forbidden.tool().to_string()); // Forbidden = high risk
73                }
74                crate::model::SequenceRule::Sequence { tools, .. } => {
75                    for tool in tools {
76                        policy_tools.insert(tool.tool().to_string());
77                    }
78                }
79                crate::model::SequenceRule::Blocklist { pattern } => {
80                    // Pattern-based, mark as high risk indicator
81                    high_risk_tools.insert(format!("*{}*", pattern));
82                }
83            }
84        }
85
86        // Resolve aliases - add alias members to policy_tools
87        for (alias, members) in &policy.aliases {
88            policy_tools.insert(alias.clone());
89            for member in members {
90                policy_tools.insert(member.clone());
91            }
92        }
93
94        Self {
95            policy_tools,
96            high_risk_tools,
97            rule_ids,
98            aliases: policy.aliases.clone(),
99        }
100    }
101
102    /// Generate a rule ID from rule type and index
103    fn rule_id(rule: &crate::model::SequenceRule, _idx: usize) -> String {
104        match rule {
105            crate::model::SequenceRule::Require { tool } => {
106                format!("require_{}", tool.tool().to_lowercase())
107            }
108            crate::model::SequenceRule::Eventually { tool, within } => {
109                format!("eventually_{}_{}", tool.tool().to_lowercase(), within)
110            }
111            crate::model::SequenceRule::MaxCalls { tool, max } => {
112                format!("max_calls_{}_{}", tool.tool().to_lowercase(), max)
113            }
114            crate::model::SequenceRule::Before { first, then } => {
115                format!(
116                    "before_{}_then_{}",
117                    first.tool().to_lowercase(),
118                    then.tool().to_lowercase()
119                )
120            }
121            crate::model::SequenceRule::After { trigger, then, .. } => {
122                format!(
123                    "after_{}_then_{}",
124                    trigger.tool().to_lowercase(),
125                    then.tool().to_lowercase()
126                )
127            }
128            crate::model::SequenceRule::NeverAfter { trigger, forbidden } => {
129                format!(
130                    "never_after_{}_forbidden_{}",
131                    trigger.tool().to_lowercase(),
132                    forbidden.tool().to_lowercase()
133                )
134            }
135            crate::model::SequenceRule::Sequence { tools, strict } => {
136                let mode = if *strict { "strict" } else { "seq" };
137                format!(
138                    "{}_{}",
139                    mode,
140                    tools
141                        .iter()
142                        .map(|t| t.tool().to_lowercase())
143                        .collect::<Vec<_>>()
144                        .join("_")
145                        .to_lowercase()
146                )
147            }
148            crate::model::SequenceRule::Blocklist { pattern } => {
149                format!("blocklist_{}", pattern.to_lowercase())
150            }
151        }
152    }
153
154    /// Analyze coverage from a set of traces
155    pub fn analyze(&self, traces: &[TraceRecord], threshold: f64) -> CoverageReport {
156        let mut tools_seen: HashSet<String> = HashSet::new();
157        let mut rules_triggered: HashSet<String> = HashSet::new();
158        let mut unexpected_tools: HashSet<String> = HashSet::new();
159
160        // Collect all tools and triggered rules from traces
161        for trace in traces {
162            for tool in &trace.tools_called {
163                tools_seen.insert(tool.clone());
164
165                // Check if tool is in policy (including alias resolution)
166                if !self.is_policy_tool(tool) {
167                    unexpected_tools.insert(tool.clone());
168                }
169            }
170
171            for rule_id in &trace.rules_triggered {
172                rules_triggered.insert(rule_id.clone());
173            }
174        }
175
176        // Calculate tool coverage
177        let policy_tool_count = self.policy_tools.len();
178        let seen_policy_tools: HashSet<_> = tools_seen
179            .iter()
180            .filter(|t| self.is_policy_tool(t))
181            .cloned()
182            .collect();
183        let tools_seen_count = seen_policy_tools.len();
184
185        let unseen_tools: Vec<String> = self
186            .policy_tools
187            .iter()
188            .filter(|t| !self.is_tool_seen(t, &tools_seen))
189            .cloned()
190            .collect();
191
192        let tool_coverage_pct = if policy_tool_count > 0 {
193            (tools_seen_count as f64 / policy_tool_count as f64) * 100.0
194        } else {
195            100.0
196        };
197
198        // Calculate rule coverage
199        let total_rules = self.rule_ids.len();
200        let triggered_count = rules_triggered.len();
201
202        let untriggered_rules: Vec<String> = self
203            .rule_ids
204            .iter()
205            .filter(|r| !rules_triggered.contains(*r))
206            .cloned()
207            .collect();
208
209        let rule_coverage_pct = if total_rules > 0 {
210            (triggered_count as f64 / total_rules as f64) * 100.0
211        } else {
212            100.0
213        };
214
215        // Identify high-risk gaps
216        let high_risk_gaps: Vec<HighRiskGap> = self
217            .high_risk_tools
218            .iter()
219            .filter(|t| !t.starts_with('*')) // Skip patterns
220            .filter(|t| !self.is_tool_seen(t, &tools_seen))
221            .map(|t| HighRiskGap {
222                tool: t.clone(),
223                reason: "Tool is in deny list but never appeared in test traces".to_string(),
224                severity: "high".to_string(),
225            })
226            .collect();
227
228        // Overall coverage (average of tool and rule coverage)
229        let overall_coverage_pct = (tool_coverage_pct + rule_coverage_pct) / 2.0;
230        let meets_threshold = overall_coverage_pct >= threshold;
231
232        CoverageReport {
233            tool_coverage: ToolCoverage {
234                total_tools_in_policy: policy_tool_count,
235                tools_seen_in_traces: tools_seen_count,
236                coverage_pct: tool_coverage_pct,
237                unseen_tools,
238                unexpected_tools: unexpected_tools.into_iter().collect(),
239            },
240            rule_coverage: RuleCoverage {
241                total_rules,
242                rules_triggered: triggered_count,
243                coverage_pct: rule_coverage_pct,
244                untriggered_rules,
245            },
246            high_risk_gaps,
247            policy_violations: Vec::new(),
248            policy_warnings: Vec::new(),
249            overall_coverage_pct,
250            meets_threshold,
251            threshold,
252        }
253    }
254
255    /// Check if a tool is in the policy (including alias resolution)
256    fn is_policy_tool(&self, tool: &str) -> bool {
257        if self.policy_tools.contains(tool) {
258            return true;
259        }
260
261        // Check if tool is a member of any alias
262        for members in self.aliases.values() {
263            if members.contains(&tool.to_string()) {
264                return true;
265            }
266        }
267
268        false
269    }
270
271    /// Check if a tool (or any of its alias members) was seen
272    fn is_tool_seen(&self, tool: &str, seen: &HashSet<String>) -> bool {
273        if seen.contains(tool) {
274            return true;
275        }
276
277        // Check if this tool is an alias and any member was seen
278        if let Some(members) = self.aliases.get(tool) {
279            return members.iter().any(|m| seen.contains(m));
280        }
281
282        // Check if tool is a member of an alias that was seen
283        for (alias, members) in &self.aliases {
284            if members.contains(&tool.to_string()) && seen.contains(alias) {
285                return true;
286            }
287        }
288
289        false
290    }
291}