use std::time::{Duration, SystemTime};
use crate::error::Error;
use crate::vault::Vault;
const MIN_ENTROPY_BYTES: usize = 8;
const DEFAULT_MAX_AGE_DAYS: u64 = 90;
#[derive(Debug)]
pub struct SecretHealth {
pub name: String,
pub age: Option<Duration>,
pub needs_rotation: bool,
pub entropy_warnings: Vec<crate::guard::Signal>,
}
impl SecretHealth {
#[must_use]
pub fn age_string(&self) -> String {
self.age
.as_ref()
.map_or_else(|| "unknown".to_string(), format_age)
}
}
#[derive(Debug)]
pub struct HealthReport {
pub secrets: Vec<SecretHealth>,
pub stale_count: usize,
pub weak_count: usize,
pub total: usize,
}
impl HealthReport {
pub fn is_healthy(&self) -> bool {
self.stale_count == 0 && self.weak_count == 0
}
}
pub fn shannon_entropy(data: &[u8]) -> f64 {
if data.is_empty() {
return 0.0;
}
let mut counts = [0u64; 256];
for &b in data {
counts[b as usize] += 1;
}
#[allow(clippy::cast_precision_loss)]
let len = data.len() as f64;
let mut entropy = 0.0;
for &count in &counts {
if count > 0 {
#[allow(clippy::cast_precision_loss)]
let p = count as f64 / len;
entropy -= p * p.log2();
}
}
entropy
}
pub fn check_entropy(name: &str, value: &[u8]) -> Vec<crate::guard::Signal> {
use crate::guard::{Category, Severity, Signal, SignalId};
let mut signals = Vec::new();
if value.len() < MIN_ENTROPY_BYTES {
signals.push(Signal::new(
SignalId::new("secret.entropy.too_short"),
Category::SecretQuality,
Severity::Warn,
"secret is shorter than typical API keys",
format!(
"secret '{name}' is only {len} bytes — most API keys are 32+ bytes",
len = value.len()
),
"double-check that you stored the full key, not a truncated copy",
));
}
if value.len() >= 4 {
let entropy = shannon_entropy(value);
if entropy < 3.0 {
signals.push(Signal::new(
SignalId::new("secret.entropy.low_shannon"),
Category::SecretQuality,
Severity::Warn,
"secret has low Shannon entropy",
format!(
"secret '{name}' has {entropy:.1} bits/byte — likely a \
placeholder, dictionary word, or weak password rather \
than a randomly-generated key"
),
"regenerate the secret from a real source if this isn't intentional",
));
}
}
if let Ok(s) = std::str::from_utf8(value) {
let lower = s.to_lowercase();
let placeholders: &[&str] = &[
"todo",
"changeme",
"change-me",
"change_me",
"your-api-key",
"your_api_key",
"replace-me",
"replace_me",
"placeholder",
"test-key",
"test_key",
"insert-key-here",
"insert_key_here",
"put-your",
"put_your",
"add-your",
"add_your",
"enter-your",
"enter_your",
"sk-your",
"pk-your",
"set-this",
"set_this",
"fill-in",
"fill_in",
];
for placeholder in placeholders {
if lower.contains(placeholder) {
signals.push(Signal::new(
SignalId::scoped("secret.entropy.placeholder", placeholder),
Category::SecretQuality,
Severity::Warn,
"secret looks like a placeholder",
format!(
"secret '{name}' contains the placeholder text '{placeholder}' — \
likely copy-pasted from a template or example file"
),
"replace with the real secret value",
));
break;
}
}
}
signals
}
pub fn secret_age(vault: &Vault, name: &str) -> Option<Duration> {
let path = vault.secret_path(name);
let metadata = std::fs::metadata(&path).ok()?;
let modified = metadata.modified().ok()?;
SystemTime::now().duration_since(modified).ok()
}
pub fn health_report(vault: &Vault, max_age_days: Option<u64>) -> Result<HealthReport, Error> {
let max_age = Duration::from_secs(max_age_days.unwrap_or(DEFAULT_MAX_AGE_DAYS) * 86400);
let names = vault.list()?;
let total = names.len();
let mut secrets = Vec::with_capacity(total);
let mut stale_count = 0;
let mut weak_count = 0;
for name in &names {
let age = secret_age(vault, name);
let needs_rotation = age.is_some_and(|a| a > max_age);
let entropy_warnings = if name.eq_ignore_ascii_case("ctf-flag") {
Vec::new()
} else {
match vault.decrypt(name) {
Ok(plaintext) => check_entropy(name, &plaintext),
Err(_) => vec![crate::guard::Signal::new(
crate::guard::SignalId::scoped("secret.health.decrypt_failed", name),
crate::guard::Category::SecretQuality,
crate::guard::Severity::Warn,
"could not decrypt secret for health check",
format!(
"secret '{name}' could not be decrypted — wrong key or corrupted ciphertext"
),
"ensure the vault is unlocked with the correct passphrase",
)],
}
};
if needs_rotation {
stale_count += 1;
}
if !entropy_warnings.is_empty() {
weak_count += 1;
}
secrets.push(SecretHealth {
name: name.clone(),
age,
needs_rotation,
entropy_warnings,
});
}
Ok(HealthReport {
secrets,
stale_count,
weak_count,
total,
})
}
pub fn format_age(duration: &Duration) -> String {
let secs = duration.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86400)
}
}
pub fn gitignore_snippet() -> &'static str {
r"# envseal — never commit these
*.seal
master.key
policy.sig
audit.log
security.toml
"
}
pub fn pre_commit_hook() -> &'static str {
r#"#!/usr/bin/env bash
# envseal pre-commit hook — prevents accidental secret leaks.
#
# Install: cp this to .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
# Or: envseal git-hook install
set -euo pipefail
RED='\033[0;31m'
NC='\033[0m'
# Pattern: common API key prefixes that should never be committed
PATTERNS=(
'sk-[a-zA-Z0-9]{20,}' # OpenAI
'AIza[0-9A-Za-z_-]{35}' # Google API
'AKIA[0-9A-Z]{16}' # AWS access key
'ghp_[a-zA-Z0-9]{36}' # GitHub PAT
'glpat-[a-zA-Z0-9_-]{20,}' # GitLab PAT
'xoxb-[0-9]{10,}' # Slack bot token
'sq0atp-[a-zA-Z0-9_-]{22}' # Square
'SG\.[a-zA-Z0-9_-]{22}\.' # SendGrid
)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
if [ -z "$STAGED_FILES" ]; then
exit 0
fi
FOUND=0
for pattern in "${PATTERNS[@]}"; do
# Use grep -E for extended regex, -n for line numbers
MATCHES=$(echo "$STAGED_FILES" | xargs grep -EnH "$pattern" 2>/dev/null || true)
if [ -n "$MATCHES" ]; then
if [ $FOUND -eq 0 ]; then
echo -e "${RED}🔒 envseal: potential secret leak detected!${NC}"
echo ""
fi
echo "$MATCHES"
FOUND=1
fi
done
if [ $FOUND -eq 1 ]; then
echo ""
echo "Use 'envseal store <name>' to store secrets safely."
echo "Use 'git commit --no-verify' to bypass (not recommended)."
exit 1
fi
"#
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entropy_of_random_data() {
let random: Vec<u8> = (0..=255u8).collect();
let e = shannon_entropy(&random);
assert!(e > 7.5, "random bytes should have high entropy, got {e}");
}
#[test]
fn entropy_of_repeated_data() {
let repeated = vec![b'a'; 100];
let e = shannon_entropy(&repeated);
assert!(e < 0.1, "repeated bytes should have ~0 entropy, got {e}");
}
#[test]
fn entropy_empty() {
assert!((shannon_entropy(&[]) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn detects_placeholder() {
let signals = check_entropy("test", b"your-api-key-here");
assert!(!signals.is_empty(), "should detect placeholder");
assert!(signals
.iter()
.any(|s| s.id.as_str().starts_with("secret.entropy.placeholder.")));
}
#[test]
fn detects_todo() {
let warnings = check_entropy("test", b"TODO");
assert!(!warnings.is_empty(), "should detect TODO");
}
#[test]
fn accepts_real_api_key() {
let signals = check_entropy("key", b"sk-proj-abc123defghijklmnopqrstuvwxyz1234567890");
let placeholder_warns: Vec<_> = signals
.iter()
.filter(|s| s.id.as_str().starts_with("secret.entropy.placeholder."))
.collect();
assert!(
placeholder_warns.is_empty(),
"real key shouldn't be flagged as placeholder"
);
}
#[test]
fn detects_short_secret() {
let signals = check_entropy("pw", b"abc");
assert!(!signals.is_empty());
assert!(signals
.iter()
.any(|s| s.id.as_str() == "secret.entropy.too_short"
&& s.detail.contains("only 3 bytes")));
}
#[test]
fn gitignore_covers_seal_files() {
let snippet = gitignore_snippet();
assert!(snippet.contains("*.seal"));
assert!(snippet.contains("master.key"));
assert!(snippet.contains("audit.log"));
}
#[test]
fn pre_commit_hook_detects_patterns() {
let hook = pre_commit_hook();
assert!(hook.contains("sk-[a-zA-Z0-9]"));
assert!(hook.contains("AKIA"));
assert!(hook.contains("ghp_"));
}
#[test]
fn format_age_works() {
assert_eq!(format_age(&Duration::from_secs(30)), "30s");
assert_eq!(format_age(&Duration::from_secs(120)), "2m");
assert_eq!(format_age(&Duration::from_secs(7200)), "2h");
assert_eq!(format_age(&Duration::from_secs(86400 * 5)), "5d");
}
}