Skip to main content

selfware/safety/
scanner.rs

1//! Security Scanner
2//!
3//! Security analysis capabilities:
4//! - Secret detection (API keys, passwords, tokens)
5//! - Vulnerability detection (SAST-style)
6//! - Dependency auditing
7//! - Compliance checking
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::RwLock;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14/// Severity of a security finding
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum SecuritySeverity {
17    /// Informational finding
18    Info,
19    /// Low severity
20    Low,
21    /// Medium severity
22    Medium,
23    /// High severity
24    High,
25    /// Critical severity
26    Critical,
27}
28
29impl SecuritySeverity {
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Self::Info => "info",
33            Self::Low => "low",
34            Self::Medium => "medium",
35            Self::High => "high",
36            Self::Critical => "critical",
37        }
38    }
39
40    /// CVSS-like score
41    pub fn score(&self) -> f32 {
42        match self {
43            Self::Info => 0.0,
44            Self::Low => 3.0,
45            Self::Medium => 5.5,
46            Self::High => 7.5,
47            Self::Critical => 9.5,
48        }
49    }
50}
51
52/// Category of security finding
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub enum SecurityCategory {
55    /// Hardcoded secrets
56    HardcodedSecret,
57    /// Injection vulnerability
58    Injection,
59    /// Authentication issue
60    Authentication,
61    /// Authorization issue
62    Authorization,
63    /// Data exposure
64    DataExposure,
65    /// Cryptographic weakness
66    Cryptography,
67    /// Configuration issue
68    Configuration,
69    /// Vulnerable dependency
70    Dependency,
71    /// Compliance violation
72    Compliance,
73    /// Code quality security issue
74    CodeQuality,
75    /// Custom category
76    Custom(String),
77}
78
79impl SecurityCategory {
80    pub fn as_str(&self) -> &str {
81        match self {
82            Self::HardcodedSecret => "hardcoded_secret",
83            Self::Injection => "injection",
84            Self::Authentication => "authentication",
85            Self::Authorization => "authorization",
86            Self::DataExposure => "data_exposure",
87            Self::Cryptography => "cryptography",
88            Self::Configuration => "configuration",
89            Self::Dependency => "dependency",
90            Self::Compliance => "compliance",
91            Self::CodeQuality => "code_quality",
92            Self::Custom(s) => s,
93        }
94    }
95}
96
97/// A security finding
98#[derive(Debug, Clone)]
99pub struct SecurityFinding {
100    /// Finding ID
101    pub id: String,
102    /// Title
103    pub title: String,
104    /// Description
105    pub description: String,
106    /// Category
107    pub category: SecurityCategory,
108    /// Severity
109    pub severity: SecuritySeverity,
110    /// File path
111    pub file: Option<PathBuf>,
112    /// Line number
113    pub line: Option<u32>,
114    /// Code snippet
115    pub snippet: Option<String>,
116    /// Remediation advice
117    pub remediation: Option<String>,
118    /// CWE ID
119    pub cwe: Option<String>,
120    /// Timestamp
121    pub timestamp: u64,
122}
123
124impl SecurityFinding {
125    pub fn new(title: &str, category: SecurityCategory, severity: SecuritySeverity) -> Self {
126        let id = format!(
127            "SEC-{:x}",
128            SystemTime::now()
129                .duration_since(UNIX_EPOCH)
130                .unwrap_or_default()
131                .as_nanos() as u64
132        );
133        Self {
134            id,
135            title: title.to_string(),
136            description: String::new(),
137            category,
138            severity,
139            file: None,
140            line: None,
141            snippet: None,
142            remediation: None,
143            cwe: None,
144            timestamp: SystemTime::now()
145                .duration_since(UNIX_EPOCH)
146                .unwrap_or_default()
147                .as_secs(),
148        }
149    }
150
151    pub fn with_description(mut self, desc: &str) -> Self {
152        self.description = desc.to_string();
153        self
154    }
155
156    pub fn with_location(mut self, file: PathBuf, line: u32) -> Self {
157        self.file = Some(file);
158        self.line = Some(line);
159        self
160    }
161
162    pub fn with_snippet(mut self, snippet: &str) -> Self {
163        self.snippet = Some(snippet.to_string());
164        self
165    }
166
167    pub fn with_remediation(mut self, remediation: &str) -> Self {
168        self.remediation = Some(remediation.to_string());
169        self
170    }
171
172    pub fn with_cwe(mut self, cwe: &str) -> Self {
173        self.cwe = Some(cwe.to_string());
174        self
175    }
176}
177
178/// Pattern for detecting secrets
179#[derive(Debug, Clone)]
180pub struct SecretPattern {
181    /// Pattern name
182    pub name: String,
183    /// Regex pattern
184    pub pattern: String,
185    /// Pre-compiled regex (avoids recompilation on every scan and enables size limits)
186    pub compiled: Option<regex::Regex>,
187    /// Severity
188    pub severity: SecuritySeverity,
189    /// Description
190    pub description: String,
191}
192
193impl SecretPattern {
194    pub fn new(name: &str, pattern: &str, severity: SecuritySeverity) -> Self {
195        let compiled = regex::RegexBuilder::new(pattern)
196            .size_limit(1 << 20) // 1 MB limit to mitigate ReDoS
197            .build()
198            .ok();
199        Self {
200            name: name.to_string(),
201            pattern: pattern.to_string(),
202            compiled,
203            severity,
204            description: format!("Potential {} detected", name),
205        }
206    }
207}
208
209/// Scanner for hardcoded secrets
210pub struct SecretScanner {
211    /// Secret patterns
212    patterns: Vec<SecretPattern>,
213    /// Files to skip
214    _skip_files: Vec<String>,
215    /// Findings
216    findings: Vec<SecurityFinding>,
217}
218
219impl SecretScanner {
220    pub fn new() -> Self {
221        Self {
222            patterns: Self::default_patterns(),
223            _skip_files: vec![
224                ".git".to_string(),
225                "node_modules".to_string(),
226                "target".to_string(),
227                ".env.example".to_string(),
228            ],
229            findings: Vec::new(),
230        }
231    }
232
233    fn default_patterns() -> Vec<SecretPattern> {
234        vec![
235            SecretPattern::new(
236                "AWS Access Key",
237                r"AKIA[0-9A-Z]{16}",
238                SecuritySeverity::Critical,
239            ),
240            SecretPattern::new(
241                "AWS Secret Key",
242                r#"(?i)aws(.{0,20})?['"][0-9a-zA-Z/+]{40}['"]"#,
243                SecuritySeverity::Critical,
244            ),
245            // GitHub classic tokens (ghp_, gho_, ghu_, ghs_, ghr_)
246            SecretPattern::new(
247                "GitHub Token",
248                r"gh[pousr]_[A-Za-z0-9_]{36,}",
249                SecuritySeverity::Critical,
250            ),
251            // GitHub fine-grained personal access tokens
252            SecretPattern::new(
253                "GitHub Fine-Grained Token",
254                r"github_pat_[A-Za-z0-9_]{22,}",
255                SecuritySeverity::Critical,
256            ),
257            // GitLab personal/project/group access tokens
258            SecretPattern::new(
259                "GitLab Token",
260                r"glpat-[A-Za-z0-9_\-]{20,}",
261                SecuritySeverity::Critical,
262            ),
263            SecretPattern::new(
264                "Generic API Key",
265                r#"(?i)(api[_-]?key|apikey)['"]?\s*[:=]\s*['"][a-zA-Z0-9]{20,}['"]"#,
266                SecuritySeverity::High,
267            ),
268            SecretPattern::new(
269                "Private Key",
270                r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
271                SecuritySeverity::Critical,
272            ),
273            // Google API keys (AIza...)
274            SecretPattern::new(
275                "Google API Key",
276                r"AIza[a-zA-Z0-9_\-]{35}",
277                SecuritySeverity::High,
278            ),
279            // Stripe secret keys
280            SecretPattern::new(
281                "Stripe Key",
282                r"(sk_live_|rk_live_|pk_live_)[a-zA-Z0-9]{24,}",
283                SecuritySeverity::Critical,
284            ),
285            SecretPattern::new(
286                "Password in Code",
287                r#"(?i)(password|passwd|pwd)['"]?\s*[:=]\s*['"][^'"]{8,}['"]"#,
288                SecuritySeverity::High,
289            ),
290            SecretPattern::new(
291                "Bearer Token",
292                r"(?i)bearer\s+[a-zA-Z0-9_\-\.]+",
293                SecuritySeverity::High,
294            ),
295            SecretPattern::new(
296                "JWT Token",
297                r"eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*",
298                SecuritySeverity::High,
299            ),
300            SecretPattern::new(
301                "Database URL",
302                r"(?i)(postgres|mysql|mongodb)://[^:]+:[^@]+@",
303                SecuritySeverity::High,
304            ),
305            // Slack tokens: bot (xoxb-), user (xoxp-), app-level (xoxa-),
306            // legacy (xoxs-), refresh (xoxr-)
307            SecretPattern::new(
308                "Slack Token",
309                r"xox[bpsar]-[0-9A-Za-z\-]{10,}",
310                SecuritySeverity::High,
311            ),
312            // Partial JWT / base64-encoded token starting with eyJ
313            SecretPattern::new(
314                "JWT Partial",
315                r"eyJ[a-zA-Z0-9_/+\-]{30,}",
316                SecuritySeverity::Medium,
317            ),
318            // Generic high-entropy base64 strings
319            SecretPattern::new(
320                "Base64 Secret",
321                r#"(?i)(?:key|token|secret|password|credential|auth)\s*[:=]\s*['"]?[A-Za-z0-9+/=_\-]{40,}['"]?"#,
322                SecuritySeverity::Medium,
323            ),
324        ]
325    }
326
327    /// Add a custom pattern
328    pub fn add_pattern(&mut self, pattern: SecretPattern) {
329        self.patterns.push(pattern);
330    }
331
332    /// Scan content for secrets
333    pub fn scan_content(&mut self, content: &str, file: Option<&PathBuf>) -> Vec<SecurityFinding> {
334        let mut findings = Vec::new();
335
336        for (line_num, line) in content.lines().enumerate() {
337            // Skip comments
338            let trimmed = line.trim();
339            if trimmed.starts_with("//") || trimmed.starts_with('#') || trimmed.starts_with("/*") {
340                continue;
341            }
342
343            for pattern in &self.patterns {
344                if let Some(ref re) = pattern.compiled {
345                    for mat in re.find_iter(line) {
346                        let mut finding = SecurityFinding::new(
347                            &pattern.name,
348                            SecurityCategory::HardcodedSecret,
349                            pattern.severity,
350                        )
351                        .with_description(&pattern.description)
352                        .with_cwe("CWE-798");
353
354                        if let Some(f) = file {
355                            finding = finding.with_location(f.clone(), (line_num + 1) as u32);
356                        }
357
358                        // Mask the secret in snippet
359                        let masked = Self::mask_secret(mat.as_str());
360                        finding = finding.with_snippet(&masked);
361                        finding = finding.with_remediation(
362                            "Remove hardcoded secret and use environment variables or a secrets manager"
363                        );
364
365                        findings.push(finding.clone());
366                        self.findings.push(finding);
367                    }
368                }
369            }
370        }
371
372        findings
373    }
374
375    /// Mask a secret for safe display
376    fn mask_secret(secret: &str) -> String {
377        if secret.len() <= 8 {
378            "*".repeat(secret.len())
379        } else {
380            format!(
381                "{}...{}",
382                secret.chars().take(4).collect::<String>(),
383                "*".repeat(secret.chars().count().saturating_sub(4))
384            )
385        }
386    }
387
388    /// Get all findings
389    pub fn findings(&self) -> &[SecurityFinding] {
390        &self.findings
391    }
392
393    /// Clear findings
394    pub fn clear(&mut self) {
395        self.findings.clear();
396    }
397}
398
399impl Default for SecretScanner {
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405/// SAST-style vulnerability pattern
406#[derive(Debug, Clone)]
407pub struct VulnerabilityPattern {
408    /// Pattern ID
409    pub id: String,
410    /// Name
411    pub name: String,
412    /// Language
413    pub language: String,
414    /// Pattern to match
415    pub pattern: String,
416    /// Severity
417    pub severity: SecuritySeverity,
418    /// CWE ID
419    pub cwe: String,
420    /// Description
421    pub description: String,
422    /// Remediation
423    pub remediation: String,
424}
425
426/// Detector for code vulnerabilities
427pub struct VulnerabilityDetector {
428    /// Vulnerability patterns
429    patterns: Vec<VulnerabilityPattern>,
430    /// Findings
431    findings: Vec<SecurityFinding>,
432}
433
434impl VulnerabilityDetector {
435    pub fn new() -> Self {
436        Self {
437            patterns: Self::default_patterns(),
438            findings: Vec::new(),
439        }
440    }
441
442    fn default_patterns() -> Vec<VulnerabilityPattern> {
443        vec![
444            VulnerabilityPattern {
445                id: "RUST001".to_string(),
446                name: "Unsafe Block".to_string(),
447                language: "rust".to_string(),
448                pattern: r"unsafe\s*\{".to_string(),
449                severity: SecuritySeverity::Medium,
450                cwe: "CWE-242".to_string(),
451                description: "Unsafe block found - requires careful review".to_string(),
452                remediation: "Document safety invariants or use safe alternatives".to_string(),
453            },
454            VulnerabilityPattern {
455                id: "RUST002".to_string(),
456                name: "Unwrap on Result/Option".to_string(),
457                language: "rust".to_string(),
458                pattern: r"\.unwrap\(\)".to_string(),
459                severity: SecuritySeverity::Low,
460                cwe: "CWE-252".to_string(),
461                description: "Unwrap can panic on None/Err".to_string(),
462                remediation: "Use proper error handling with ? or match".to_string(),
463            },
464            VulnerabilityPattern {
465                id: "RUST003".to_string(),
466                name: "SQL Query String Building".to_string(),
467                language: "rust".to_string(),
468                pattern: r#"format!\s*\(\s*["']SELECT|format!\s*\(\s*["']INSERT|format!\s*\(\s*["']UPDATE|format!\s*\(\s*["']DELETE"#.to_string(),
469                severity: SecuritySeverity::High,
470                cwe: "CWE-89".to_string(),
471                description: "Potential SQL injection vulnerability".to_string(),
472                remediation: "Use parameterized queries instead of string formatting".to_string(),
473            },
474            VulnerabilityPattern {
475                id: "RUST004".to_string(),
476                name: "Command Injection".to_string(),
477                language: "rust".to_string(),
478                pattern: r"Command::new\s*\(\s*&?format!".to_string(),
479                severity: SecuritySeverity::Critical,
480                cwe: "CWE-78".to_string(),
481                description: "Potential command injection vulnerability".to_string(),
482                remediation: "Use static command strings or validate/sanitize input".to_string(),
483            },
484            VulnerabilityPattern {
485                id: "RUST005".to_string(),
486                name: "Path Traversal".to_string(),
487                language: "rust".to_string(),
488                pattern: r#"Path::new\s*\(\s*&?format!|PathBuf::from\s*\(\s*&?format!"#.to_string(),
489                severity: SecuritySeverity::High,
490                cwe: "CWE-22".to_string(),
491                description: "Potential path traversal vulnerability".to_string(),
492                remediation: "Validate paths and use canonicalize()".to_string(),
493            },
494            VulnerabilityPattern {
495                id: "JS001".to_string(),
496                name: "eval() Usage".to_string(),
497                language: "javascript".to_string(),
498                pattern: r"\beval\s*\(".to_string(),
499                severity: SecuritySeverity::Critical,
500                cwe: "CWE-95".to_string(),
501                description: "eval() can execute arbitrary code".to_string(),
502                remediation: "Avoid eval() - use safer alternatives".to_string(),
503            },
504            VulnerabilityPattern {
505                id: "JS002".to_string(),
506                name: "innerHTML Assignment".to_string(),
507                language: "javascript".to_string(),
508                pattern: r"\.innerHTML\s*=".to_string(),
509                severity: SecuritySeverity::High,
510                cwe: "CWE-79".to_string(),
511                description: "Potential XSS via innerHTML".to_string(),
512                remediation: "Use textContent or sanitize HTML".to_string(),
513            },
514            VulnerabilityPattern {
515                id: "PY001".to_string(),
516                name: "Python exec/eval".to_string(),
517                language: "python".to_string(),
518                pattern: r"\b(exec|eval)\s*\(".to_string(),
519                severity: SecuritySeverity::Critical,
520                cwe: "CWE-95".to_string(),
521                description: "exec/eval can execute arbitrary code".to_string(),
522                remediation: "Avoid exec/eval with untrusted input".to_string(),
523            },
524            VulnerabilityPattern {
525                id: "PY002".to_string(),
526                name: "Python pickle".to_string(),
527                language: "python".to_string(),
528                pattern: r"pickle\.(load|loads)\s*\(".to_string(),
529                severity: SecuritySeverity::High,
530                cwe: "CWE-502".to_string(),
531                description: "pickle can deserialize malicious code".to_string(),
532                remediation: "Use json or other safe serialization formats".to_string(),
533            },
534        ]
535    }
536
537    /// Add a custom pattern
538    pub fn add_pattern(&mut self, pattern: VulnerabilityPattern) {
539        self.patterns.push(pattern);
540    }
541
542    /// Scan content for vulnerabilities
543    pub fn scan_content(
544        &mut self,
545        content: &str,
546        file: Option<&PathBuf>,
547        language: &str,
548    ) -> Vec<SecurityFinding> {
549        let mut findings = Vec::new();
550
551        // Filter patterns for this language
552        let applicable: Vec<_> = self
553            .patterns
554            .iter()
555            .filter(|p| p.language == language || p.language == "*")
556            .collect();
557
558        for (line_num, line) in content.lines().enumerate() {
559            for pattern in &applicable {
560                if let Ok(re) = regex::Regex::new(&pattern.pattern) {
561                    if re.is_match(line) {
562                        let mut finding = SecurityFinding::new(
563                            &pattern.name,
564                            SecurityCategory::CodeQuality,
565                            pattern.severity,
566                        )
567                        .with_description(&pattern.description)
568                        .with_cwe(&pattern.cwe)
569                        .with_remediation(&pattern.remediation)
570                        .with_snippet(line.trim());
571
572                        if let Some(f) = file {
573                            finding = finding.with_location(f.clone(), (line_num + 1) as u32);
574                        }
575
576                        findings.push(finding.clone());
577                        self.findings.push(finding);
578                    }
579                }
580            }
581        }
582
583        findings
584    }
585
586    /// Get all findings
587    pub fn findings(&self) -> &[SecurityFinding] {
588        &self.findings
589    }
590
591    /// Clear findings
592    pub fn clear(&mut self) {
593        self.findings.clear();
594    }
595}
596
597impl Default for VulnerabilityDetector {
598    fn default() -> Self {
599        Self::new()
600    }
601}
602
603/// A dependency with security info
604#[derive(Debug, Clone)]
605pub struct Dependency {
606    /// Package name
607    pub name: String,
608    /// Version
609    pub version: String,
610    /// Source (crates.io, npm, pypi, etc.)
611    pub source: String,
612    /// Known vulnerabilities
613    pub vulnerabilities: Vec<KnownVulnerability>,
614}
615
616impl Dependency {
617    pub fn new(name: &str, version: &str, source: &str) -> Self {
618        Self {
619            name: name.to_string(),
620            version: version.to_string(),
621            source: source.to_string(),
622            vulnerabilities: Vec::new(),
623        }
624    }
625
626    pub fn is_vulnerable(&self) -> bool {
627        !self.vulnerabilities.is_empty()
628    }
629
630    pub fn max_severity(&self) -> Option<SecuritySeverity> {
631        self.vulnerabilities.iter().map(|v| v.severity).max()
632    }
633}
634
635/// A known vulnerability in a dependency
636#[derive(Debug, Clone)]
637pub struct KnownVulnerability {
638    /// CVE or advisory ID
639    pub id: String,
640    /// Severity
641    pub severity: SecuritySeverity,
642    /// Description
643    pub description: String,
644    /// Fixed version
645    pub fixed_version: Option<String>,
646    /// URL for more info
647    pub url: Option<String>,
648}
649
650impl KnownVulnerability {
651    pub fn new(id: &str, severity: SecuritySeverity, description: &str) -> Self {
652        Self {
653            id: id.to_string(),
654            severity,
655            description: description.to_string(),
656            fixed_version: None,
657            url: None,
658        }
659    }
660
661    pub fn with_fixed_version(mut self, version: &str) -> Self {
662        self.fixed_version = Some(version.to_string());
663        self
664    }
665
666    pub fn with_url(mut self, url: &str) -> Self {
667        self.url = Some(url.to_string());
668        self
669    }
670}
671
672/// Auditor for dependencies
673pub struct DependencyAuditor {
674    /// Known vulnerable packages (simplified for demo)
675    vulnerability_db: HashMap<String, Vec<KnownVulnerability>>,
676    /// Scanned dependencies
677    dependencies: Vec<Dependency>,
678    /// Findings
679    findings: Vec<SecurityFinding>,
680}
681
682impl DependencyAuditor {
683    pub fn new() -> Self {
684        Self {
685            vulnerability_db: Self::default_db(),
686            dependencies: Vec::new(),
687            findings: Vec::new(),
688        }
689    }
690
691    fn default_db() -> HashMap<String, Vec<KnownVulnerability>> {
692        let mut db = HashMap::new();
693
694        // Example known vulnerabilities (in practice, this would be populated from a real database)
695        db.insert(
696            "lodash".to_string(),
697            vec![KnownVulnerability::new(
698                "CVE-2021-23337",
699                SecuritySeverity::High,
700                "Command Injection in lodash",
701            )
702            .with_fixed_version("4.17.21")],
703        );
704
705        db.insert(
706            "log4j".to_string(),
707            vec![KnownVulnerability::new(
708                "CVE-2021-44228",
709                SecuritySeverity::Critical,
710                "Log4Shell RCE vulnerability",
711            )
712            .with_fixed_version("2.17.0")],
713        );
714
715        db
716    }
717
718    /// Add a vulnerability to the database
719    pub fn add_vulnerability(&mut self, package: &str, vuln: KnownVulnerability) {
720        self.vulnerability_db
721            .entry(package.to_string())
722            .or_default()
723            .push(vuln);
724    }
725
726    /// Audit a dependency
727    pub fn audit_dependency(&mut self, name: &str, version: &str, source: &str) -> Dependency {
728        let mut dep = Dependency::new(name, version, source);
729
730        // Check vulnerability database
731        if let Some(vulns) = self.vulnerability_db.get(name) {
732            for vuln in vulns {
733                // In practice, would check version ranges
734                dep.vulnerabilities.push(vuln.clone());
735
736                let finding = SecurityFinding::new(
737                    &format!("Vulnerable dependency: {}", name),
738                    SecurityCategory::Dependency,
739                    vuln.severity,
740                )
741                .with_description(&vuln.description)
742                .with_remediation(&format!(
743                    "Update {} to version {}",
744                    name,
745                    vuln.fixed_version.as_deref().unwrap_or("latest")
746                ));
747
748                self.findings.push(finding);
749            }
750        }
751
752        self.dependencies.push(dep.clone());
753        dep
754    }
755
756    /// Get vulnerable dependencies
757    pub fn vulnerable_dependencies(&self) -> Vec<&Dependency> {
758        self.dependencies
759            .iter()
760            .filter(|d| d.is_vulnerable())
761            .collect()
762    }
763
764    /// Get all findings
765    pub fn findings(&self) -> &[SecurityFinding] {
766        &self.findings
767    }
768
769    /// Clear
770    pub fn clear(&mut self) {
771        self.dependencies.clear();
772        self.findings.clear();
773    }
774}
775
776impl Default for DependencyAuditor {
777    fn default() -> Self {
778        Self::new()
779    }
780}
781
782/// Compliance rule
783#[derive(Debug, Clone)]
784pub struct ComplianceRule {
785    /// Rule ID
786    pub id: String,
787    /// Standard (OWASP, PCI-DSS, HIPAA, etc.)
788    pub standard: String,
789    /// Description
790    pub description: String,
791    /// Check function (simplified as pattern)
792    pub pattern: Option<String>,
793    /// Severity
794    pub severity: SecuritySeverity,
795}
796
797impl ComplianceRule {
798    pub fn new(id: &str, standard: &str, description: &str) -> Self {
799        Self {
800            id: id.to_string(),
801            standard: standard.to_string(),
802            description: description.to_string(),
803            pattern: None,
804            severity: SecuritySeverity::Medium,
805        }
806    }
807
808    pub fn with_pattern(mut self, pattern: &str) -> Self {
809        self.pattern = Some(pattern.to_string());
810        self
811    }
812
813    pub fn with_severity(mut self, severity: SecuritySeverity) -> Self {
814        self.severity = severity;
815        self
816    }
817}
818
819/// Checker for compliance rules
820pub struct ComplianceChecker {
821    /// Compliance rules
822    rules: Vec<ComplianceRule>,
823    /// Findings
824    findings: Vec<SecurityFinding>,
825}
826
827impl ComplianceChecker {
828    pub fn new() -> Self {
829        Self {
830            rules: Self::default_rules(),
831            findings: Vec::new(),
832        }
833    }
834
835    fn default_rules() -> Vec<ComplianceRule> {
836        vec![
837            ComplianceRule::new("OWASP-A01", "OWASP Top 10", "Broken Access Control")
838                .with_severity(SecuritySeverity::High),
839            ComplianceRule::new("OWASP-A02", "OWASP Top 10", "Cryptographic Failures")
840                .with_pattern(r"(?i)(md5|sha1)\s*\(")
841                .with_severity(SecuritySeverity::High),
842            ComplianceRule::new("OWASP-A03", "OWASP Top 10", "Injection")
843                .with_severity(SecuritySeverity::Critical),
844            ComplianceRule::new("PCI-DSS-6.5.1", "PCI-DSS", "Address injection flaws")
845                .with_severity(SecuritySeverity::High),
846            ComplianceRule::new("HIPAA-164.312", "HIPAA", "Encryption of PHI at rest")
847                .with_severity(SecuritySeverity::High),
848        ]
849    }
850
851    /// Add a custom rule
852    pub fn add_rule(&mut self, rule: ComplianceRule) {
853        self.rules.push(rule);
854    }
855
856    /// Check content against rules with patterns
857    pub fn check_content(&mut self, content: &str, file: Option<&PathBuf>) -> Vec<SecurityFinding> {
858        let mut findings = Vec::new();
859
860        for (line_num, line) in content.lines().enumerate() {
861            for rule in &self.rules {
862                if let Some(pattern) = &rule.pattern {
863                    if let Ok(re) = regex::Regex::new(pattern) {
864                        if re.is_match(line) {
865                            let mut finding = SecurityFinding::new(
866                                &format!("{}: {}", rule.id, rule.description),
867                                SecurityCategory::Compliance,
868                                rule.severity,
869                            )
870                            .with_description(&format!("Potential {} violation", rule.standard))
871                            .with_snippet(line.trim());
872
873                            if let Some(f) = file {
874                                finding = finding.with_location(f.clone(), (line_num + 1) as u32);
875                            }
876
877                            findings.push(finding.clone());
878                            self.findings.push(finding);
879                        }
880                    }
881                }
882            }
883        }
884
885        findings
886    }
887
888    /// Get applicable standards
889    pub fn standards(&self) -> Vec<String> {
890        let mut standards: Vec<_> = self.rules.iter().map(|r| r.standard.clone()).collect();
891        standards.sort();
892        standards.dedup();
893        standards
894    }
895
896    /// Get all findings
897    pub fn findings(&self) -> &[SecurityFinding] {
898        &self.findings
899    }
900
901    /// Clear
902    pub fn clear(&mut self) {
903        self.findings.clear();
904    }
905}
906
907impl Default for ComplianceChecker {
908    fn default() -> Self {
909        Self::new()
910    }
911}
912
913/// Security scan result
914#[derive(Debug, Clone)]
915pub struct ScanResult {
916    /// All findings
917    pub findings: Vec<SecurityFinding>,
918    /// Summary by severity
919    pub by_severity: HashMap<SecuritySeverity, usize>,
920    /// Summary by category
921    pub by_category: HashMap<String, usize>,
922    /// Scan duration (ms)
923    pub duration_ms: u64,
924    /// Files scanned
925    pub files_scanned: usize,
926    /// Lines scanned
927    pub lines_scanned: usize,
928}
929
930impl ScanResult {
931    pub fn new() -> Self {
932        Self {
933            findings: Vec::new(),
934            by_severity: HashMap::new(),
935            by_category: HashMap::new(),
936            duration_ms: 0,
937            files_scanned: 0,
938            lines_scanned: 0,
939        }
940    }
941
942    /// Total finding count
943    pub fn total_findings(&self) -> usize {
944        self.findings.len()
945    }
946
947    /// Has critical findings?
948    pub fn has_critical(&self) -> bool {
949        self.by_severity
950            .get(&SecuritySeverity::Critical)
951            .is_some_and(|&c| c > 0)
952    }
953
954    /// Has high findings?
955    pub fn has_high(&self) -> bool {
956        self.by_severity
957            .get(&SecuritySeverity::High)
958            .is_some_and(|&c| c > 0)
959    }
960
961    /// Overall risk score
962    pub fn risk_score(&self) -> f32 {
963        self.findings.iter().map(|f| f.severity.score()).sum()
964    }
965}
966
967impl Default for ScanResult {
968    fn default() -> Self {
969        Self::new()
970    }
971}
972
973/// Main security scanner
974pub struct SecurityScanner {
975    /// Secret scanner
976    secret_scanner: RwLock<SecretScanner>,
977    /// Vulnerability detector
978    vuln_detector: RwLock<VulnerabilityDetector>,
979    /// Dependency auditor
980    dep_auditor: RwLock<DependencyAuditor>,
981    /// Compliance checker
982    compliance_checker: RwLock<ComplianceChecker>,
983    /// Scan history
984    scan_history: RwLock<Vec<ScanResult>>,
985}
986
987impl SecurityScanner {
988    pub fn new() -> Self {
989        Self {
990            secret_scanner: RwLock::new(SecretScanner::new()),
991            vuln_detector: RwLock::new(VulnerabilityDetector::new()),
992            dep_auditor: RwLock::new(DependencyAuditor::new()),
993            compliance_checker: RwLock::new(ComplianceChecker::new()),
994            scan_history: RwLock::new(Vec::new()),
995        }
996    }
997
998    /// Scan content for all security issues
999    pub fn scan_content(
1000        &self,
1001        content: &str,
1002        file: Option<&PathBuf>,
1003        language: &str,
1004    ) -> ScanResult {
1005        let start = std::time::Instant::now();
1006        let mut result = ScanResult::new();
1007
1008        // Scan for secrets
1009        if let Ok(mut scanner) = self.secret_scanner.write() {
1010            result.findings.extend(scanner.scan_content(content, file));
1011        }
1012
1013        // Scan for vulnerabilities
1014        if let Ok(mut detector) = self.vuln_detector.write() {
1015            result
1016                .findings
1017                .extend(detector.scan_content(content, file, language));
1018        }
1019
1020        // Check compliance
1021        if let Ok(mut checker) = self.compliance_checker.write() {
1022            result.findings.extend(checker.check_content(content, file));
1023        }
1024
1025        // Calculate summaries
1026        for finding in &result.findings {
1027            *result.by_severity.entry(finding.severity).or_insert(0) += 1;
1028            *result
1029                .by_category
1030                .entry(finding.category.as_str().to_string())
1031                .or_insert(0) += 1;
1032        }
1033
1034        result.duration_ms = start.elapsed().as_millis() as u64;
1035        result.files_scanned = 1;
1036        result.lines_scanned = content.lines().count();
1037
1038        // Save to history
1039        if let Ok(mut history) = self.scan_history.write() {
1040            history.push(result.clone());
1041            if history.len() > 100 {
1042                history.remove(0);
1043            }
1044        }
1045
1046        result
1047    }
1048
1049    /// Audit a dependency
1050    pub fn audit_dependency(&self, name: &str, version: &str, source: &str) -> Dependency {
1051        if let Ok(mut auditor) = self.dep_auditor.write() {
1052            auditor.audit_dependency(name, version, source)
1053        } else {
1054            Dependency::new(name, version, source)
1055        }
1056    }
1057
1058    /// Get scan statistics
1059    pub fn get_stats(&self) -> ScannerStats {
1060        let history = self.scan_history.read().ok();
1061        let total_scans = history.as_ref().map_or(0, |h| h.len());
1062        let total_findings: usize = history
1063            .as_ref()
1064            .map_or(0, |h| h.iter().map(|r| r.total_findings()).sum());
1065
1066        ScannerStats {
1067            total_scans,
1068            total_findings,
1069            critical_findings: history.as_ref().map_or(0, |h| {
1070                h.iter()
1071                    .map(|r| {
1072                        r.by_severity
1073                            .get(&SecuritySeverity::Critical)
1074                            .copied()
1075                            .unwrap_or(0)
1076                    })
1077                    .sum()
1078            }),
1079            high_findings: history.as_ref().map_or(0, |h| {
1080                h.iter()
1081                    .map(|r| {
1082                        r.by_severity
1083                            .get(&SecuritySeverity::High)
1084                            .copied()
1085                            .unwrap_or(0)
1086                    })
1087                    .sum()
1088            }),
1089        }
1090    }
1091
1092    /// Generate security report
1093    pub fn generate_report(&self, result: &ScanResult) -> String {
1094        let mut report = String::new();
1095        report.push_str("# Security Scan Report\n\n");
1096
1097        report.push_str(&format!("- Files scanned: {}\n", result.files_scanned));
1098        report.push_str(&format!("- Lines scanned: {}\n", result.lines_scanned));
1099        report.push_str(&format!("- Scan duration: {}ms\n", result.duration_ms));
1100        report.push_str(&format!("- Total findings: {}\n", result.total_findings()));
1101        report.push_str(&format!("- Risk score: {:.1}\n\n", result.risk_score()));
1102
1103        if result.has_critical() {
1104            report.push_str("## CRITICAL Findings\n");
1105            for finding in result
1106                .findings
1107                .iter()
1108                .filter(|f| f.severity == SecuritySeverity::Critical)
1109            {
1110                report.push_str(&format!(
1111                    "- **{}**: {}\n",
1112                    finding.title, finding.description
1113                ));
1114                if let Some(file) = &finding.file {
1115                    report.push_str(&format!(
1116                        "  Location: {}:{}\n",
1117                        file.display(),
1118                        finding.line.unwrap_or(0)
1119                    ));
1120                }
1121            }
1122            report.push('\n');
1123        }
1124
1125        if result.has_high() {
1126            report.push_str("## HIGH Findings\n");
1127            for finding in result
1128                .findings
1129                .iter()
1130                .filter(|f| f.severity == SecuritySeverity::High)
1131            {
1132                report.push_str(&format!(
1133                    "- **{}**: {}\n",
1134                    finding.title, finding.description
1135                ));
1136            }
1137            report.push('\n');
1138        }
1139
1140        report.push_str("## Summary by Category\n");
1141        for (cat, count) in &result.by_category {
1142            report.push_str(&format!("- {}: {}\n", cat, count));
1143        }
1144
1145        report
1146    }
1147}
1148
1149impl Default for SecurityScanner {
1150    fn default() -> Self {
1151        Self::new()
1152    }
1153}
1154
1155/// Scanner statistics
1156#[derive(Debug, Clone)]
1157pub struct ScannerStats {
1158    pub total_scans: usize,
1159    pub total_findings: usize,
1160    pub critical_findings: usize,
1161    pub high_findings: usize,
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167
1168    // -------------------------------------------------------------------------
1169    // SecuritySeverity
1170    // -------------------------------------------------------------------------
1171
1172    #[test]
1173    fn test_security_severity_ordering() {
1174        assert!(SecuritySeverity::Critical > SecuritySeverity::High);
1175        assert!(SecuritySeverity::High > SecuritySeverity::Medium);
1176        assert!(SecuritySeverity::Medium > SecuritySeverity::Low);
1177        assert!(SecuritySeverity::Low > SecuritySeverity::Info);
1178    }
1179
1180    #[test]
1181    fn test_security_severity_score() {
1182        assert!(SecuritySeverity::Critical.score() > SecuritySeverity::High.score());
1183        assert!(SecuritySeverity::High.score() > SecuritySeverity::Medium.score());
1184        assert!(SecuritySeverity::Medium.score() > SecuritySeverity::Low.score());
1185        assert!(SecuritySeverity::Low.score() > SecuritySeverity::Info.score());
1186        assert_eq!(SecuritySeverity::Info.score(), 0.0);
1187    }
1188
1189    #[test]
1190    fn test_security_severity_as_str() {
1191        assert_eq!(SecuritySeverity::Info.as_str(), "info");
1192        assert_eq!(SecuritySeverity::Low.as_str(), "low");
1193        assert_eq!(SecuritySeverity::Medium.as_str(), "medium");
1194        assert_eq!(SecuritySeverity::High.as_str(), "high");
1195        assert_eq!(SecuritySeverity::Critical.as_str(), "critical");
1196    }
1197
1198    #[test]
1199    fn test_security_severity_scores_concrete() {
1200        assert_eq!(SecuritySeverity::Info.score(), 0.0);
1201        assert_eq!(SecuritySeverity::Low.score(), 3.0);
1202        assert_eq!(SecuritySeverity::Medium.score(), 5.5);
1203        assert_eq!(SecuritySeverity::High.score(), 7.5);
1204        assert_eq!(SecuritySeverity::Critical.score(), 9.5);
1205    }
1206
1207    // -------------------------------------------------------------------------
1208    // SecurityCategory
1209    // -------------------------------------------------------------------------
1210
1211    #[test]
1212    fn test_security_category_as_str() {
1213        assert_eq!(
1214            SecurityCategory::HardcodedSecret.as_str(),
1215            "hardcoded_secret"
1216        );
1217        assert_eq!(SecurityCategory::Injection.as_str(), "injection");
1218        assert_eq!(SecurityCategory::Authentication.as_str(), "authentication");
1219        assert_eq!(SecurityCategory::Authorization.as_str(), "authorization");
1220        assert_eq!(SecurityCategory::DataExposure.as_str(), "data_exposure");
1221        assert_eq!(SecurityCategory::Cryptography.as_str(), "cryptography");
1222        assert_eq!(SecurityCategory::Configuration.as_str(), "configuration");
1223        assert_eq!(SecurityCategory::Dependency.as_str(), "dependency");
1224        assert_eq!(SecurityCategory::Compliance.as_str(), "compliance");
1225        assert_eq!(SecurityCategory::CodeQuality.as_str(), "code_quality");
1226    }
1227
1228    #[test]
1229    fn test_security_category_custom() {
1230        let cat = SecurityCategory::Custom("my_category".to_string());
1231        assert_eq!(cat.as_str(), "my_category");
1232    }
1233
1234    // -------------------------------------------------------------------------
1235    // SecurityFinding
1236    // -------------------------------------------------------------------------
1237
1238    #[test]
1239    fn test_security_finding_new() {
1240        let finding = SecurityFinding::new(
1241            "Test Finding",
1242            SecurityCategory::HardcodedSecret,
1243            SecuritySeverity::High,
1244        );
1245        assert!(!finding.id.is_empty());
1246        assert!(finding.id.starts_with("SEC-"));
1247        assert!(finding.timestamp > 0);
1248        assert_eq!(finding.title, "Test Finding");
1249        assert_eq!(finding.description, "");
1250        assert!(finding.file.is_none());
1251        assert!(finding.line.is_none());
1252        assert!(finding.snippet.is_none());
1253        assert!(finding.remediation.is_none());
1254        assert!(finding.cwe.is_none());
1255    }
1256
1257    #[test]
1258    fn test_security_finding_with_description() {
1259        let finding = SecurityFinding::new("T", SecurityCategory::Injection, SecuritySeverity::Low)
1260            .with_description("Detailed description here");
1261        assert_eq!(finding.description, "Detailed description here");
1262    }
1263
1264    #[test]
1265    fn test_security_finding_with_location() {
1266        let finding =
1267            SecurityFinding::new("Test", SecurityCategory::Injection, SecuritySeverity::High)
1268                .with_location(PathBuf::from("test.rs"), 42);
1269        assert_eq!(finding.line, Some(42));
1270        assert_eq!(finding.file, Some(PathBuf::from("test.rs")));
1271    }
1272
1273    #[test]
1274    fn test_security_finding_with_snippet() {
1275        let finding = SecurityFinding::new("T", SecurityCategory::Injection, SecuritySeverity::Low)
1276            .with_snippet("let x = dangerous();");
1277        assert_eq!(finding.snippet.as_deref(), Some("let x = dangerous();"));
1278    }
1279
1280    #[test]
1281    fn test_security_finding_with_remediation() {
1282        let finding = SecurityFinding::new("T", SecurityCategory::Injection, SecuritySeverity::Low)
1283            .with_remediation("Use parameterized queries");
1284        assert_eq!(
1285            finding.remediation.as_deref(),
1286            Some("Use parameterized queries")
1287        );
1288    }
1289
1290    #[test]
1291    fn test_security_finding_with_cwe() {
1292        let finding = SecurityFinding::new("T", SecurityCategory::Injection, SecuritySeverity::Low)
1293            .with_cwe("CWE-89");
1294        assert_eq!(finding.cwe.as_deref(), Some("CWE-89"));
1295    }
1296
1297    #[test]
1298    fn test_security_finding_builder_chain() {
1299        let finding = SecurityFinding::new(
1300            "Chain Test",
1301            SecurityCategory::HardcodedSecret,
1302            SecuritySeverity::Critical,
1303        )
1304        .with_description("desc")
1305        .with_location(PathBuf::from("foo.rs"), 10)
1306        .with_snippet("snippet")
1307        .with_remediation("fix")
1308        .with_cwe("CWE-798");
1309
1310        assert_eq!(finding.title, "Chain Test");
1311        assert_eq!(finding.description, "desc");
1312        assert_eq!(finding.line, Some(10));
1313        assert_eq!(finding.snippet.as_deref(), Some("snippet"));
1314        assert_eq!(finding.remediation.as_deref(), Some("fix"));
1315        assert_eq!(finding.cwe.as_deref(), Some("CWE-798"));
1316        assert_eq!(finding.severity, SecuritySeverity::Critical);
1317        assert_eq!(finding.category, SecurityCategory::HardcodedSecret);
1318    }
1319
1320    // -------------------------------------------------------------------------
1321    // SecretPattern
1322    // -------------------------------------------------------------------------
1323
1324    #[test]
1325    fn test_secret_pattern_new() {
1326        let pattern = SecretPattern::new("Test", r"\d+", SecuritySeverity::Low);
1327        assert_eq!(pattern.name, "Test");
1328        assert_eq!(pattern.severity, SecuritySeverity::Low);
1329        assert!(pattern.compiled.is_some(), "valid regex should compile");
1330    }
1331
1332    #[test]
1333    fn test_secret_pattern_invalid_regex() {
1334        let pattern = SecretPattern::new("Bad", r"[invalid", SecuritySeverity::Low);
1335        assert!(
1336            pattern.compiled.is_none(),
1337            "invalid regex should produce None"
1338        );
1339    }
1340
1341    #[test]
1342    fn test_secret_pattern_description_default() {
1343        let pattern = SecretPattern::new("AWS Key", r"\d+", SecuritySeverity::Critical);
1344        assert!(pattern.description.contains("AWS Key"));
1345    }
1346
1347    // -------------------------------------------------------------------------
1348    // SecretScanner
1349    // -------------------------------------------------------------------------
1350
1351    #[test]
1352    fn test_secret_scanner_new() {
1353        let scanner = SecretScanner::new();
1354        assert!(scanner.findings().is_empty());
1355    }
1356
1357    #[test]
1358    fn test_secret_scanner_default() {
1359        let scanner = SecretScanner::default();
1360        assert!(scanner.findings().is_empty());
1361    }
1362
1363    #[test]
1364    fn test_secret_scanner_empty_content() {
1365        let mut scanner = SecretScanner::new();
1366        let findings = scanner.scan_content("", None);
1367        assert!(findings.is_empty());
1368    }
1369
1370    #[test]
1371    fn test_secret_scanner_no_secrets() {
1372        let mut scanner = SecretScanner::new();
1373        let content = "let x = 42;\nfn main() {}\n// This is safe code";
1374        let findings = scanner.scan_content(content, None);
1375        assert!(findings.is_empty());
1376    }
1377
1378    #[test]
1379    fn test_secret_scanner_detect_aws_access_key() {
1380        let mut scanner = SecretScanner::new();
1381        // AKIA followed by 16 uppercase alphanumeric chars
1382        let content = "aws_access_key = AKIAIOSFODNN7EXAMPLE";
1383        let findings = scanner.scan_content(content, None);
1384        assert!(!findings.is_empty());
1385        assert!(findings
1386            .iter()
1387            .any(|f| f.severity == SecuritySeverity::Critical));
1388    }
1389
1390    #[test]
1391    fn test_secret_scanner_detect_private_key() {
1392        let mut scanner = SecretScanner::new();
1393        let content = "-----BEGIN RSA PRIVATE KEY-----";
1394        let findings = scanner.scan_content(content, None);
1395        assert!(!findings.is_empty());
1396    }
1397
1398    #[test]
1399    fn test_secret_scanner_detect_ec_private_key() {
1400        let mut scanner = SecretScanner::new();
1401        let content = "-----BEGIN EC PRIVATE KEY-----";
1402        let findings = scanner.scan_content(content, None);
1403        assert!(!findings.is_empty());
1404    }
1405
1406    #[test]
1407    fn test_secret_scanner_detect_openssh_private_key() {
1408        let mut scanner = SecretScanner::new();
1409        let content = "-----BEGIN OPENSSH PRIVATE KEY-----";
1410        let findings = scanner.scan_content(content, None);
1411        assert!(!findings.is_empty());
1412    }
1413
1414    #[test]
1415    fn test_secret_scanner_detect_generic_private_key() {
1416        let mut scanner = SecretScanner::new();
1417        let content = "-----BEGIN PRIVATE KEY-----";
1418        let findings = scanner.scan_content(content, None);
1419        assert!(!findings.is_empty());
1420    }
1421
1422    #[test]
1423    fn test_secret_scanner_detect_github_token_ghp() {
1424        let mut scanner = SecretScanner::new();
1425        // ghp_ followed by 36 alphanumeric/underscore chars (pattern: gh[pousr]_[A-Za-z0-9_]{36,})
1426        let content =
1427            "token = ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ab";
1428        let findings = scanner.scan_content(content, None);
1429        assert!(!findings.is_empty());
1430        assert!(findings
1431            .iter()
1432            .any(|f| f.severity == SecuritySeverity::Critical));
1433    }
1434
1435    #[test]
1436    fn test_secret_scanner_detect_github_token_gho() {
1437        let mut scanner = SecretScanner::new();
1438        let content = "gho_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
1439        let findings = scanner.scan_content(content, None);
1440        assert!(!findings.is_empty());
1441    }
1442
1443    #[test]
1444    fn test_secret_scanner_detect_github_token_ghu() {
1445        let mut scanner = SecretScanner::new();
1446        let content = "ghu_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
1447        let findings = scanner.scan_content(content, None);
1448        assert!(!findings.is_empty());
1449    }
1450
1451    #[test]
1452    fn test_secret_scanner_detect_github_fine_grained_token() {
1453        let mut scanner = SecretScanner::new();
1454        // github_pat_ followed by 22+ chars
1455        let content = "github_pat_ABCDEFGHIJKLMNOPQRSTUVWXYZabcde";
1456        let findings = scanner.scan_content(content, None);
1457        assert!(!findings.is_empty());
1458    }
1459
1460    #[test]
1461    fn test_secret_scanner_detect_gitlab_token() {
1462        let mut scanner = SecretScanner::new();
1463        // glpat- followed by 20+ chars
1464        let content = "glpat-ABCDEFGHIJKLMNOPQRSTUVWXYZabcde";
1465        let findings = scanner.scan_content(content, None);
1466        assert!(!findings.is_empty());
1467    }
1468
1469    #[test]
1470    fn test_secret_scanner_detect_google_api_key() {
1471        let mut scanner = SecretScanner::new();
1472        // AIza followed by exactly 35 alphanumeric/underscore/hyphen chars
1473        // Pattern: AIza[a-zA-Z0-9_\-]{35}
1474        // Pattern: AIza[a-zA-Z0-9_\-]{35}  →  AIza + exactly 35 body chars
1475        let content = "key = AIzaSyDabcdefghijklmnopqrstuvwxyz012345";
1476        let findings = scanner.scan_content(content, None);
1477        assert!(!findings.is_empty());
1478    }
1479
1480    #[test]
1481    fn test_secret_scanner_detect_stripe_secret_key() {
1482        let mut scanner = SecretScanner::new();
1483        // Build the test key dynamically to avoid triggering push protection
1484        let prefix = "sk_live_";
1485        let suffix = "TESTKEYTESTKEYTESTKEYTESTK";
1486        let content = format!("stripe_key = {}{}", prefix, suffix);
1487        let findings = scanner.scan_content(&content, None);
1488        assert!(!findings.is_empty());
1489        assert!(findings
1490            .iter()
1491            .any(|f| f.severity == SecuritySeverity::Critical));
1492    }
1493
1494    #[test]
1495    fn test_secret_scanner_detect_stripe_restricted_key() {
1496        let mut scanner = SecretScanner::new();
1497        // Build the test key dynamically to avoid triggering push protection
1498        let prefix = "rk_live_";
1499        let suffix = "TESTKEYTESTKEYTESTKEYTESTK";
1500        let content = format!("{}{}", prefix, suffix);
1501        let findings = scanner.scan_content(&content, None);
1502        assert!(!findings.is_empty());
1503    }
1504
1505    #[test]
1506    fn test_secret_scanner_detect_generic_api_key() {
1507        let mut scanner = SecretScanner::new();
1508        // api_key = 'somevalue20chars+'
1509        let content = r#"api_key = "abcdefghij1234567890extra""#;
1510        let findings = scanner.scan_content(content, None);
1511        assert!(!findings.is_empty());
1512    }
1513
1514    #[test]
1515    fn test_secret_scanner_detect_apikey_variant() {
1516        let mut scanner = SecretScanner::new();
1517        let content = r#"apikey = "abcdefghijklmnopqrstuv""#;
1518        let findings = scanner.scan_content(content, None);
1519        assert!(!findings.is_empty());
1520    }
1521
1522    #[test]
1523    fn test_secret_scanner_detect_password_in_code() {
1524        let mut scanner = SecretScanner::new();
1525        let content = r#"password = "s3cr3tpassword""#;
1526        let findings = scanner.scan_content(content, None);
1527        assert!(!findings.is_empty());
1528    }
1529
1530    #[test]
1531    fn test_secret_scanner_detect_passwd_variant() {
1532        let mut scanner = SecretScanner::new();
1533        let content = r#"passwd = "mysecretpass1234""#;
1534        let findings = scanner.scan_content(content, None);
1535        assert!(!findings.is_empty());
1536    }
1537
1538    #[test]
1539    fn test_secret_scanner_detect_bearer_token() {
1540        let mut scanner = SecretScanner::new();
1541        let content = "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9";
1542        let findings = scanner.scan_content(content, None);
1543        assert!(!findings.is_empty());
1544    }
1545
1546    #[test]
1547    fn test_secret_scanner_detect_jwt_token() {
1548        let mut scanner = SecretScanner::new();
1549        // Full JWT: header.payload.signature, each base64url encoded starting with eyJ
1550        let content =
1551            "token = eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
1552        let findings = scanner.scan_content(content, None);
1553        assert!(!findings.is_empty());
1554    }
1555
1556    #[test]
1557    fn test_secret_scanner_detect_database_url_postgres() {
1558        let mut scanner = SecretScanner::new();
1559        let content = "db_url = postgres://user:password@localhost/mydb";
1560        let findings = scanner.scan_content(content, None);
1561        assert!(!findings.is_empty());
1562    }
1563
1564    #[test]
1565    fn test_secret_scanner_detect_database_url_mysql() {
1566        let mut scanner = SecretScanner::new();
1567        let content = "url = mysql://admin:s3cr3t@db.example.com/prod";
1568        let findings = scanner.scan_content(content, None);
1569        assert!(!findings.is_empty());
1570    }
1571
1572    #[test]
1573    fn test_secret_scanner_detect_database_url_mongodb() {
1574        let mut scanner = SecretScanner::new();
1575        let content = "mongo_uri = mongodb://root:hunter2@mongo.local/admin";
1576        let findings = scanner.scan_content(content, None);
1577        assert!(!findings.is_empty());
1578    }
1579
1580    #[test]
1581    fn test_secret_scanner_detect_slack_bot_token() {
1582        let mut scanner = SecretScanner::new();
1583        // xoxb- followed by 10+ alphanumeric/hyphen
1584        let content = "slack_token = xoxb-1234567890-abcdefghij";
1585        let findings = scanner.scan_content(content, None);
1586        assert!(!findings.is_empty());
1587    }
1588
1589    #[test]
1590    fn test_secret_scanner_detect_slack_user_token() {
1591        let mut scanner = SecretScanner::new();
1592        let content = "xoxp-1234567890-0987654321-abcdef";
1593        let findings = scanner.scan_content(content, None);
1594        assert!(!findings.is_empty());
1595    }
1596
1597    #[test]
1598    fn test_secret_scanner_detect_base64_secret() {
1599        let mut scanner = SecretScanner::new();
1600        // "secret = " followed by 40+ base64 chars
1601        let content = "secret = ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/==";
1602        let findings = scanner.scan_content(content, None);
1603        assert!(!findings.is_empty());
1604    }
1605
1606    #[test]
1607    fn test_secret_scanner_skip_double_slash_comment() {
1608        let mut scanner = SecretScanner::new();
1609        let content = "// aws_key = AKIAIOSFODNN7EXAMPLE";
1610        let findings = scanner.scan_content(content, None);
1611        assert!(findings.is_empty());
1612    }
1613
1614    #[test]
1615    fn test_secret_scanner_skip_hash_comment() {
1616        let mut scanner = SecretScanner::new();
1617        let content = "# password = supersecretvalue";
1618        let findings = scanner.scan_content(content, None);
1619        assert!(findings.is_empty());
1620    }
1621
1622    #[test]
1623    fn test_secret_scanner_skip_block_comment() {
1624        let mut scanner = SecretScanner::new();
1625        let content = "/* password = supersecretvalue */";
1626        let findings = scanner.scan_content(content, None);
1627        assert!(findings.is_empty());
1628    }
1629
1630    #[test]
1631    fn test_secret_scanner_with_file_path() {
1632        let mut scanner = SecretScanner::new();
1633        let path = PathBuf::from("src/config.rs");
1634        let content = "AKIAIOSFODNN7EXAMPLE";
1635        let findings = scanner.scan_content(content, Some(&path));
1636        assert!(!findings.is_empty());
1637        assert_eq!(findings[0].file, Some(path));
1638        assert_eq!(findings[0].line, Some(1));
1639    }
1640
1641    #[test]
1642    fn test_secret_scanner_line_numbers() {
1643        let mut scanner = SecretScanner::new();
1644        let path = PathBuf::from("foo.rs");
1645        let content = "line1\nline2\nAKIAIOSFODNN7EXAMPLE\nline4";
1646        let findings = scanner.scan_content(content, Some(&path));
1647        assert!(!findings.is_empty());
1648        assert_eq!(findings[0].line, Some(3));
1649    }
1650
1651    #[test]
1652    fn test_secret_scanner_accumulates_findings() {
1653        let mut scanner = SecretScanner::new();
1654        scanner.scan_content("AKIAIOSFODNN7EXAMPLE", None);
1655        scanner.scan_content("-----BEGIN RSA PRIVATE KEY-----", None);
1656        assert!(scanner.findings().len() >= 2);
1657    }
1658
1659    #[test]
1660    fn test_secret_scanner_clear() {
1661        let mut scanner = SecretScanner::new();
1662        scanner.scan_content("AKIAIOSFODNN7EXAMPLE", None);
1663        assert!(!scanner.findings().is_empty());
1664        scanner.clear();
1665        assert!(scanner.findings().is_empty());
1666    }
1667
1668    #[test]
1669    fn test_secret_scanner_masked_snippet() {
1670        let mut scanner = SecretScanner::new();
1671        let content = "AKIAIOSFODNN7EXAMPLE";
1672        let findings = scanner.scan_content(content, None);
1673        assert!(!findings.is_empty());
1674        // The snippet should be masked (contains "..." or all "*")
1675        if let Some(snippet) = &findings[0].snippet {
1676            assert!(snippet.contains("...") || snippet.chars().all(|c| c == '*'));
1677        }
1678    }
1679
1680    #[test]
1681    fn test_secret_scanner_remediation_set() {
1682        let mut scanner = SecretScanner::new();
1683        let findings = scanner.scan_content("AKIAIOSFODNN7EXAMPLE", None);
1684        assert!(!findings.is_empty());
1685        assert!(findings[0].remediation.is_some());
1686    }
1687
1688    #[test]
1689    fn test_secret_scanner_cwe_set() {
1690        let mut scanner = SecretScanner::new();
1691        let findings = scanner.scan_content("AKIAIOSFODNN7EXAMPLE", None);
1692        assert!(!findings.is_empty());
1693        assert_eq!(findings[0].cwe.as_deref(), Some("CWE-798"));
1694    }
1695
1696    #[test]
1697    fn test_secret_scanner_add_custom_pattern() {
1698        let mut scanner = SecretScanner::new();
1699        let custom = SecretPattern::new("Custom Secret", r"MYSECRET\d{6}", SecuritySeverity::High);
1700        scanner.add_pattern(custom);
1701        let content = "key = MYSECRET123456";
1702        let findings = scanner.scan_content(content, None);
1703        assert!(findings.iter().any(|f| f.title == "Custom Secret"));
1704    }
1705
1706    #[test]
1707    fn test_secret_scanner_unicode_content() {
1708        let mut scanner = SecretScanner::new();
1709        // Unicode characters should not cause panics
1710        let content = "let msg = \"こんにちは世界\"; // no secrets here";
1711        let findings = scanner.scan_content(content, None);
1712        // Comment line is skipped; non-comment line has no secret pattern match
1713        assert!(findings.is_empty());
1714    }
1715
1716    #[test]
1717    fn test_secret_scanner_very_long_line() {
1718        let mut scanner = SecretScanner::new();
1719        // 10 000-character line with no secrets; should complete without panic
1720        let content = "a".repeat(10_000);
1721        let findings = scanner.scan_content(&content, None);
1722        assert!(findings.is_empty());
1723    }
1724
1725    #[test]
1726    fn test_secret_scanner_multiline_content() {
1727        let mut scanner = SecretScanner::new();
1728        let content = "line1\nline2\nline3\nline4\nline5";
1729        let findings = scanner.scan_content(content, None);
1730        assert!(findings.is_empty());
1731    }
1732
1733    // -------------------------------------------------------------------------
1734    // VulnerabilityDetector
1735    // -------------------------------------------------------------------------
1736
1737    #[test]
1738    fn test_vulnerability_detector_new() {
1739        let detector = VulnerabilityDetector::new();
1740        assert!(detector.findings().is_empty());
1741    }
1742
1743    #[test]
1744    fn test_vulnerability_detector_default() {
1745        let detector = VulnerabilityDetector::default();
1746        assert!(detector.findings().is_empty());
1747    }
1748
1749    #[test]
1750    fn test_vulnerability_detector_empty_content() {
1751        let mut detector = VulnerabilityDetector::new();
1752        let findings = detector.scan_content("", None, "rust");
1753        assert!(findings.is_empty());
1754    }
1755
1756    #[test]
1757    fn test_vulnerability_detector_rust_unsafe() {
1758        let mut detector = VulnerabilityDetector::new();
1759        let content = "unsafe { ptr::read(addr) }";
1760        let findings = detector.scan_content(content, None, "rust");
1761        assert!(!findings.is_empty());
1762        assert!(findings.iter().any(|f| f.cwe.as_deref() == Some("CWE-242")));
1763    }
1764
1765    #[test]
1766    fn test_vulnerability_detector_rust_unsafe_severity() {
1767        let mut detector = VulnerabilityDetector::new();
1768        let content = "unsafe { }";
1769        let findings = detector.scan_content(content, None, "rust");
1770        assert!(!findings.is_empty());
1771        // RUST001 is Medium
1772        assert!(findings
1773            .iter()
1774            .any(|f| f.severity == SecuritySeverity::Medium));
1775    }
1776
1777    #[test]
1778    fn test_vulnerability_detector_rust_unwrap() {
1779        let mut detector = VulnerabilityDetector::new();
1780        let content = "let x = option.unwrap();";
1781        let findings = detector.scan_content(content, None, "rust");
1782        assert!(!findings.is_empty());
1783        assert!(findings.iter().any(|f| f.cwe.as_deref() == Some("CWE-252")));
1784    }
1785
1786    #[test]
1787    fn test_vulnerability_detector_rust_unwrap_severity() {
1788        let mut detector = VulnerabilityDetector::new();
1789        let content = "let x = result.unwrap();";
1790        let findings = detector.scan_content(content, None, "rust");
1791        assert!(!findings.is_empty());
1792        // RUST002 is Low
1793        assert!(findings.iter().any(|f| f.severity == SecuritySeverity::Low));
1794    }
1795
1796    #[test]
1797    fn test_vulnerability_detector_rust_sql_injection_select() {
1798        let mut detector = VulnerabilityDetector::new();
1799        let content = r#"let q = format!("SELECT * FROM users WHERE id={}", id);"#;
1800        let findings = detector.scan_content(content, None, "rust");
1801        assert!(!findings.is_empty());
1802        assert!(findings.iter().any(|f| f.cwe.as_deref() == Some("CWE-89")));
1803    }
1804
1805    #[test]
1806    fn test_vulnerability_detector_rust_sql_injection_insert() {
1807        let mut detector = VulnerabilityDetector::new();
1808        let content = r#"let q = format!("INSERT INTO logs VALUES({})", val);"#;
1809        let findings = detector.scan_content(content, None, "rust");
1810        assert!(!findings.is_empty());
1811    }
1812
1813    #[test]
1814    fn test_vulnerability_detector_rust_sql_injection_update() {
1815        let mut detector = VulnerabilityDetector::new();
1816        let content = r#"let q = format!("UPDATE users SET name={}", name);"#;
1817        let findings = detector.scan_content(content, None, "rust");
1818        assert!(!findings.is_empty());
1819    }
1820
1821    #[test]
1822    fn test_vulnerability_detector_rust_sql_injection_delete() {
1823        let mut detector = VulnerabilityDetector::new();
1824        let content = r#"let q = format!("DELETE FROM sessions WHERE id={}", id);"#;
1825        let findings = detector.scan_content(content, None, "rust");
1826        assert!(!findings.is_empty());
1827    }
1828
1829    #[test]
1830    fn test_vulnerability_detector_rust_command_injection() {
1831        let mut detector = VulnerabilityDetector::new();
1832        let content = r#"Command::new(&format!("{}", user_input)).spawn().unwrap();"#;
1833        let findings = detector.scan_content(content, None, "rust");
1834        assert!(!findings.is_empty());
1835        assert!(findings
1836            .iter()
1837            .any(|f| f.severity == SecuritySeverity::Critical));
1838        assert!(findings.iter().any(|f| f.cwe.as_deref() == Some("CWE-78")));
1839    }
1840
1841    #[test]
1842    fn test_vulnerability_detector_rust_path_traversal_path_new() {
1843        let mut detector = VulnerabilityDetector::new();
1844        let content = r#"let p = Path::new(&format!("/uploads/{}", filename));"#;
1845        let findings = detector.scan_content(content, None, "rust");
1846        assert!(!findings.is_empty());
1847        assert!(findings.iter().any(|f| f.cwe.as_deref() == Some("CWE-22")));
1848    }
1849
1850    #[test]
1851    fn test_vulnerability_detector_rust_path_traversal_pathbuf_from() {
1852        let mut detector = VulnerabilityDetector::new();
1853        let content = r#"let p = PathBuf::from(&format!("/data/{}", input));"#;
1854        let findings = detector.scan_content(content, None, "rust");
1855        assert!(!findings.is_empty());
1856    }
1857
1858    #[test]
1859    fn test_vulnerability_detector_js_eval() {
1860        let mut detector = VulnerabilityDetector::new();
1861        let content = "eval(userInput)";
1862        let findings = detector.scan_content(content, None, "javascript");
1863        assert!(!findings.is_empty());
1864        assert!(findings
1865            .iter()
1866            .any(|f| f.severity == SecuritySeverity::Critical));
1867        assert!(findings.iter().any(|f| f.cwe.as_deref() == Some("CWE-95")));
1868    }
1869
1870    #[test]
1871    fn test_vulnerability_detector_js_inner_html() {
1872        let mut detector = VulnerabilityDetector::new();
1873        let content = "element.innerHTML = userInput;";
1874        let findings = detector.scan_content(content, None, "javascript");
1875        assert!(!findings.is_empty());
1876        assert!(findings.iter().any(|f| f.cwe.as_deref() == Some("CWE-79")));
1877    }
1878
1879    #[test]
1880    fn test_vulnerability_detector_python_exec() {
1881        let mut detector = VulnerabilityDetector::new();
1882        let content = "exec(user_code)";
1883        let findings = detector.scan_content(content, None, "python");
1884        assert!(!findings.is_empty());
1885        assert!(findings
1886            .iter()
1887            .any(|f| f.severity == SecuritySeverity::Critical));
1888    }
1889
1890    #[test]
1891    fn test_vulnerability_detector_python_eval() {
1892        let mut detector = VulnerabilityDetector::new();
1893        let content = "result = eval(expression)";
1894        let findings = detector.scan_content(content, None, "python");
1895        assert!(!findings.is_empty());
1896    }
1897
1898    #[test]
1899    fn test_vulnerability_detector_python_pickle_load() {
1900        let mut detector = VulnerabilityDetector::new();
1901        let content = "obj = pickle.load(file_handle)";
1902        let findings = detector.scan_content(content, None, "python");
1903        assert!(!findings.is_empty());
1904        assert!(findings.iter().any(|f| f.cwe.as_deref() == Some("CWE-502")));
1905    }
1906
1907    #[test]
1908    fn test_vulnerability_detector_python_pickle_loads() {
1909        let mut detector = VulnerabilityDetector::new();
1910        let content = "data = pickle.loads(raw_bytes)";
1911        let findings = detector.scan_content(content, None, "python");
1912        assert!(!findings.is_empty());
1913    }
1914
1915    #[test]
1916    fn test_vulnerability_detector_language_filter() {
1917        // JS patterns should NOT fire on Rust code
1918        let mut detector = VulnerabilityDetector::new();
1919        let content = "eval(something)";
1920        let findings = detector.scan_content(content, None, "rust");
1921        // JS001 (eval) should not match when language is "rust"
1922        assert!(!findings.iter().any(|f| f.cwe.as_deref() == Some("CWE-95")));
1923    }
1924
1925    #[test]
1926    fn test_vulnerability_detector_with_file_location() {
1927        let mut detector = VulnerabilityDetector::new();
1928        let path = PathBuf::from("src/main.rs");
1929        let content = "unsafe { }";
1930        let findings = detector.scan_content(content, Some(&path), "rust");
1931        assert!(!findings.is_empty());
1932        assert_eq!(findings[0].file, Some(path));
1933        assert_eq!(findings[0].line, Some(1));
1934    }
1935
1936    #[test]
1937    fn test_vulnerability_detector_snippet_is_trimmed() {
1938        let mut detector = VulnerabilityDetector::new();
1939        let content = "    unsafe { }  ";
1940        let findings = detector.scan_content(content, None, "rust");
1941        assert!(!findings.is_empty());
1942        // snippet should be trimmed
1943        if let Some(snippet) = &findings[0].snippet {
1944            assert_eq!(snippet.trim(), snippet.as_str());
1945        }
1946    }
1947
1948    #[test]
1949    fn test_vulnerability_detector_accumulates_findings() {
1950        let mut detector = VulnerabilityDetector::new();
1951        detector.scan_content("unsafe { }", None, "rust");
1952        detector.scan_content("let x = option.unwrap();", None, "rust");
1953        assert!(detector.findings().len() >= 2);
1954    }
1955
1956    #[test]
1957    fn test_vulnerability_detector_clear() {
1958        let mut detector = VulnerabilityDetector::new();
1959        detector.scan_content("unsafe { }", None, "rust");
1960        assert!(!detector.findings().is_empty());
1961        detector.clear();
1962        assert!(detector.findings().is_empty());
1963    }
1964
1965    #[test]
1966    fn test_vulnerability_detector_add_custom_pattern() {
1967        let mut detector = VulnerabilityDetector::new();
1968        detector.add_pattern(VulnerabilityPattern {
1969            id: "CUSTOM001".to_string(),
1970            name: "Custom Check".to_string(),
1971            language: "rust".to_string(),
1972            pattern: r"todo!\(\)".to_string(),
1973            severity: SecuritySeverity::Info,
1974            cwe: "CWE-0".to_string(),
1975            description: "Incomplete code marker".to_string(),
1976            remediation: "Remove todo!() before production".to_string(),
1977        });
1978        let findings = detector.scan_content("todo!()", None, "rust");
1979        assert!(findings.iter().any(|f| f.title == "Custom Check"));
1980    }
1981
1982    #[test]
1983    fn test_vulnerability_detector_unknown_language_no_matches() {
1984        let mut detector = VulnerabilityDetector::new();
1985        // "cobol" matches no built-in patterns
1986        let content = "eval(foo) unsafe { } pickle.loads(x)";
1987        let findings = detector.scan_content(content, None, "cobol");
1988        assert!(findings.is_empty());
1989    }
1990
1991    #[test]
1992    fn test_vulnerability_detector_wildcard_language() {
1993        // A pattern with language "*" should match all languages
1994        let mut detector = VulnerabilityDetector::new();
1995        detector.add_pattern(VulnerabilityPattern {
1996            id: "ALL001".to_string(),
1997            name: "Universal Check".to_string(),
1998            language: "*".to_string(),
1999            pattern: r"FORBIDDEN".to_string(),
2000            severity: SecuritySeverity::High,
2001            cwe: "CWE-999".to_string(),
2002            description: "Forbidden token".to_string(),
2003            remediation: "Remove it".to_string(),
2004        });
2005        let findings_rust = detector.scan_content("FORBIDDEN", None, "rust");
2006        assert!(!findings_rust.is_empty());
2007        let findings_js = detector.scan_content("FORBIDDEN", None, "javascript");
2008        assert!(!findings_js.is_empty());
2009    }
2010
2011    // -------------------------------------------------------------------------
2012    // KnownVulnerability
2013    // -------------------------------------------------------------------------
2014
2015    #[test]
2016    fn test_known_vulnerability_new() {
2017        let vuln = KnownVulnerability::new("CVE-2021-1234", SecuritySeverity::High, "Test vuln");
2018        assert_eq!(vuln.id, "CVE-2021-1234");
2019        assert_eq!(vuln.severity, SecuritySeverity::High);
2020        assert_eq!(vuln.description, "Test vuln");
2021        assert!(vuln.fixed_version.is_none());
2022        assert!(vuln.url.is_none());
2023    }
2024
2025    #[test]
2026    fn test_known_vulnerability_with_fixed_version() {
2027        let vuln = KnownVulnerability::new("CVE-X", SecuritySeverity::Low, "desc")
2028            .with_fixed_version("2.0.0");
2029        assert_eq!(vuln.fixed_version.as_deref(), Some("2.0.0"));
2030    }
2031
2032    #[test]
2033    fn test_known_vulnerability_with_url() {
2034        let vuln = KnownVulnerability::new("CVE-X", SecuritySeverity::Low, "desc")
2035            .with_url("https://nvd.nist.gov/vuln/detail/CVE-X");
2036        assert_eq!(
2037            vuln.url.as_deref(),
2038            Some("https://nvd.nist.gov/vuln/detail/CVE-X")
2039        );
2040    }
2041
2042    // -------------------------------------------------------------------------
2043    // Dependency
2044    // -------------------------------------------------------------------------
2045
2046    #[test]
2047    fn test_dependency_new() {
2048        let dep = Dependency::new("test-pkg", "1.0.0", "npm");
2049        assert_eq!(dep.name, "test-pkg");
2050        assert_eq!(dep.version, "1.0.0");
2051        assert_eq!(dep.source, "npm");
2052        assert!(!dep.is_vulnerable());
2053        assert!(dep.vulnerabilities.is_empty());
2054    }
2055
2056    #[test]
2057    fn test_dependency_is_vulnerable_with_vuln() {
2058        let mut dep = Dependency::new("pkg", "1.0", "crates.io");
2059        dep.vulnerabilities.push(KnownVulnerability::new(
2060            "CVE-X",
2061            SecuritySeverity::High,
2062            "d",
2063        ));
2064        assert!(dep.is_vulnerable());
2065    }
2066
2067    #[test]
2068    fn test_dependency_max_severity_none() {
2069        let dep = Dependency::new("pkg", "1.0", "crates.io");
2070        assert_eq!(dep.max_severity(), None);
2071    }
2072
2073    #[test]
2074    fn test_dependency_max_severity_single() {
2075        let mut dep = Dependency::new("pkg", "1.0", "crates.io");
2076        dep.vulnerabilities.push(KnownVulnerability::new(
2077            "CVE-X",
2078            SecuritySeverity::High,
2079            "d",
2080        ));
2081        assert_eq!(dep.max_severity(), Some(SecuritySeverity::High));
2082    }
2083
2084    #[test]
2085    fn test_dependency_max_severity_multiple() {
2086        let mut dep = Dependency::new("pkg", "1.0", "crates.io");
2087        dep.vulnerabilities
2088            .push(KnownVulnerability::new("CVE-A", SecuritySeverity::Low, "d"));
2089        dep.vulnerabilities.push(KnownVulnerability::new(
2090            "CVE-B",
2091            SecuritySeverity::Critical,
2092            "e",
2093        ));
2094        dep.vulnerabilities.push(KnownVulnerability::new(
2095            "CVE-C",
2096            SecuritySeverity::High,
2097            "f",
2098        ));
2099        assert_eq!(dep.max_severity(), Some(SecuritySeverity::Critical));
2100    }
2101
2102    // -------------------------------------------------------------------------
2103    // DependencyAuditor
2104    // -------------------------------------------------------------------------
2105
2106    #[test]
2107    fn test_dependency_auditor_new() {
2108        let auditor = DependencyAuditor::new();
2109        assert!(auditor.vulnerable_dependencies().is_empty());
2110        assert!(auditor.findings().is_empty());
2111    }
2112
2113    #[test]
2114    fn test_dependency_auditor_default() {
2115        let auditor = DependencyAuditor::default();
2116        assert!(auditor.findings().is_empty());
2117    }
2118
2119    #[test]
2120    fn test_dependency_auditor_known_vuln_lodash() {
2121        let mut auditor = DependencyAuditor::new();
2122        let dep = auditor.audit_dependency("lodash", "4.17.0", "npm");
2123        assert!(dep.is_vulnerable());
2124        assert!(!auditor.findings().is_empty());
2125        assert_eq!(auditor.findings()[0].category, SecurityCategory::Dependency);
2126    }
2127
2128    #[test]
2129    fn test_dependency_auditor_known_vuln_log4j() {
2130        let mut auditor = DependencyAuditor::new();
2131        let dep = auditor.audit_dependency("log4j", "2.14.0", "maven");
2132        assert!(dep.is_vulnerable());
2133        assert!(dep.vulnerabilities.iter().any(|v| v.id == "CVE-2021-44228"));
2134        assert!(dep
2135            .vulnerabilities
2136            .iter()
2137            .any(|v| v.severity == SecuritySeverity::Critical));
2138    }
2139
2140    #[test]
2141    fn test_dependency_auditor_safe_dep() {
2142        let mut auditor = DependencyAuditor::new();
2143        let dep = auditor.audit_dependency("safe-package", "1.0.0", "npm");
2144        assert!(!dep.is_vulnerable());
2145    }
2146
2147    #[test]
2148    fn test_dependency_auditor_vulnerable_dependencies_list() {
2149        let mut auditor = DependencyAuditor::new();
2150        auditor.audit_dependency("safe-package", "1.0.0", "npm");
2151        auditor.audit_dependency("lodash", "4.17.0", "npm");
2152        let vulns = auditor.vulnerable_dependencies();
2153        assert_eq!(vulns.len(), 1);
2154        assert_eq!(vulns[0].name, "lodash");
2155    }
2156
2157    #[test]
2158    fn test_dependency_auditor_add_vulnerability() {
2159        let mut auditor = DependencyAuditor::new();
2160        let vuln = KnownVulnerability::new("CVE-CUSTOM", SecuritySeverity::Medium, "Custom vuln");
2161        auditor.add_vulnerability("my-package", vuln);
2162        let dep = auditor.audit_dependency("my-package", "1.0.0", "custom");
2163        assert!(dep.is_vulnerable());
2164    }
2165
2166    #[test]
2167    fn test_dependency_auditor_finding_remediation_contains_fixed_version() {
2168        let mut auditor = DependencyAuditor::new();
2169        auditor.audit_dependency("lodash", "4.17.0", "npm");
2170        let finding = &auditor.findings()[0];
2171        // remediation should mention the fixed version
2172        assert!(finding
2173            .remediation
2174            .as_deref()
2175            .unwrap_or("")
2176            .contains("4.17.21"));
2177    }
2178
2179    #[test]
2180    fn test_dependency_auditor_clear() {
2181        let mut auditor = DependencyAuditor::new();
2182        auditor.audit_dependency("lodash", "4.17.0", "npm");
2183        assert!(!auditor.findings().is_empty());
2184        auditor.clear();
2185        assert!(auditor.findings().is_empty());
2186        assert!(auditor.vulnerable_dependencies().is_empty());
2187    }
2188
2189    // -------------------------------------------------------------------------
2190    // ComplianceRule
2191    // -------------------------------------------------------------------------
2192
2193    #[test]
2194    fn test_compliance_rule_new() {
2195        let rule = ComplianceRule::new("R1", "OWASP", "Test rule");
2196        assert_eq!(rule.id, "R1");
2197        assert_eq!(rule.standard, "OWASP");
2198        assert_eq!(rule.description, "Test rule");
2199        assert!(rule.pattern.is_none());
2200        assert_eq!(rule.severity, SecuritySeverity::Medium);
2201    }
2202
2203    #[test]
2204    fn test_compliance_rule_with_pattern() {
2205        let rule = ComplianceRule::new("R1", "OWASP", "desc").with_pattern(r"md5\(");
2206        assert!(rule.pattern.is_some());
2207    }
2208
2209    #[test]
2210    fn test_compliance_rule_with_severity() {
2211        let rule =
2212            ComplianceRule::new("R1", "PCI", "desc").with_severity(SecuritySeverity::Critical);
2213        assert_eq!(rule.severity, SecuritySeverity::Critical);
2214    }
2215
2216    // -------------------------------------------------------------------------
2217    // ComplianceChecker
2218    // -------------------------------------------------------------------------
2219
2220    #[test]
2221    fn test_compliance_checker_new() {
2222        let checker = ComplianceChecker::new();
2223        assert!(!checker.standards().is_empty());
2224        assert!(checker.findings().is_empty());
2225    }
2226
2227    #[test]
2228    fn test_compliance_checker_default() {
2229        let checker = ComplianceChecker::default();
2230        assert!(!checker.standards().is_empty());
2231    }
2232
2233    #[test]
2234    fn test_compliance_checker_standards_sorted_deduped() {
2235        let checker = ComplianceChecker::new();
2236        let standards = checker.standards();
2237        // Check sorted
2238        for window in standards.windows(2) {
2239            assert!(window[0] <= window[1]);
2240        }
2241        // Check deduped
2242        let mut prev = "";
2243        for s in &standards {
2244            assert_ne!(s, prev, "duplicate standard found: {s}");
2245            prev = s;
2246        }
2247    }
2248
2249    #[test]
2250    fn test_compliance_checker_contains_known_standards() {
2251        let checker = ComplianceChecker::new();
2252        let standards = checker.standards();
2253        assert!(standards.iter().any(|s| s == "OWASP Top 10"));
2254        assert!(standards.iter().any(|s| s == "PCI-DSS"));
2255        assert!(standards.iter().any(|s| s == "HIPAA"));
2256    }
2257
2258    #[test]
2259    fn test_compliance_checker_weak_crypto_md5() {
2260        let mut checker = ComplianceChecker::new();
2261        let content = "hash = md5(password)";
2262        let findings = checker.check_content(content, None);
2263        assert!(!findings.is_empty());
2264        assert!(findings
2265            .iter()
2266            .any(|f| f.category == SecurityCategory::Compliance));
2267    }
2268
2269    #[test]
2270    fn test_compliance_checker_weak_crypto_sha1() {
2271        let mut checker = ComplianceChecker::new();
2272        let content = "digest = sha1(data)";
2273        let findings = checker.check_content(content, None);
2274        assert!(!findings.is_empty());
2275    }
2276
2277    #[test]
2278    fn test_compliance_checker_case_insensitive_md5() {
2279        let mut checker = ComplianceChecker::new();
2280        let content = "hash = MD5(password)";
2281        let findings = checker.check_content(content, None);
2282        assert!(!findings.is_empty());
2283    }
2284
2285    #[test]
2286    fn test_compliance_checker_case_insensitive_sha1() {
2287        let mut checker = ComplianceChecker::new();
2288        let content = "digest = SHA1(data)";
2289        let findings = checker.check_content(content, None);
2290        assert!(!findings.is_empty());
2291    }
2292
2293    #[test]
2294    fn test_compliance_checker_clean_code() {
2295        let mut checker = ComplianceChecker::new();
2296        // sha256 is not a weak hash; should not trigger
2297        let content = "digest = sha256(data)";
2298        let findings = checker.check_content(content, None);
2299        assert!(findings.is_empty());
2300    }
2301
2302    #[test]
2303    fn test_compliance_checker_empty_content() {
2304        let mut checker = ComplianceChecker::new();
2305        let findings = checker.check_content("", None);
2306        assert!(findings.is_empty());
2307    }
2308
2309    #[test]
2310    fn test_compliance_checker_with_file_path() {
2311        let mut checker = ComplianceChecker::new();
2312        let path = PathBuf::from("crypto.py");
2313        let content = "hash = md5(data)";
2314        let findings = checker.check_content(content, Some(&path));
2315        assert!(!findings.is_empty());
2316        assert_eq!(findings[0].file, Some(path));
2317    }
2318
2319    #[test]
2320    fn test_compliance_checker_snippet_is_trimmed() {
2321        let mut checker = ComplianceChecker::new();
2322        let content = "   hash = md5(data)   ";
2323        let findings = checker.check_content(content, None);
2324        assert!(!findings.is_empty());
2325        if let Some(snippet) = &findings[0].snippet {
2326            assert_eq!(snippet.trim(), snippet.as_str());
2327        }
2328    }
2329
2330    #[test]
2331    fn test_compliance_checker_accumulates_findings() {
2332        let mut checker = ComplianceChecker::new();
2333        checker.check_content("md5(x)", None);
2334        checker.check_content("sha1(y)", None);
2335        assert!(checker.findings().len() >= 2);
2336    }
2337
2338    #[test]
2339    fn test_compliance_checker_clear() {
2340        let mut checker = ComplianceChecker::new();
2341        checker.check_content("md5(x)", None);
2342        assert!(!checker.findings().is_empty());
2343        checker.clear();
2344        assert!(checker.findings().is_empty());
2345    }
2346
2347    #[test]
2348    fn test_compliance_checker_add_rule() {
2349        let mut checker = ComplianceChecker::new();
2350        checker.add_rule(
2351            ComplianceRule::new("CUSTOM-001", "CUSTOM_STD", "No goto")
2352                .with_pattern(r"\bgoto\b")
2353                .with_severity(SecuritySeverity::Low),
2354        );
2355        let findings = checker.check_content("goto label;", None);
2356        assert!(findings.iter().any(|f| f.title.contains("CUSTOM-001")));
2357    }
2358
2359    // -------------------------------------------------------------------------
2360    // ScanResult
2361    // -------------------------------------------------------------------------
2362
2363    #[test]
2364    fn test_scan_result_new() {
2365        let result = ScanResult::new();
2366        assert_eq!(result.total_findings(), 0);
2367        assert!(!result.has_critical());
2368        assert!(!result.has_high());
2369        assert_eq!(result.risk_score(), 0.0);
2370        assert_eq!(result.files_scanned, 0);
2371        assert_eq!(result.lines_scanned, 0);
2372    }
2373
2374    #[test]
2375    fn test_scan_result_default() {
2376        let result = ScanResult::default();
2377        assert_eq!(result.total_findings(), 0);
2378    }
2379
2380    #[test]
2381    fn test_scan_result_total_findings() {
2382        let mut result = ScanResult::new();
2383        result.findings.push(SecurityFinding::new(
2384            "F1",
2385            SecurityCategory::Injection,
2386            SecuritySeverity::Low,
2387        ));
2388        result.findings.push(SecurityFinding::new(
2389            "F2",
2390            SecurityCategory::Injection,
2391            SecuritySeverity::High,
2392        ));
2393        assert_eq!(result.total_findings(), 2);
2394    }
2395
2396    #[test]
2397    fn test_scan_result_has_critical_true() {
2398        let mut result = ScanResult::new();
2399        result.by_severity.insert(SecuritySeverity::Critical, 1);
2400        assert!(result.has_critical());
2401    }
2402
2403    #[test]
2404    fn test_scan_result_has_critical_zero() {
2405        let mut result = ScanResult::new();
2406        result.by_severity.insert(SecuritySeverity::Critical, 0);
2407        assert!(!result.has_critical());
2408    }
2409
2410    #[test]
2411    fn test_scan_result_has_high_true() {
2412        let mut result = ScanResult::new();
2413        result.by_severity.insert(SecuritySeverity::High, 2);
2414        assert!(result.has_high());
2415    }
2416
2417    #[test]
2418    fn test_scan_result_has_high_zero() {
2419        let mut result = ScanResult::new();
2420        result.by_severity.insert(SecuritySeverity::High, 0);
2421        assert!(!result.has_high());
2422    }
2423
2424    #[test]
2425    fn test_scan_result_risk_score_empty() {
2426        let result = ScanResult::new();
2427        assert_eq!(result.risk_score(), 0.0);
2428    }
2429
2430    #[test]
2431    fn test_scan_result_risk_score_one_critical() {
2432        let mut result = ScanResult::new();
2433        result.findings.push(SecurityFinding::new(
2434            "Test",
2435            SecurityCategory::Injection,
2436            SecuritySeverity::Critical,
2437        ));
2438        assert_eq!(result.risk_score(), 9.5);
2439    }
2440
2441    #[test]
2442    fn test_scan_result_risk_score_additive() {
2443        let mut result = ScanResult::new();
2444        result.findings.push(SecurityFinding::new(
2445            "F1",
2446            SecurityCategory::Injection,
2447            SecuritySeverity::High,
2448        ));
2449        result.findings.push(SecurityFinding::new(
2450            "F2",
2451            SecurityCategory::Injection,
2452            SecuritySeverity::Medium,
2453        ));
2454        // 7.5 + 5.5 = 13.0
2455        assert!((result.risk_score() - 13.0).abs() < f32::EPSILON);
2456    }
2457
2458    // -------------------------------------------------------------------------
2459    // SecurityScanner (top-level integration)
2460    // -------------------------------------------------------------------------
2461
2462    #[test]
2463    fn test_security_scanner_new() {
2464        let scanner = SecurityScanner::new();
2465        let stats = scanner.get_stats();
2466        assert_eq!(stats.total_scans, 0);
2467        assert_eq!(stats.total_findings, 0);
2468        assert_eq!(stats.critical_findings, 0);
2469        assert_eq!(stats.high_findings, 0);
2470    }
2471
2472    #[test]
2473    fn test_security_scanner_default() {
2474        let scanner = SecurityScanner::default();
2475        let stats = scanner.get_stats();
2476        assert_eq!(stats.total_scans, 0);
2477    }
2478
2479    #[test]
2480    fn test_security_scanner_scan_clean_code() {
2481        let scanner = SecurityScanner::new();
2482        let result = scanner.scan_content("let x = 1;", None, "rust");
2483        assert_eq!(result.files_scanned, 1);
2484        assert_eq!(result.lines_scanned, 1);
2485    }
2486
2487    #[test]
2488    fn test_security_scanner_scan_empty_content() {
2489        let scanner = SecurityScanner::new();
2490        let result = scanner.scan_content("", None, "rust");
2491        assert_eq!(result.total_findings(), 0);
2492        assert_eq!(result.lines_scanned, 0);
2493    }
2494
2495    #[test]
2496    fn test_security_scanner_scan_detects_aws_secret() {
2497        let scanner = SecurityScanner::new();
2498        let result = scanner.scan_content("api_key = \"AKIAIOSFODNN7EXAMPLE\"", None, "rust");
2499        assert!(result.total_findings() > 0);
2500        assert!(result.by_category.contains_key("hardcoded_secret"));
2501    }
2502
2503    #[test]
2504    fn test_security_scanner_scan_detects_rust_unsafe() {
2505        let scanner = SecurityScanner::new();
2506        let result = scanner.scan_content("unsafe { }", None, "rust");
2507        assert!(result.total_findings() > 0);
2508        assert!(result.by_category.contains_key("code_quality"));
2509    }
2510
2511    #[test]
2512    fn test_security_scanner_scan_detects_weak_crypto() {
2513        let scanner = SecurityScanner::new();
2514        let result = scanner.scan_content("md5(password)", None, "python");
2515        assert!(result.total_findings() > 0);
2516        assert!(result.by_category.contains_key("compliance"));
2517    }
2518
2519    #[test]
2520    fn test_security_scanner_by_severity_populated() {
2521        let scanner = SecurityScanner::new();
2522        // unsafe is Medium severity
2523        let result = scanner.scan_content("unsafe { }", None, "rust");
2524        let total: usize = result.by_severity.values().sum();
2525        assert_eq!(total, result.total_findings());
2526    }
2527
2528    #[test]
2529    fn test_security_scanner_by_category_populated() {
2530        let scanner = SecurityScanner::new();
2531        let result = scanner.scan_content("unsafe { }", None, "rust");
2532        let total: usize = result.by_category.values().sum();
2533        assert_eq!(total, result.total_findings());
2534    }
2535
2536    #[test]
2537    fn test_security_scanner_duration_recorded() {
2538        let scanner = SecurityScanner::new();
2539        let result = scanner.scan_content("let x = 1;", None, "rust");
2540        // duration_ms may be 0 for fast scans but should not be negative (it's u64)
2541        let _ = result.duration_ms;
2542    }
2543
2544    #[test]
2545    fn test_security_scanner_stats_accumulate_across_scans() {
2546        let scanner = SecurityScanner::new();
2547        scanner.scan_content("let x = 1;", None, "rust");
2548        scanner.scan_content("let y = 2;", None, "rust");
2549        let stats = scanner.get_stats();
2550        assert_eq!(stats.total_scans, 2);
2551    }
2552
2553    #[test]
2554    fn test_security_scanner_stats_count_findings() {
2555        let scanner = SecurityScanner::new();
2556        scanner.scan_content("unsafe { }", None, "rust");
2557        let stats = scanner.get_stats();
2558        assert!(stats.total_findings >= 1);
2559    }
2560
2561    #[test]
2562    fn test_security_scanner_stats_count_critical() {
2563        let scanner = SecurityScanner::new();
2564        // An AWS key is Critical
2565        scanner.scan_content("AKIAIOSFODNN7EXAMPLE", None, "rust");
2566        let stats = scanner.get_stats();
2567        assert!(stats.critical_findings >= 1);
2568    }
2569
2570    #[test]
2571    fn test_security_scanner_stats_count_high() {
2572        let scanner = SecurityScanner::new();
2573        // A bearer token is High
2574        scanner.scan_content("Authorization: Bearer sometoken123", None, "rust");
2575        let stats = scanner.get_stats();
2576        assert!(stats.high_findings >= 1);
2577    }
2578
2579    #[test]
2580    fn test_security_scanner_audit_dependency_vulnerable() {
2581        let scanner = SecurityScanner::new();
2582        let dep = scanner.audit_dependency("lodash", "4.17.0", "npm");
2583        assert!(dep.is_vulnerable());
2584    }
2585
2586    #[test]
2587    fn test_security_scanner_audit_dependency_safe() {
2588        let scanner = SecurityScanner::new();
2589        let dep = scanner.audit_dependency("totally-safe-pkg", "9.9.9", "npm");
2590        assert!(!dep.is_vulnerable());
2591    }
2592
2593    #[test]
2594    fn test_security_scanner_audit_dependency_log4j() {
2595        let scanner = SecurityScanner::new();
2596        let dep = scanner.audit_dependency("log4j", "2.14.0", "maven");
2597        assert!(dep.is_vulnerable());
2598        assert!(dep
2599            .vulnerabilities
2600            .iter()
2601            .any(|v| v.severity == SecuritySeverity::Critical));
2602    }
2603
2604    #[test]
2605    fn test_security_scanner_report_contains_header() {
2606        let scanner = SecurityScanner::new();
2607        let result = scanner.scan_content("let x = 1;", None, "rust");
2608        let report = scanner.generate_report(&result);
2609        assert!(report.contains("# Security Scan Report"));
2610    }
2611
2612    #[test]
2613    fn test_security_scanner_report_contains_stats() {
2614        let scanner = SecurityScanner::new();
2615        let result = scanner.scan_content("let x = 1;\nlet y = 2;", None, "rust");
2616        let report = scanner.generate_report(&result);
2617        assert!(report.contains("Files scanned: 1"));
2618        assert!(report.contains("Lines scanned: 2"));
2619        assert!(report.contains("Total findings:"));
2620        assert!(report.contains("Risk score:"));
2621    }
2622
2623    #[test]
2624    fn test_security_scanner_report_critical_section() {
2625        let scanner = SecurityScanner::new();
2626        // AWS key triggers Critical
2627        let result = scanner.scan_content("AKIAIOSFODNN7EXAMPLE", None, "rust");
2628        let report = scanner.generate_report(&result);
2629        assert!(report.contains("CRITICAL"));
2630    }
2631
2632    #[test]
2633    fn test_security_scanner_report_high_section() {
2634        let scanner = SecurityScanner::new();
2635        // Bearer token triggers High (no Critical expected for a bearer token alone)
2636        let result = scanner.scan_content("Authorization: Bearer sometoken", None, "rust");
2637        let report = scanner.generate_report(&result);
2638        // At minimum we get summary section
2639        assert!(report.contains("Summary by Category"));
2640    }
2641
2642    #[test]
2643    fn test_security_scanner_report_summary_by_category() {
2644        let scanner = SecurityScanner::new();
2645        let result = scanner.scan_content("unsafe { }", None, "rust");
2646        let report = scanner.generate_report(&result);
2647        assert!(report.contains("Summary by Category"));
2648    }
2649
2650    #[test]
2651    fn test_security_scanner_report_with_file_location() {
2652        let scanner = SecurityScanner::new();
2653        let path = PathBuf::from("src/secrets.rs");
2654        let result = scanner.scan_content("AKIAIOSFODNN7EXAMPLE", Some(&path), "rust");
2655        let report = scanner.generate_report(&result);
2656        assert!(report.contains("src/secrets.rs"));
2657    }
2658
2659    #[test]
2660    fn test_security_scanner_scan_history_capped() {
2661        let scanner = SecurityScanner::new();
2662        // Scan 110 times; history should be capped at 100
2663        for _ in 0..110 {
2664            scanner.scan_content("let x = 1;", None, "rust");
2665        }
2666        let stats = scanner.get_stats();
2667        assert!(stats.total_scans <= 100);
2668    }
2669
2670    #[test]
2671    fn test_security_scanner_multiline_rust_vuln() {
2672        let scanner = SecurityScanner::new();
2673        let content = "fn safe() {}\nfn danger() { unsafe { *ptr = 0; } }\nfn also_safe() {}";
2674        let result = scanner.scan_content(content, None, "rust");
2675        assert!(result.total_findings() >= 1);
2676        assert_eq!(result.lines_scanned, 3);
2677    }
2678
2679    // -------------------------------------------------------------------------
2680    // Edge cases & advanced scenarios
2681    // -------------------------------------------------------------------------
2682
2683    #[test]
2684    fn test_scan_only_whitespace() {
2685        let mut scanner = SecretScanner::new();
2686        let content = "   \n\t\n   ";
2687        let findings = scanner.scan_content(content, None);
2688        assert!(findings.is_empty());
2689    }
2690
2691    #[test]
2692    fn test_scan_windows_line_endings() {
2693        let mut scanner = SecretScanner::new();
2694        // Windows CRLF - should not cause panics
2695        let content = "let x = 1;\r\nlet y = 2;\r\n";
2696        let findings = scanner.scan_content(content, None);
2697        assert!(findings.is_empty());
2698    }
2699
2700    #[test]
2701    fn test_secret_scanner_short_secret_masking() {
2702        // mask_secret: len <= 8 → all stars
2703        let mut scanner = SecretScanner::new();
2704        // Craft content that matches the private key pattern (short enough to trigger <=8 branch)
2705        // The pattern match "-----BEGIN PRIVATE KEY-----" is > 8 chars, so we test the other branch
2706        // Just verify the scanner does not panic on short snippets
2707        let content = "-----BEGIN PRIVATE KEY-----";
2708        let findings = scanner.scan_content(content, None);
2709        if let Some(f) = findings.first() {
2710            // snippet must be Some and non-empty
2711            assert!(f.snippet.is_some());
2712        }
2713    }
2714
2715    #[test]
2716    fn test_vulnerability_detector_multiple_vulns_same_line() {
2717        let mut detector = VulnerabilityDetector::new();
2718        // This line matches both unsafe (RUST001) and unwrap (RUST002)
2719        let content = "unsafe { x.unwrap() }";
2720        let findings = detector.scan_content(content, None, "rust");
2721        assert!(findings.len() >= 2);
2722    }
2723
2724    #[test]
2725    fn test_compliance_checker_multiple_findings_same_line() {
2726        let mut checker = ComplianceChecker::new();
2727        // Both md5 and sha1 patterns on one line
2728        let content = "use_md5_and_sha1()";
2729        // At most one OWASP-A02 pattern fires since there is only one pattern rule for weak crypto
2730        let findings = checker.check_content(content, None);
2731        // Results depend on how many rules have patterns that match
2732        let _ = findings; // No panic is the key assertion
2733    }
2734
2735    #[test]
2736    fn test_security_finding_ids_are_unique() {
2737        // IDs are based on nanosecond timestamps; with sleep this would be guaranteed,
2738        // but we just check that the structure produces non-empty IDs and that two
2739        // findings created sequentially get the SEC- prefix.
2740        let f1 = SecurityFinding::new("A", SecurityCategory::Injection, SecuritySeverity::Low);
2741        let f2 = SecurityFinding::new("B", SecurityCategory::Injection, SecuritySeverity::Low);
2742        assert!(f1.id.starts_with("SEC-"));
2743        assert!(f2.id.starts_with("SEC-"));
2744    }
2745
2746    #[test]
2747    fn test_scan_result_has_critical_absent_key() {
2748        let result = ScanResult::new();
2749        // by_severity is empty → has_critical should return false
2750        assert!(!result.has_critical());
2751    }
2752
2753    #[test]
2754    fn test_scan_result_has_high_absent_key() {
2755        let result = ScanResult::new();
2756        assert!(!result.has_high());
2757    }
2758
2759    #[test]
2760    fn test_dependency_auditor_multiple_audits() {
2761        let mut auditor = DependencyAuditor::new();
2762        auditor.audit_dependency("lodash", "4.17.0", "npm");
2763        auditor.audit_dependency("log4j", "2.14.0", "maven");
2764        auditor.audit_dependency("safe-pkg", "1.0", "npm");
2765        assert_eq!(auditor.vulnerable_dependencies().len(), 2);
2766    }
2767
2768    #[test]
2769    fn test_scanner_stats_fields() {
2770        let scanner = SecurityScanner::new();
2771        // Scan with known critical finding
2772        scanner.scan_content("AKIAIOSFODNN7EXAMPLE", None, "rust");
2773        let stats = scanner.get_stats();
2774        assert_eq!(stats.total_scans, 1);
2775        assert!(stats.total_findings >= 1);
2776        assert!(stats.critical_findings >= 1);
2777    }
2778
2779    #[test]
2780    fn test_vulnerability_pattern_struct_fields() {
2781        let pattern = VulnerabilityPattern {
2782            id: "V1".to_string(),
2783            name: "Test Pattern".to_string(),
2784            language: "rust".to_string(),
2785            pattern: r"test".to_string(),
2786            severity: SecuritySeverity::Low,
2787            cwe: "CWE-1".to_string(),
2788            description: "A test pattern".to_string(),
2789            remediation: "Fix it".to_string(),
2790        };
2791        assert_eq!(pattern.id, "V1");
2792        assert_eq!(pattern.name, "Test Pattern");
2793        assert_eq!(pattern.language, "rust");
2794        assert_eq!(pattern.cwe, "CWE-1");
2795        assert_eq!(pattern.severity, SecuritySeverity::Low);
2796    }
2797
2798    #[test]
2799    fn test_secret_scanner_detects_multiline_secrets() {
2800        let mut scanner = SecretScanner::new();
2801        let content = "line1\nAKIAIOSFODNN7EXAMPLE\nline3";
2802        let findings = scanner.scan_content(content, None);
2803        assert!(!findings.is_empty());
2804    }
2805
2806    #[test]
2807    fn test_compliance_checker_no_match_when_no_pattern() {
2808        // Rules without a pattern should never produce findings via check_content
2809        let mut checker = ComplianceChecker::new();
2810        checker.add_rule(ComplianceRule::new(
2811            "OWASP-A01",
2812            "OWASP Top 10",
2813            "Broken Access Control",
2814        ));
2815        // "Broken Access Control" rule has no pattern, so check_content should not trigger it
2816        let findings = checker.check_content("something dangerous", None);
2817        // Only pattern-based rules fire; ensure no panic
2818        let _ = findings;
2819    }
2820}