Skip to main content

agentshield/rules/
custom.rs

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/// Match specification inside a custom rule definition.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct CustomRuleMatch {
16    /// Regular expression pattern to search for in source files.
17    pub regex: Option<String>,
18    /// File glob filter (e.g. "*.py", "*.{ts,js}"). If omitted, matches all source files.
19    pub file_glob: Option<String>,
20    /// List of banned dependencies.
21    pub banned_dependencies: Option<Vec<BannedDepSpec>>,
22    /// Regex pattern matching prohibited tool names.
23    pub tool_name_regex: Option<String>,
24    /// Custom finding message override.
25    pub message: Option<String>,
26    /// Remediation advice.
27    pub remediation: Option<String>,
28}
29
30/// Banned dependency entry in custom rule definition.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct BannedDepSpec {
33    pub name: String,
34    pub reason: Option<String>,
35}
36
37/// A declarative custom rule definition (parsed from YAML or JSON).
38#[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
60/// Runtime detector instantiated from a `CustomRuleDef`.
61pub 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        // 1. Match source files with regex & file_glob
127        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        // 2. Match banned dependencies
182        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        // 3. Match prohibited tool names
218        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
250/// Load a custom rule from a YAML or JSON file.
251pub 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
287/// Load all custom rules from a directory (.yaml, .yml, .json).
288pub 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}