1use anyhow::Result;
7use colored::*;
8use serde::{Deserialize, Serialize};
9use std::fs;
11use std::path::{Path, PathBuf};
12use walkdir::WalkDir;
13
14use crate::core::Parser;
15use crate::utils::patterns::{detect_secret, Confidence};
16
17#[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#[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
39pub 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 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 for file in &files {
84 if verbose {
85 println!(" Scanning: {}", file.display());
86 }
87 scan_file(file, &mut results, ignore_placeholders)?;
88 }
89
90 match format.as_str() {
92 "json" => output_json(&results)?,
93 "sarif" => output_sarif(&results)?,
94 _ => output_pretty(&results, &files)?,
95 }
96
97 if !exit_zero && results.secrets_found > 0 {
99 std::process::exit(1);
100 }
101
102 Ok(())
103}
104
105fn 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 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
136fn should_exclude(path: &Path, exclude: &[String]) -> bool {
138 let path_str = path.to_string_lossy();
139
140 for pattern in exclude {
141 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 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
173fn 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 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
224fn 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(()), };
230
231 if path.to_string_lossy().contains(".env") {
233 scan_env_file(path, &content, results, ignore_placeholders)?;
234 } else {
235 scan_text_file(path, &content, results, ignore_placeholders)?;
237 }
238
239 Ok(())
240}
241
242fn 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(()), };
254
255 for (line_num, line) in content.lines().enumerate() {
256 let line_num = line_num + 1;
257
258 if line.trim().is_empty() || line.trim().starts_with('#') {
260 continue;
261 }
262
263 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 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
298fn 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 let tokens: Vec<&str> = line
311 .split(|c: char| c.is_whitespace() || c == '=' || c == ':' || c == '"' || c == '\'')
312 .filter(|t| t.len() > 20) .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
345const 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
362fn 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
449fn output_json(results: &ScanResults) -> Result<()> {
451 let json = serde_json::to_string_pretty(results)?;
452 println!("{}", json);
453 Ok(())
454}
455
456fn 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 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}