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 composite_flows =
66 crate::adapter::mcp::loader::compute_composite_flows_for_target(target);
67 let input = DetectionInput {
68 target,
69 composite_flows: &composite_flows,
70 };
71 self.run_with_context(&input)
72 }
73
74 pub(crate) fn run_with_context(&self, input: &DetectionInput<'_>) -> Vec<Finding> {
76 let mut findings = self
77 .detectors
78 .iter()
79 .flat_map(|detector| detector.run(input.target))
80 .collect::<Vec<_>>();
81 findings.extend(
82 self.context_detectors
83 .iter()
84 .flat_map(|detector| detector.run(input)),
85 );
86 findings.extend(
87 self.custom_detectors
88 .iter()
89 .flat_map(|detector| detector.run(input.target)),
90 );
91 apply_overlapping_rule_suppression(findings)
92 }
93
94 pub fn list_rules(&self) -> Vec<RuleMetadata> {
97 let mut rules: Vec<RuleMetadata> = self.detectors.iter().map(|d| d.metadata()).collect();
98 rules.extend(self.custom_detectors.iter().map(|d| d.metadata()));
99 rules
100 }
101
102 pub fn list_scanner_rules(&self) -> Vec<RuleMetadata> {
104 let mut rules = self
105 .detectors
106 .iter()
107 .map(|d| d.metadata())
108 .collect::<Vec<_>>();
109 rules.extend(self.context_detectors.iter().map(|d| d.metadata()));
110 rules.extend(self.custom_detectors.iter().map(|d| d.metadata()));
111 rules
112 }
113}
114
115impl Default for RuleEngine {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121const OVERLAPPING_RULE_PAIRS: &[(&str, &str)] = &[
122 ("SHIELD-013", "SHIELD-003"), ("SHIELD-002", "SHIELD-018"), ("SHIELD-004", "SHIELD-015"), ("SHIELD-004", "SHIELD-006"), ("SHIELD-011", "SHIELD-016"), ];
129
130#[derive(Debug, Clone, PartialEq, Eq, Hash)]
131struct SourceLocationKey {
132 file: PathBuf,
133 line: usize,
134 column: usize,
135 end_line: Option<usize>,
136 end_column: Option<usize>,
137}
138
139impl From<&SourceLocation> for SourceLocationKey {
140 fn from(location: &SourceLocation) -> Self {
141 Self {
142 file: location.file.clone(),
143 line: location.line,
144 column: location.column,
145 end_line: location.end_line,
146 end_column: location.end_column,
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Hash)]
152struct SuppressionKey {
153 dominant_rule: &'static str,
154 location: SourceLocationKey,
155}
156
157fn apply_overlapping_rule_suppression(findings: Vec<Finding>) -> Vec<Finding> {
158 let suppressors: HashSet<SuppressionKey> = findings
161 .iter()
162 .filter_map(|dominant| {
163 let dominant_rule = dominant_rule_for_dominant(dominant.rule_id.as_str())?;
164 let location = dominant.location.as_ref()?;
165 dominant_finding_can_suppress(dominant).then_some(SuppressionKey {
166 dominant_rule,
167 location: location.into(),
168 })
169 })
170 .collect();
171
172 findings
173 .into_iter()
174 .filter(|candidate| {
175 let Some(dominant_rule) = dominant_rule_for_candidate(candidate.rule_id.as_str())
176 else {
177 return true;
178 };
179 let Some(location) = candidate.location.as_ref() else {
180 return true;
181 };
182
183 !suppressors.contains(&SuppressionKey {
184 dominant_rule,
185 location: location.into(),
186 })
187 })
188 .collect()
189}
190
191fn dominant_rule_for_candidate(candidate_rule: &str) -> Option<&'static str> {
192 OVERLAPPING_RULE_PAIRS
193 .iter()
194 .find_map(|(dominant, dominated)| (*dominated == candidate_rule).then_some(*dominant))
195}
196
197fn dominant_rule_for_dominant(dominant_rule: &str) -> Option<&'static str> {
198 OVERLAPPING_RULE_PAIRS
199 .iter()
200 .find_map(|(dominant, _)| (*dominant == dominant_rule).then_some(*dominant))
201}
202
203fn dominant_finding_can_suppress(dominant: &Finding) -> bool {
204 if dominant.rule_id == "SHIELD-013"
210 && dominant.taint_path.is_some()
211 && !dominant
212 .evidence
213 .iter()
214 .any(|evidence| evidence.description.starts_with("Sink: HTTP request to "))
215 {
216 return false;
217 }
218
219 true
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::ir::ArgumentSource;
226 use crate::ir::data_surface::*;
227 use crate::ir::execution_surface::*;
228 use std::path::PathBuf;
229
230 fn loc() -> SourceLocation {
231 SourceLocation {
232 file: PathBuf::from("server.py"),
233 line: 10,
234 column: 0,
235 end_line: None,
236 end_column: None,
237 }
238 }
239
240 fn empty_target() -> ScanTarget {
241 ScanTarget {
242 name: "test".into(),
243 framework: crate::ir::Framework::Mcp,
244 root_path: PathBuf::from("."),
245 tools: vec![],
246 execution: ExecutionSurface::default(),
247 data: DataSurface::default(),
248 dependencies: Default::default(),
249 provenance: Default::default(),
250 source_files: vec![],
251 }
252 }
253
254 fn simple_finding(rule_id: &str, location: Option<SourceLocation>) -> Finding {
255 Finding {
256 rule_id: rule_id.to_string(),
257 rule_name: rule_id.to_string(),
258 severity: Severity::Critical,
259 confidence: Confidence::High,
260 attack_category: AttackCategory::ArbitraryFileAccess,
261 message: "test".into(),
262 location,
263 evidence: vec![],
264 taint_path: None,
265 remediation: None,
266 cwe_id: None,
267 }
268 }
269
270 #[test]
271 fn all_builtin_rules_have_owasp_mcp_mapping() {
272 let engine = RuleEngine::new();
273 let rules = engine.list_rules();
274 assert!(!rules.is_empty());
275 for rule in &rules {
276 assert!(
277 rule.owasp_mcp.is_some(),
278 "rule {} is missing an OWASP MCP Top 10 mapping",
279 rule.id
280 );
281 }
282 }
283
284 #[test]
285 fn suppresses_overlapping_014_pairs_in_engine_output() {
286 let target = {
287 let mut target = empty_target();
288 let overlap_loc = loc();
289
290 target.data.taint_paths.push(TaintPath {
291 source: TaintSource {
292 source_type: TaintSourceType::ToolArgument,
293 description: "url".into(),
294 location: overlap_loc.clone(),
295 },
296 sink: TaintSink {
297 sink_type: TaintSinkType::HttpRequest,
298 description: "requests.get".into(),
299 location: overlap_loc.clone(),
300 },
301 through: vec![],
302 confidence: 0.9,
303 });
304 target.execution.network_operations.push(NetworkOperation {
305 function: "requests.get".into(),
306 url_arg: ArgumentSource::Literal("http://169.254.169.254/latest/meta-data/".into()),
307 method: Some("GET".into()),
308 sends_data: false,
309 location: overlap_loc.clone(),
310 });
311
312 target.execution.file_operations.push(FileOperation {
313 operation: FileOpType::Read,
314 path_arg: ArgumentSource::Parameter {
315 name: "path".into(),
316 },
317 location: overlap_loc.clone(),
318 });
319
320 target
321 };
322
323 let findings = RuleEngine::new().run(&target);
324 let has_metadata_ssrf = findings.iter().any(|f| f.rule_id == "SHIELD-013");
325 let has_ssrf = findings.iter().any(|f| f.rule_id == "SHIELD-003");
326 assert!(has_metadata_ssrf, "should keep SHIELD-013");
327 assert!(
328 !has_ssrf,
329 "SHIELD-003 should be suppressed when SHIELD-013 is present at same location"
330 );
331 assert!(has_metadata_ssrf || has_ssrf);
332 }
333
334 #[test]
335 fn suppresses_overlapping_findings_for_arbitrary_file_and_overbroad_fs() {
336 let target = {
337 let mut target = empty_target();
338 target.execution.file_operations.push(FileOperation {
339 operation: FileOpType::Write,
340 path_arg: ArgumentSource::Parameter {
341 name: "file_path".into(),
342 },
343 location: loc(),
344 });
345 target
346 };
347
348 let findings = RuleEngine::new().run(&target);
349 let has_arf = findings.iter().any(|f| f.rule_id == "SHIELD-004");
350 let has_overbroad = findings.iter().any(|f| f.rule_id == "SHIELD-015");
351 assert!(has_arf);
352 assert!(!has_overbroad);
353 }
354
355 #[test]
356 fn suppresses_overlapping_pairs_by_rule_with_same_location() {
357 let overlap_loc = loc();
358 let findings = vec![
359 simple_finding("SHIELD-016", Some(overlap_loc.clone())),
360 simple_finding("SHIELD-011", Some(overlap_loc.clone())),
361 simple_finding("SHIELD-018", Some(overlap_loc.clone())),
362 simple_finding("SHIELD-002", Some(overlap_loc.clone())),
363 simple_finding("SHIELD-003", Some(overlap_loc.clone())),
364 simple_finding("SHIELD-013", Some(overlap_loc)),
365 ];
366 let filtered = apply_overlapping_rule_suppression(findings);
367 let ids: Vec<_> = filtered.iter().map(|f| f.rule_id.as_str()).collect();
368 assert!(ids.contains(&"SHIELD-013"));
369 assert!(!ids.contains(&"SHIELD-003"));
370 assert!(ids.contains(&"SHIELD-002"));
371 assert!(!ids.contains(&"SHIELD-018"));
372 assert!(ids.contains(&"SHIELD-011"));
373 assert!(!ids.contains(&"SHIELD-016"));
374 }
375
376 #[test]
377 fn suppresses_self_modification_overlap_with_arbitrary_file_access() {
378 let overlap_loc = loc();
381 let findings = vec![
382 simple_finding("SHIELD-004", Some(overlap_loc.clone())),
383 simple_finding("SHIELD-006", Some(overlap_loc)),
384 ];
385 let filtered = apply_overlapping_rule_suppression(findings);
386 let ids: Vec<_> = filtered.iter().map(|f| f.rule_id.as_str()).collect();
387 assert!(ids.contains(&"SHIELD-004"));
388 assert!(!ids.contains(&"SHIELD-006"));
389 }
390
391 #[test]
392 fn engine_suppresses_self_modification_for_parameter_driven_writes() {
393 let mut target = empty_target();
394 target.execution.file_operations.push(FileOperation {
395 operation: FileOpType::Write,
396 path_arg: ArgumentSource::Parameter {
397 name: "file_path".into(),
398 },
399 location: loc(),
400 });
401
402 let findings = RuleEngine::new().run(&target);
403 assert!(findings.iter().any(|f| f.rule_id == "SHIELD-004"));
404 assert!(
405 !findings.iter().any(|f| f.rule_id == "SHIELD-006"),
406 "SHIELD-006 should be suppressed when SHIELD-004 fires at the same location"
407 );
408 }
409
410 #[test]
411 fn keeps_overlapping_rules_for_distinct_spans_on_the_same_line() {
412 let mut first_span = loc();
413 first_span.column = 4;
414 first_span.end_line = Some(first_span.line);
415 first_span.end_column = Some(14);
416
417 let mut second_span = first_span.clone();
418 second_span.column = 16;
419 second_span.end_column = Some(33);
420
421 let findings = vec![
422 simple_finding("SHIELD-004", Some(first_span)),
423 simple_finding("SHIELD-015", Some(second_span)),
424 ];
425
426 let filtered = apply_overlapping_rule_suppression(findings);
427 let ids: Vec<_> = filtered
428 .iter()
429 .map(|finding| finding.rule_id.as_str())
430 .collect();
431
432 assert_eq!(filtered.len(), 2);
433 assert!(ids.contains(&"SHIELD-004"));
434 assert!(ids.contains(&"SHIELD-015"));
435 }
436}