1pub mod builtin;
2pub mod custom;
3pub mod finding;
4pub mod policy;
5
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9use crate::analysis::DetectionInput;
10use crate::ir::ScanTarget;
11use crate::ir::SourceLocation;
12
13pub use custom::{CustomRuleDef, CustomRuleDetector, load_custom_rules_from_dir};
14pub use finding::{
15 AttackCategory, Confidence, Evidence, Finding, OwaspMcp, RuleMetadata, Severity,
16};
17
18pub trait Detector: Send + Sync {
20 fn metadata(&self) -> RuleMetadata;
22
23 fn run(&self, target: &ScanTarget) -> Vec<Finding>;
25}
26
27pub(crate) trait ContextDetector: Send + Sync {
28 fn metadata(&self) -> RuleMetadata;
29
30 fn run(&self, input: &DetectionInput<'_>) -> Vec<Finding>;
31}
32
33pub struct RuleEngine {
35 detectors: Vec<Box<dyn Detector>>,
36 context_detectors: Vec<Box<dyn ContextDetector>>,
37 custom_detectors: Vec<CustomRuleDetector>,
38}
39
40impl RuleEngine {
41 pub fn new() -> Self {
43 Self {
44 detectors: builtin::all_detectors(),
45 context_detectors: builtin::all_context_detectors(),
46 custom_detectors: Vec::new(),
47 }
48 }
49
50 pub fn with_custom_rules(mut self, custom: Vec<CustomRuleDetector>) -> Self {
52 self.custom_detectors.extend(custom);
53 self
54 }
55
56 pub fn load_custom_rules_from(&mut self, dir: &Path) -> crate::error::Result<()> {
58 let loaded = custom::load_custom_rules_from_dir(dir)?;
59 self.custom_detectors.extend(loaded);
60 Ok(())
61 }
62
63 pub fn run(&self, target: &ScanTarget) -> Vec<Finding> {
65 let mut findings: Vec<Finding> =
66 self.detectors.iter().flat_map(|d| d.run(target)).collect();
67 findings.extend(self.custom_detectors.iter().flat_map(|d| d.run(target)));
68 apply_overlapping_rule_suppression(findings)
69 }
70
71 pub(crate) fn run_with_context(&self, input: &DetectionInput<'_>) -> Vec<Finding> {
73 let mut findings = self
74 .detectors
75 .iter()
76 .flat_map(|detector| detector.run(input.target))
77 .collect::<Vec<_>>();
78 findings.extend(
79 self.context_detectors
80 .iter()
81 .flat_map(|detector| detector.run(input)),
82 );
83 findings.extend(
84 self.custom_detectors
85 .iter()
86 .flat_map(|detector| detector.run(input.target)),
87 );
88 apply_overlapping_rule_suppression(findings)
89 }
90
91 pub fn list_rules(&self) -> Vec<RuleMetadata> {
93 let mut rules: Vec<RuleMetadata> = self.detectors.iter().map(|d| d.metadata()).collect();
94 rules.extend(self.custom_detectors.iter().map(|d| d.metadata()));
95 rules
96 }
97
98 pub fn list_scanner_rules(&self) -> Vec<RuleMetadata> {
100 let mut rules = self
101 .detectors
102 .iter()
103 .map(|d| d.metadata())
104 .collect::<Vec<_>>();
105 rules.extend(self.context_detectors.iter().map(|d| d.metadata()));
106 rules.extend(self.custom_detectors.iter().map(|d| d.metadata()));
107 rules
108 }
109}
110
111impl Default for RuleEngine {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117const OVERLAPPING_RULE_PAIRS: &[(&str, &str)] = &[
118 ("SHIELD-013", "SHIELD-003"), ("SHIELD-002", "SHIELD-018"), ("SHIELD-004", "SHIELD-015"), ("SHIELD-011", "SHIELD-016"), ];
124
125#[derive(Debug, Clone, PartialEq, Eq, Hash)]
126struct SourceLocationKey {
127 file: PathBuf,
128 line: usize,
129 column: usize,
130 end_line: Option<usize>,
131 end_column: Option<usize>,
132}
133
134impl From<&SourceLocation> for SourceLocationKey {
135 fn from(location: &SourceLocation) -> Self {
136 Self {
137 file: location.file.clone(),
138 line: location.line,
139 column: location.column,
140 end_line: location.end_line,
141 end_column: location.end_column,
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Hash)]
147struct SuppressionKey {
148 dominant_rule: &'static str,
149 location: SourceLocationKey,
150}
151
152fn apply_overlapping_rule_suppression(findings: Vec<Finding>) -> Vec<Finding> {
153 let suppressors: HashSet<SuppressionKey> = findings
156 .iter()
157 .filter_map(|dominant| {
158 let dominant_rule = dominant_rule_for_dominant(dominant.rule_id.as_str())?;
159 let location = dominant.location.as_ref()?;
160 dominant_finding_can_suppress(dominant).then_some(SuppressionKey {
161 dominant_rule,
162 location: location.into(),
163 })
164 })
165 .collect();
166
167 findings
168 .into_iter()
169 .filter(|candidate| {
170 let Some(dominant_rule) = dominant_rule_for_candidate(candidate.rule_id.as_str())
171 else {
172 return true;
173 };
174 let Some(location) = candidate.location.as_ref() else {
175 return true;
176 };
177
178 !suppressors.contains(&SuppressionKey {
179 dominant_rule,
180 location: location.into(),
181 })
182 })
183 .collect()
184}
185
186fn dominant_rule_for_candidate(candidate_rule: &str) -> Option<&'static str> {
187 OVERLAPPING_RULE_PAIRS
188 .iter()
189 .find_map(|(dominant, dominated)| (*dominated == candidate_rule).then_some(*dominant))
190}
191
192fn dominant_rule_for_dominant(dominant_rule: &str) -> Option<&'static str> {
193 OVERLAPPING_RULE_PAIRS
194 .iter()
195 .find_map(|(dominant, _)| (*dominant == dominant_rule).then_some(*dominant))
196}
197
198fn dominant_finding_can_suppress(dominant: &Finding) -> bool {
199 if dominant.rule_id == "SHIELD-013"
205 && dominant.taint_path.is_some()
206 && !dominant
207 .evidence
208 .iter()
209 .any(|evidence| evidence.description.starts_with("Sink: HTTP request to "))
210 {
211 return false;
212 }
213
214 true
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::ir::ArgumentSource;
221 use crate::ir::data_surface::*;
222 use crate::ir::execution_surface::*;
223 use std::path::PathBuf;
224
225 fn loc() -> SourceLocation {
226 SourceLocation {
227 file: PathBuf::from("server.py"),
228 line: 10,
229 column: 0,
230 end_line: None,
231 end_column: None,
232 }
233 }
234
235 fn empty_target() -> ScanTarget {
236 ScanTarget {
237 name: "test".into(),
238 framework: crate::ir::Framework::Mcp,
239 root_path: PathBuf::from("."),
240 tools: vec![],
241 execution: ExecutionSurface::default(),
242 data: DataSurface::default(),
243 dependencies: Default::default(),
244 provenance: Default::default(),
245 source_files: vec![],
246 }
247 }
248
249 fn simple_finding(rule_id: &str, location: Option<SourceLocation>) -> Finding {
250 Finding {
251 rule_id: rule_id.to_string(),
252 rule_name: rule_id.to_string(),
253 severity: Severity::Critical,
254 confidence: Confidence::High,
255 attack_category: AttackCategory::ArbitraryFileAccess,
256 message: "test".into(),
257 location,
258 evidence: vec![],
259 taint_path: None,
260 remediation: None,
261 cwe_id: None,
262 }
263 }
264
265 #[test]
266 fn all_builtin_rules_have_owasp_mcp_mapping() {
267 let engine = RuleEngine::new();
268 let rules = engine.list_rules();
269 assert!(!rules.is_empty());
270 for rule in &rules {
271 assert!(
272 rule.owasp_mcp.is_some(),
273 "rule {} is missing an OWASP MCP Top 10 mapping",
274 rule.id
275 );
276 }
277 }
278
279 #[test]
280 fn suppresses_overlapping_014_pairs_in_engine_output() {
281 let target = {
282 let mut target = empty_target();
283 let overlap_loc = loc();
284
285 target.data.taint_paths.push(TaintPath {
286 source: TaintSource {
287 source_type: TaintSourceType::ToolArgument,
288 description: "url".into(),
289 location: overlap_loc.clone(),
290 },
291 sink: TaintSink {
292 sink_type: TaintSinkType::HttpRequest,
293 description: "requests.get".into(),
294 location: overlap_loc.clone(),
295 },
296 through: vec![],
297 confidence: 0.9,
298 });
299 target.execution.network_operations.push(NetworkOperation {
300 function: "requests.get".into(),
301 url_arg: ArgumentSource::Literal("http://169.254.169.254/latest/meta-data/".into()),
302 method: Some("GET".into()),
303 sends_data: false,
304 location: overlap_loc.clone(),
305 });
306
307 target.execution.file_operations.push(FileOperation {
308 operation: FileOpType::Read,
309 path_arg: ArgumentSource::Parameter {
310 name: "path".into(),
311 },
312 location: overlap_loc.clone(),
313 });
314
315 target
316 };
317
318 let findings = RuleEngine::new().run(&target);
319 let has_metadata_ssrf = findings.iter().any(|f| f.rule_id == "SHIELD-013");
320 let has_ssrf = findings.iter().any(|f| f.rule_id == "SHIELD-003");
321 assert!(has_metadata_ssrf, "should keep SHIELD-013");
322 assert!(
323 !has_ssrf,
324 "SHIELD-003 should be suppressed when SHIELD-013 is present at same location"
325 );
326 assert!(has_metadata_ssrf || has_ssrf);
327 }
328
329 #[test]
330 fn suppresses_overlapping_findings_for_arbitrary_file_and_overbroad_fs() {
331 let target = {
332 let mut target = empty_target();
333 target.execution.file_operations.push(FileOperation {
334 operation: FileOpType::Write,
335 path_arg: ArgumentSource::Parameter {
336 name: "file_path".into(),
337 },
338 location: loc(),
339 });
340 target
341 };
342
343 let findings = RuleEngine::new().run(&target);
344 let has_arf = findings.iter().any(|f| f.rule_id == "SHIELD-004");
345 let has_overbroad = findings.iter().any(|f| f.rule_id == "SHIELD-015");
346 assert!(has_arf);
347 assert!(!has_overbroad);
348 }
349
350 #[test]
351 fn suppresses_overlapping_pairs_by_rule_with_same_location() {
352 let overlap_loc = loc();
353 let findings = vec![
354 simple_finding("SHIELD-016", Some(overlap_loc.clone())),
355 simple_finding("SHIELD-011", Some(overlap_loc.clone())),
356 simple_finding("SHIELD-018", Some(overlap_loc.clone())),
357 simple_finding("SHIELD-002", Some(overlap_loc.clone())),
358 simple_finding("SHIELD-003", Some(overlap_loc.clone())),
359 simple_finding("SHIELD-013", Some(overlap_loc)),
360 ];
361 let filtered = apply_overlapping_rule_suppression(findings);
362 let ids: Vec<_> = filtered.iter().map(|f| f.rule_id.as_str()).collect();
363 assert!(ids.contains(&"SHIELD-013"));
364 assert!(!ids.contains(&"SHIELD-003"));
365 assert!(ids.contains(&"SHIELD-002"));
366 assert!(!ids.contains(&"SHIELD-018"));
367 assert!(ids.contains(&"SHIELD-011"));
368 assert!(!ids.contains(&"SHIELD-016"));
369 }
370
371 #[test]
372 fn keeps_overlapping_rules_for_distinct_spans_on_the_same_line() {
373 let mut first_span = loc();
374 first_span.column = 4;
375 first_span.end_line = Some(first_span.line);
376 first_span.end_column = Some(14);
377
378 let mut second_span = first_span.clone();
379 second_span.column = 16;
380 second_span.end_column = Some(33);
381
382 let findings = vec![
383 simple_finding("SHIELD-004", Some(first_span)),
384 simple_finding("SHIELD-015", Some(second_span)),
385 ];
386
387 let filtered = apply_overlapping_rule_suppression(findings);
388 let ids: Vec<_> = filtered
389 .iter()
390 .map(|finding| finding.rule_id.as_str())
391 .collect();
392
393 assert_eq!(filtered.len(), 2);
394 assert!(ids.contains(&"SHIELD-004"));
395 assert!(ids.contains(&"SHIELD-015"));
396 }
397}