go_brrr/security/
types.rs

1//! Unified security types for the security analysis module.
2//!
3//! This module provides common types used across all security analyzers,
4//! enabling a unified API and consistent output format.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11// =============================================================================
12// Unified Severity and Confidence
13// =============================================================================
14
15/// Unified severity level for all security findings.
16/// Follows standard vulnerability scoring conventions.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Severity {
20    /// Informational - may not be exploitable but worth reviewing
21    Info,
22    /// Low severity - limited impact or requires specific conditions
23    Low,
24    /// Medium severity - potential for significant impact
25    Medium,
26    /// High severity - likely exploitable with serious impact
27    High,
28    /// Critical - easily exploitable with severe consequences
29    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    /// Returns the CVSS-like numeric score (0.0 - 10.0)
67    #[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    /// Check if this severity is at least as severe as `other`
79    #[must_use]
80    pub fn at_least(&self, other: Self) -> bool {
81        *self >= other
82    }
83}
84
85/// Confidence level for the detection.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum Confidence {
89    /// Low confidence - pattern match only, no data flow confirmation
90    Low,
91    /// Medium confidence - some data flow indicators but incomplete path
92    Medium,
93    /// High confidence - clear data flow from source to sink
94    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// =============================================================================
127// Location Types
128// =============================================================================
129
130/// Unified location in source code.
131#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
132pub struct Location {
133    /// File path (relative to project root)
134    pub file: String,
135    /// Start line number (1-indexed)
136    pub start_line: usize,
137    /// Start column number (1-indexed)
138    pub start_column: usize,
139    /// End line number (1-indexed)
140    pub end_line: usize,
141    /// End column number (1-indexed)
142    pub end_column: usize,
143}
144
145impl Location {
146    /// Create a new location from a file path and line/column info.
147    #[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    /// Create a single-line location.
165    #[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    /// Make the path relative to a base directory.
177    #[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// =============================================================================
193// Security Categories
194// =============================================================================
195
196/// Type of injection vulnerability.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
198#[serde(rename_all = "snake_case")]
199pub enum InjectionType {
200    /// SQL Injection (CWE-89)
201    Sql,
202    /// Command Injection / OS Command Injection (CWE-78)
203    Command,
204    /// Cross-Site Scripting (CWE-79)
205    Xss,
206    /// Path Traversal / Directory Traversal (CWE-22)
207    PathTraversal,
208    /// Code Injection via eval/exec (CWE-94)
209    Code,
210    /// LDAP Injection (CWE-90)
211    Ldap,
212    /// XML Injection / XXE (CWE-91)
213    Xml,
214    /// Template Injection (CWE-1336)
215    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/// Category of security vulnerability.
234#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
235#[serde(tag = "type", content = "subtype")]
236pub enum SecurityCategory {
237    /// Injection vulnerabilities (SQL, Command, XSS, Path Traversal, etc.)
238    Injection(InjectionType),
239    /// Secrets/credentials exposed in source code
240    SecretsExposure,
241    /// Weak or insecure cryptographic usage
242    WeakCrypto,
243    /// Unsafe deserialization (pickle, yaml.load, ObjectInputStream, etc.)
244    UnsafeDeserialization,
245    /// Regular expression denial of service
246    ReDoS,
247    /// Insecure configuration
248    InsecureConfig,
249    /// Authentication/authorization issues
250    AuthIssue,
251    /// Information disclosure
252    InfoDisclosure,
253    /// Other security issue
254    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    /// Get the CWE ID for this category (if applicable).
275    #[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), // Use of Hard-coded Credentials
287            Self::WeakCrypto => Some(327),       // Broken Crypto
288            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    /// Get the OWASP Top 10 (2021) category if applicable.
298    #[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// =============================================================================
314// Unified Security Finding
315// =============================================================================
316
317/// A unified security finding that can represent any type of vulnerability.
318///
319/// This struct provides a consistent interface for all security analyzers,
320/// enabling unified reporting, filtering, and output formatting.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct SecurityFinding {
323    /// Unique identifier for the finding type (e.g., "SQLI-001", "CMD-002")
324    pub id: String,
325
326    /// Category of the security issue
327    pub category: SecurityCategory,
328
329    /// Severity level
330    pub severity: Severity,
331
332    /// Confidence in the finding
333    pub confidence: Confidence,
334
335    /// Location in source code
336    pub location: Location,
337
338    /// Short title describing the issue
339    pub title: String,
340
341    /// Detailed description of the vulnerability
342    pub description: String,
343
344    /// CWE (Common Weakness Enumeration) reference ID
345    pub cwe_id: Option<u32>,
346
347    /// Suggested remediation/fix
348    pub remediation: String,
349
350    /// Code snippet showing the vulnerable code
351    pub code_snippet: String,
352
353    /// Additional metadata (analyzer-specific information)
354    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
355    pub metadata: HashMap<String, String>,
356
357    /// Whether this finding has been suppressed via comment
358    #[serde(default)]
359    pub suppressed: bool,
360
361    /// Hash for deduplication (based on location + category)
362    #[serde(skip)]
363    pub dedup_hash: u64,
364}
365
366impl SecurityFinding {
367    /// Create a new security finding with required fields.
368    #[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        // Compute deduplication hash
384        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    /// Add remediation advice.
411    #[must_use]
412    pub fn with_remediation(mut self, remediation: impl Into<String>) -> Self {
413        self.remediation = remediation.into();
414        self
415    }
416
417    /// Add code snippet.
418    #[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    /// Add metadata key-value pair.
425    #[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    /// Override the CWE ID.
432    #[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    /// Mark as suppressed.
439    #[must_use]
440    pub fn suppressed(mut self) -> Self {
441        self.suppressed = true;
442        self
443    }
444
445    /// Get a fingerprint for this finding (used for deduplication).
446    #[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// =============================================================================
484// Scan Configuration
485// =============================================================================
486
487/// Configuration for security scanning.
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct SecurityConfig {
490    /// Minimum severity to report
491    pub min_severity: Severity,
492
493    /// Minimum confidence to report
494    pub min_confidence: Confidence,
495
496    /// Categories to scan (None = all)
497    pub categories: Option<Vec<String>>,
498
499    /// Categories to exclude
500    pub exclude_categories: Vec<String>,
501
502    /// File patterns to exclude
503    pub exclude_patterns: Vec<String>,
504
505    /// Whether to include suppressed findings in the report
506    pub include_suppressed: bool,
507
508    /// Whether to deduplicate findings
509    pub deduplicate: bool,
510
511    /// Language filter (None = all languages)
512    pub language: Option<String>,
513
514    /// Maximum number of files to scan (0 = unlimited)
515    pub max_files: usize,
516
517    /// Number of parallel workers (0 = auto)
518    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    /// Create a config that scans all issues.
546    #[must_use]
547    pub fn all() -> Self {
548        Self::default()
549    }
550
551    /// Create a config for CI/CD with stricter settings.
552    #[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    /// Set minimum severity.
562    #[must_use]
563    pub fn with_min_severity(mut self, severity: Severity) -> Self {
564        self.min_severity = severity;
565        self
566    }
567
568    /// Set minimum confidence.
569    #[must_use]
570    pub fn with_min_confidence(mut self, confidence: Confidence) -> Self {
571        self.min_confidence = confidence;
572        self
573    }
574
575    /// Set language filter.
576    #[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    /// Set categories to scan.
583    #[must_use]
584    pub fn with_categories(mut self, categories: Vec<String>) -> Self {
585        self.categories = Some(categories);
586        self
587    }
588
589    /// Check if a finding passes the filters.
590    #[must_use]
591    pub fn should_include(&self, finding: &SecurityFinding) -> bool {
592        // Check severity
593        if finding.severity < self.min_severity {
594            return false;
595        }
596
597        // Check confidence
598        if finding.confidence < self.min_confidence {
599            return false;
600        }
601
602        // Check suppression
603        if finding.suppressed && !self.include_suppressed {
604            return false;
605        }
606
607        // Check category filter
608        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        // Check exclusions
627        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// =============================================================================
642// Scan Report
643// =============================================================================
644
645/// Summary statistics for a security scan.
646#[derive(Debug, Clone, Default, Serialize, Deserialize)]
647pub struct ScanSummary {
648    /// Total number of findings
649    pub total_findings: usize,
650    /// Number of findings by severity
651    pub by_severity: HashMap<String, usize>,
652    /// Number of findings by category
653    pub by_category: HashMap<String, usize>,
654    /// Number of files scanned
655    pub files_scanned: usize,
656    /// Number of files with findings
657    pub files_with_findings: usize,
658    /// Number of suppressed findings
659    pub suppressed_count: usize,
660    /// Number of duplicates removed
661    pub duplicates_removed: usize,
662    /// Scan duration in milliseconds
663    pub scan_duration_ms: u64,
664}
665
666impl ScanSummary {
667    /// Create a summary from a list of findings.
668    #[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            // Count by severity
677            *by_severity
678                .entry(finding.severity.to_string())
679                .or_insert(0) += 1;
680
681            // Count by category
682            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            // Track files
689            files_with_findings.insert(&finding.location.file);
690
691            // Count suppressed
692            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/// Result of a security scan.
711#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct SecurityReport {
713    /// All findings (after filtering)
714    pub findings: Vec<SecurityFinding>,
715    /// Summary statistics
716    pub summary: ScanSummary,
717    /// Version of the scanner
718    pub scanner_version: String,
719    /// Timestamp of the scan
720    pub timestamp: String,
721    /// Configuration used
722    #[serde(skip_serializing_if = "Option::is_none")]
723    pub config: Option<SecurityConfig>,
724}
725
726impl SecurityReport {
727    /// Create a new security report.
728    #[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    /// Check if the scan found any high/critical issues.
741    #[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    /// Get the exit code for CI/CD (0 = pass, 1 = fail)
749    #[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
763/// Simple timestamp without chrono dependency.
764fn 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    // Convert to basic ISO format
771    let days_since_epoch = secs / 86400;
772    let years = 1970 + days_since_epoch / 365;
773    format!("{years}-01-01T00:00:00Z")
774}
775
776// =============================================================================
777// Suppression Comment Parsing
778// =============================================================================
779
780/// Check if a line contains a suppression comment for a finding ID.
781#[must_use]
782pub fn is_suppressed(line: &str, finding_id: &str) -> bool {
783    // Support various comment formats:
784    // # brrr-ignore: SQLI-001
785    // // brrr-ignore: SQLI-001
786    // /* brrr-ignore: SQLI-001 */
787    // # noqa: SQLI-001
788    // # nosec SQLI-001
789
790    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            // Check if the finding ID is mentioned
803            if rest.contains(finding_id) || rest.contains(&finding_id.to_lowercase()) {
804                return true;
805            }
806            // Also support "all" to suppress all findings on this line
807            if rest.starts_with("all") || rest.is_empty() {
808                return true;
809            }
810        }
811    }
812
813    false
814}
815
816/// Check surrounding lines for suppression comments.
817#[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    // Check the line itself
822    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    // Check the previous line (common pattern)
829    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// =============================================================================
841// Tests
842// =============================================================================
843
844#[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}