use std::path::{Path, PathBuf};
use crate::error::Error;
pub use crate::migration::history::{HistoryFinding, HistoryKind};
pub use crate::migration::preexec::{Confidence, DetectedSecret};
pub use crate::migration::shell_hooks::{InstallReport, Shell};
pub fn detect_secrets_in_command(cmd: &str) -> Vec<DetectedSecret> {
crate::migration::preexec::detect_in_command(cmd)
}
pub fn install_shell_hook(shell: Shell, rc_path: &Path) -> Result<InstallReport, Error> {
crate::migration::shell_hooks::install(shell, rc_path)
}
pub fn uninstall_shell_hook(rc_path: &Path) -> Result<bool, Error> {
crate::migration::shell_hooks::uninstall(rc_path)
}
#[must_use]
pub fn shell_hook_snippet(shell: Shell) -> &'static str {
shell.snippet()
}
#[must_use]
pub fn detect_user_shell() -> Option<Shell> {
crate::migration::shell_hooks::detect_user_shell()
}
#[must_use]
pub fn shell_default_rc_path(shell: Shell) -> Option<PathBuf> {
shell.default_rc_path()
}
#[must_use]
pub fn env_var_to_secret_name(env_var: &str) -> String {
crate::file::parser::env_var_to_secret_name(env_var)
}
pub fn scan_shell_history() -> Result<Vec<HistoryFinding>, Error> {
crate::migration::history::scan_all_default()
}
#[derive(Debug, Clone)]
pub struct PreexecOutcome {
pub detected: usize,
pub stored: usize,
pub skipped: usize,
}
fn preexec_skip_path() -> Option<PathBuf> {
Some(super::vault_root().ok()?.join("preexec-skip.txt"))
}
fn preexec_skip_load() -> std::collections::BTreeSet<String> {
let Some(p) = preexec_skip_path() else {
return std::collections::BTreeSet::new();
};
let Ok(text) = std::fs::read_to_string(&p) else {
return std::collections::BTreeSet::new();
};
text.lines()
.filter_map(|l| {
let t = l.trim();
if t.is_empty() || t.starts_with('#') {
None
} else {
Some(t.to_string())
}
})
.collect()
}
fn preexec_skip_record(fingerprint: &str) -> Result<(), Error> {
use std::io::Write as _;
let Some(p) = preexec_skip_path() else {
return Ok(());
};
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).map_err(Error::StorageIo)?;
}
crate::guard::verify_not_symlink(&p)?;
let mut f = {
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
opts.open(&p).map_err(Error::StorageIo)?
};
writeln!(f, "{fingerprint}").map_err(Error::StorageIo)?;
Ok(())
}
fn preexec_fingerprint(env_var: &str, value: &str) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let mut h = Sha256::new();
h.update(env_var.as_bytes());
h.update([0u8]);
h.update(value.as_bytes());
let digest = h.finalize();
let mut out = String::with_capacity(16);
for b in &digest[..8] {
let _ = write!(out, "{b:02x}");
}
out
}
pub fn handle_preexec_command(cmd: &str) -> Result<PreexecOutcome, Error> {
let mut outcome = PreexecOutcome {
detected: 0,
stored: 0,
skipped: 0,
};
let detections: Vec<_> = detect_secrets_in_command(cmd)
.into_iter()
.filter(|d| d.confidence == Confidence::High)
.collect();
if detections.is_empty() {
return Ok(outcome);
}
outcome.detected = detections.len();
let skip = preexec_skip_load();
for det in detections {
let fp = preexec_fingerprint(&det.env_var, &det.value);
if skip.contains(&fp) {
outcome.skipped += 1;
continue;
}
let provider_label = det.provider.map(|p| format!(" ({p})")).unwrap_or_default();
let secret_name = crate::file::parser::env_var_to_secret_name(&det.env_var);
let prompt_message = format!(
"envseal detected an API key{provider_label} in your shell command:\n\n \
{env_var}={preview}\n\nStore it as '{secret_name}' so you don't have to paste it again?\n\
From now on, run: envseal inject {secret_name}={env_var} -- <cmd>",
env_var = det.env_var,
preview = redact_for_prompt(&det.value)
);
match crate::gui::preexec_capture_prompt(&prompt_message)? {
crate::gui::PreexecChoice::Store => {
let vault = crate::vault::Vault::open_default()?;
if let Err(e) = vault.store(&secret_name, det.value.as_bytes(), false) {
let _ =
crate::audit::log_required(&crate::audit::AuditEvent::SecretStoreFailed {
name: secret_name.clone(),
reason: e.to_string(),
});
continue;
}
let _ = crate::audit::log_required(&crate::audit::AuditEvent::SecretStored {
name: secret_name.clone(),
});
outcome.stored += 1;
}
crate::gui::PreexecChoice::Skip => {}
crate::gui::PreexecChoice::DontAskAgain => {
let _ = preexec_skip_record(&fp);
outcome.skipped += 1;
}
}
}
Ok(outcome)
}
fn redact_for_prompt(value: &str) -> String {
if value.len() <= 8 {
return "****".to_string();
}
let head: String = value.chars().take(4).collect();
let tail: String = value
.chars()
.rev()
.take(4)
.collect::<String>()
.chars()
.rev()
.collect();
format!("{head}…{tail}")
}