1pub mod crypto;
79pub mod deserialization;
80pub mod injection;
81pub mod redos;
82pub mod sarif;
83pub mod secrets;
84pub mod taint;
85pub mod types;
86
87pub use sarif::SarifLog;
89pub use types::{
90 check_suppression, is_suppressed, Confidence, InjectionType, Location, ScanSummary,
91 SecurityCategory, SecurityConfig, SecurityFinding, SecurityReport, Severity,
92};
93
94pub 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
113pub 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 let files = collect_source_files(path, config)?;
153 let files_scanned = files.len();
154
155 let mut all_findings: Vec<SecurityFinding> = Vec::new();
157
158 if files.len() > 1 {
160 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 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_suppressions(&mut all_findings);
195
196 let filtered_findings: Vec<SecurityFinding> = all_findings
198 .into_iter()
199 .filter(|f| config.should_include(f))
200 .collect();
201
202 let (findings, duplicates_removed) = if config.deduplicate {
204 deduplicate_findings(filtered_findings)
205 } else {
206 (filtered_findings, 0)
207 };
208
209 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
218fn 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 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
246fn apply_suppressions(findings: &mut [SecurityFinding]) {
248 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 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
271fn 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
288fn run_sql_injection_scan(path: &Path, config: &SecurityConfig) -> Vec<SecurityFinding> {
294 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
346fn 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
402fn 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
453fn 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
506fn 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
551fn 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
603fn 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
648fn 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
695fn 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 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
857impl SecurityReport {
862 #[must_use]
864 pub fn to_text(&self) -> String {
865 let mut output = String::new();
866
867 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 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 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 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#[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 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}