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.clone());
54                }
55                crate::model::SequenceRule::Eventually { tool, .. } => {
56                    policy_tools.insert(tool.clone());
57                }
58                crate::model::SequenceRule::MaxCalls { tool, .. } => {
59                    policy_tools.insert(tool.clone());
60                }
61                crate::model::SequenceRule::Before { first, then } => {
62                    policy_tools.insert(first.clone());
63                    policy_tools.insert(then.clone());
64                }
65                crate::model::SequenceRule::After { trigger, then, .. } => {
66                    policy_tools.insert(trigger.clone());
67                    policy_tools.insert(then.clone());
68                }
69                crate::model::SequenceRule::NeverAfter { trigger, forbidden } => {
70                    policy_tools.insert(trigger.clone());
71                    policy_tools.insert(forbidden.clone());
72                    high_risk_tools.insert(forbidden.clone()); // Forbidden = high risk
73                }
74                crate::model::SequenceRule::Sequence { tools, .. } => {
75                    for tool in tools {
76                        policy_tools.insert(tool.clone());
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.to_lowercase())
107            }
108            crate::model::SequenceRule::Eventually { tool, within } => {
109                format!("eventually_{}_{}", tool.to_lowercase(), within)
110            }
111            crate::model::SequenceRule::MaxCalls { tool, max } => {
112                format!("max_calls_{}_{}", tool.to_lowercase(), max)
113            }
114            crate::model::SequenceRule::Before { first, then } => {
115                format!(
116                    "before_{}_then_{}",
117                    first.to_lowercase(),
118                    then.to_lowercase()
119                )
120            }
121            crate::model::SequenceRule::After { trigger, then, .. } => {
122                format!(
123                    "after_{}_then_{}",
124                    trigger.to_lowercase(),
125                    then.to_lowercase()
126                )
127            }
128            crate::model::SequenceRule::NeverAfter { trigger, forbidden } => {
129                format!(
130                    "never_after_{}_forbidden_{}",
131                    trigger.to_lowercase(),
132                    forbidden.to_lowercase()
133                )
134            }
135            crate::model::SequenceRule::Sequence { tools, strict } => {
136                let mode = if *strict { "strict" } else { "seq" };
137                format!("{}_{}", mode, tools.join("_").to_lowercase())
138            }
139            crate::model::SequenceRule::Blocklist { pattern } => {
140                format!("blocklist_{}", pattern.to_lowercase())
141            }
142        }
143    }
144
145    /// Analyze coverage from a set of traces
146    pub fn analyze(&self, traces: &[TraceRecord], threshold: f64) -> CoverageReport {
147        let mut tools_seen: HashSet<String> = HashSet::new();
148        let mut rules_triggered: HashSet<String> = HashSet::new();
149        let mut unexpected_tools: HashSet<String> = HashSet::new();
150
151        // Collect all tools and triggered rules from traces
152        for trace in traces {
153            for tool in &trace.tools_called {
154                tools_seen.insert(tool.clone());
155
156                // Check if tool is in policy (including alias resolution)
157                if !self.is_policy_tool(tool) {
158                    unexpected_tools.insert(tool.clone());
159                }
160            }
161
162            for rule_id in &trace.rules_triggered {
163                rules_triggered.insert(rule_id.clone());
164            }
165        }
166
167        // Calculate tool coverage
168        let policy_tool_count = self.policy_tools.len();
169        let seen_policy_tools: HashSet<_> = tools_seen
170            .iter()
171            .filter(|t| self.is_policy_tool(t))
172            .cloned()
173            .collect();
174        let tools_seen_count = seen_policy_tools.len();
175
176        let unseen_tools: Vec<String> = self
177            .policy_tools
178            .iter()
179            .filter(|t| !self.is_tool_seen(t, &tools_seen))
180            .cloned()
181            .collect();
182
183        let tool_coverage_pct = if policy_tool_count > 0 {
184            (tools_seen_count as f64 / policy_tool_count as f64) * 100.0
185        } else {
186            100.0
187        };
188
189        // Calculate rule coverage
190        let total_rules = self.rule_ids.len();
191        let triggered_count = rules_triggered.len();
192
193        let untriggered_rules: Vec<String> = self
194            .rule_ids
195            .iter()
196            .filter(|r| !rules_triggered.contains(*r))
197            .cloned()
198            .collect();
199
200        let rule_coverage_pct = if total_rules > 0 {
201            (triggered_count as f64 / total_rules as f64) * 100.0
202        } else {
203            100.0
204        };
205
206        // Identify high-risk gaps
207        let high_risk_gaps: Vec<HighRiskGap> = self
208            .high_risk_tools
209            .iter()
210            .filter(|t| !t.starts_with('*')) // Skip patterns
211            .filter(|t| !self.is_tool_seen(t, &tools_seen))
212            .map(|t| HighRiskGap {
213                tool: t.clone(),
214                reason: "Tool is in deny list but never appeared in test traces".to_string(),
215                severity: "high".to_string(),
216            })
217            .collect();
218
219        // Overall coverage (average of tool and rule coverage)
220        let overall_coverage_pct = (tool_coverage_pct + rule_coverage_pct) / 2.0;
221        let meets_threshold = overall_coverage_pct >= threshold;
222
223        CoverageReport {
224            tool_coverage: ToolCoverage {
225                total_tools_in_policy: policy_tool_count,
226                tools_seen_in_traces: tools_seen_count,
227                coverage_pct: tool_coverage_pct,
228                unseen_tools,
229                unexpected_tools: unexpected_tools.into_iter().collect(),
230            },
231            rule_coverage: RuleCoverage {
232                total_rules,
233                rules_triggered: triggered_count,
234                coverage_pct: rule_coverage_pct,
235                untriggered_rules,
236            },
237            high_risk_gaps,
238            policy_violations: Vec::new(),
239            policy_warnings: Vec::new(),
240            overall_coverage_pct,
241            meets_threshold,
242            threshold,
243        }
244    }
245
246    /// Check if a tool is in the policy (including alias resolution)
247    fn is_policy_tool(&self, tool: &str) -> bool {
248        if self.policy_tools.contains(tool) {
249            return true;
250        }
251
252        // Check if tool is a member of any alias
253        for members in self.aliases.values() {
254            if members.contains(&tool.to_string()) {
255                return true;
256            }
257        }
258
259        false
260    }
261
262    /// Check if a tool (or any of its alias members) was seen
263    fn is_tool_seen(&self, tool: &str, seen: &HashSet<String>) -> bool {
264        if seen.contains(tool) {
265            return true;
266        }
267
268        // Check if this tool is an alias and any member was seen
269        if let Some(members) = self.aliases.get(tool) {
270            return members.iter().any(|m| seen.contains(m));
271        }
272
273        // Check if tool is a member of an alias that was seen
274        for (alias, members) in &self.aliases {
275            if members.contains(&tool.to_string()) && seen.contains(alias) {
276                return true;
277            }
278        }
279
280        false
281    }
282}