1use std::path::Path;
2
3use glob::Pattern;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6
7use crate::error::{Result, ShieldError};
8use crate::ir::{ScanTarget, SourceLocation};
9use crate::rules::{
10 AttackCategory, Confidence, Detector, Evidence, Finding, OwaspMcp, RuleMetadata, Severity,
11};
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct CustomRuleMatch {
16 pub regex: Option<String>,
18 pub file_glob: Option<String>,
20 pub banned_dependencies: Option<Vec<BannedDepSpec>>,
22 pub tool_name_regex: Option<String>,
24 pub message: Option<String>,
26 pub remediation: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct BannedDepSpec {
33 pub name: String,
34 pub reason: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CustomRuleDef {
40 pub id: String,
41 pub name: String,
42 pub description: String,
43 #[serde(default = "default_severity")]
44 pub severity: Severity,
45 #[serde(default = "default_attack_category")]
46 pub attack_category: AttackCategory,
47 pub cwe_id: Option<String>,
48 pub owasp_mcp: Option<OwaspMcp>,
49 pub r#match: CustomRuleMatch,
50}
51
52fn default_severity() -> Severity {
53 Severity::Medium
54}
55
56fn default_attack_category() -> AttackCategory {
57 AttackCategory::SupplyChain
58}
59
60pub struct CustomRuleDetector {
62 def: CustomRuleDef,
63 compiled_regex: Option<Regex>,
64 compiled_glob: Option<Pattern>,
65 compiled_tool_name_regex: Option<Regex>,
66}
67
68impl CustomRuleDetector {
69 pub fn from_def(def: CustomRuleDef) -> Result<Self> {
70 let compiled_regex = match &def.r#match.regex {
71 Some(pattern) => Some(Regex::new(pattern).map_err(|e| {
72 ShieldError::Config(format!("Invalid regex in custom rule '{}': {}", def.id, e))
73 })?),
74 None => None,
75 };
76
77 let compiled_glob = match &def.r#match.file_glob {
78 Some(glob_pattern) => Some(Pattern::new(glob_pattern).map_err(|e| {
79 ShieldError::Config(format!(
80 "Invalid file_glob in custom rule '{}': {}",
81 def.id, e
82 ))
83 })?),
84 None => None,
85 };
86
87 let compiled_tool_name_regex = match &def.r#match.tool_name_regex {
88 Some(pattern) => Some(Regex::new(pattern).map_err(|e| {
89 ShieldError::Config(format!(
90 "Invalid tool_name_regex in custom rule '{}': {}",
91 def.id, e
92 ))
93 })?),
94 None => None,
95 };
96
97 Ok(Self {
98 def,
99 compiled_regex,
100 compiled_glob,
101 compiled_tool_name_regex,
102 })
103 }
104
105 pub fn def(&self) -> &CustomRuleDef {
106 &self.def
107 }
108}
109
110impl Detector for CustomRuleDetector {
111 fn metadata(&self) -> RuleMetadata {
112 RuleMetadata {
113 id: self.def.id.clone(),
114 name: self.def.name.clone(),
115 description: self.def.description.clone(),
116 default_severity: self.def.severity,
117 attack_category: self.def.attack_category,
118 cwe_id: self.def.cwe_id.clone(),
119 owasp_mcp: self.def.owasp_mcp,
120 }
121 }
122
123 fn run(&self, target: &ScanTarget) -> Vec<Finding> {
124 let mut findings = Vec::new();
125
126 if let Some(ref re) = self.compiled_regex {
128 for sf in &target.source_files {
129 let file_name = sf.path.file_name().and_then(|f| f.to_str()).unwrap_or("");
130
131 if let Some(ref glob) = self.compiled_glob {
132 let rel_path = sf.path.strip_prefix(&target.root_path).unwrap_or(&sf.path);
133 if !glob.matches(file_name) && !glob.matches_path(rel_path) {
134 continue;
135 }
136 }
137
138 for (line_idx, line) in sf.content.lines().enumerate() {
139 if re.is_match(line) {
140 let msg = self.def.r#match.message.clone().unwrap_or_else(|| {
141 format!(
142 "Line matches custom rule '{}' pattern: {}",
143 self.def.id,
144 line.trim()
145 )
146 });
147
148 let loc = SourceLocation {
149 file: sf.path.clone(),
150 line: line_idx + 1,
151 column: 1,
152 end_line: Some(line_idx + 1),
153 end_column: Some(line.len()),
154 };
155
156 findings.push(Finding {
157 rule_id: self.def.id.clone(),
158 rule_name: self.def.name.clone(),
159 severity: self.def.severity,
160 confidence: Confidence::High,
161 attack_category: self.def.attack_category,
162 message: msg,
163 location: Some(loc.clone()),
164 evidence: vec![Evidence {
165 description: format!(
166 "Matched custom pattern '{}'",
167 self.def.r#match.regex.as_deref().unwrap_or_default()
168 ),
169 location: Some(loc),
170 snippet: Some(line.trim().to_string()),
171 }],
172 taint_path: None,
173 remediation: self.def.r#match.remediation.clone(),
174 cwe_id: self.def.cwe_id.clone(),
175 });
176 }
177 }
178 }
179 }
180
181 if let Some(ref banned_deps) = self.def.r#match.banned_dependencies {
183 for banned in banned_deps {
184 for dep in &target.dependencies.dependencies {
185 if dep.name.eq_ignore_ascii_case(&banned.name) {
186 let reason_str = banned
187 .reason
188 .as_ref()
189 .map(|r| format!(" ({})", r))
190 .unwrap_or_default();
191
192 let msg =
193 format!("Banned dependency '{}' detected{}", dep.name, reason_str);
194
195 findings.push(Finding {
196 rule_id: self.def.id.clone(),
197 rule_name: self.def.name.clone(),
198 severity: self.def.severity,
199 confidence: Confidence::High,
200 attack_category: self.def.attack_category,
201 message: msg.clone(),
202 location: dep.location.clone(),
203 evidence: vec![Evidence {
204 description: format!("Banned dependency '{}'", dep.name),
205 location: dep.location.clone(),
206 snippet: dep.version_constraint.clone(),
207 }],
208 taint_path: None,
209 remediation: self.def.r#match.remediation.clone(),
210 cwe_id: self.def.cwe_id.clone(),
211 });
212 }
213 }
214 }
215 }
216
217 if let Some(ref tool_re) = self.compiled_tool_name_regex {
219 for tool in &target.tools {
220 if tool_re.is_match(&tool.name) {
221 let msg = self.def.r#match.message.clone().unwrap_or_else(|| {
222 format!("Tool '{}' matches prohibited tool name pattern", tool.name)
223 });
224
225 findings.push(Finding {
226 rule_id: self.def.id.clone(),
227 rule_name: self.def.name.clone(),
228 severity: self.def.severity,
229 confidence: Confidence::High,
230 attack_category: self.def.attack_category,
231 message: msg,
232 location: tool.defined_at.clone(),
233 evidence: vec![Evidence {
234 description: format!("Prohibited tool declaration '{}'", tool.name),
235 location: tool.defined_at.clone(),
236 snippet: tool.description.clone(),
237 }],
238 taint_path: None,
239 remediation: self.def.r#match.remediation.clone(),
240 cwe_id: self.def.cwe_id.clone(),
241 });
242 }
243 }
244 }
245
246 findings
247 }
248}
249
250pub fn load_custom_rule_file(path: &Path) -> Result<CustomRuleDetector> {
252 let content = std::fs::read_to_string(path).map_err(|e| {
253 ShieldError::Config(format!(
254 "Failed to read custom rule file '{}': {}",
255 path.display(),
256 e
257 ))
258 })?;
259
260 let ext = path
261 .extension()
262 .and_then(|e| e.to_str())
263 .unwrap_or("")
264 .to_ascii_lowercase();
265
266 let def: CustomRuleDef = if ext == "json" {
267 serde_json::from_str(&content).map_err(|e| {
268 ShieldError::Config(format!(
269 "Failed to parse custom rule JSON '{}': {}",
270 path.display(),
271 e
272 ))
273 })?
274 } else {
275 serde_yaml::from_str(&content).map_err(|e| {
276 ShieldError::Config(format!(
277 "Failed to parse custom rule YAML '{}': {}",
278 path.display(),
279 e
280 ))
281 })?
282 };
283
284 CustomRuleDetector::from_def(def)
285}
286
287pub fn load_custom_rules_from_dir(dir: &Path) -> Result<Vec<CustomRuleDetector>> {
289 if !dir.exists() || !dir.is_dir() {
290 return Ok(Vec::new());
291 }
292
293 let mut detectors = Vec::new();
294 let entries = std::fs::read_dir(dir).map_err(|e| {
295 ShieldError::Config(format!(
296 "Failed to read custom rules dir '{}': {}",
297 dir.display(),
298 e
299 ))
300 })?;
301
302 for entry in entries.flatten() {
303 let path = entry.path();
304 if path.is_file() {
305 let ext = path
306 .extension()
307 .and_then(|e| e.to_str())
308 .unwrap_or("")
309 .to_ascii_lowercase();
310 if ext == "yaml" || ext == "yml" || ext == "json" {
311 if let Ok(detector) = load_custom_rule_file(&path) {
312 detectors.push(detector);
313 }
314 }
315 }
316 }
317
318 Ok(detectors)
319}
320
321#[cfg(test)]
322mod tests {
323 use std::path::PathBuf;
324
325 use super::*;
326 use crate::ir::dependency_surface::{Dependency, DependencySurface};
327 use crate::ir::{Language, SourceFile};
328
329 #[test]
330 fn test_parse_custom_yaml_rule() {
331 let yaml = r#"
332id: "ORG-001"
333name: "Banned Internal Token Prefix"
334description: "Detects internal secret tokens"
335severity: "high"
336attack_category: "credential_exfiltration"
337cwe_id: "CWE-798"
338match:
339 regex: "CORP_KEY_[A-Z0-9]{8,}"
340 file_glob: "*.py"
341 banned_dependencies:
342 - name: "insecure-pkg"
343 reason: "deprecated"
344 tool_name_regex: "^admin_.*"
345"#;
346 let def: CustomRuleDef = serde_yaml::from_str(yaml).unwrap();
347 assert_eq!(def.id, "ORG-001");
348 assert_eq!(def.severity, Severity::High);
349 assert_eq!(def.attack_category, AttackCategory::CredentialExfiltration);
350 assert_eq!(def.cwe_id.as_deref(), Some("CWE-798"));
351
352 let detector = CustomRuleDetector::from_def(def).unwrap();
353 assert_eq!(detector.metadata().id, "ORG-001");
354 }
355
356 #[test]
357 fn test_custom_rule_detects_regex_and_banned_dep() {
358 let yaml = r#"
359id: "CUSTOM-TEST"
360name: "Custom Test Rule"
361description: "Detects custom pattern"
362severity: "critical"
363attack_category: "command_injection"
364match:
365 regex: "danger_zone\\(\\)"
366 banned_dependencies:
367 - name: "evil-dep"
368"#;
369 let def: CustomRuleDef = serde_yaml::from_str(yaml).unwrap();
370 let detector = CustomRuleDetector::from_def(def).unwrap();
371
372 let target = ScanTarget {
373 name: "test-target".into(),
374 framework: crate::ir::Framework::Mcp,
375 root_path: PathBuf::from("/test"),
376 tools: vec![],
377 execution: Default::default(),
378 data: Default::default(),
379 dependencies: DependencySurface {
380 dependencies: vec![Dependency {
381 name: "evil-dep".into(),
382 version_constraint: Some("1.0.0".into()),
383 location: None,
384 is_dev: false,
385 locked_version: None,
386 locked_hash: None,
387 registry: "pypi".into(),
388 }],
389 lockfile: None,
390 issues: vec![],
391 },
392 provenance: Default::default(),
393 source_files: vec![SourceFile {
394 path: PathBuf::from("/test/main.py"),
395 language: Language::Python,
396 size_bytes: 50,
397 content_hash: "abc".into(),
398 content: "import sys\ndanger_zone()\n".into(),
399 }],
400 };
401
402 let findings = detector.run(&target);
403 assert_eq!(findings.len(), 2);
404 let rule_ids: Vec<&str> = findings.iter().map(|f| f.rule_id.as_str()).collect();
405 assert_eq!(rule_ids, vec!["CUSTOM-TEST", "CUSTOM-TEST"]);
406 }
407}