Skip to main content

cargo_mate/tools/
secret_scanner.rs

1use super::{Tool, Result, ToolError, common_options, parse_output_format, OutputFormat};
2use clap::{Arg, ArgMatches, Command};
3use colored::*;
4use std::path::Path;
5use std::fs;
6use std::collections::HashMap;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9#[derive(Debug, Clone)]
10pub struct SecretScannerTool;
11#[derive(Debug, Clone, Serialize, Deserialize)]
12struct SecretScanReport {
13    files_scanned: usize,
14    secrets_found: usize,
15    findings: Vec<SecretFinding>,
16    false_positives: Vec<FalsePositive>,
17    statistics: ScanStatistics,
18    recommendations: Vec<String>,
19    timestamp: String,
20}
21#[derive(Debug, Clone, Serialize, Deserialize)]
22struct SecretFinding {
23    file_path: String,
24    line_number: usize,
25    secret_type: String,
26    confidence: String,
27    context: String,
28    secret_value: String,
29    masked_secret: String,
30    severity: String,
31    recommendation: String,
32}
33#[derive(Debug, Clone, Serialize, Deserialize)]
34struct FalsePositive {
35    file_path: String,
36    line_number: usize,
37    pattern_matched: String,
38    reason: String,
39}
40#[derive(Debug, Clone, Serialize, Deserialize)]
41struct ScanStatistics {
42    high_confidence: usize,
43    medium_confidence: usize,
44    low_confidence: usize,
45    false_positives: usize,
46    files_with_secrets: usize,
47    most_common_type: String,
48}
49impl SecretScannerTool {
50    pub fn new() -> Self {
51        Self
52    }
53    fn get_secret_patterns(&self) -> Vec<SecretPattern> {
54        vec![
55            SecretPattern { name : "AWS Access Key".to_string(), pattern :
56            r"AKIA[0-9A-Z]{16}".to_string(), confidence : "high".to_string(), description
57            : "AWS Access Key ID".to_string(), example : "AKIAIOSFODNN7EXAMPLE"
58            .to_string(), }, SecretPattern { name : "AWS Secret Key".to_string(), pattern
59            : r"[0-9a-zA-Z/+]{40}".to_string(), confidence : "medium".to_string(),
60            description : "AWS Secret Access Key".to_string(), example :
61            "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), }, SecretPattern {
62            name : "GitHub Token".to_string(), pattern : r"ghp_[0-9a-zA-Z]{36}"
63            .to_string(), confidence : "high".to_string(), description :
64            "GitHub Personal Access Token".to_string(), example :
65            "ghp_1234567890abcdef1234567890abcdef1234".to_string(), }, SecretPattern {
66            name : "GitHub OAuth".to_string(), pattern : r"gho_[0-9a-zA-Z]{36}"
67            .to_string(), confidence : "high".to_string(), description :
68            "GitHub OAuth Access Token".to_string(), example :
69            "gho_1234567890abcdef1234567890abcdef1234".to_string(), }, SecretPattern {
70            name : "GitLab Token".to_string(), pattern : r"glpat-[0-9a-zA-Z\-_]{20}"
71            .to_string(), confidence : "high".to_string(), description :
72            "GitLab Personal Access Token".to_string(), example :
73            "glpat-1234567890abcdefghij".to_string(), }, SecretPattern { name :
74            "Slack Token".to_string(), pattern : r"xox[baprs]-[0-9a-zA-Z]{10,48}"
75            .to_string(), confidence : "high".to_string(), description :
76            "Slack API Token".to_string(), example :
77            "xoxb-1234567890-1234567890-abcdefghijklmnopqrstuvwx".to_string(), },
78            SecretPattern { name : "Discord Token".to_string(), pattern :
79            r"[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}".to_string(), confidence : "high"
80            .to_string(), description : "Discord Bot Token".to_string(), example :
81            "MTAwMDAwMDAwMDAwMDAwMDAw.GQ0wHm.example".to_string(), }, SecretPattern {
82            name : "Stripe Key".to_string(), pattern : r"[rs]k_live_[0-9a-zA-Z]{24}"
83            .to_string(), confidence : "high".to_string(), description : "Stripe API Key"
84            .to_string(), example : "sk_live_1234567890abcdefghijklmnopqrstuvwxyz"
85            .to_string(), }, SecretPattern { name : "Database URL".to_string(), pattern :
86            r"(?i)postgres://[^:\s]+:[^@\s]+@".to_string(), confidence : "high"
87            .to_string(), description : "PostgreSQL connection string".to_string(),
88            example : "postgres://username:password@host:port/database".to_string(), },
89            SecretPattern { name : "Generic API Key".to_string(), pattern :
90            r#"(?i)(api[_-]?key|apikey|secret|token|auth[_-]?token)\s*[:=]\s*['"]?.*['"]?"#
91            .to_string(), confidence : "medium".to_string(), description :
92            "Generic API key or token".to_string(), example :
93            "api_key=1234567890abcdef1234567890abcdef".to_string(), }, SecretPattern {
94            name : "Private Key".to_string(), pattern :
95            r"-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----".to_string(), confidence :
96            "high".to_string(), description : "RSA Private Key".to_string(), example :
97            "-----BEGIN PRIVATE KEY-----".to_string(), }, SecretPattern { name :
98            "SSH Private Key".to_string(), pattern :
99            r"-----BEGIN\s+OPENSSH\s+PRIVATE\s+KEY-----".to_string(), confidence : "high"
100            .to_string(), description : "SSH Private Key".to_string(), example :
101            "-----BEGIN OPENSSH PRIVATE KEY-----".to_string(), }, SecretPattern { name :
102            "JWT Token".to_string(), pattern :
103            r#"eyJ[A-Za-z0-9_.-]*\.[A-Za-z0-9_.-]*\.[A-Za-z0-9_.-]*"#.to_string(),
104            confidence : "medium".to_string(), description : "JSON Web Token"
105            .to_string(), example :
106            "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
107            .to_string(), }, SecretPattern { name : "Password in Config".to_string(),
108            pattern : r#"(?i)(password|passwd|pwd)\s*[:=]\s*['"]?.*['"]?"#.to_string(),
109            confidence : "low".to_string(), description :
110            "Potential password in configuration".to_string(), example :
111            "password=secret123".to_string(), }, SecretPattern { name : "Bearer Token"
112            .to_string(), pattern : r#"(?i)bearer\s+[^'"\s]+"#.to_string(), confidence :
113            "medium".to_string(), description : "Bearer token in Authorization header"
114            .to_string(), example : "Authorization: Bearer abcdef1234567890".to_string(),
115            },
116        ]
117    }
118    fn scan_file_for_secrets(&self, file_path: &str) -> Result<Vec<SecretFinding>> {
119        let content = fs::read_to_string(file_path)?;
120        let mut findings = Vec::new();
121        let file_name = Path::new(file_path)
122            .file_name()
123            .unwrap_or_default()
124            .to_string_lossy();
125        if file_name.ends_with(".min.js") || file_name.ends_with(".min.css")
126            || file_name.contains("jquery") || file_name.contains("bootstrap")
127        {
128            return Ok(vec![]);
129        }
130        let patterns = self.get_secret_patterns();
131        for (line_number, line) in content.lines().enumerate() {
132            for pattern in &patterns {
133                if let Ok(regex) = Regex::new(&pattern.pattern) {
134                    if let Some(captures) = regex.captures(line) {
135                        if let Some(secret_match) = captures
136                            .get(1)
137                            .or_else(|| captures.get(0))
138                        {
139                            let secret_value = secret_match.as_str().to_string();
140                            if self.is_false_positive(&secret_value, pattern, line) {
141                                continue;
142                            }
143                            let masked_secret = self.mask_secret(&secret_value);
144                            let context = self.extract_context(&content, line_number, 2);
145                            let recommendation = self
146                                .generate_recommendation(pattern, file_path);
147                            findings
148                                .push(SecretFinding {
149                                    file_path: file_path.to_string(),
150                                    line_number: line_number + 1,
151                                    secret_type: pattern.name.clone(),
152                                    confidence: pattern.confidence.clone(),
153                                    context,
154                                    secret_value: secret_value.clone(),
155                                    masked_secret,
156                                    severity: self
157                                        .calculate_severity(&pattern.confidence, &secret_value),
158                                    recommendation,
159                                });
160                        }
161                    }
162                }
163            }
164        }
165        Ok(findings)
166    }
167    fn is_false_positive(
168        &self,
169        secret: &str,
170        pattern: &SecretPattern,
171        line: &str,
172    ) -> bool {
173        let false_positive_patterns = vec![
174            "example", "test", "demo", "sample", "placeholder", "your", "fake", "dummy",
175            "mock", "xxx", "1234567890",
176        ];
177        let lower_secret = secret.to_lowercase();
178        let lower_line = line.to_lowercase();
179        if lower_line.contains("//")
180            && lower_line.find("//").unwrap()
181                < lower_line.find(&lower_secret).unwrap_or(0)
182        {
183            return true;
184        }
185        for pattern in false_positive_patterns {
186            if lower_secret.contains(pattern) {
187                return true;
188            }
189        }
190        if secret.len() < 10 && secret.chars().all(|c| c.is_alphabetic()) {
191            return true;
192        }
193        false
194    }
195    fn mask_secret(&self, secret: &str) -> String {
196        if secret.len() <= 4 {
197            return "*".repeat(secret.len());
198        }
199        let visible_chars = 2;
200        let masked_chars = secret.len() - (visible_chars * 2);
201        format!(
202            "{}{}{}", & secret[0..visible_chars], "*".repeat(masked_chars), &
203            secret[secret.len() - visible_chars..]
204        )
205    }
206    fn extract_context(
207        &self,
208        content: &str,
209        line_number: usize,
210        context_lines: usize,
211    ) -> String {
212        let lines: Vec<&str> = content.lines().collect();
213        let start = line_number.saturating_sub(context_lines);
214        let end = std::cmp::min(line_number + context_lines + 1, lines.len());
215        lines[start..end].join("\n")
216    }
217    fn calculate_severity(&self, confidence: &str, secret: &str) -> String {
218        match confidence {
219            "high" => {
220                if secret.len() > 20 {
221                    "critical".to_string()
222                } else {
223                    "high".to_string()
224                }
225            }
226            "medium" => {
227                if secret.len() > 15 { "high".to_string() } else { "medium".to_string() }
228            }
229            "low" => {
230                if secret.len() > 20 { "medium".to_string() } else { "low".to_string() }
231            }
232            _ => "medium".to_string(),
233        }
234    }
235    fn generate_recommendation(
236        &self,
237        pattern: &SecretPattern,
238        file_path: &str,
239    ) -> String {
240        let base_recommendation = match pattern.name.as_str() {
241            "AWS Access Key" | "AWS Secret Key" => {
242                "Move to AWS IAM roles or use environment variables with proper access control"
243            }
244            "GitHub Token" | "GitHub OAuth" => {
245                "Use GitHub Actions secrets or environment variables"
246            }
247            "GitLab Token" => "Use GitLab CI/CD variables or environment variables",
248            "Slack Token" => "Use Slack app configuration or environment variables",
249            "Discord Token" => "Use environment variables or secure configuration files",
250            "Stripe Key" => "Use environment variables and never commit live keys",
251            "Database URL" => {
252                "Use environment variables or connection configuration files"
253            }
254            "Generic API Key" => {
255                "Use environment variables or secure configuration management"
256            }
257            "Private Key" | "SSH Private Key" => "Store in secure key management system",
258            "JWT Token" => "Use environment variables and proper token rotation",
259            "Password in Config" => {
260                "Use secure password hashing and environment variables"
261            }
262            "Bearer Token" => "Use environment variables and secure token storage",
263            _ => "Use environment variables or secure configuration management",
264        };
265        format!(
266            "{} (found in {})", base_recommendation, file_path.split('/').last()
267            .unwrap_or(file_path)
268        )
269    }
270    fn find_files_to_scan(&self, directory: &str) -> Result<Vec<String>> {
271        let mut files = Vec::new();
272        self.find_files_recursive(directory, &mut files)?;
273        Ok(files)
274    }
275    fn find_files_recursive(&self, dir: &str, files: &mut Vec<String>) -> Result<()> {
276        let path = Path::new(dir);
277        if !path.exists() {
278            return Ok(());
279        }
280        for entry in fs::read_dir(path)? {
281            let entry = entry?;
282            let path = entry.path();
283            if path.is_dir() {
284                let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
285                if !matches!(
286                    dir_name.as_ref(), "target" | ".git" | "node_modules" | ".cargo" |
287                    ".vscode" | ".idea" | ".DS_Store" | "dist" | "build" | "out"
288                ) {
289                    self.find_files_recursive(&path.to_string_lossy(), files)?;
290                }
291            } else if let Some(ext) = path.extension() {
292                match ext.to_string_lossy().as_ref() {
293                    "rs" | "toml" | "json" | "yaml" | "yml" | "env" | "config" | "ini"
294                    | "cfg" | "properties" | "sh" | "bash" | "py" | "js" | "ts" | "php"
295                    | "rb" | "go" | "java" | "kt" | "scala" => {
296                        files.push(path.to_string_lossy().to_string());
297                    }
298                    _ => {}
299                }
300            } else if let Some(file_name) = path.file_name() {
301                let file_name_str = file_name.to_string_lossy();
302                if matches!(
303                    file_name_str.as_ref(), ".env" | ".env.local" | ".env.production" |
304                    "secrets" | "config" | "settings" | "credentials"
305                ) {
306                    files.push(path.to_string_lossy().to_string());
307                }
308            }
309        }
310        Ok(())
311    }
312    fn calculate_statistics(&self, findings: &[SecretFinding]) -> ScanStatistics {
313        let mut high_confidence = 0;
314        let mut medium_confidence = 0;
315        let mut low_confidence = 0;
316        let mut files_with_secrets = std::collections::HashSet::new();
317        let mut type_counts = std::collections::HashMap::new();
318        for finding in findings {
319            files_with_secrets.insert(&finding.file_path);
320            match finding.confidence.as_str() {
321                "high" => high_confidence += 1,
322                "medium" => medium_confidence += 1,
323                "low" => low_confidence += 1,
324                _ => {}
325            }
326            *type_counts.entry(&finding.secret_type).or_insert(0) += 1;
327        }
328        let most_common_type = type_counts
329            .into_iter()
330            .max_by_key(|&(_, count)| count)
331            .map(|(ty, _)| ty.clone())
332            .unwrap_or_else(|| "None".to_string());
333        ScanStatistics {
334            high_confidence,
335            medium_confidence,
336            low_confidence,
337            false_positives: 0,
338            files_with_secrets: files_with_secrets.len(),
339            most_common_type,
340        }
341    }
342    fn generate_recommendations(&self, findings: &[SecretFinding]) -> Vec<String> {
343        let mut recommendations = Vec::new();
344        if !findings.is_empty() {
345            recommendations
346                .push(
347                    "Use environment variables instead of hardcoding secrets".to_string(),
348                );
349            recommendations
350                .push(
351                    "Store secrets in secure configuration management systems"
352                        .to_string(),
353                );
354            recommendations
355                .push(
356                    "Use .env files for local development (add to .gitignore)"
357                        .to_string(),
358                );
359            recommendations
360                .push(
361                    "Implement pre-commit hooks to prevent secret commits".to_string(),
362                );
363            recommendations.push("Rotate exposed secrets immediately".to_string());
364            recommendations.push("Use secret scanning in CI/CD pipelines".to_string());
365        }
366        let has_high_severity = findings.iter().any(|f| f.severity == "critical");
367        if has_high_severity {
368            recommendations
369                .insert(
370                    0,
371                    "🚨 CRITICAL: High-severity secrets found - rotate immediately!"
372                        .to_string(),
373                );
374        }
375        recommendations
376    }
377    fn display_report(
378        &self,
379        report: &SecretScanReport,
380        output_format: OutputFormat,
381        verbose: bool,
382    ) {
383        match output_format {
384            OutputFormat::Human => {
385                println!(
386                    "\nšŸ” {} - Secret Scanner Report", "CargoMate SecretScanner".bold()
387                    .blue()
388                );
389                println!("{}", "═".repeat(60).blue());
390                println!("\nšŸ“Š Summary:");
391                println!("  • Files Scanned: {}", report.files_scanned);
392                println!("  • Secrets Found: {}", report.secrets_found);
393                println!(
394                    "  • Files with Secrets: {}", report.statistics.files_with_secrets
395                );
396                println!("  • High Confidence: {}", report.statistics.high_confidence);
397                println!(
398                    "  • Medium Confidence: {}", report.statistics.medium_confidence
399                );
400                println!("  • Low Confidence: {}", report.statistics.low_confidence);
401                println!(
402                    "  • Most Common Type: {}", report.statistics.most_common_type
403                );
404                if !report.findings.is_empty() {
405                    println!("\n🚨 Secrets Found:");
406                    for finding in &report.findings {
407                        let severity_icon = match finding.severity.as_str() {
408                            "critical" => "🚨",
409                            "high" => "āŒ",
410                            "medium" => "āš ļø",
411                            "low" => "ā„¹ļø",
412                            _ => "•",
413                        };
414                        let confidence_icon = match finding.confidence.as_str() {
415                            "high" => "šŸŽÆ",
416                            "medium" => "šŸ”",
417                            "low" => "ā“",
418                            _ => "•",
419                        };
420                        println!(
421                            "  {} {} {} - {} (Line {})", severity_icon, confidence_icon,
422                            finding.secret_type.red(), finding.file_path.split('/')
423                            .last().unwrap_or(& finding.file_path), finding.line_number
424                        );
425                        if verbose {
426                            println!(
427                                "    šŸ”’ Secret: {}", finding.masked_secret.dimmed()
428                            );
429                            println!("    šŸ“ Context:");
430                            for line in finding.context.lines() {
431                                if line.contains(&finding.secret_value) {
432                                    println!("    > {}", line.red());
433                                } else {
434                                    println!("    > {}", line.dimmed());
435                                }
436                            }
437                            println!("    šŸ’” {}", finding.recommendation.cyan());
438                        }
439                    }
440                }
441                if !report.recommendations.is_empty() {
442                    println!("\nšŸ’” Recommendations:");
443                    for rec in &report.recommendations {
444                        println!("  • {}", rec.cyan());
445                    }
446                }
447                println!("\nāœ… Scan complete!");
448                if report.secrets_found == 0 {
449                    println!("   No secrets found - good job!");
450                } else {
451                    println!(
452                        "   Found {} potential secret(s) to review", report.secrets_found
453                    );
454                }
455            }
456            OutputFormat::Json => {
457                let json = serde_json::to_string_pretty(report)
458                    .unwrap_or_else(|_| "{}".to_string());
459                println!("{}", json);
460            }
461            OutputFormat::Table => {
462                println!(
463                    "{:<35} {:<12} {:<15} {:<10} {:<20}", "File", "Line", "Type",
464                    "Severity", "Masked Secret"
465                );
466                println!("{}", "─".repeat(100));
467                for finding in &report.findings {
468                    let file_name = finding
469                        .file_path
470                        .split('/')
471                        .last()
472                        .unwrap_or(&finding.file_path);
473                    println!(
474                        "{:<35} {:<12} {:<15} {:<10} {:<20}", file_name.chars().take(34)
475                        .collect::< String > (), finding.line_number.to_string(), finding
476                        .secret_type.chars().take(14).collect::< String > (), finding
477                        .severity, finding.masked_secret.chars().take(19).collect::<
478                        String > ()
479                    );
480                }
481            }
482        }
483    }
484}
485#[derive(Debug, Clone)]
486struct SecretPattern {
487    name: String,
488    pattern: String,
489    confidence: String,
490    description: String,
491    example: String,
492}
493impl Tool for SecretScannerTool {
494    fn name(&self) -> &'static str {
495        "secret-scanner"
496    }
497    fn description(&self) -> &'static str {
498        "Scan for hardcoded secrets and API keys"
499    }
500    fn command(&self) -> Command {
501        Command::new(self.name())
502            .about(self.description())
503            .long_about(
504                "Scan your codebase for hardcoded secrets, API keys, tokens, and other \
505                        sensitive information that should not be committed to version control.
506
507EXAMPLES:
508    cm tool secret-scanner --directory src/
509    cm tool secret-scanner --workspace --exclude-vendor
510    cm tool secret-scanner --format json --output secrets.json",
511            )
512            .args(
513                &[
514                    Arg::new("directory")
515                        .long("directory")
516                        .short('d')
517                        .help("Directory to scan (default: current)")
518                        .default_value("."),
519                    Arg::new("workspace")
520                        .long("workspace")
521                        .help("Scan entire workspace")
522                        .action(clap::ArgAction::SetTrue),
523                    Arg::new("exclude-vendor")
524                        .long("exclude-vendor")
525                        .help("Exclude vendor directories")
526                        .action(clap::ArgAction::SetTrue),
527                    Arg::new("include-tests")
528                        .long("include-tests")
529                        .help("Include test files in scan")
530                        .action(clap::ArgAction::SetTrue),
531                    Arg::new("confidence")
532                        .long("confidence")
533                        .short('c')
534                        .help("Minimum confidence level")
535                        .default_value("low")
536                        .value_parser(["low", "medium", "high"]),
537                    Arg::new("format")
538                        .long("format")
539                        .short('f')
540                        .help("Output format for secrets")
541                        .default_value("masked")
542                        .value_parser(["masked", "full", "none"]),
543                    Arg::new("output")
544                        .long("output")
545                        .short('o')
546                        .help("Output file for results"),
547                    Arg::new("ci-mode")
548                        .long("ci-mode")
549                        .help("CI-friendly output with exit codes")
550                        .action(clap::ArgAction::SetTrue),
551                ],
552            )
553            .args(&common_options())
554    }
555    fn execute(&self, matches: &ArgMatches) -> Result<()> {
556        let directory = matches.get_one::<String>("directory").unwrap();
557        let workspace = matches.get_flag("workspace");
558        let exclude_vendor = matches.get_flag("exclude-vendor");
559        let include_tests = matches.get_flag("include-tests");
560        let min_confidence = matches.get_one::<String>("confidence").unwrap();
561        let format = matches.get_one::<String>("format").unwrap();
562        let output_file = matches.get_one::<String>("output");
563        let ci_mode = matches.get_flag("ci-mode");
564        let output_format = parse_output_format(matches);
565        let verbose = matches.get_flag("verbose");
566        println!(
567            "šŸ” {} - Scanning for Secrets", "CargoMate SecretScanner".bold().blue()
568        );
569        let scan_directory = if workspace { ".".to_string() } else { directory.clone() };
570        let files_to_scan = self.find_files_to_scan(&scan_directory)?;
571        if files_to_scan.is_empty() {
572            println!("{}", "No files found to scan".yellow());
573            return Ok(());
574        }
575        let mut all_findings = Vec::new();
576        for file_path in &files_to_scan {
577            match self.scan_file_for_secrets(file_path) {
578                Ok(findings) => {
579                    for finding in findings {
580                        let include_finding = match min_confidence.as_str() {
581                            "high" => finding.confidence == "high",
582                            "medium" => {
583                                finding.confidence == "high"
584                                    || finding.confidence == "medium"
585                            }
586                            "low" => true,
587                            _ => true,
588                        };
589                        if include_finding {
590                            all_findings.push(finding);
591                        }
592                    }
593                }
594                Err(e) => {
595                    if verbose {
596                        println!("āš ļø  Failed to scan {}: {}", file_path, e);
597                    }
598                }
599            }
600        }
601        let final_findings = all_findings
602            .into_iter()
603            .filter(|finding| {
604                match format.as_str() {
605                    "none" => false,
606                    "masked" => true,
607                    "full" => true,
608                    _ => true,
609                }
610            })
611            .collect::<Vec<_>>();
612        let statistics = self.calculate_statistics(&final_findings);
613        let recommendations = self.generate_recommendations(&final_findings);
614        let report = SecretScanReport {
615            files_scanned: files_to_scan.len(),
616            secrets_found: final_findings.len(),
617            findings: final_findings,
618            false_positives: Vec::new(),
619            statistics,
620            recommendations,
621            timestamp: chrono::Utc::now().to_rfc3339(),
622        };
623        if let Some(output_path) = output_file {
624            let json = serde_json::to_string_pretty(&report)?;
625            fs::write(output_path, json)?;
626            println!("šŸ“„ Report saved to {}", output_path);
627        }
628        self.display_report(&report, output_format, verbose);
629        if ci_mode && !report.findings.is_empty() {
630            let critical_count = report
631                .findings
632                .iter()
633                .filter(|f| f.severity == "critical")
634                .count();
635            let high_count = report
636                .findings
637                .iter()
638                .filter(|f| f.severity == "high")
639                .count();
640            println!("::set-output name=secrets-found::{}", report.secrets_found);
641            println!("::set-output name=secrets-critical::{}", critical_count);
642            println!("::set-output name=secrets-high::{}", high_count);
643            if critical_count > 0 {
644                println!(
645                    "::error title=Critical Secrets Found::Found {} critical secrets that must be addressed",
646                    critical_count
647                );
648            }
649            if critical_count > 0 || high_count > 0 {
650                std::process::exit(1);
651            }
652        }
653        Ok(())
654    }
655}
656impl Default for SecretScannerTool {
657    fn default() -> Self {
658        Self::new()
659    }
660}