go_brrr/security/
mod.rs

1//! Security analysis module for detecting vulnerabilities in source code.
2//!
3//! This module provides static analysis capabilities for detecting common
4//! security vulnerabilities such as command injection, SQL injection,
5//! unsafe deserialization, hardcoded secrets, and other security issues.
6//!
7//! # Unified API
8//!
9//! The security module provides a unified `scan_security` function that runs
10//! all security analyzers in parallel and returns a consolidated report:
11//!
12//! ```ignore
13//! use go_brrr::security::{scan_security, SecurityConfig, Severity};
14//!
15//! // Scan with default config (all analyzers, all severity levels)
16//! let report = scan_security("./src", &SecurityConfig::default())?;
17//!
18//! // Scan for CI/CD (medium+ severity, medium+ confidence)
19//! let ci_report = scan_security("./src", &SecurityConfig::ci())?;
20//!
21//! // Print SARIF output for GitHub/GitLab integration
22//! println!("{}", report.to_sarif_json()?);
23//!
24//! // Check if scan found critical issues
25//! let exit_code = report.exit_code(Severity::High);
26//! ```
27//!
28//! # Individual Analyzers
29//!
30//! Each analyzer can also be used independently:
31//!
32//! ## SQL Injection Detection
33//!
34//! ```ignore
35//! use go_brrr::security::injection::sql::SqlInjectionDetector;
36//!
37//! let detector = SqlInjectionDetector::new();
38//! let findings = detector.scan_file("./src/app.py")?;
39//! ```
40//!
41//! ## Command Injection Detection
42//!
43//! ```ignore
44//! use go_brrr::security::injection::command::scan_command_injection;
45//!
46//! let findings = scan_command_injection("./src", None)?;
47//! ```
48//!
49//! ## Secrets Detection
50//!
51//! ```ignore
52//! use go_brrr::security::secrets::scan_secrets;
53//!
54//! let result = scan_secrets("./src", None)?;
55//! ```
56//!
57//! # Output Formats
58//!
59//! - **JSON**: Standard JSON output for programmatic consumption
60//! - **SARIF**: Static Analysis Results Interchange Format for CI/CD integration
61//! - **Text**: Human-readable output for terminal display
62//!
63//! # Suppression
64//!
65//! Findings can be suppressed via inline comments:
66//!
67//! ```python
68//! # brrr-ignore: SQLI-001
69//! cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
70//! ```
71//!
72//! Supported comment formats:
73//! - `# brrr-ignore: <ID>` or `// brrr-ignore: <ID>`
74//! - `# nosec <ID>`
75//! - `# noqa: <ID>`
76//! - `# security-ignore: <ID>`
77
78pub mod crypto;
79pub mod deserialization;
80pub mod injection;
81pub mod redos;
82pub mod sarif;
83pub mod secrets;
84pub mod taint;
85pub mod types;
86
87// Re-export unified types
88pub use sarif::SarifLog;
89pub use types::{
90    check_suppression, is_suppressed, Confidence, InjectionType, Location, ScanSummary,
91    SecurityCategory, SecurityConfig, SecurityFinding, SecurityReport, Severity,
92};
93
94// Re-export individual analyzers for direct access
95pub use crypto::{scan_file_weak_crypto, scan_weak_crypto, WeakCryptoDetector};
96pub use deserialization::scan_deserialization;
97pub use injection::command::{scan_command_injection, scan_file_command_injection};
98pub use injection::path_traversal::{scan_file_path_traversal, scan_path_traversal};
99pub use injection::sql::SqlInjectionDetector;
100pub use injection::xss::{scan_file_xss, scan_xss};
101pub use redos::{scan_redos, ReDoSDetector};
102pub use secrets::scan_secrets;
103
104use std::collections::HashSet;
105use std::path::Path;
106use std::time::Instant;
107
108use rayon::prelude::*;
109
110use crate::callgraph::scanner::{ProjectScanner, ScanConfig};
111use crate::error::{Result, BrrrError};
112
113// =============================================================================
114// Unified Security Scan
115// =============================================================================
116
117/// Run all security analyzers on a path and return a unified report.
118///
119/// This function runs all enabled analyzers in parallel, deduplicates findings,
120/// applies suppression rules, and returns a consolidated security report.
121///
122/// # Arguments
123///
124/// * `path` - Path to scan (file or directory)
125/// * `config` - Security configuration (severity filters, categories, etc.)
126///
127/// # Returns
128///
129/// A `SecurityReport` containing all findings and summary statistics.
130///
131/// # Errors
132///
133/// Returns an error if the path doesn't exist or scanning fails critically.
134///
135/// # Example
136///
137/// ```ignore
138/// use go_brrr::security::{scan_security, SecurityConfig};
139///
140/// let report = scan_security("./src", &SecurityConfig::default())?;
141/// println!("Found {} issues", report.summary.total_findings);
142///
143/// // For CI/CD with stricter settings
144/// let ci_report = scan_security("./src", &SecurityConfig::ci())?;
145/// std::process::exit(ci_report.exit_code(Severity::High));
146/// ```
147pub fn scan_security(path: impl AsRef<Path>, config: &SecurityConfig) -> Result<SecurityReport> {
148    let path = path.as_ref();
149    let start_time = Instant::now();
150
151    // Collect all source files
152    let files = collect_source_files(path, config)?;
153    let files_scanned = files.len();
154
155    // Run all analyzers in parallel
156    let mut all_findings: Vec<SecurityFinding> = Vec::new();
157
158    // Use parallel processing if we have multiple files
159    if files.len() > 1 {
160        // Build a vector of boxed closures for parallel execution
161        type ScannerFn<'a> = Box<dyn Fn() -> Vec<SecurityFinding> + Send + Sync + 'a>;
162        let scanners: Vec<ScannerFn> = vec![
163            Box::new(|| run_sql_injection_scan(path, config)),
164            Box::new(|| run_command_injection_scan(path, config)),
165            Box::new(|| run_xss_scan(path, config)),
166            Box::new(|| run_path_traversal_scan(path, config)),
167            Box::new(|| run_secrets_scan(path, config)),
168            Box::new(|| run_crypto_scan(path, config)),
169            Box::new(|| run_deserialization_scan(path, config)),
170            Box::new(|| run_redos_scan(path, config)),
171        ];
172
173        let findings_per_analyzer: Vec<Vec<SecurityFinding>> = scanners
174            .par_iter()
175            .map(|scanner| scanner())
176            .collect();
177
178        for findings in findings_per_analyzer {
179            all_findings.extend(findings);
180        }
181    } else if !files.is_empty() {
182        // Single file - run analyzers sequentially
183        all_findings.extend(run_sql_injection_scan(path, config));
184        all_findings.extend(run_command_injection_scan(path, config));
185        all_findings.extend(run_xss_scan(path, config));
186        all_findings.extend(run_path_traversal_scan(path, config));
187        all_findings.extend(run_secrets_scan(path, config));
188        all_findings.extend(run_crypto_scan(path, config));
189        all_findings.extend(run_deserialization_scan(path, config));
190        all_findings.extend(run_redos_scan(path, config));
191    }
192
193    // Apply suppression detection by reading source files
194    apply_suppressions(&mut all_findings);
195
196    // Filter findings based on config
197    let filtered_findings: Vec<SecurityFinding> = all_findings
198        .into_iter()
199        .filter(|f| config.should_include(f))
200        .collect();
201
202    // Deduplicate findings
203    let (findings, duplicates_removed) = if config.deduplicate {
204        deduplicate_findings(filtered_findings)
205    } else {
206        (filtered_findings, 0)
207    };
208
209    // Build report
210    let mut report = SecurityReport::new(findings, files_scanned);
211    report.summary.duplicates_removed = duplicates_removed;
212    report.summary.scan_duration_ms = start_time.elapsed().as_millis() as u64;
213    report.config = Some(config.clone());
214
215    Ok(report)
216}
217
218/// Collect source files to scan based on config.
219fn collect_source_files(path: &Path, config: &SecurityConfig) -> Result<Vec<std::path::PathBuf>> {
220    if path.is_file() {
221        return Ok(vec![path.to_path_buf()]);
222    }
223
224    let path_str = path.to_str().ok_or_else(|| {
225        BrrrError::InvalidArgument("Invalid path encoding".to_string())
226    })?;
227
228    let scan_config = match &config.language {
229        Some(lang) => ScanConfig::for_language(lang),
230        None => ScanConfig::default(),
231    };
232
233    let scanner = ProjectScanner::new(path_str)?;
234    let result = scanner.scan_with_config(&scan_config)?;
235
236    // Limit files if max_files is specified
237    let files = if config.max_files > 0 && result.files.len() > config.max_files {
238        result.files.into_iter().take(config.max_files).collect()
239    } else {
240        result.files
241    };
242
243    Ok(files)
244}
245
246/// Apply suppression detection to findings by checking source file comments.
247fn apply_suppressions(findings: &mut [SecurityFinding]) {
248    // Group findings by file to minimize file reads
249    let mut files_to_check: HashSet<String> = HashSet::new();
250    for finding in findings.iter() {
251        files_to_check.insert(finding.location.file.clone());
252    }
253
254    // Read each file once and check suppressions
255    for file_path in files_to_check {
256        let source = match std::fs::read_to_string(&file_path) {
257            Ok(s) => s,
258            Err(_) => continue,
259        };
260
261        for finding in findings.iter_mut() {
262            if finding.location.file == file_path && !finding.suppressed {
263                if check_suppression(&source, finding.location.start_line, &finding.id) {
264                    finding.suppressed = true;
265                }
266            }
267        }
268    }
269}
270
271/// Deduplicate findings based on location and category.
272/// Returns the deduplicated list and count of removed duplicates.
273fn deduplicate_findings(findings: Vec<SecurityFinding>) -> (Vec<SecurityFinding>, usize) {
274    let original_count = findings.len();
275    let mut seen: HashSet<u64> = HashSet::new();
276    let mut result: Vec<SecurityFinding> = Vec::new();
277
278    for finding in findings {
279        if seen.insert(finding.dedup_hash) {
280            result.push(finding);
281        }
282    }
283
284    let duplicates_removed = original_count - result.len();
285    (result, duplicates_removed)
286}
287
288// =============================================================================
289// Individual Scanner Wrappers
290// =============================================================================
291
292/// Run SQL injection scanner and convert to unified findings.
293fn run_sql_injection_scan(path: &Path, config: &SecurityConfig) -> Vec<SecurityFinding> {
294    // Check if SQL injection is in the category filter
295    if let Some(ref cats) = config.categories {
296        if !cats.iter().any(|c| {
297            c.to_lowercase().contains("sql")
298                || c.to_lowercase().contains("injection")
299                || c.to_lowercase() == "all"
300        }) {
301            return Vec::new();
302        }
303    }
304
305    let detector = injection::sql::SqlInjectionDetector::new();
306    let lang_str = config.language.as_deref();
307
308    let result = if path.is_file() {
309        match detector.scan_file(path.to_string_lossy().as_ref()) {
310            Ok(findings) => findings,
311            Err(_) => return Vec::new(),
312        }
313    } else {
314        match detector.scan_directory(path.to_string_lossy().as_ref(), lang_str) {
315            Ok(result) => result.findings,
316            Err(_) => return Vec::new(),
317        }
318    };
319
320    result
321        .into_iter()
322        .map(|f| {
323            SecurityFinding::new(
324                format!("SQLI-{:03}", severity_to_id(&f.severity.to_string())),
325                SecurityCategory::Injection(InjectionType::Sql),
326                convert_sql_severity(f.severity),
327                Confidence::from_float(f.confidence),
328                Location::new(
329                    &f.location.file,
330                    f.location.line,
331                    f.location.column,
332                    f.location.end_line,
333                    f.location.end_column,
334                ),
335                format!("SQL Injection via {}", f.pattern),
336                f.description,
337            )
338            .with_remediation(f.remediation)
339            .with_code_snippet(f.code_snippet)
340            .with_metadata("sink_function", f.sink_function.to_string())
341            .with_metadata("pattern", f.pattern.to_string())
342        })
343        .collect()
344}
345
346/// Run command injection scanner and convert to unified findings.
347fn run_command_injection_scan(path: &Path, config: &SecurityConfig) -> Vec<SecurityFinding> {
348    if let Some(ref cats) = config.categories {
349        if !cats.iter().any(|c| {
350            c.to_lowercase().contains("command")
351                || c.to_lowercase().contains("injection")
352                || c.to_lowercase() == "all"
353        }) {
354            return Vec::new();
355        }
356    }
357
358    let lang_str = config.language.as_deref();
359
360    let result = if path.is_file() {
361        match injection::command::scan_file_command_injection(path, lang_str) {
362            Ok(findings) => findings,
363            Err(_) => return Vec::new(),
364        }
365    } else {
366        match injection::command::scan_command_injection(path, lang_str) {
367            Ok(findings) => findings,
368            Err(_) => return Vec::new(),
369        }
370    };
371
372    result
373        .into_iter()
374        .map(|f| {
375            let description = format!(
376                "{} via {} - tainted input: {}",
377                f.kind, f.sink_function, f.tainted_input
378            );
379            SecurityFinding::new(
380                format!("CMD-{:03}", severity_to_id(&f.severity.to_string())),
381                SecurityCategory::Injection(InjectionType::Command),
382                convert_cmd_severity(f.severity),
383                convert_cmd_confidence(f.confidence),
384                Location::new(
385                    &f.location.file,
386                    f.location.line,
387                    f.location.column,
388                    f.location.end_line,
389                    f.location.end_column,
390                ),
391                format!("Command Injection via {}", f.sink_function),
392                description,
393            )
394            .with_remediation(f.remediation)
395            .with_code_snippet(f.code_snippet.unwrap_or_default())
396            .with_metadata("sink_function", f.sink_function.clone())
397            .with_metadata("kind", f.kind.to_string())
398        })
399        .collect()
400}
401
402/// Run XSS scanner and convert to unified findings.
403fn run_xss_scan(path: &Path, config: &SecurityConfig) -> Vec<SecurityFinding> {
404    if let Some(ref cats) = config.categories {
405        if !cats.iter().any(|c| {
406            c.to_lowercase().contains("xss")
407                || c.to_lowercase().contains("injection")
408                || c.to_lowercase() == "all"
409        }) {
410            return Vec::new();
411        }
412    }
413
414    let lang_str = config.language.as_deref();
415
416    let result = if path.is_file() {
417        match injection::xss::scan_file_xss(path) {
418            Ok(findings) => findings,
419            Err(_) => return Vec::new(),
420        }
421    } else {
422        match injection::xss::scan_xss(path, lang_str) {
423            Ok(scan_result) => scan_result.findings,
424            Err(_) => return Vec::new(),
425        }
426    };
427
428    result
429        .into_iter()
430        .map(|f| {
431            SecurityFinding::new(
432                format!("XSS-{:03}", severity_to_id(&f.severity.to_string())),
433                SecurityCategory::Injection(InjectionType::Xss),
434                convert_xss_severity(f.severity),
435                convert_xss_confidence(f.confidence),
436                Location::new(
437                    &f.location.file,
438                    f.location.line,
439                    f.location.column,
440                    f.location.end_line,
441                    f.location.end_column,
442                ),
443                format!("Cross-Site Scripting via {}", f.sink_type),
444                f.description,
445            )
446            .with_remediation(f.remediation)
447            .with_code_snippet(f.code_snippet.unwrap_or_default())
448            .with_metadata("sink_type", f.sink_type.to_string())
449        })
450        .collect()
451}
452
453/// Run path traversal scanner and convert to unified findings.
454fn run_path_traversal_scan(path: &Path, config: &SecurityConfig) -> Vec<SecurityFinding> {
455    if let Some(ref cats) = config.categories {
456        if !cats.iter().any(|c| {
457            c.to_lowercase().contains("path")
458                || c.to_lowercase().contains("traversal")
459                || c.to_lowercase().contains("injection")
460                || c.to_lowercase() == "all"
461        }) {
462            return Vec::new();
463        }
464    }
465
466    let lang_str = config.language.as_deref();
467
468    let result = if path.is_file() {
469        match injection::path_traversal::scan_file_path_traversal(path, lang_str) {
470            Ok(findings) => findings,
471            Err(_) => return Vec::new(),
472        }
473    } else {
474        match injection::path_traversal::scan_path_traversal(path, lang_str) {
475            Ok(findings) => findings,
476            Err(_) => return Vec::new(),
477        }
478    };
479
480    result
481        .into_iter()
482        .map(|f| {
483            SecurityFinding::new(
484                format!("PATH-{:03}", severity_to_id(&f.severity.to_string())),
485                SecurityCategory::Injection(InjectionType::PathTraversal),
486                convert_path_severity(f.severity),
487                convert_path_confidence(f.confidence),
488                Location::new(
489                    &f.location.file,
490                    f.location.line,
491                    f.location.column,
492                    f.location.end_line,
493                    f.location.end_column,
494                ),
495                format!("Path Traversal via {}", f.pattern),
496                f.description,
497            )
498            .with_remediation(f.remediation)
499            .with_code_snippet(f.code_snippet.unwrap_or_default())
500            .with_metadata("operation_type", f.operation_type.to_string())
501            .with_metadata("pattern", f.pattern.to_string())
502        })
503        .collect()
504}
505
506/// Run secrets scanner and convert to unified findings.
507fn run_secrets_scan(path: &Path, config: &SecurityConfig) -> Vec<SecurityFinding> {
508    if let Some(ref cats) = config.categories {
509        if !cats.iter().any(|c| {
510            c.to_lowercase().contains("secret")
511                || c.to_lowercase().contains("credential")
512                || c.to_lowercase() == "all"
513        }) {
514            return Vec::new();
515        }
516    }
517
518    let lang_str = config.language.as_deref();
519
520    let result = match secrets::scan_secrets(path.to_string_lossy().as_ref(), lang_str) {
521        Ok(result) => result.findings,
522        Err(_) => return Vec::new(),
523    };
524
525    result
526        .into_iter()
527        .map(|f| {
528            SecurityFinding::new(
529                format!("SEC-{:03}", severity_to_id(&f.severity.to_string())),
530                SecurityCategory::SecretsExposure,
531                convert_secrets_severity(f.severity),
532                convert_secrets_confidence(f.confidence),
533                Location::new(
534                    &f.location.file,
535                    f.location.line,
536                    f.location.column,
537                    f.location.end_line,
538                    f.location.end_column,
539                ),
540                format!("{} Exposed", f.secret_type),
541                f.description,
542            )
543            .with_remediation(f.remediation)
544            .with_code_snippet(f.masked_value.clone())
545            .with_metadata("secret_type", f.secret_type.to_string())
546            .with_metadata("masked_value", f.masked_value)
547        })
548        .collect()
549}
550
551/// Run crypto scanner and convert to unified findings.
552fn run_crypto_scan(path: &Path, config: &SecurityConfig) -> Vec<SecurityFinding> {
553    if let Some(ref cats) = config.categories {
554        if !cats.iter().any(|c| {
555            c.to_lowercase().contains("crypto")
556                || c.to_lowercase().contains("encryption")
557                || c.to_lowercase() == "all"
558        }) {
559            return Vec::new();
560        }
561    }
562
563    let lang_str = config.language.as_deref();
564
565    let result = if path.is_file() {
566        match crypto::scan_file_weak_crypto(path, lang_str) {
567            Ok(findings) => findings,
568            Err(_) => return Vec::new(),
569        }
570    } else {
571        match crypto::scan_weak_crypto(path.to_string_lossy().as_ref(), lang_str) {
572            Ok(result) => result.findings,
573            Err(_) => return Vec::new(),
574        }
575    };
576
577    result
578        .into_iter()
579        .map(|f| {
580            SecurityFinding::new(
581                format!("CRYPTO-{:03}", severity_to_id(&f.severity.to_string())),
582                SecurityCategory::WeakCrypto,
583                convert_crypto_severity(f.severity),
584                convert_crypto_confidence(f.confidence),
585                Location::new(
586                    &f.location.file,
587                    f.location.line,
588                    f.location.column,
589                    f.location.end_line,
590                    f.location.end_column,
591                ),
592                format!("{}: {}", f.issue_type, f.algorithm),
593                f.description,
594            )
595            .with_remediation(f.remediation)
596            .with_code_snippet(f.code_snippet)
597            .with_metadata("algorithm", f.algorithm.to_string())
598            .with_metadata("issue_type", f.issue_type.to_string())
599        })
600        .collect()
601}
602
603/// Run deserialization scanner and convert to unified findings.
604fn run_deserialization_scan(path: &Path, config: &SecurityConfig) -> Vec<SecurityFinding> {
605    if let Some(ref cats) = config.categories {
606        if !cats.iter().any(|c| {
607            c.to_lowercase().contains("deser")
608                || c.to_lowercase().contains("pickle")
609                || c.to_lowercase() == "all"
610        }) {
611            return Vec::new();
612        }
613    }
614
615    let lang_str = config.language.as_deref();
616
617    let result = match deserialization::scan_deserialization(path, lang_str) {
618        Ok(findings) => findings,
619        Err(_) => return Vec::new(),
620    };
621
622    result
623        .into_iter()
624        .map(|f| {
625            SecurityFinding::new(
626                format!("DESER-{:03}", severity_to_id(&f.severity.to_string())),
627                SecurityCategory::UnsafeDeserialization,
628                convert_deser_severity(f.severity),
629                convert_deser_confidence(f.confidence),
630                Location::new(
631                    &f.location.file,
632                    f.location.line,
633                    f.location.column,
634                    f.location.end_line,
635                    f.location.end_column,
636                ),
637                format!("Unsafe Deserialization via {}", f.method),
638                f.description,
639            )
640            .with_remediation(f.remediation)
641            .with_code_snippet(f.code_snippet.unwrap_or_default())
642            .with_metadata("method", f.method.to_string())
643            .with_metadata("input_source", f.input_source.to_string())
644        })
645        .collect()
646}
647
648/// Run ReDoS scanner and convert to unified findings.
649fn run_redos_scan(path: &Path, config: &SecurityConfig) -> Vec<SecurityFinding> {
650    if let Some(ref cats) = config.categories {
651        if !cats.iter().any(|c| {
652            c.to_lowercase().contains("redos")
653                || c.to_lowercase().contains("regex")
654                || c.to_lowercase() == "all"
655        }) {
656            return Vec::new();
657        }
658    }
659
660    let lang_str = config.language.as_deref();
661
662    let result = match redos::scan_redos(path.to_string_lossy().as_ref(), lang_str) {
663        Ok(result) => result.findings,
664        Err(_) => return Vec::new(),
665    };
666
667    result
668        .into_iter()
669        .map(|f| {
670            SecurityFinding::new(
671                format!("REDOS-{:03}", severity_to_id(&f.severity.to_string())),
672                SecurityCategory::ReDoS,
673                convert_redos_severity(f.severity),
674                convert_redos_confidence(f.confidence),
675                Location::new(
676                    &f.location.file,
677                    f.location.line,
678                    f.location.column,
679                    f.location.end_line,
680                    f.location.end_column,
681                ),
682                format!("ReDoS: {} in {}", f.vulnerability_type, f.regex_function),
683                f.description,
684            )
685            .with_remediation(f.remediation)
686            .with_code_snippet(f.code_snippet)
687            .with_metadata("regex_pattern", f.regex_pattern)
688            .with_metadata("complexity", f.complexity)
689            .with_metadata("attack_string", f.attack_string)
690            .with_metadata("vulnerability_type", f.vulnerability_type.to_string())
691        })
692        .collect()
693}
694
695// =============================================================================
696// Severity/Confidence Conversion Helpers
697// =============================================================================
698
699fn severity_to_id(sev: &str) -> u32 {
700    match sev.to_uppercase().as_str() {
701        "CRITICAL" => 001,
702        "HIGH" => 002,
703        "MEDIUM" => 003,
704        "LOW" => 004,
705        _ => 005,
706    }
707}
708
709fn convert_sql_severity(sev: injection::sql::Severity) -> Severity {
710    match sev {
711        injection::sql::Severity::Critical => Severity::Critical,
712        injection::sql::Severity::High => Severity::High,
713        injection::sql::Severity::Medium => Severity::Medium,
714        injection::sql::Severity::Low => Severity::Low,
715    }
716}
717
718fn convert_cmd_severity(sev: injection::command::Severity) -> Severity {
719    match sev {
720        injection::command::Severity::Critical => Severity::Critical,
721        injection::command::Severity::High => Severity::High,
722        injection::command::Severity::Medium => Severity::Medium,
723        injection::command::Severity::Low => Severity::Low,
724        injection::command::Severity::Info => Severity::Info,
725    }
726}
727
728fn convert_cmd_confidence(conf: injection::command::Confidence) -> Confidence {
729    match conf {
730        injection::command::Confidence::High => Confidence::High,
731        injection::command::Confidence::Medium => Confidence::Medium,
732        injection::command::Confidence::Low => Confidence::Low,
733    }
734}
735
736fn convert_xss_severity(sev: injection::xss::Severity) -> Severity {
737    match sev {
738        injection::xss::Severity::Critical => Severity::Critical,
739        injection::xss::Severity::High => Severity::High,
740        injection::xss::Severity::Medium => Severity::Medium,
741        injection::xss::Severity::Low => Severity::Low,
742        injection::xss::Severity::Info => Severity::Info,
743    }
744}
745
746fn convert_xss_confidence(conf: injection::xss::Confidence) -> Confidence {
747    match conf {
748        injection::xss::Confidence::High => Confidence::High,
749        injection::xss::Confidence::Medium => Confidence::Medium,
750        injection::xss::Confidence::Low => Confidence::Low,
751    }
752}
753
754fn convert_path_severity(sev: injection::path_traversal::Severity) -> Severity {
755    match sev {
756        injection::path_traversal::Severity::Critical => Severity::Critical,
757        injection::path_traversal::Severity::High => Severity::High,
758        injection::path_traversal::Severity::Medium => Severity::Medium,
759        injection::path_traversal::Severity::Low => Severity::Low,
760        injection::path_traversal::Severity::Info => Severity::Info,
761    }
762}
763
764fn convert_path_confidence(conf: injection::path_traversal::Confidence) -> Confidence {
765    match conf {
766        injection::path_traversal::Confidence::High => Confidence::High,
767        injection::path_traversal::Confidence::Medium => Confidence::Medium,
768        injection::path_traversal::Confidence::Low => Confidence::Low,
769    }
770}
771
772fn convert_secrets_severity(sev: secrets::Severity) -> Severity {
773    match sev {
774        secrets::Severity::Critical => Severity::Critical,
775        secrets::Severity::High => Severity::High,
776        secrets::Severity::Medium => Severity::Medium,
777        secrets::Severity::Low => Severity::Low,
778        secrets::Severity::Info => Severity::Info,
779    }
780}
781
782fn convert_secrets_confidence(conf: secrets::Confidence) -> Confidence {
783    match conf {
784        secrets::Confidence::High => Confidence::High,
785        secrets::Confidence::Medium => Confidence::Medium,
786        secrets::Confidence::Low => Confidence::Low,
787    }
788}
789
790fn convert_crypto_severity(sev: crypto::Severity) -> Severity {
791    match sev {
792        crypto::Severity::Critical => Severity::Critical,
793        crypto::Severity::High => Severity::High,
794        crypto::Severity::Medium => Severity::Medium,
795        crypto::Severity::Low => Severity::Low,
796        crypto::Severity::Info => Severity::Info,
797    }
798}
799
800fn convert_crypto_confidence(conf: crypto::Confidence) -> Confidence {
801    match conf {
802        crypto::Confidence::High => Confidence::High,
803        crypto::Confidence::Medium => Confidence::Medium,
804        crypto::Confidence::Low => Confidence::Low,
805    }
806}
807
808fn convert_deser_severity(sev: deserialization::Severity) -> Severity {
809    match sev {
810        deserialization::Severity::Critical => Severity::Critical,
811        deserialization::Severity::High => Severity::High,
812        deserialization::Severity::Medium => Severity::Medium,
813        deserialization::Severity::Low => Severity::Low,
814        deserialization::Severity::Info => Severity::Info,
815    }
816}
817
818fn convert_deser_confidence(conf: deserialization::Confidence) -> Confidence {
819    match conf {
820        deserialization::Confidence::High => Confidence::High,
821        deserialization::Confidence::Medium => Confidence::Medium,
822        deserialization::Confidence::Low => Confidence::Low,
823    }
824}
825
826fn convert_redos_severity(sev: redos::Severity) -> Severity {
827    match sev {
828        redos::Severity::Critical => Severity::Critical,
829        redos::Severity::High => Severity::High,
830        redos::Severity::Medium => Severity::Medium,
831        redos::Severity::Low => Severity::Low,
832        redos::Severity::Info => Severity::Info,
833    }
834}
835
836fn convert_redos_confidence(conf: redos::Confidence) -> Confidence {
837    match conf {
838        redos::Confidence::High => Confidence::High,
839        redos::Confidence::Medium => Confidence::Medium,
840        redos::Confidence::Low => Confidence::Low,
841    }
842}
843
844impl Confidence {
845    /// Convert a float confidence score to Confidence level.
846    fn from_float(score: f64) -> Self {
847        if score >= 0.8 {
848            Self::High
849        } else if score >= 0.5 {
850            Self::Medium
851        } else {
852            Self::Low
853        }
854    }
855}
856
857// =============================================================================
858// Text Formatting
859// =============================================================================
860
861impl SecurityReport {
862    /// Format the report as human-readable text.
863    #[must_use]
864    pub fn to_text(&self) -> String {
865        let mut output = String::new();
866
867        // Header
868        output.push_str("=== Security Scan Report ===\n\n");
869        output.push_str(&format!(
870            "Scanned {} files in {}ms\n",
871            self.summary.files_scanned, self.summary.scan_duration_ms
872        ));
873        output.push_str(&format!(
874            "Found {} issues ({} suppressed, {} duplicates removed)\n\n",
875            self.summary.total_findings,
876            self.summary.suppressed_count,
877            self.summary.duplicates_removed
878        ));
879
880        // Summary by severity
881        if !self.summary.by_severity.is_empty() {
882            output.push_str("By Severity:\n");
883            for (sev, count) in &self.summary.by_severity {
884                output.push_str(&format!("  {}: {}\n", sev, count));
885            }
886            output.push('\n');
887        }
888
889        // Summary by category
890        if !self.summary.by_category.is_empty() {
891            output.push_str("By Category:\n");
892            for (cat, count) in &self.summary.by_category {
893                output.push_str(&format!("  {}: {}\n", cat, count));
894            }
895            output.push('\n');
896        }
897
898        // Findings
899        if !self.findings.is_empty() {
900            output.push_str("=== Findings ===\n\n");
901
902            for (i, finding) in self.findings.iter().enumerate() {
903                let suppressed_marker = if finding.suppressed { " [SUPPRESSED]" } else { "" };
904
905                output.push_str(&format!(
906                    "{}. [{}] {} - {}{}\n",
907                    i + 1,
908                    finding.severity,
909                    finding.id,
910                    finding.title,
911                    suppressed_marker
912                ));
913                output.push_str(&format!("   Location: {}\n", finding.location));
914                output.push_str(&format!("   Confidence: {}\n", finding.confidence));
915
916                if let Some(cwe) = finding.cwe_id {
917                    output.push_str(&format!("   CWE: CWE-{}\n", cwe));
918                }
919
920                output.push_str(&format!("   Description: {}\n", finding.description));
921
922                if !finding.code_snippet.is_empty() {
923                    output.push_str("   Code:\n");
924                    for line in finding.code_snippet.lines() {
925                        output.push_str(&format!("     | {}\n", line));
926                    }
927                }
928
929                if !finding.remediation.is_empty() {
930                    output.push_str(&format!("   Fix: {}\n", finding.remediation));
931                }
932
933                output.push('\n');
934            }
935        }
936
937        output
938    }
939}
940
941// =============================================================================
942// Tests
943// =============================================================================
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948
949    #[test]
950    fn test_security_config_defaults() {
951        let config = SecurityConfig::default();
952        assert_eq!(config.min_severity, Severity::Low);
953        assert_eq!(config.min_confidence, Confidence::Low);
954        assert!(config.deduplicate);
955    }
956
957    #[test]
958    fn test_ci_config() {
959        let config = SecurityConfig::ci();
960        assert_eq!(config.min_severity, Severity::Medium);
961        assert_eq!(config.min_confidence, Confidence::Medium);
962    }
963
964    #[test]
965    fn test_finding_filtering() {
966        let config = SecurityConfig::default().with_min_severity(Severity::High);
967
968        let low_finding = SecurityFinding::new(
969            "TEST-001",
970            SecurityCategory::SecretsExposure,
971            Severity::Low,
972            Confidence::High,
973            Location::new("test.py", 1, 1, 1, 10),
974            "Test",
975            "Test finding",
976        );
977
978        let high_finding = SecurityFinding::new(
979            "TEST-002",
980            SecurityCategory::SecretsExposure,
981            Severity::High,
982            Confidence::High,
983            Location::new("test.py", 2, 1, 2, 10),
984            "Test",
985            "Test finding",
986        );
987
988        assert!(!config.should_include(&low_finding));
989        assert!(config.should_include(&high_finding));
990    }
991
992    #[test]
993    fn test_deduplication() {
994        let finding1 = SecurityFinding::new(
995            "TEST-001",
996            SecurityCategory::SecretsExposure,
997            Severity::High,
998            Confidence::High,
999            Location::new("test.py", 10, 1, 10, 50),
1000            "Test",
1001            "Test finding",
1002        );
1003
1004        // Same location = same dedup_hash
1005        let finding2 = SecurityFinding::new(
1006            "TEST-001",
1007            SecurityCategory::SecretsExposure,
1008            Severity::High,
1009            Confidence::High,
1010            Location::new("test.py", 10, 1, 10, 50),
1011            "Test",
1012            "Test finding",
1013        );
1014
1015        let findings = vec![finding1, finding2];
1016        let (deduped, removed) = deduplicate_findings(findings);
1017
1018        assert_eq!(deduped.len(), 1);
1019        assert_eq!(removed, 1);
1020    }
1021}