use crate::config::Config;
use crate::finding::{Finding, ScanResult, Severity};
use crate::scanners::{which_exists, RuleInfo, Scanner};
use std::path::Path;
use std::time::Instant;
pub struct SecretsScanner;
impl Scanner for SecretsScanner {
fn name(&self) -> &'static str {
"secrets"
}
fn description(&self) -> &'static str {
"Secret scanning via gitleaks (external tool)"
}
fn is_available(&self) -> bool {
which_exists("gitleaks")
}
fn scan(&self, path: &Path, _config: &Config) -> ScanResult {
let start = Instant::now();
let report_file = match tempfile::NamedTempFile::new() {
Ok(f) => f,
Err(e) => {
return ScanResult::error(
self.name(),
format!("Failed to create temp file: {}", e),
start.elapsed().as_millis() as u64,
);
}
};
let report_path = report_file.path().to_path_buf();
let mut cmd = std::process::Command::new("gitleaks");
cmd.arg("detect")
.arg("--source")
.arg(path)
.arg("--no-git")
.arg("--report-format")
.arg("json")
.arg("--report-path")
.arg(&report_path);
let output = match crate::scanners::run_with_timeout(
cmd,
crate::scanners::EXTERNAL_TOOL_TIMEOUT,
self.name(),
start,
) {
Ok(o) => o,
Err(scan_result) => return scan_result,
};
if let Some(code) = output.status.code() {
if code > 1 {
let stderr = String::from_utf8_lossy(&output.stderr);
return ScanResult::error(
self.name(),
format!("gitleaks error (exit {}): {}", code, stderr.trim()),
start.elapsed().as_millis() as u64,
);
}
}
let content = match std::fs::read_to_string(&report_path) {
Ok(c) => c,
Err(e) => {
return ScanResult::error(
self.name(),
format!("Failed to read gitleaks report: {e}"),
start.elapsed().as_millis() as u64,
);
}
};
let items: Vec<serde_json::Value> = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
return ScanResult::error(
self.name(),
format!("Failed to parse gitleaks report: {}", e),
start.elapsed().as_millis() as u64,
);
}
};
let mut findings = Vec::new();
for item in &items {
let rule = item["RuleID"]
.as_str()
.or_else(|| item["ruleId"].as_str())
.unwrap_or("unknown");
let rule_id = format!("secrets/{}", rule);
let description = item["Description"]
.as_str()
.or_else(|| item["description"].as_str())
.unwrap_or("Secret detected")
.to_string();
let file_path = item["File"]
.as_str()
.or_else(|| item["file"].as_str())
.map(std::path::PathBuf::from);
let line = item["StartLine"]
.as_u64()
.or_else(|| item["startLine"].as_u64())
.map(|l| l as usize);
let snippet = item["Match"]
.as_str()
.or_else(|| item["match"].as_str())
.map(|s| {
if s.len() > 8 {
format!("{}...{}", &s[..4], "*".repeat(s.len().min(20) - 4))
} else {
"*".repeat(s.len())
}
});
findings.push(Finding {
rule_id,
message: description,
severity: Severity::Error,
file: file_path,
line,
column: None,
scanner: self.name().to_string(),
snippet,
suppressed: false,
suppression_reason: None,
remediation: Some(
"Rotate the leaked secret immediately and remove it from the codebase"
.to_string(),
),
});
}
let _ = std::fs::remove_file(&report_path);
let files_scanned = {
use std::collections::HashSet;
let unique: HashSet<_> = findings.iter().filter_map(|f| f.file.as_ref()).collect();
if unique.is_empty() {
1
} else {
unique.len()
}
};
ScanResult {
scanner_name: self.name().to_string(),
findings,
files_scanned,
skipped: false,
skip_reason: None,
error: None,
duration_ms: start.elapsed().as_millis() as u64,
scanner_score: None,
scanner_grade: None,
}
}
}
pub fn rules() -> Vec<RuleInfo> {
vec![
RuleInfo {
id: "secrets/generic-api-key",
severity: "error",
scanner: "secrets",
message: "Detected a Generic API Key",
remediation: "Rotate the leaked secret immediately and remove it from the codebase",
},
RuleInfo {
id: "secrets/aws-access-key",
severity: "error",
scanner: "secrets",
message: "Detected an AWS Access Key",
remediation: "Revoke the key immediately in AWS console",
},
RuleInfo {
id: "secrets/github-pat",
severity: "error",
scanner: "secrets",
message: "Detected a GitHub Personal Access Token",
remediation: "Revoke the token in GitHub settings",
},
RuleInfo {
id: "secrets/private-key",
severity: "error",
scanner: "secrets",
message: "Detected a Private Key (SSH, RSA, etc.)",
remediation: "Remove the key and rotate any credentials it protected",
},
]
}