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.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()); }
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 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.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 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 for trace in traces {
162 for tool in &trace.tools_called {
163 tools_seen.insert(tool.clone());
164
165 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 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 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 let high_risk_gaps: Vec<HighRiskGap> = self
217 .high_risk_tools
218 .iter()
219 .filter(|t| !t.starts_with('*')) .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 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 fn is_policy_tool(&self, tool: &str) -> bool {
257 if self.policy_tools.contains(tool) {
258 return true;
259 }
260
261 for members in self.aliases.values() {
263 if members.contains(&tool.to_string()) {
264 return true;
265 }
266 }
267
268 false
269 }
270
271 fn is_tool_seen(&self, tool: &str, seen: &HashSet<String>) -> bool {
273 if seen.contains(tool) {
274 return true;
275 }
276
277 if let Some(members) = self.aliases.get(tool) {
279 return members.iter().any(|m| seen.contains(m));
280 }
281
282 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}