Skip to main content

cc_audit/rules/
types.rs

1use crate::scoring::RiskScore;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
5#[serde(rename_all = "lowercase")]
6pub enum Severity {
7    Low,
8    Medium,
9    High,
10    Critical,
11}
12
13/// Confidence level for findings. Higher confidence means less likely to be a false positive.
14#[derive(
15    Debug,
16    Clone,
17    Copy,
18    PartialEq,
19    Eq,
20    PartialOrd,
21    Ord,
22    Serialize,
23    Deserialize,
24    Default,
25    clap::ValueEnum,
26)]
27#[serde(rename_all = "lowercase")]
28pub enum Confidence {
29    /// Tentative: May be a false positive, requires review
30    Tentative,
31    /// Firm: Likely a real issue, but context-dependent
32    #[default]
33    Firm,
34    /// Certain: Very high confidence, unlikely to be a false positive
35    Certain,
36}
37
38impl Confidence {
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            Confidence::Tentative => "tentative",
42            Confidence::Firm => "firm",
43            Confidence::Certain => "certain",
44        }
45    }
46}
47
48impl std::fmt::Display for Confidence {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(f, "{}", self.as_str())
51    }
52}
53
54impl Severity {
55    pub fn as_str(&self) -> &'static str {
56        match self {
57            Severity::Low => "low",
58            Severity::Medium => "medium",
59            Severity::High => "high",
60            Severity::Critical => "critical",
61        }
62    }
63}
64
65impl std::fmt::Display for Severity {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(f, "{}", self.as_str().to_uppercase())
68    }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "lowercase")]
73pub enum Category {
74    Exfiltration,
75    PrivilegeEscalation,
76    Persistence,
77    PromptInjection,
78    Overpermission,
79    Obfuscation,
80    SupplyChain,
81    SecretLeak,
82}
83
84impl Category {
85    pub fn as_str(&self) -> &'static str {
86        match self {
87            Category::Exfiltration => "exfiltration",
88            Category::PrivilegeEscalation => "privilege_escalation",
89            Category::Persistence => "persistence",
90            Category::PromptInjection => "prompt_injection",
91            Category::Overpermission => "overpermission",
92            Category::Obfuscation => "obfuscation",
93            Category::SupplyChain => "supply_chain",
94            Category::SecretLeak => "secret_leak",
95        }
96    }
97}
98
99#[derive(Debug, Clone)]
100pub struct Rule {
101    pub id: &'static str,
102    pub name: &'static str,
103    pub description: &'static str,
104    pub severity: Severity,
105    pub category: Category,
106    pub confidence: Confidence,
107    pub patterns: Vec<regex::Regex>,
108    pub exclusions: Vec<regex::Regex>,
109    pub message: &'static str,
110    pub recommendation: &'static str,
111    /// Optional concrete fix hint (e.g., command to run, code pattern to use)
112    pub fix_hint: Option<&'static str>,
113    /// CWE IDs associated with this rule (e.g., ["CWE-200", "CWE-78"])
114    pub cwe_ids: &'static [&'static str],
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Location {
119    pub file: String,
120    pub line: usize,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub column: Option<usize>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Finding {
127    pub id: String,
128    pub severity: Severity,
129    pub category: Category,
130    pub confidence: Confidence,
131    pub name: String,
132    pub location: Location,
133    pub code: String,
134    pub message: String,
135    pub recommendation: String,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub fix_hint: Option<String>,
138    /// CWE IDs associated with this finding
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub cwe_ids: Vec<String>,
141}
142
143impl Finding {
144    pub fn new(rule: &Rule, location: Location, code: String) -> Self {
145        Self {
146            id: rule.id.to_string(),
147            severity: rule.severity,
148            category: rule.category,
149            confidence: rule.confidence,
150            name: rule.name.to_string(),
151            location,
152            code,
153            message: rule.message.to_string(),
154            recommendation: rule.recommendation.to_string(),
155            fix_hint: rule.fix_hint.map(|s| s.to_string()),
156            cwe_ids: rule.cwe_ids.iter().map(|s| s.to_string()).collect(),
157        }
158    }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Summary {
163    pub critical: usize,
164    pub high: usize,
165    pub medium: usize,
166    pub low: usize,
167    pub passed: bool,
168}
169
170impl Summary {
171    pub fn from_findings(findings: &[Finding]) -> Self {
172        let (critical, high, medium, low) =
173            findings
174                .iter()
175                .fold((0, 0, 0, 0), |(c, h, m, l), f| match f.severity {
176                    Severity::Critical => (c + 1, h, m, l),
177                    Severity::High => (c, h + 1, m, l),
178                    Severity::Medium => (c, h, m + 1, l),
179                    Severity::Low => (c, h, m, l + 1),
180                });
181
182        Self {
183            critical,
184            high,
185            medium,
186            low,
187            passed: critical == 0 && high == 0,
188        }
189    }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct ScanResult {
194    pub version: String,
195    pub scanned_at: String,
196    pub target: String,
197    pub summary: Summary,
198    pub findings: Vec<Finding>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub risk_score: Option<RiskScore>,
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_severity_as_str() {
209        assert_eq!(Severity::Low.as_str(), "low");
210        assert_eq!(Severity::Medium.as_str(), "medium");
211        assert_eq!(Severity::High.as_str(), "high");
212        assert_eq!(Severity::Critical.as_str(), "critical");
213    }
214
215    #[test]
216    fn test_severity_display() {
217        assert_eq!(format!("{}", Severity::Low), "LOW");
218        assert_eq!(format!("{}", Severity::Medium), "MEDIUM");
219        assert_eq!(format!("{}", Severity::High), "HIGH");
220        assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
221    }
222
223    #[test]
224    fn test_severity_ordering() {
225        assert!(Severity::Low < Severity::Medium);
226        assert!(Severity::Medium < Severity::High);
227        assert!(Severity::High < Severity::Critical);
228    }
229
230    #[test]
231    fn test_category_as_str() {
232        assert_eq!(Category::Exfiltration.as_str(), "exfiltration");
233        assert_eq!(
234            Category::PrivilegeEscalation.as_str(),
235            "privilege_escalation"
236        );
237        assert_eq!(Category::Persistence.as_str(), "persistence");
238        assert_eq!(Category::PromptInjection.as_str(), "prompt_injection");
239        assert_eq!(Category::Overpermission.as_str(), "overpermission");
240        assert_eq!(Category::Obfuscation.as_str(), "obfuscation");
241        assert_eq!(Category::SupplyChain.as_str(), "supply_chain");
242        assert_eq!(Category::SecretLeak.as_str(), "secret_leak");
243    }
244
245    #[test]
246    fn test_summary_from_empty_findings() {
247        let findings: Vec<Finding> = vec![];
248        let summary = Summary::from_findings(&findings);
249        assert_eq!(summary.critical, 0);
250        assert_eq!(summary.high, 0);
251        assert_eq!(summary.medium, 0);
252        assert_eq!(summary.low, 0);
253        assert!(summary.passed);
254    }
255
256    #[test]
257    fn test_summary_from_findings_with_critical() {
258        let findings = vec![Finding {
259            id: "EX-001".to_string(),
260            severity: Severity::Critical,
261            category: Category::Exfiltration,
262            confidence: Confidence::Certain,
263            name: "Test".to_string(),
264            location: Location {
265                file: "test.sh".to_string(),
266                line: 1,
267                column: None,
268            },
269            code: "test".to_string(),
270            message: "test".to_string(),
271            recommendation: "test".to_string(),
272            fix_hint: None,
273            cwe_ids: vec![],
274        }];
275        let summary = Summary::from_findings(&findings);
276        assert_eq!(summary.critical, 1);
277        assert!(!summary.passed);
278    }
279
280    #[test]
281    fn test_summary_from_findings_all_severities() {
282        let findings = vec![
283            Finding {
284                id: "C-001".to_string(),
285                severity: Severity::Critical,
286                category: Category::Exfiltration,
287                confidence: Confidence::Certain,
288                name: "Critical".to_string(),
289                location: Location {
290                    file: "test.sh".to_string(),
291                    line: 1,
292                    column: None,
293                },
294                code: "test".to_string(),
295                message: "test".to_string(),
296                recommendation: "test".to_string(),
297                fix_hint: None,
298                cwe_ids: vec![],
299            },
300            Finding {
301                id: "H-001".to_string(),
302                severity: Severity::High,
303                category: Category::PrivilegeEscalation,
304                confidence: Confidence::Firm,
305                name: "High".to_string(),
306                location: Location {
307                    file: "test.sh".to_string(),
308                    line: 2,
309                    column: None,
310                },
311                code: "test".to_string(),
312                message: "test".to_string(),
313                recommendation: "test".to_string(),
314                fix_hint: None,
315                cwe_ids: vec![],
316            },
317            Finding {
318                id: "M-001".to_string(),
319                severity: Severity::Medium,
320                category: Category::Persistence,
321                confidence: Confidence::Tentative,
322                name: "Medium".to_string(),
323                location: Location {
324                    file: "test.sh".to_string(),
325                    line: 3,
326                    column: Some(5),
327                },
328                code: "test".to_string(),
329                message: "test".to_string(),
330                recommendation: "test".to_string(),
331                fix_hint: None,
332                cwe_ids: vec![],
333            },
334            Finding {
335                id: "L-001".to_string(),
336                severity: Severity::Low,
337                category: Category::Overpermission,
338                confidence: Confidence::Firm,
339                name: "Low".to_string(),
340                location: Location {
341                    file: "test.sh".to_string(),
342                    line: 4,
343                    column: None,
344                },
345                code: "test".to_string(),
346                message: "test".to_string(),
347                recommendation: "test".to_string(),
348                fix_hint: None,
349                cwe_ids: vec![],
350            },
351        ];
352        let summary = Summary::from_findings(&findings);
353        assert_eq!(summary.critical, 1);
354        assert_eq!(summary.high, 1);
355        assert_eq!(summary.medium, 1);
356        assert_eq!(summary.low, 1);
357        assert!(!summary.passed);
358    }
359
360    #[test]
361    fn test_summary_passes_with_only_medium_low() {
362        let findings = vec![
363            Finding {
364                id: "M-001".to_string(),
365                severity: Severity::Medium,
366                category: Category::Persistence,
367                confidence: Confidence::Firm,
368                name: "Medium".to_string(),
369                location: Location {
370                    file: "test.sh".to_string(),
371                    line: 1,
372                    column: None,
373                },
374                code: "test".to_string(),
375                message: "test".to_string(),
376                recommendation: "test".to_string(),
377                fix_hint: None,
378                cwe_ids: vec![],
379            },
380            Finding {
381                id: "L-001".to_string(),
382                severity: Severity::Low,
383                category: Category::Overpermission,
384                confidence: Confidence::Firm,
385                name: "Low".to_string(),
386                location: Location {
387                    file: "test.sh".to_string(),
388                    line: 2,
389                    column: None,
390                },
391                code: "test".to_string(),
392                message: "test".to_string(),
393                recommendation: "test".to_string(),
394                fix_hint: None,
395                cwe_ids: vec![],
396            },
397        ];
398        let summary = Summary::from_findings(&findings);
399        assert!(summary.passed);
400    }
401
402    #[test]
403    fn test_finding_new() {
404        let rule = Rule {
405            id: "TEST-001",
406            name: "Test Rule",
407            description: "A test rule",
408            severity: Severity::High,
409            category: Category::Exfiltration,
410            confidence: Confidence::Certain,
411            patterns: vec![],
412            exclusions: vec![],
413            message: "Test message",
414            recommendation: "Test recommendation",
415            fix_hint: Some("Test fix hint"),
416            cwe_ids: &["CWE-200", "CWE-78"],
417        };
418        let location = Location {
419            file: "test.sh".to_string(),
420            line: 42,
421            column: Some(10),
422        };
423        let finding = Finding::new(&rule, location, "test code".to_string());
424
425        assert_eq!(finding.id, "TEST-001");
426        assert_eq!(finding.name, "Test Rule");
427        assert_eq!(finding.severity, Severity::High);
428        assert_eq!(finding.category, Category::Exfiltration);
429        assert_eq!(finding.confidence, Confidence::Certain);
430        assert_eq!(finding.location.file, "test.sh");
431        assert_eq!(finding.location.line, 42);
432        assert_eq!(finding.location.column, Some(10));
433        assert_eq!(finding.code, "test code");
434        assert_eq!(finding.message, "Test message");
435        assert_eq!(finding.recommendation, "Test recommendation");
436        assert_eq!(finding.cwe_ids, vec!["CWE-200", "CWE-78"]);
437    }
438
439    #[test]
440    fn test_confidence_as_str() {
441        assert_eq!(Confidence::Tentative.as_str(), "tentative");
442        assert_eq!(Confidence::Firm.as_str(), "firm");
443        assert_eq!(Confidence::Certain.as_str(), "certain");
444    }
445
446    #[test]
447    fn test_confidence_display() {
448        assert_eq!(format!("{}", Confidence::Tentative), "tentative");
449        assert_eq!(format!("{}", Confidence::Firm), "firm");
450        assert_eq!(format!("{}", Confidence::Certain), "certain");
451    }
452
453    #[test]
454    fn test_confidence_ordering() {
455        assert!(Confidence::Tentative < Confidence::Firm);
456        assert!(Confidence::Firm < Confidence::Certain);
457    }
458
459    #[test]
460    fn test_confidence_default() {
461        assert_eq!(Confidence::default(), Confidence::Firm);
462    }
463
464    #[test]
465    fn test_confidence_serialization() {
466        let confidence = Confidence::Certain;
467        let json = serde_json::to_string(&confidence).unwrap();
468        assert_eq!(json, "\"certain\"");
469
470        let deserialized: Confidence = serde_json::from_str(&json).unwrap();
471        assert_eq!(deserialized, Confidence::Certain);
472    }
473
474    #[test]
475    fn test_severity_serialization() {
476        let severity = Severity::Critical;
477        let json = serde_json::to_string(&severity).unwrap();
478        assert_eq!(json, "\"critical\"");
479
480        let deserialized: Severity = serde_json::from_str(&json).unwrap();
481        assert_eq!(deserialized, Severity::Critical);
482    }
483
484    #[test]
485    fn test_category_serialization() {
486        let category = Category::PromptInjection;
487        let json = serde_json::to_string(&category).unwrap();
488        assert_eq!(json, "\"promptinjection\"");
489
490        let deserialized: Category = serde_json::from_str(&json).unwrap();
491        assert_eq!(deserialized, Category::PromptInjection);
492    }
493
494    #[test]
495    fn test_location_without_column_serialization() {
496        let location = Location {
497            file: "test.sh".to_string(),
498            line: 10,
499            column: None,
500        };
501        let json = serde_json::to_string(&location).unwrap();
502        assert!(!json.contains("column"));
503    }
504
505    #[test]
506    fn test_location_with_column_serialization() {
507        let location = Location {
508            file: "test.sh".to_string(),
509            line: 10,
510            column: Some(5),
511        };
512        let json = serde_json::to_string(&location).unwrap();
513        assert!(json.contains("\"column\":5"));
514    }
515}