envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Secret health monitoring — expiration, entropy, rotation age.
//!
//! Provides the tools enterprises and developers need to keep secrets healthy:
//!
//! - **Entropy validation**: Warns when a stored secret looks weak (too short
//!   or low-entropy compared to what you'd expect for an API key).
//! - **Age tracking**: Computes how old each secret is from file metadata.
//! - **Expiration policy**: Flags secrets that haven't been rotated in N days.
//! - **Health report**: One-shot summary of all secret health issues.
//!
//! # Personas
//!
//! - **AI developer**: "Am I accidentally storing a placeholder instead of
//!   a real key?"
//! - **Bank**: "Which secrets are older than 90 days and need rotation?"
//! - **Enterprise**: "Give me a compliance report of all secret hygiene issues."

use std::time::{Duration, SystemTime};

use crate::error::Error;
use crate::vault::Vault;

/// Minimum recommended entropy in bytes for a secret (8 bytes = ~64 bits)
const MIN_ENTROPY_BYTES: usize = 8;

/// Default rotation warning threshold (90 days).
const DEFAULT_MAX_AGE_DAYS: u64 = 90;

/// Health status for a single secret.
#[derive(Debug)]
pub struct SecretHealth {
    /// Secret name.
    pub name: String,
    /// Age of the secret file (time since last modification).
    pub age: Option<Duration>,
    /// Whether the age exceeds the rotation threshold.
    pub needs_rotation: bool,
    /// Entropy warnings (empty = OK).
    pub entropy_warnings: Vec<String>,
}

/// Full health report across all secrets in a vault.
#[derive(Debug)]
pub struct HealthReport {
    /// Health of each individual secret.
    pub secrets: Vec<SecretHealth>,
    /// Number of secrets that need rotation.
    pub stale_count: usize,
    /// Number of secrets with low entropy.
    pub weak_count: usize,
    /// Total number of secrets.
    pub total: usize,
}

impl HealthReport {
    /// Whether the vault is fully healthy.
    pub fn is_healthy(&self) -> bool {
        self.stale_count == 0 && self.weak_count == 0
    }
}

/// Compute Shannon entropy of a byte slice (bits per byte).
///
/// Returns 0.0 for empty input, up to 8.0 for perfectly random bytes.
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
}

/// Analyze a secret value for entropy/strength issues.
///
/// Catches common mistakes:
/// - Placeholder values ("TODO", "changeme", "your-api-key-here")
/// - Extremely short secrets (< 8 bytes)
/// - Low entropy (< 3.0 bits/byte — likely a dictionary word or pattern)
pub fn check_entropy(name: &str, value: &[u8]) -> Vec<String> {
    let mut warnings = Vec::new();

    // Too short
    if value.len() < MIN_ENTROPY_BYTES {
        warnings.push(format!(
            "secret '{name}' is only {} bytes — most API keys are 32+ bytes",
            value.len()
        ));
    }

    // Low entropy
    if value.len() >= 4 {
        let entropy = shannon_entropy(value);
        if entropy < 3.0 {
            warnings.push(format!(
                "secret '{name}' has low entropy ({entropy:.1} bits/byte) — \
                 may be a placeholder or weak password"
            ));
        }
    }

    // Common placeholder patterns
    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) {
                warnings.push(format!(
                    "secret '{name}' looks like a placeholder (contains '{placeholder}')"
                ));
                break;
            }
        }
    }

    warnings
}

/// Get the age of a secret file from filesystem metadata.
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()
}

/// Generate a full health report for all secrets in the vault.
///
/// `max_age_days` controls the rotation threshold (default: 90 days).
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);

        // Decrypt to check entropy (skip if vault can't decrypt — e.g., wrong key)
        let entropy_warnings = match vault.decrypt(name) {
            Ok(plaintext) => check_entropy(name, &plaintext),
            Err(_) => vec![format!("secret '{name}' could not be decrypted for health check")],
        };

        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,
    })
}

/// Format a Duration as a human-readable age string.
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)
    }
}

/// Generate a `.gitignore` snippet to prevent accidental commits.
pub fn gitignore_snippet() -> &'static str {
    r"# envseal — never commit these
*.seal
master.key
policy.sig
audit.log
security.toml
"
}

/// Generate a git pre-commit hook script that scans for leaked secrets.
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() {
        // High entropy
        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() {
        // Low entropy
        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 warnings = check_entropy("test", b"your-api-key-here");
        assert!(!warnings.is_empty(), "should detect placeholder");
        assert!(warnings.iter().any(|w| w.contains("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 warnings = check_entropy("key", b"sk-proj-abc123defghijklmnopqrstuvwxyz1234567890");
        // Should have zero placeholder warnings (may have zero entropy warnings too)
        let placeholder_warns: Vec<_> = warnings.iter().filter(|w| w.contains("placeholder")).collect();
        assert!(placeholder_warns.is_empty(), "real key shouldn't be flagged as placeholder");
    }

    #[test]
    fn detects_short_secret() {
        let warnings = check_entropy("pw", b"abc");
        assert!(!warnings.is_empty());
        assert!(warnings.iter().any(|w| w.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");
    }
}