use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use crate::db::{ProjectId, Vault};
use super::CoreError;
const MIN_MATCH_LEN: usize = 6;
const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
const BINARY_SNIFF_BYTES: usize = 8000;
#[derive(Debug, Clone)]
pub struct ScanMatch {
pub key: String,
pub environment: String,
pub path: PathBuf,
pub line: usize,
pub value: Option<Zeroizing<String>>,
}
pub fn scan_for_leaks(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env_name: Option<&str>,
root: &Path,
reveal: bool,
) -> Result<Vec<ScanMatch>, CoreError> {
let env_names: Vec<String> = match env_name {
Some(n) => vec![n.to_lowercase()],
None => vault
.list_environments(project_id)?
.into_iter()
.map(|e| e.name)
.collect(),
};
let mut needles: Vec<(String, String, Zeroizing<String>)> = Vec::new();
for name in &env_names {
let pairs = super::list_secrets_with_values(vault, master_key, project_id, name)?;
for (key, value) in pairs {
if value.len() >= MIN_MATCH_LEN {
needles.push((key, name.clone(), Zeroizing::new(value)));
}
}
}
let mut matches = Vec::new();
if needles.is_empty() {
return Ok(matches);
}
for path in walk_project_files(root) {
let Ok(metadata) = std::fs::metadata(&path) else {
continue;
};
if metadata.len() == 0 || metadata.len() > MAX_FILE_BYTES {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if is_probably_binary(&bytes) {
continue;
}
let text = String::from_utf8_lossy(&bytes);
let display_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
for (line_no, line) in text.lines().enumerate() {
for (key, env, needle) in &needles {
if line.contains(needle.as_str()) {
matches.push(ScanMatch {
key: key.clone(),
environment: env.clone(),
path: display_path.clone(),
line: line_no + 1,
value: if reveal {
Some(Zeroizing::new(needle.to_string()))
} else {
None
},
});
}
}
}
}
Ok(matches)
}
fn walk_project_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let walker = ignore::WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(false)
.build();
for result in walker {
let Ok(entry) = result else { continue };
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
if path.components().any(|c| c.as_os_str() == ".git") {
continue;
}
if path.file_name().is_some_and(|n| n == "envy.enc") {
continue;
}
out.push(path.to_path_buf());
}
out
}
fn is_probably_binary(bytes: &[u8]) -> bool {
bytes.iter().take(BINARY_SNIFF_BYTES).any(|&b| b == 0)
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY: [u8; 32] = [0xABu8; 32];
fn open_test_vault() -> (tempfile::TempDir, Vault, ProjectId) {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("vault.db");
let vault = Vault::open(&path, &TEST_KEY).expect("vault open");
let pid = vault
.create_project("test-project")
.expect("create project");
(tmp, vault, pid)
}
#[test]
fn finds_leaked_secret_in_plaintext_file() {
let (_tmp, vault, pid) = open_test_vault();
crate::core::set_secret(
&vault,
&TEST_KEY,
&pid,
"development",
"API_KEY",
"sk_live_super_secret_value",
)
.expect("set_secret");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(
scan_root.path().join("leaked.txt"),
"const key = 'sk_live_super_secret_value';\n",
)
.expect("write leaked file");
let matches = scan_for_leaks(&vault, &TEST_KEY, &pid, None, scan_root.path(), false)
.expect("scan must succeed");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].key, "API_KEY");
assert_eq!(matches[0].line, 1);
assert!(
matches[0].value.is_none(),
"value must be masked by default"
);
}
#[test]
fn reveal_true_populates_value() {
let (_tmp, vault, pid) = open_test_vault();
crate::core::set_secret(
&vault,
&TEST_KEY,
&pid,
"development",
"KEY",
"leaked-value-1",
)
.expect("set_secret");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(scan_root.path().join("f.txt"), "leaked-value-1\n").expect("write");
let matches = scan_for_leaks(&vault, &TEST_KEY, &pid, None, scan_root.path(), true)
.expect("scan must succeed");
assert_eq!(
matches[0].value.as_deref().map(|v| v.as_str()),
Some("leaked-value-1")
);
}
#[test]
fn clean_repo_returns_no_matches() {
let (_tmp, vault, pid) = open_test_vault();
crate::core::set_secret(
&vault,
&TEST_KEY,
&pid,
"development",
"KEY",
"totally-secret",
)
.expect("set_secret");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(scan_root.path().join("f.txt"), "nothing sensitive here\n").expect("write");
let matches = scan_for_leaks(&vault, &TEST_KEY, &pid, None, scan_root.path(), false)
.expect("scan must succeed");
assert!(matches.is_empty());
}
#[test]
fn short_secret_values_are_skipped() {
let (_tmp, vault, pid) = open_test_vault();
crate::core::set_secret(&vault, &TEST_KEY, &pid, "development", "SHORT", "abc")
.expect("set_secret");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(scan_root.path().join("f.txt"), "abc\n").expect("write");
let matches = scan_for_leaks(&vault, &TEST_KEY, &pid, None, scan_root.path(), false)
.expect("scan must succeed");
assert!(
matches.is_empty(),
"values shorter than MIN_MATCH_LEN must never be searched for"
);
}
#[test]
fn respects_gitignore() {
let (_tmp, vault, pid) = open_test_vault();
crate::core::set_secret(
&vault,
&TEST_KEY,
&pid,
"development",
"KEY",
"ignored-secret",
)
.expect("set_secret");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(scan_root.path().join(".gitignore"), "ignored-dir/\n").expect("write");
std::fs::create_dir(scan_root.path().join("ignored-dir")).expect("mkdir");
std::fs::write(
scan_root.path().join("ignored-dir").join("f.txt"),
"ignored-secret\n",
)
.expect("write");
let matches = scan_for_leaks(&vault, &TEST_KEY, &pid, None, scan_root.path(), false)
.expect("scan must succeed");
assert!(matches.is_empty(), "gitignored paths must not be scanned");
}
#[test]
fn scans_dotfiles_like_env() {
let (_tmp, vault, pid) = open_test_vault();
crate::core::set_secret(
&vault,
&TEST_KEY,
&pid,
"development",
"KEY",
"dotfile-secret",
)
.expect("set_secret");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(scan_root.path().join(".env"), "KEY=dotfile-secret\n").expect("write");
let matches = scan_for_leaks(&vault, &TEST_KEY, &pid, None, scan_root.path(), false)
.expect("scan must succeed");
assert_eq!(
matches.len(),
1,
"dotfiles like .env must be scanned, not skipped as 'hidden'"
);
}
#[test]
fn env_filter_restricts_which_secrets_are_searched() {
let (_tmp, vault, pid) = open_test_vault();
crate::core::set_secret(
&vault,
&TEST_KEY,
&pid,
"development",
"DEV",
"dev-secret-value",
)
.expect("set dev");
crate::core::set_secret(
&vault,
&TEST_KEY,
&pid,
"production",
"PROD",
"prod-secret-value",
)
.expect("set prod");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(
scan_root.path().join("f.txt"),
"dev-secret-value\nprod-secret-value\n",
)
.expect("write");
let matches = scan_for_leaks(
&vault,
&TEST_KEY,
&pid,
Some("production"),
scan_root.path(),
false,
)
.expect("scan must succeed");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].key, "PROD");
}
}