naru-config 0.7.0

A security-first configuration manager with encryption and audit logging
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuditLogEntry {
    pub timestamp: DateTime<Utc>,
    pub action: String,
    pub environment: String,
    pub key: Option<String>,
    pub old_value: Option<String>,
    pub new_value: Option<String>,
    pub user: Option<String>,
    pub previous_hash: Option<String>,
    pub hash: Option<String>,
}

impl AuditLogEntry {
    fn sanitize_for_log(s: &str) -> String {
        let mut result = String::new();
        for c in s.chars() {
            if c.is_control() && c != '\n' && c != '\r' && c != '\t' {
                continue;
            }
            match c {
                '\n' => result.push_str("\\n"),
                '\r' => result.push_str("\\r"),
                '\t' => result.push_str("\\t"),
                _ => result.push(c),
            }
        }
        result
    }

    fn mask_sensitive_value(key: &str, value: Option<String>) -> Option<String> {
        if let Some(val) = value {
            let k_lower = key.to_lowercase();
            if k_lower.contains("pass")
                || k_lower.contains("secret")
                || k_lower.contains("key")
                || k_lower.contains("token")
                || k_lower.contains("auth")
            {
                return Some("********".to_string());
            }
            Some(val)
        } else {
            None
        }
    }

    pub fn new(
        action: String,
        environment: String,
        key: Option<String>,
        old_value: Option<String>,
        new_value: Option<String>,
    ) -> Self {
        let sanitized_action = Self::sanitize_for_log(&action);
        let sanitized_environment = Self::sanitize_for_log(&environment);
        let sanitized_key = key.map(|k| Self::sanitize_for_log(&k));
        let sanitized_old_value = old_value.map(|v| Self::sanitize_for_log(&v));
        let sanitized_new_value = new_value.map(|v| Self::sanitize_for_log(&v));

        let final_old_value = if let Some(ref k) = sanitized_key {
            Self::mask_sensitive_value(k, sanitized_old_value)
        } else {
            sanitized_old_value
        };

        let final_new_value = if let Some(ref k) = sanitized_key {
            Self::mask_sensitive_value(k, sanitized_new_value)
        } else {
            sanitized_new_value
        };

        AuditLogEntry {
            timestamp: Utc::now(),
            action: sanitized_action,
            environment: sanitized_environment,
            key: sanitized_key,
            old_value: final_old_value,
            new_value: final_new_value,
            user: std::env::var("USER")
                .or_else(|_| std::env::var("USERNAME"))
                .ok(),
            previous_hash: None,
            hash: None,
        }
    }

    pub fn calculate_hash(&self) -> String {
        use sha2::{Digest, Sha256};
        let mut hasher = Sha256::new();
        let content = format!(
            "{}{}{}{:?}{:?}{:?}{:?}{:?}",
            self.timestamp.to_rfc3339(),
            self.action,
            self.environment,
            self.key,
            self.old_value,
            self.new_value,
            self.user,
            self.previous_hash
        );
        hasher.update(content);
        hex::encode(hasher.finalize())
    }

    pub fn get_recent_logs(
        log_path: &str,
        count: usize,
    ) -> Result<Vec<AuditLogEntry>, Box<dyn std::error::Error>> {
        use std::fs::File;
        use std::io::{BufRead, BufReader};

        let sanitized = crate::core::path_sanitizer::sanitize_file_path_internal(log_path)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;

        if !std::path::Path::new(&sanitized).exists() {
            return Ok(Vec::new());
        }

        let file = File::open(&sanitized)?;
        let reader = BufReader::new(file);
        let mut entries: Vec<AuditLogEntry> = Vec::with_capacity(count);

        for line_str in reader.lines().map_while(Result::ok) {
            if let Ok(entry) = serde_json::from_str::<AuditLogEntry>(&line_str) {
                entries.push(entry);
            }
        }

        let start = if entries.len() > count {
            entries.len() - count
        } else {
            0
        };

        Ok(entries.into_iter().skip(start).collect())
    }

    pub fn verify_log_integrity(log_path: &str) -> Result<bool, Box<dyn std::error::Error>> {
        use std::fs::File;
        use std::io::{BufRead, BufReader};

        let sanitized = crate::core::path_sanitizer::sanitize_file_path_internal(log_path)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;

        if !std::path::Path::new(&sanitized).exists() {
            return Ok(true);
        }

        let file = File::open(&sanitized)?;
        let reader = BufReader::new(file);

        let mut expected_prev_hash =
            "0000000000000000000000000000000000000000000000000000000000000000".to_string();

        for line in reader.lines().map_while(Result::ok) {
            let entry: AuditLogEntry = serde_json::from_str(&line)?;

            if let Some(ph) = &entry.previous_hash {
                if ph != &expected_prev_hash {
                    return Ok(false);
                }
            } else {
                return Ok(false);
            }

            let calculated_hash = entry.calculate_hash();
            if let Some(h) = &entry.hash {
                if h != &calculated_hash {
                    return Ok(false);
                }
                expected_prev_hash = h.clone();
            } else {
                return Ok(false);
            }
        }

        Ok(true)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_audit_entry_new() {
        let entry = AuditLogEntry::new(
            "SET".to_string(),
            "dev".to_string(),
            Some("KEY1".to_string()),
            None,
            Some("value1".to_string()),
        );

        assert_eq!(entry.action, "SET");
        assert_eq!(entry.environment, "dev");
    }

    #[test]
    fn test_audit_masking() {
        let entry = AuditLogEntry::new(
            "SET".to_string(),
            "dev".to_string(),
            Some("DB_PASSWORD".to_string()),
            Some("old_secret".to_string()),
            Some("new_secret".to_string()),
        );

        assert_eq!(entry.old_value, Some("********".to_string()));
        assert_eq!(entry.new_value, Some("********".to_string()));
    }

    #[test]
    fn test_audit_no_mask_for_non_secret() {
        let entry = AuditLogEntry::new(
            "SET".to_string(),
            "dev".to_string(),
            Some("PORT".to_string()),
            None,
            Some("8080".to_string()),
        );

        assert_eq!(entry.new_value, Some("8080".to_string()));
    }

    #[test]
    fn test_audit_hash_consistency() {
        let mut entry = AuditLogEntry::new(
            "TEST".to_string(),
            "env".to_string(),
            Some("key".to_string()),
            None,
            Some("value".to_string()),
        );

        let hash1 = entry.calculate_hash();
        let hash2 = entry.calculate_hash();
        assert_eq!(hash1, hash2);
    }
}