1use std::collections::HashMap;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Severity {
20 Info,
22 Low,
24 Medium,
26 High,
28 Critical,
30}
31
32impl Default for Severity {
33 fn default() -> Self {
34 Self::Low
35 }
36}
37
38impl std::fmt::Display for Severity {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::Info => write!(f, "INFO"),
42 Self::Low => write!(f, "LOW"),
43 Self::Medium => write!(f, "MEDIUM"),
44 Self::High => write!(f, "HIGH"),
45 Self::Critical => write!(f, "CRITICAL"),
46 }
47 }
48}
49
50impl std::str::FromStr for Severity {
51 type Err = String;
52
53 fn from_str(s: &str) -> Result<Self, Self::Err> {
54 match s.to_lowercase().as_str() {
55 "info" | "informational" => Ok(Self::Info),
56 "low" => Ok(Self::Low),
57 "medium" | "med" => Ok(Self::Medium),
58 "high" => Ok(Self::High),
59 "critical" | "crit" => Ok(Self::Critical),
60 _ => Err(format!("Unknown severity: {s}")),
61 }
62 }
63}
64
65impl Severity {
66 #[must_use]
68 pub const fn cvss_score(&self) -> f64 {
69 match self {
70 Self::Info => 0.0,
71 Self::Low => 3.9,
72 Self::Medium => 6.9,
73 Self::High => 8.9,
74 Self::Critical => 10.0,
75 }
76 }
77
78 #[must_use]
80 pub fn at_least(&self, other: Self) -> bool {
81 *self >= other
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum Confidence {
89 Low,
91 Medium,
93 High,
95}
96
97impl Default for Confidence {
98 fn default() -> Self {
99 Self::Low
100 }
101}
102
103impl std::fmt::Display for Confidence {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 match self {
106 Self::Low => write!(f, "LOW"),
107 Self::Medium => write!(f, "MEDIUM"),
108 Self::High => write!(f, "HIGH"),
109 }
110 }
111}
112
113impl std::str::FromStr for Confidence {
114 type Err = String;
115
116 fn from_str(s: &str) -> Result<Self, Self::Err> {
117 match s.to_lowercase().as_str() {
118 "low" => Ok(Self::Low),
119 "medium" | "med" => Ok(Self::Medium),
120 "high" => Ok(Self::High),
121 _ => Err(format!("Unknown confidence: {s}")),
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
132pub struct Location {
133 pub file: String,
135 pub start_line: usize,
137 pub start_column: usize,
139 pub end_line: usize,
141 pub end_column: usize,
143}
144
145impl Location {
146 #[must_use]
148 pub fn new(
149 file: impl Into<String>,
150 start_line: usize,
151 start_column: usize,
152 end_line: usize,
153 end_column: usize,
154 ) -> Self {
155 Self {
156 file: file.into(),
157 start_line,
158 start_column,
159 end_line,
160 end_column,
161 }
162 }
163
164 #[must_use]
166 pub fn single_line(file: impl Into<String>, line: usize, column: usize) -> Self {
167 Self {
168 file: file.into(),
169 start_line: line,
170 start_column: column,
171 end_line: line,
172 end_column: column,
173 }
174 }
175
176 #[must_use]
178 pub fn with_relative_path(mut self, base: &Path) -> Self {
179 if let Ok(relative) = Path::new(&self.file).strip_prefix(base) {
180 self.file = relative.display().to_string();
181 }
182 self
183 }
184}
185
186impl std::fmt::Display for Location {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 write!(f, "{}:{}:{}", self.file, self.start_line, self.start_column)
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
198#[serde(rename_all = "snake_case")]
199pub enum InjectionType {
200 Sql,
202 Command,
204 Xss,
206 PathTraversal,
208 Code,
210 Ldap,
212 Xml,
214 Template,
216}
217
218impl std::fmt::Display for InjectionType {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 match self {
221 Self::Sql => write!(f, "SQL Injection"),
222 Self::Command => write!(f, "Command Injection"),
223 Self::Xss => write!(f, "Cross-Site Scripting (XSS)"),
224 Self::PathTraversal => write!(f, "Path Traversal"),
225 Self::Code => write!(f, "Code Injection"),
226 Self::Ldap => write!(f, "LDAP Injection"),
227 Self::Xml => write!(f, "XML Injection"),
228 Self::Template => write!(f, "Template Injection"),
229 }
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
235#[serde(tag = "type", content = "subtype")]
236pub enum SecurityCategory {
237 Injection(InjectionType),
239 SecretsExposure,
241 WeakCrypto,
243 UnsafeDeserialization,
245 ReDoS,
247 InsecureConfig,
249 AuthIssue,
251 InfoDisclosure,
253 Other(String),
255}
256
257impl std::fmt::Display for SecurityCategory {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 match self {
260 Self::Injection(t) => write!(f, "{t}"),
261 Self::SecretsExposure => write!(f, "Secrets Exposure"),
262 Self::WeakCrypto => write!(f, "Weak Cryptography"),
263 Self::UnsafeDeserialization => write!(f, "Unsafe Deserialization"),
264 Self::ReDoS => write!(f, "ReDoS"),
265 Self::InsecureConfig => write!(f, "Insecure Configuration"),
266 Self::AuthIssue => write!(f, "Authentication Issue"),
267 Self::InfoDisclosure => write!(f, "Information Disclosure"),
268 Self::Other(s) => write!(f, "{s}"),
269 }
270 }
271}
272
273impl SecurityCategory {
274 #[must_use]
276 pub fn cwe_id(&self) -> Option<u32> {
277 match self {
278 Self::Injection(InjectionType::Sql) => Some(89),
279 Self::Injection(InjectionType::Command) => Some(78),
280 Self::Injection(InjectionType::Xss) => Some(79),
281 Self::Injection(InjectionType::PathTraversal) => Some(22),
282 Self::Injection(InjectionType::Code) => Some(94),
283 Self::Injection(InjectionType::Ldap) => Some(90),
284 Self::Injection(InjectionType::Xml) => Some(91),
285 Self::Injection(InjectionType::Template) => Some(1336),
286 Self::SecretsExposure => Some(798), Self::WeakCrypto => Some(327), Self::UnsafeDeserialization => Some(502),
289 Self::ReDoS => Some(1333),
290 Self::InsecureConfig => Some(16),
291 Self::AuthIssue => Some(287),
292 Self::InfoDisclosure => Some(200),
293 Self::Other(_) => None,
294 }
295 }
296
297 #[must_use]
299 pub fn owasp_category(&self) -> Option<&'static str> {
300 match self {
301 Self::Injection(_) => Some("A03:2021 - Injection"),
302 Self::SecretsExposure => Some("A07:2021 - Identification and Authentication Failures"),
303 Self::WeakCrypto => Some("A02:2021 - Cryptographic Failures"),
304 Self::UnsafeDeserialization => Some("A08:2021 - Software and Data Integrity Failures"),
305 Self::InsecureConfig => Some("A05:2021 - Security Misconfiguration"),
306 Self::AuthIssue => Some("A07:2021 - Identification and Authentication Failures"),
307 Self::InfoDisclosure => Some("A01:2021 - Broken Access Control"),
308 Self::ReDoS | Self::Other(_) => None,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct SecurityFinding {
323 pub id: String,
325
326 pub category: SecurityCategory,
328
329 pub severity: Severity,
331
332 pub confidence: Confidence,
334
335 pub location: Location,
337
338 pub title: String,
340
341 pub description: String,
343
344 pub cwe_id: Option<u32>,
346
347 pub remediation: String,
349
350 pub code_snippet: String,
352
353 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
355 pub metadata: HashMap<String, String>,
356
357 #[serde(default)]
359 pub suppressed: bool,
360
361 #[serde(skip)]
363 pub dedup_hash: u64,
364}
365
366impl SecurityFinding {
367 #[must_use]
369 pub fn new(
370 id: impl Into<String>,
371 category: SecurityCategory,
372 severity: Severity,
373 confidence: Confidence,
374 location: Location,
375 title: impl Into<String>,
376 description: impl Into<String>,
377 ) -> Self {
378 let id = id.into();
379 let title = title.into();
380 let description = description.into();
381 let cwe_id = category.cwe_id();
382
383 let dedup_hash = {
385 use std::hash::{Hash, Hasher};
386 let mut hasher = rustc_hash::FxHasher::default();
387 location.file.hash(&mut hasher);
388 location.start_line.hash(&mut hasher);
389 std::mem::discriminant(&category).hash(&mut hasher);
390 hasher.finish()
391 };
392
393 Self {
394 id,
395 category,
396 severity,
397 confidence,
398 location,
399 title,
400 description,
401 cwe_id,
402 remediation: String::new(),
403 code_snippet: String::new(),
404 metadata: HashMap::new(),
405 suppressed: false,
406 dedup_hash,
407 }
408 }
409
410 #[must_use]
412 pub fn with_remediation(mut self, remediation: impl Into<String>) -> Self {
413 self.remediation = remediation.into();
414 self
415 }
416
417 #[must_use]
419 pub fn with_code_snippet(mut self, snippet: impl Into<String>) -> Self {
420 self.code_snippet = snippet.into();
421 self
422 }
423
424 #[must_use]
426 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
427 self.metadata.insert(key.into(), value.into());
428 self
429 }
430
431 #[must_use]
433 pub fn with_cwe(mut self, cwe_id: u32) -> Self {
434 self.cwe_id = Some(cwe_id);
435 self
436 }
437
438 #[must_use]
440 pub fn suppressed(mut self) -> Self {
441 self.suppressed = true;
442 self
443 }
444
445 #[must_use]
447 pub fn fingerprint(&self) -> String {
448 format!(
449 "{}:{}:{}:{}",
450 self.location.file,
451 self.location.start_line,
452 self.id,
453 match &self.category {
454 SecurityCategory::Injection(t) => format!("injection:{t:?}"),
455 SecurityCategory::SecretsExposure => "secrets".to_string(),
456 SecurityCategory::WeakCrypto => "crypto".to_string(),
457 SecurityCategory::UnsafeDeserialization => "deser".to_string(),
458 SecurityCategory::ReDoS => "redos".to_string(),
459 SecurityCategory::InsecureConfig => "config".to_string(),
460 SecurityCategory::AuthIssue => "auth".to_string(),
461 SecurityCategory::InfoDisclosure => "disclosure".to_string(),
462 SecurityCategory::Other(s) => format!("other:{s}"),
463 }
464 )
465 }
466}
467
468impl PartialEq for SecurityFinding {
469 fn eq(&self, other: &Self) -> bool {
470 self.dedup_hash == other.dedup_hash && self.id == other.id
471 }
472}
473
474impl Eq for SecurityFinding {}
475
476impl std::hash::Hash for SecurityFinding {
477 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
478 self.dedup_hash.hash(state);
479 self.id.hash(state);
480 }
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct SecurityConfig {
490 pub min_severity: Severity,
492
493 pub min_confidence: Confidence,
495
496 pub categories: Option<Vec<String>>,
498
499 pub exclude_categories: Vec<String>,
501
502 pub exclude_patterns: Vec<String>,
504
505 pub include_suppressed: bool,
507
508 pub deduplicate: bool,
510
511 pub language: Option<String>,
513
514 pub max_files: usize,
516
517 pub parallelism: usize,
519}
520
521impl Default for SecurityConfig {
522 fn default() -> Self {
523 Self {
524 min_severity: Severity::Low,
525 min_confidence: Confidence::Low,
526 categories: None,
527 exclude_categories: Vec::new(),
528 exclude_patterns: vec![
529 "**/node_modules/**".to_string(),
530 "**/.git/**".to_string(),
531 "**/vendor/**".to_string(),
532 "**/target/**".to_string(),
533 "**/__pycache__/**".to_string(),
534 ],
535 include_suppressed: false,
536 deduplicate: true,
537 language: None,
538 max_files: 0,
539 parallelism: 0,
540 }
541 }
542}
543
544impl SecurityConfig {
545 #[must_use]
547 pub fn all() -> Self {
548 Self::default()
549 }
550
551 #[must_use]
553 pub fn ci() -> Self {
554 Self {
555 min_severity: Severity::Medium,
556 min_confidence: Confidence::Medium,
557 ..Self::default()
558 }
559 }
560
561 #[must_use]
563 pub fn with_min_severity(mut self, severity: Severity) -> Self {
564 self.min_severity = severity;
565 self
566 }
567
568 #[must_use]
570 pub fn with_min_confidence(mut self, confidence: Confidence) -> Self {
571 self.min_confidence = confidence;
572 self
573 }
574
575 #[must_use]
577 pub fn with_language(mut self, language: impl Into<String>) -> Self {
578 self.language = Some(language.into());
579 self
580 }
581
582 #[must_use]
584 pub fn with_categories(mut self, categories: Vec<String>) -> Self {
585 self.categories = Some(categories);
586 self
587 }
588
589 #[must_use]
591 pub fn should_include(&self, finding: &SecurityFinding) -> bool {
592 if finding.severity < self.min_severity {
594 return false;
595 }
596
597 if finding.confidence < self.min_confidence {
599 return false;
600 }
601
602 if finding.suppressed && !self.include_suppressed {
604 return false;
605 }
606
607 if let Some(ref categories) = self.categories {
609 let cat_str = match &finding.category {
610 SecurityCategory::Injection(t) => format!("injection:{t:?}").to_lowercase(),
611 SecurityCategory::SecretsExposure => "secrets".to_string(),
612 SecurityCategory::WeakCrypto => "crypto".to_string(),
613 SecurityCategory::UnsafeDeserialization => "deserialization".to_string(),
614 SecurityCategory::ReDoS => "redos".to_string(),
615 SecurityCategory::InsecureConfig => "config".to_string(),
616 SecurityCategory::AuthIssue => "auth".to_string(),
617 SecurityCategory::InfoDisclosure => "disclosure".to_string(),
618 SecurityCategory::Other(s) => s.to_lowercase(),
619 };
620
621 if !categories.iter().any(|c| cat_str.contains(&c.to_lowercase())) {
622 return false;
623 }
624 }
625
626 for excl in &self.exclude_categories {
628 let cat_str = match &finding.category {
629 SecurityCategory::Injection(t) => format!("injection:{t:?}").to_lowercase(),
630 cat => format!("{cat:?}").to_lowercase(),
631 };
632 if cat_str.contains(&excl.to_lowercase()) {
633 return false;
634 }
635 }
636
637 true
638 }
639}
640
641#[derive(Debug, Clone, Default, Serialize, Deserialize)]
647pub struct ScanSummary {
648 pub total_findings: usize,
650 pub by_severity: HashMap<String, usize>,
652 pub by_category: HashMap<String, usize>,
654 pub files_scanned: usize,
656 pub files_with_findings: usize,
658 pub suppressed_count: usize,
660 pub duplicates_removed: usize,
662 pub scan_duration_ms: u64,
664}
665
666impl ScanSummary {
667 #[must_use]
669 pub fn from_findings(findings: &[SecurityFinding], files_scanned: usize) -> Self {
670 let mut by_severity = HashMap::new();
671 let mut by_category = HashMap::new();
672 let mut files_with_findings = std::collections::HashSet::new();
673 let mut suppressed_count = 0;
674
675 for finding in findings {
676 *by_severity
678 .entry(finding.severity.to_string())
679 .or_insert(0) += 1;
680
681 let cat_name = match &finding.category {
683 SecurityCategory::Injection(t) => format!("{t}"),
684 cat => format!("{cat}"),
685 };
686 *by_category.entry(cat_name).or_insert(0) += 1;
687
688 files_with_findings.insert(&finding.location.file);
690
691 if finding.suppressed {
693 suppressed_count += 1;
694 }
695 }
696
697 Self {
698 total_findings: findings.len(),
699 by_severity,
700 by_category,
701 files_scanned,
702 files_with_findings: files_with_findings.len(),
703 suppressed_count,
704 duplicates_removed: 0,
705 scan_duration_ms: 0,
706 }
707 }
708}
709
710#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct SecurityReport {
713 pub findings: Vec<SecurityFinding>,
715 pub summary: ScanSummary,
717 pub scanner_version: String,
719 pub timestamp: String,
721 #[serde(skip_serializing_if = "Option::is_none")]
723 pub config: Option<SecurityConfig>,
724}
725
726impl SecurityReport {
727 #[must_use]
729 pub fn new(findings: Vec<SecurityFinding>, files_scanned: usize) -> Self {
730 let summary = ScanSummary::from_findings(&findings, files_scanned);
731 Self {
732 findings,
733 summary,
734 scanner_version: env!("CARGO_PKG_VERSION").to_string(),
735 timestamp: chrono_lite_timestamp(),
736 config: None,
737 }
738 }
739
740 #[must_use]
742 pub fn has_critical_findings(&self) -> bool {
743 self.findings
744 .iter()
745 .any(|f| f.severity >= Severity::High && !f.suppressed)
746 }
747
748 #[must_use]
750 pub fn exit_code(&self, fail_on: Severity) -> i32 {
751 if self
752 .findings
753 .iter()
754 .any(|f| f.severity >= fail_on && !f.suppressed)
755 {
756 1
757 } else {
758 0
759 }
760 }
761}
762
763fn chrono_lite_timestamp() -> String {
765 use std::time::{SystemTime, UNIX_EPOCH};
766 let duration = SystemTime::now()
767 .duration_since(UNIX_EPOCH)
768 .unwrap_or_default();
769 let secs = duration.as_secs();
770 let days_since_epoch = secs / 86400;
772 let years = 1970 + days_since_epoch / 365;
773 format!("{years}-01-01T00:00:00Z")
774}
775
776#[must_use]
782pub fn is_suppressed(line: &str, finding_id: &str) -> bool {
783 let patterns = [
791 "brrr-ignore:",
792 "brrr-disable:",
793 "security-ignore:",
794 "nosec",
795 "noqa:",
796 ];
797
798 let lower = line.to_lowercase();
799 for pattern in patterns {
800 if let Some(idx) = lower.find(pattern) {
801 let rest = &line[idx + pattern.len()..].trim();
802 if rest.contains(finding_id) || rest.contains(&finding_id.to_lowercase()) {
804 return true;
805 }
806 if rest.starts_with("all") || rest.is_empty() {
808 return true;
809 }
810 }
811 }
812
813 false
814}
815
816#[must_use]
818pub fn check_suppression(source: &str, line_number: usize, finding_id: &str) -> bool {
819 let lines: Vec<&str> = source.lines().collect();
820
821 if let Some(line) = lines.get(line_number.saturating_sub(1)) {
823 if is_suppressed(line, finding_id) {
824 return true;
825 }
826 }
827
828 if line_number > 1 {
830 if let Some(prev_line) = lines.get(line_number.saturating_sub(2)) {
831 if is_suppressed(prev_line, finding_id) {
832 return true;
833 }
834 }
835 }
836
837 false
838}
839
840#[cfg(test)]
845mod tests {
846 use super::*;
847
848 #[test]
849 fn test_severity_ordering() {
850 assert!(Severity::Critical > Severity::High);
851 assert!(Severity::High > Severity::Medium);
852 assert!(Severity::Medium > Severity::Low);
853 assert!(Severity::Low > Severity::Info);
854 }
855
856 #[test]
857 fn test_severity_from_str() {
858 assert_eq!("critical".parse::<Severity>().unwrap(), Severity::Critical);
859 assert_eq!("HIGH".parse::<Severity>().unwrap(), Severity::High);
860 assert_eq!("med".parse::<Severity>().unwrap(), Severity::Medium);
861 }
862
863 #[test]
864 fn test_cwe_mapping() {
865 assert_eq!(
866 SecurityCategory::Injection(InjectionType::Sql).cwe_id(),
867 Some(89)
868 );
869 assert_eq!(
870 SecurityCategory::Injection(InjectionType::Command).cwe_id(),
871 Some(78)
872 );
873 assert_eq!(SecurityCategory::UnsafeDeserialization.cwe_id(), Some(502));
874 }
875
876 #[test]
877 fn test_suppression_detection() {
878 assert!(is_suppressed("# brrr-ignore: SQLI-001", "SQLI-001"));
879 assert!(is_suppressed("// brrr-ignore: SQLI-001", "SQLI-001"));
880 assert!(is_suppressed("# nosec SQLI-001", "SQLI-001"));
881 assert!(!is_suppressed("# regular comment", "SQLI-001"));
882 }
883
884 #[test]
885 fn test_finding_fingerprint() {
886 let finding = SecurityFinding::new(
887 "SQLI-001",
888 SecurityCategory::Injection(InjectionType::Sql),
889 Severity::High,
890 Confidence::High,
891 Location::new("test.py", 10, 1, 10, 50),
892 "SQL Injection",
893 "User input in SQL query",
894 );
895
896 let fingerprint = finding.fingerprint();
897 assert!(fingerprint.contains("test.py"));
898 assert!(fingerprint.contains("10"));
899 assert!(fingerprint.contains("SQLI-001"));
900 }
901}