assay_core/coverage_next/
analyzer.rs1use super::{CoverageReport, HighRiskGap, RuleCoverage, ToolCoverage, TraceRecord};
2use std::collections::{HashMap, HashSet};
3
4pub struct CoverageAnalyzer {
6 policy_tools: HashSet<String>,
8
9 high_risk_tools: HashSet<String>,
11
12 rule_ids: Vec<String>,
14
15 aliases: HashMap<String, Vec<String>>,
17}
18
19impl CoverageAnalyzer {
20 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 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()); }
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 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()); }
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 high_risk_tools.insert(format!("*{}*", pattern));
82 }
83 }
84 }
85
86 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 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 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 for trace in traces {
153 for tool in &trace.tools_called {
154 tools_seen.insert(tool.clone());
155
156 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 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 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 let high_risk_gaps: Vec<HighRiskGap> = self
208 .high_risk_tools
209 .iter()
210 .filter(|t| !t.starts_with('*')) .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 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 fn is_policy_tool(&self, tool: &str) -> bool {
248 if self.policy_tools.contains(tool) {
249 return true;
250 }
251
252 for members in self.aliases.values() {
254 if members.contains(&tool.to_string()) {
255 return true;
256 }
257 }
258
259 false
260 }
261
262 fn is_tool_seen(&self, tool: &str, seen: &HashSet<String>) -> bool {
264 if seen.contains(tool) {
265 return true;
266 }
267
268 if let Some(members) = self.aliases.get(tool) {
270 return members.iter().any(|m| seen.contains(m));
271 }
272
273 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}