1use crate::rules::types::{Category, Confidence, Finding, Location, Severity};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Debug, Clone)]
8pub struct DynamicRule {
9 pub id: String,
10 pub name: String,
11 pub description: String,
12 pub severity: Severity,
13 pub category: Category,
14 pub confidence: Confidence,
15 pub patterns: Vec<Regex>,
16 pub exclusions: Vec<Regex>,
17 pub message: String,
18 pub recommendation: String,
19 pub fix_hint: Option<String>,
20 pub cwe_ids: Vec<String>,
21}
22
23impl DynamicRule {
24 pub fn matches(&self, line: &str) -> bool {
26 let pattern_match = self.patterns.iter().any(|p| p.is_match(line));
27 let excluded = self.exclusions.iter().any(|e| e.is_match(line));
28 pattern_match && !excluded
29 }
30
31 pub fn create_finding(&self, location: Location, code: String) -> Finding {
33 Finding {
34 id: self.id.clone(),
35 severity: self.severity,
36 category: self.category,
37 confidence: self.confidence,
38 name: self.name.clone(),
39 location,
40 code,
41 message: self.message.clone(),
42 recommendation: self.recommendation.clone(),
43 fix_hint: self.fix_hint.clone(),
44 cwe_ids: self.cwe_ids.clone(),
45 rule_severity: None,
46 client: None,
47 context: None,
48 }
49 }
50}
51
52#[derive(Debug, Serialize, Deserialize)]
54pub struct CustomRulesConfig {
55 pub version: String,
56 pub rules: Vec<YamlRule>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct YamlRule {
61 pub id: String,
62 pub name: String,
63 #[serde(default)]
64 pub description: String,
65 pub severity: String,
66 pub category: String,
67 #[serde(default = "default_confidence")]
68 pub confidence: String,
69 pub patterns: Vec<String>,
70 #[serde(default)]
71 pub exclusions: Vec<String>,
72 pub message: String,
73 #[serde(default)]
74 pub recommendation: String,
75 #[serde(default)]
76 pub fix_hint: Option<String>,
77 #[serde(default)]
78 pub cwe: Vec<String>,
79}
80
81fn default_confidence() -> String {
82 "firm".to_string()
83}
84
85#[derive(Debug)]
87pub enum CustomRuleError {
88 IoError(std::io::Error),
89 ParseError(serde_yaml::Error),
90 InvalidPattern {
91 rule_id: String,
92 pattern: String,
93 error: regex::Error,
94 },
95 InvalidSeverity {
96 rule_id: String,
97 value: String,
98 },
99 InvalidCategory {
100 rule_id: String,
101 value: String,
102 },
103 InvalidConfidence {
104 rule_id: String,
105 value: String,
106 },
107}
108
109impl std::fmt::Display for CustomRuleError {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 Self::IoError(e) => write!(f, "Failed to read custom rules file: {}", e),
113 Self::ParseError(e) => write!(f, "Failed to parse custom rules YAML: {}", e),
114 Self::InvalidPattern {
115 rule_id,
116 pattern,
117 error,
118 } => {
119 write!(
120 f,
121 "Invalid regex pattern '{}' in rule {}: {}",
122 pattern, rule_id, error
123 )
124 }
125 Self::InvalidSeverity { rule_id, value } => {
126 write!(
127 f,
128 "Invalid severity '{}' in rule {}. Expected: critical, high, medium, low",
129 value, rule_id
130 )
131 }
132 Self::InvalidCategory { rule_id, value } => {
133 write!(
134 f,
135 "Invalid category '{}' in rule {}. Expected: exfiltration, privilege-escalation, persistence, prompt-injection, overpermission, obfuscation, supply-chain, secret-leak",
136 value, rule_id
137 )
138 }
139 Self::InvalidConfidence { rule_id, value } => {
140 write!(
141 f,
142 "Invalid confidence '{}' in rule {}. Expected: certain, firm, tentative",
143 value, rule_id
144 )
145 }
146 }
147 }
148}
149
150impl std::error::Error for CustomRuleError {}
151
152pub struct CustomRuleLoader;
154
155impl CustomRuleLoader {
156 pub fn load_from_file(path: &Path) -> Result<Vec<DynamicRule>, CustomRuleError> {
158 let content = std::fs::read_to_string(path).map_err(CustomRuleError::IoError)?;
159 Self::load_from_string(&content)
160 }
161
162 pub fn load_from_string(content: &str) -> Result<Vec<DynamicRule>, CustomRuleError> {
164 let config: CustomRulesConfig =
165 serde_yaml::from_str(content).map_err(CustomRuleError::ParseError)?;
166
167 let mut rules = Vec::new();
168 for yaml_rule in config.rules {
169 let rule = Self::convert_yaml_rule(yaml_rule)?;
170 rules.push(rule);
171 }
172 Ok(rules)
173 }
174
175 pub fn convert_yaml_rules(rules: Vec<YamlRule>) -> Result<Vec<DynamicRule>, CustomRuleError> {
177 rules.into_iter().map(Self::convert_yaml_rule).collect()
178 }
179
180 pub fn convert_yaml_rule(yaml: YamlRule) -> Result<DynamicRule, CustomRuleError> {
182 let severity = Self::parse_severity(&yaml.id, &yaml.severity)?;
183 let category = Self::parse_category(&yaml.id, &yaml.category)?;
184 let confidence = Self::parse_confidence(&yaml.id, &yaml.confidence)?;
185
186 let patterns = yaml
187 .patterns
188 .iter()
189 .map(|p| {
190 Regex::new(p).map_err(|e| CustomRuleError::InvalidPattern {
191 rule_id: yaml.id.clone(),
192 pattern: p.clone(),
193 error: e,
194 })
195 })
196 .collect::<Result<Vec<_>, _>>()?;
197
198 let exclusions = yaml
199 .exclusions
200 .iter()
201 .map(|p| {
202 Regex::new(p).map_err(|e| CustomRuleError::InvalidPattern {
203 rule_id: yaml.id.clone(),
204 pattern: p.clone(),
205 error: e,
206 })
207 })
208 .collect::<Result<Vec<_>, _>>()?;
209
210 Ok(DynamicRule {
211 id: yaml.id,
212 name: yaml.name,
213 description: yaml.description,
214 severity,
215 category,
216 confidence,
217 patterns,
218 exclusions,
219 message: yaml.message,
220 recommendation: yaml.recommendation,
221 fix_hint: yaml.fix_hint,
222 cwe_ids: yaml.cwe,
223 })
224 }
225
226 fn parse_severity(rule_id: &str, value: &str) -> Result<Severity, CustomRuleError> {
227 match value.to_lowercase().as_str() {
228 "critical" => Ok(Severity::Critical),
229 "high" => Ok(Severity::High),
230 "medium" => Ok(Severity::Medium),
231 "low" => Ok(Severity::Low),
232 _ => Err(CustomRuleError::InvalidSeverity {
233 rule_id: rule_id.to_string(),
234 value: value.to_string(),
235 }),
236 }
237 }
238
239 fn parse_category(rule_id: &str, value: &str) -> Result<Category, CustomRuleError> {
240 match value.to_lowercase().replace('_', "-").as_str() {
241 "exfiltration" | "data-exfiltration" => Ok(Category::Exfiltration),
242 "privilege-escalation" | "privilege" => Ok(Category::PrivilegeEscalation),
243 "persistence" => Ok(Category::Persistence),
244 "prompt-injection" | "injection" => Ok(Category::PromptInjection),
245 "overpermission" | "permission" => Ok(Category::Overpermission),
246 "obfuscation" => Ok(Category::Obfuscation),
247 "supply-chain" | "supplychain" => Ok(Category::SupplyChain),
248 "secret-leak" | "secrets" | "secretleak" => Ok(Category::SecretLeak),
249 _ => Err(CustomRuleError::InvalidCategory {
250 rule_id: rule_id.to_string(),
251 value: value.to_string(),
252 }),
253 }
254 }
255
256 fn parse_confidence(rule_id: &str, value: &str) -> Result<Confidence, CustomRuleError> {
257 match value.to_lowercase().as_str() {
258 "certain" => Ok(Confidence::Certain),
259 "firm" => Ok(Confidence::Firm),
260 "tentative" => Ok(Confidence::Tentative),
261 _ => Err(CustomRuleError::InvalidConfidence {
262 rule_id: rule_id.to_string(),
263 value: value.to_string(),
264 }),
265 }
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn test_load_valid_yaml() {
275 let yaml = r#"
276version: "1"
277rules:
278 - id: "CUSTOM-001"
279 name: "Internal API access"
280 description: "Detects access to internal APIs"
281 severity: "high"
282 category: "exfiltration"
283 confidence: "firm"
284 patterns:
285 - 'https?://internal\.'
286 exclusions:
287 - 'localhost'
288 message: "Internal API access detected"
289 recommendation: "Review if this is intended"
290 cwe:
291 - "CWE-200"
292"#;
293 let rules = CustomRuleLoader::load_from_string(yaml).unwrap();
294 assert_eq!(rules.len(), 1);
295 assert_eq!(rules[0].id, "CUSTOM-001");
296 assert_eq!(rules[0].name, "Internal API access");
297 assert_eq!(rules[0].severity, Severity::High);
298 assert_eq!(rules[0].category, Category::Exfiltration);
299 assert_eq!(rules[0].confidence, Confidence::Firm);
300 assert_eq!(rules[0].cwe_ids, vec!["CWE-200"]);
301 }
302
303 #[test]
304 fn test_load_multiple_rules() {
305 let yaml = r#"
306version: "1"
307rules:
308 - id: "CUSTOM-001"
309 name: "Rule One"
310 severity: "critical"
311 category: "exfiltration"
312 patterns:
313 - 'pattern1'
314 message: "Message 1"
315 - id: "CUSTOM-002"
316 name: "Rule Two"
317 severity: "low"
318 category: "obfuscation"
319 patterns:
320 - 'pattern2'
321 message: "Message 2"
322"#;
323 let rules = CustomRuleLoader::load_from_string(yaml).unwrap();
324 assert_eq!(rules.len(), 2);
325 assert_eq!(rules[0].id, "CUSTOM-001");
326 assert_eq!(rules[1].id, "CUSTOM-002");
327 }
328
329 #[test]
330 fn test_invalid_severity() {
331 let yaml = r#"
332version: "1"
333rules:
334 - id: "CUSTOM-001"
335 name: "Test"
336 severity: "invalid"
337 category: "exfiltration"
338 patterns:
339 - 'test'
340 message: "Test"
341"#;
342 let result = CustomRuleLoader::load_from_string(yaml);
343 assert!(result.is_err());
344 let err = result.unwrap_err();
345 assert!(matches!(err, CustomRuleError::InvalidSeverity { .. }));
346 }
347
348 #[test]
349 fn test_invalid_category() {
350 let yaml = r#"
351version: "1"
352rules:
353 - id: "CUSTOM-001"
354 name: "Test"
355 severity: "high"
356 category: "invalid"
357 patterns:
358 - 'test'
359 message: "Test"
360"#;
361 let result = CustomRuleLoader::load_from_string(yaml);
362 assert!(result.is_err());
363 let err = result.unwrap_err();
364 assert!(matches!(err, CustomRuleError::InvalidCategory { .. }));
365 }
366
367 #[test]
368 fn test_invalid_regex() {
369 let yaml = r#"
370version: "1"
371rules:
372 - id: "CUSTOM-001"
373 name: "Test"
374 severity: "high"
375 category: "exfiltration"
376 patterns:
377 - '[invalid('
378 message: "Test"
379"#;
380 let result = CustomRuleLoader::load_from_string(yaml);
381 assert!(result.is_err());
382 let err = result.unwrap_err();
383 assert!(matches!(err, CustomRuleError::InvalidPattern { .. }));
384 }
385
386 #[test]
387 fn test_default_confidence() {
388 let yaml = r#"
389version: "1"
390rules:
391 - id: "CUSTOM-001"
392 name: "Test"
393 severity: "high"
394 category: "exfiltration"
395 patterns:
396 - 'test'
397 message: "Test"
398"#;
399 let rules = CustomRuleLoader::load_from_string(yaml).unwrap();
400 assert_eq!(rules[0].confidence, Confidence::Firm);
401 }
402
403 #[test]
404 fn test_rule_matches() {
405 let yaml = r#"
406version: "1"
407rules:
408 - id: "CUSTOM-001"
409 name: "API Key Pattern"
410 severity: "high"
411 category: "secret-leak"
412 patterns:
413 - 'API_KEY\s*=\s*"[^"]+"'
414 exclusions:
415 - 'test'
416 - 'example'
417 message: "API key detected"
418"#;
419 let rules = CustomRuleLoader::load_from_string(yaml).unwrap();
420 let rule = &rules[0];
421
422 assert!(rule.matches(r#"API_KEY = "secret123""#));
424
425 assert!(!rule.matches(r#"test API_KEY = "secret123""#));
427
428 assert!(!rule.matches("random text"));
430 }
431
432 #[test]
433 fn test_create_finding() {
434 let yaml = r#"
435version: "1"
436rules:
437 - id: "CUSTOM-001"
438 name: "Test Rule"
439 severity: "critical"
440 category: "exfiltration"
441 confidence: "certain"
442 patterns:
443 - 'test'
444 message: "Test message"
445 recommendation: "Fix it"
446 fix_hint: "Do this"
447 cwe:
448 - "CWE-200"
449 - "CWE-319"
450"#;
451 let rules = CustomRuleLoader::load_from_string(yaml).unwrap();
452 let rule = &rules[0];
453
454 let location = Location {
455 file: "test.txt".to_string(),
456 line: 10,
457 column: None,
458 };
459 let finding = rule.create_finding(location, "test code".to_string());
460
461 assert_eq!(finding.id, "CUSTOM-001");
462 assert_eq!(finding.name, "Test Rule");
463 assert_eq!(finding.severity, Severity::Critical);
464 assert_eq!(finding.category, Category::Exfiltration);
465 assert_eq!(finding.confidence, Confidence::Certain);
466 assert_eq!(finding.message, "Test message");
467 assert_eq!(finding.recommendation, "Fix it");
468 assert_eq!(finding.fix_hint, Some("Do this".to_string()));
469 assert_eq!(finding.cwe_ids, vec!["CWE-200", "CWE-319"]);
470 }
471
472 #[test]
473 fn test_category_variations() {
474 let test_cases = vec![
475 ("exfiltration", Category::Exfiltration),
476 ("data-exfiltration", Category::Exfiltration),
477 ("privilege-escalation", Category::PrivilegeEscalation),
478 ("privilege", Category::PrivilegeEscalation),
479 ("persistence", Category::Persistence),
480 ("prompt-injection", Category::PromptInjection),
481 ("injection", Category::PromptInjection),
482 ("overpermission", Category::Overpermission),
483 ("permission", Category::Overpermission),
484 ("obfuscation", Category::Obfuscation),
485 ("supply-chain", Category::SupplyChain),
486 ("supplychain", Category::SupplyChain),
487 ("secret-leak", Category::SecretLeak),
488 ("secrets", Category::SecretLeak),
489 ("secretleak", Category::SecretLeak),
490 ];
491
492 for (input, expected) in test_cases {
493 let result = CustomRuleLoader::parse_category("test", input);
494 assert_eq!(result.unwrap(), expected, "Failed for input: {}", input);
495 }
496 }
497
498 #[test]
499 fn test_error_display() {
500 let io_err = CustomRuleError::IoError(std::io::Error::new(
501 std::io::ErrorKind::NotFound,
502 "file not found",
503 ));
504 assert!(io_err.to_string().contains("Failed to read"));
505
506 let severity_err = CustomRuleError::InvalidSeverity {
507 rule_id: "TEST".to_string(),
508 value: "bad".to_string(),
509 };
510 assert!(severity_err.to_string().contains("Invalid severity"));
511
512 let category_err = CustomRuleError::InvalidCategory {
513 rule_id: "TEST".to_string(),
514 value: "bad".to_string(),
515 };
516 assert!(category_err.to_string().contains("Invalid category"));
517
518 let confidence_err = CustomRuleError::InvalidConfidence {
519 rule_id: "TEST".to_string(),
520 value: "bad".to_string(),
521 };
522 assert!(confidence_err.to_string().contains("Invalid confidence"));
523 }
524
525 #[test]
526 fn test_load_from_file() {
527 use std::fs;
528 use tempfile::TempDir;
529
530 let dir = TempDir::new().unwrap();
531 let file_path = dir.path().join("rules.yaml");
532 fs::write(
533 &file_path,
534 r#"
535version: "1"
536rules:
537 - id: "FILE-001"
538 name: "File Test"
539 severity: "high"
540 category: "exfiltration"
541 patterns:
542 - 'test_from_file'
543 message: "Test"
544"#,
545 )
546 .unwrap();
547
548 let rules = CustomRuleLoader::load_from_file(&file_path).unwrap();
549 assert_eq!(rules.len(), 1);
550 assert_eq!(rules[0].id, "FILE-001");
551 }
552
553 #[test]
554 fn test_load_from_file_not_found() {
555 let result =
556 CustomRuleLoader::load_from_file(std::path::Path::new("/nonexistent/file.yaml"));
557 assert!(result.is_err());
558 assert!(matches!(result, Err(CustomRuleError::IoError(_))));
559 }
560
561 #[test]
562 fn test_convert_yaml_rules() {
563 let yaml_rules = vec![YamlRule {
564 id: "CONV-001".to_string(),
565 name: "Convert Test".to_string(),
566 description: "Test".to_string(),
567 severity: "high".to_string(),
568 category: "exfiltration".to_string(),
569 confidence: "firm".to_string(),
570 patterns: vec!["test".to_string()],
571 exclusions: vec![],
572 message: "Test".to_string(),
573 recommendation: "".to_string(),
574 fix_hint: None,
575 cwe: vec![],
576 }];
577
578 let rules = CustomRuleLoader::convert_yaml_rules(yaml_rules).unwrap();
579 assert_eq!(rules.len(), 1);
580 assert_eq!(rules[0].id, "CONV-001");
581 }
582
583 #[test]
584 fn test_invalid_confidence() {
585 let yaml = r#"
586version: "1"
587rules:
588 - id: "CUSTOM-001"
589 name: "Test"
590 severity: "high"
591 category: "exfiltration"
592 confidence: "invalid"
593 patterns:
594 - 'test'
595 message: "Test"
596"#;
597 let result = CustomRuleLoader::load_from_string(yaml);
598 assert!(result.is_err());
599 let err = result.unwrap_err();
600 assert!(matches!(err, CustomRuleError::InvalidConfidence { .. }));
601 }
602
603 #[test]
604 fn test_parse_error_display() {
605 let invalid_yaml = "invalid: yaml: [";
606 let result: Result<CustomRulesConfig, _> = serde_yaml::from_str(invalid_yaml);
607 let yaml_err = result.unwrap_err();
608 let err = CustomRuleError::ParseError(yaml_err);
609 assert!(err.to_string().contains("Failed to parse"));
610 }
611
612 #[test]
613 #[allow(clippy::invalid_regex)]
614 fn test_invalid_pattern_error_display() {
615 let regex_err = Regex::new("[invalid(").unwrap_err();
616 let err = CustomRuleError::InvalidPattern {
617 rule_id: "TEST-001".to_string(),
618 pattern: "[invalid(".to_string(),
619 error: regex_err,
620 };
621 let display = err.to_string();
622 assert!(display.contains("Invalid regex pattern"));
623 assert!(display.contains("[invalid("));
624 assert!(display.contains("TEST-001"));
625 }
626
627 #[test]
628 fn test_invalid_exclusion_regex() {
629 let yaml = r#"
630version: "1"
631rules:
632 - id: "CUSTOM-001"
633 name: "Test"
634 severity: "high"
635 category: "exfiltration"
636 patterns:
637 - 'valid_pattern'
638 exclusions:
639 - '[invalid('
640 message: "Test"
641"#;
642 let result = CustomRuleLoader::load_from_string(yaml);
643 assert!(result.is_err());
644 let err = result.unwrap_err();
645 assert!(matches!(err, CustomRuleError::InvalidPattern { .. }));
646 }
647}