Skip to main content

dotenv_space/commands/
scan.rs

1/// Secret scanning command
2///
3/// Scans files for accidentally committed secrets using pattern matching
4/// and entropy analysis. Outputs findings with confidence levels and
5/// remediation steps.
6use anyhow::Result;
7use colored::*;
8use serde::{Deserialize, Serialize};
9// use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use walkdir::WalkDir;
13
14use crate::core::Parser;
15use crate::utils::patterns::{detect_secret, Confidence};
16
17/// A detected secret
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Finding {
20    pub pattern: String,
21    pub confidence: String,
22    pub value_preview: String,
23    pub location: String,
24    pub variable: Option<String>,
25    pub action_url: Option<String>,
26}
27
28/// Scan results
29#[derive(Debug, Serialize, Deserialize)]
30pub struct ScanResults {
31    pub files_scanned: usize,
32    pub secrets_found: usize,
33    pub findings: Vec<Finding>,
34    pub high_confidence: usize,
35    pub medium_confidence: usize,
36    pub low_confidence: usize,
37}
38
39/// Run the scan command
40pub fn run(
41    paths: Vec<String>,
42    exclude: Vec<String>,
43    _pattern: Vec<String>,
44    ignore_placeholders: bool,
45    format: String,
46    exit_zero: bool,
47    verbose: bool,
48) -> Result<()> {
49    if verbose {
50        println!("{}", "Running scan in verbose mode".dimmed());
51    }
52
53    println!(
54        "\n{}",
55        "┌─ Scanning for exposed secrets ──────────────────────┐".cyan()
56    );
57    println!(
58        "{}",
59        "│ Checking for real-looking credentials               │".cyan()
60    );
61    println!(
62        "{}\n",
63        "└──────────────────────────────────────────────────────┘".cyan()
64    );
65
66    // Collect files to scan
67    let files = collect_files(&paths, &exclude)?;
68
69    if verbose {
70        println!("Scanning {} files...", files.len());
71    }
72
73    let mut results = ScanResults {
74        files_scanned: files.len(),
75        secrets_found: 0,
76        findings: Vec::new(),
77        high_confidence: 0,
78        medium_confidence: 0,
79        low_confidence: 0,
80    };
81
82    // Scan each file
83    for file in &files {
84        if verbose {
85            println!("  Scanning: {}", file.display());
86        }
87        scan_file(file, &mut results, ignore_placeholders)?;
88    }
89
90    // Output results
91    match format.as_str() {
92        "json" => output_json(&results)?,
93        "sarif" => output_sarif(&results)?,
94        _ => output_pretty(&results, &files)?,
95    }
96
97    // Exit code
98    if !exit_zero && results.secrets_found > 0 {
99        std::process::exit(1);
100    }
101
102    Ok(())
103}
104
105/// Collect files to scan
106fn collect_files(paths: &[String], exclude: &[String]) -> Result<Vec<PathBuf>> {
107    let mut files = Vec::new();
108
109    for path_str in paths {
110        let path = Path::new(path_str);
111
112        if path.is_file() {
113            if !should_exclude(path, exclude) {
114                files.push(path.to_path_buf());
115            }
116        } else if path.is_dir() {
117            for entry in WalkDir::new(path)
118                .follow_links(false)
119                .into_iter()
120                .filter_map(|e| e.ok())
121            {
122                let entry_path = entry.path();
123                if entry_path.is_file() && !should_exclude(entry_path, exclude) {
124                    // Only scan text-like files
125                    if is_scannable_file(entry_path) {
126                        files.push(entry_path.to_path_buf());
127                    }
128                }
129            }
130        }
131    }
132
133    Ok(files)
134}
135
136/// Check if a file should be excluded
137fn should_exclude(path: &Path, exclude: &[String]) -> bool {
138    let path_str = path.to_string_lossy();
139
140    for pattern in exclude {
141        // Simple glob matching
142        if pattern.contains('*') {
143            let pattern = pattern.replace('*', "");
144            if path_str.contains(&pattern) {
145                return true;
146            }
147        } else if path_str.contains(pattern) {
148            return true;
149        }
150    }
151
152    // Always exclude common non-secret files
153    let always_exclude = [
154        ".git/",
155        "node_modules/",
156        "target/",
157        "dist/",
158        "build/",
159        ".env.example",
160        ".env.sample",
161        ".env.template",
162    ];
163
164    for pattern in &always_exclude {
165        if path_str.contains(pattern) {
166            return true;
167        }
168    }
169
170    false
171}
172
173/// Check if a file is scannable (text-based)
174fn is_scannable_file(path: &Path) -> bool {
175    let scannable_extensions = [
176        "env",
177        "txt",
178        "sh",
179        "bash",
180        "zsh",
181        "py",
182        "js",
183        "ts",
184        "rs",
185        "go",
186        "java",
187        "rb",
188        "php",
189        "yml",
190        "yaml",
191        "json",
192        "toml",
193        "xml",
194        "conf",
195        "config",
196        "ini",
197        "properties",
198    ];
199
200    if let Some(ext) = path.extension() {
201        let ext_str = ext.to_string_lossy().to_lowercase();
202        if scannable_extensions.contains(&ext_str.as_str()) {
203            return true;
204        }
205    }
206
207    // Files without extensions (Dockerfile, Makefile, etc.)
208    if path.extension().is_none() {
209        if let Some(name) = path.file_name() {
210            let name_str = name.to_string_lossy();
211            if name_str.starts_with(".env")
212                || name_str == "Dockerfile"
213                || name_str == "Makefile"
214                || name_str == "docker-compose.yml"
215            {
216                return true;
217            }
218        }
219    }
220
221    false
222}
223
224/// Scan a single file for secrets
225fn scan_file(path: &Path, results: &mut ScanResults, ignore_placeholders: bool) -> Result<()> {
226    let content = match fs::read_to_string(path) {
227        Ok(c) => c,
228        Err(_) => return Ok(()), // Skip binary files
229    };
230
231    // If it's a .env file, parse it properly
232    if path.to_string_lossy().contains(".env") {
233        scan_env_file(path, &content, results, ignore_placeholders)?;
234    } else {
235        // Scan line by line for secrets
236        scan_text_file(path, &content, results, ignore_placeholders)?;
237    }
238
239    Ok(())
240}
241
242/// Scan a .env file using the parser
243fn scan_env_file(
244    path: &Path,
245    content: &str,
246    results: &mut ScanResults,
247    ignore_placeholders: bool,
248) -> Result<()> {
249    let parser = Parser::default();
250    let _vars = match parser.parse_content(content) {
251        Ok(v) => v,
252        Err(_) => return Ok(()), // Skip invalid files
253    };
254
255    for (line_num, line) in content.lines().enumerate() {
256        let line_num = line_num + 1;
257
258        // Skip comments and empty lines
259        if line.trim().is_empty() || line.trim().starts_with('#') {
260            continue;
261        }
262
263        // Extract key=value
264        if let Some((key, value)) = line.split_once('=') {
265            let key = key.trim().trim_start_matches("export").trim();
266            let value = value.trim();
267
268            if let Some((pattern, confidence, action_url)) = detect_secret(value, key) {
269                // Skip if ignoring placeholders and this is one
270                if ignore_placeholders && crate::utils::patterns::is_placeholder(value) {
271                    continue;
272                }
273
274                let finding = Finding {
275                    pattern: pattern.clone(),
276                    confidence: format!("{}", confidence),
277                    value_preview: truncate_value(value),
278                    location: format!("{}:{} ({})", path.display(), line_num, key),
279                    variable: Some(key.to_string()),
280                    action_url,
281                };
282
283                results.findings.push(finding);
284                results.secrets_found += 1;
285
286                match confidence {
287                    Confidence::High => results.high_confidence += 1,
288                    Confidence::Medium => results.medium_confidence += 1,
289                    Confidence::Low => results.low_confidence += 1,
290                }
291            }
292        }
293    }
294
295    Ok(())
296}
297
298/// Scan a text file line by line
299fn scan_text_file(
300    path: &Path,
301    content: &str,
302    results: &mut ScanResults,
303    ignore_placeholders: bool,
304) -> Result<()> {
305    for (line_num, line) in content.lines().enumerate() {
306        let line_num = line_num + 1;
307
308        // Try to detect secrets in the line
309        // Split by common separators to find tokens
310        let tokens: Vec<&str> = line
311            .split(|c: char| c.is_whitespace() || c == '=' || c == ':' || c == '"' || c == '\'')
312            .filter(|t| t.len() > 20) // Only check reasonably long tokens
313            .collect();
314
315        for token in tokens {
316            if let Some((pattern, confidence, action_url)) = detect_secret(token, "") {
317                if ignore_placeholders && crate::utils::patterns::is_placeholder(token) {
318                    continue;
319                }
320
321                let finding = Finding {
322                    pattern: pattern.clone(),
323                    confidence: format!("{}", confidence),
324                    value_preview: truncate_value(token),
325                    location: format!("{}:{}", path.display(), line_num),
326                    variable: None,
327                    action_url,
328                };
329
330                results.findings.push(finding);
331                results.secrets_found += 1;
332
333                match confidence {
334                    Confidence::High => results.high_confidence += 1,
335                    Confidence::Medium => results.medium_confidence += 1,
336                    Confidence::Low => results.low_confidence += 1,
337                }
338            }
339        }
340    }
341
342    Ok(())
343}
344
345/// Truncate a value for display (show first and last few chars)
346const PREFIX_LEN: usize = 8;
347const SUFFIX_LEN: usize = 5;
348const MAX_VISIBLE: usize = 20;
349
350fn truncate_value(value: &str) -> String {
351    if value.len() <= MAX_VISIBLE {
352        value.to_string()
353    } else {
354        format!(
355            "{}...{}",
356            &value[..PREFIX_LEN],
357            &value[value.len() - SUFFIX_LEN..]
358        )
359    }
360}
361
362/// Output results in pretty format
363fn output_pretty(results: &ScanResults, files: &[PathBuf]) -> Result<()> {
364    println!(
365        "Scanning: {}",
366        files
367            .iter()
368            .take(3)
369            .map(|f| f.display().to_string())
370            .collect::<Vec<_>>()
371            .join(", ")
372    );
373    if files.len() > 3 {
374        println!("  ... and {} more files", files.len() - 3);
375    }
376    println!();
377
378    if results.secrets_found == 0 {
379        println!("{} No secrets detected", "✓".green());
380        println!("\nScanned {} files", results.files_scanned);
381        return Ok(());
382    }
383
384    println!(
385        "{} Found {} potential secrets\n",
386        "✗".red(),
387        results.secrets_found
388    );
389
390    println!("{}", "Secrets detected:".bold());
391    for (i, finding) in results.findings.iter().enumerate() {
392        let icon = match finding.confidence.as_str() {
393            "high" => "🚨",
394            "medium" => "⚠️ ",
395            _ => "ℹ️ ",
396        };
397
398        println!(
399            "  {}. {} {} ({} confidence)",
400            i + 1,
401            icon,
402            finding.pattern.bold(),
403            finding.confidence
404        );
405        println!("     Pattern: {}", finding.pattern);
406        println!("     Value: {}", finding.value_preview.dimmed());
407        println!("     Location: {}", finding.location);
408
409        if finding.confidence == "high" {
410            println!(
411                "     {}",
412                "This looks like a real secret, not a placeholder.".yellow()
413            );
414        }
415
416        if let Some(url) = &finding.action_url {
417            println!("     Action: Revoke immediately at {}", url.cyan());
418        }
419        println!();
420    }
421
422    println!("{}", "Summary:".bold());
423    println!("  🚨 {} high-confidence secrets", results.high_confidence);
424    println!(
425        "  ⚠️  {} medium-confidence secrets",
426        results.medium_confidence
427    );
428    if results.low_confidence > 0 {
429        println!("  ℹ️ {} low-confidence detections", results.low_confidence);
430    }
431
432    println!(
433        "\n  {}",
434        "Recommendation: These should NOT be committed to Git."
435            .yellow()
436            .bold()
437    );
438
439    if results.high_confidence > 0 || results.medium_confidence > 0 {
440        println!("\n  If already committed:");
441        println!("    1. Revoke/rotate all keys immediately");
442        println!("    2. Run: git filter-repo --path .env --invert-paths");
443        println!("    3. Force push (after team coordination)");
444    }
445
446    Ok(())
447}
448
449/// Output results in JSON format
450fn output_json(results: &ScanResults) -> Result<()> {
451    let json = serde_json::to_string_pretty(results)?;
452    println!("{}", json);
453    Ok(())
454}
455
456/// Output results in SARIF format (for GitHub Code Scanning)
457fn output_sarif(results: &ScanResults) -> Result<()> {
458    let sarif_results: Vec<serde_json::Value> = results
459        .findings
460        .iter()
461        .map(|f| {
462            let level = match f.confidence.as_str() {
463                "high" => "error",
464                "medium" => "warning",
465                _ => "note",
466            };
467
468            // Parse location to extract file and line
469            let parts: Vec<&str> = f.location.split(':').collect();
470            let file = parts.first().unwrap_or(&"unknown");
471            let line: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
472
473            serde_json::json!({
474                "ruleId": format!("secret/{}", f.pattern.to_lowercase().replace(' ', "-")),
475                "level": level,
476                "message": {
477                    "text": format!("{} detected", f.pattern)
478                },
479                "locations": [{
480                    "physicalLocation": {
481                        "artifactLocation": { "uri": file },
482                        "region": { "startLine": line }
483                    }
484                }]
485            })
486        })
487        .collect();
488
489    let sarif = serde_json::json!({
490        "version": "2.1.0",
491        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
492        "runs": [{
493            "tool": {
494                "driver": {
495                    "name": "evnx scan",
496                    "version": env!("CARGO_PKG_VERSION")
497                }
498            },
499            "results": sarif_results
500        }]
501    });
502
503    println!("{}", serde_json::to_string_pretty(&sarif)?);
504    Ok(())
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_truncate_value() {
513        assert_eq!(truncate_value("short"), "short");
514        assert_eq!(
515            truncate_value("this_is_a_very_long_secret_key_value_12345678"),
516            "this_is_...45678"
517        );
518    }
519
520    #[test]
521    fn test_is_scannable_file() {
522        assert!(is_scannable_file(Path::new(".env")));
523        assert!(is_scannable_file(Path::new("config.py")));
524        assert!(is_scannable_file(Path::new("Dockerfile")));
525        assert!(!is_scannable_file(Path::new("image.png")));
526    }
527
528    #[test]
529    fn test_should_exclude() {
530        assert!(should_exclude(Path::new(".env.example"), &[]));
531        assert!(should_exclude(Path::new("node_modules/package.json"), &[]));
532        assert!(!should_exclude(Path::new(".env"), &[]));
533
534        assert!(should_exclude(Path::new("test.py"), &["test*".to_string()]));
535    }
536}