Skip to main content

cac_core/
audit.rs

1use chrono::{DateTime, Utc};
2use hmac::{Hmac, Mac};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::fs::{File, OpenOptions};
6use std::io::{BufRead, BufReader, Write};
7use std::path::{Path, PathBuf};
8use thiserror::Error;
9
10type HmacSha256 = Hmac<Sha256>;
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "snake_case")]
14pub enum AuditPhase {
15    Detect,
16    Fix,
17    Validate,
18    Scan,
19    Webhook,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct AuditEvent {
24    pub id: String,
25    pub timestamp: DateTime<Utc>,
26    pub phase: AuditPhase,
27    pub agent: String,
28    pub action: String,
29    pub policy_id: Option<String>,
30    pub rule_id: Option<String>,
31    pub file_path: Option<String>,
32    pub details: serde_json::Value,
33    pub content_hash: String,
34    pub signature: Option<String>,
35}
36
37#[derive(Debug, Clone)]
38pub struct LedgerConfig {
39    pub signing_key: Option<String>,
40    pub ledger_path: PathBuf,
41}
42
43#[derive(Debug, Error)]
44pub enum AuditError {
45    #[error("io error: {0}")]
46    Io(#[from] std::io::Error),
47    #[error("json error: {0}")]
48    Json(#[from] serde_json::Error),
49}
50
51pub struct AuditLedger {
52    config: LedgerConfig,
53}
54
55impl AuditLedger {
56    pub fn new(config: LedgerConfig) -> Self {
57        Self { config }
58    }
59
60    pub fn record(
61        &self,
62        phase: AuditPhase,
63        agent: &str,
64        action: &str,
65        policy_id: Option<&str>,
66        rule_id: Option<&str>,
67        file_path: Option<&str>,
68        details: serde_json::Value,
69    ) -> Result<AuditEvent, AuditError> {
70        let timestamp = Utc::now();
71        let canonical = serde_json::json!({
72            "phase": phase,
73            "agent": agent,
74            "action": action,
75            "policy_id": policy_id,
76            "rule_id": rule_id,
77            "file_path": file_path,
78            "details": details,
79            "timestamp": timestamp.to_rfc3339(),
80        });
81        let content_hash = hash_payload(&canonical);
82        let signature = self
83            .config
84            .signing_key
85            .as_ref()
86            .map(|key| sign_payload(key, &content_hash));
87
88        let event = AuditEvent {
89            id: format!("cac-{}", &content_hash[..16]),
90            timestamp,
91            phase,
92            agent: agent.to_string(),
93            action: action.to_string(),
94            policy_id: policy_id.map(str::to_string),
95            rule_id: rule_id.map(str::to_string),
96            file_path: file_path.map(str::to_string),
97            details,
98            content_hash,
99            signature,
100        };
101
102        self.append(&event)?;
103        Ok(event)
104    }
105
106    pub fn read_all(&self) -> Result<Vec<AuditEvent>, AuditError> {
107        if !self.config.ledger_path.exists() {
108            return Ok(Vec::new());
109        }
110        let file = File::open(&self.config.ledger_path)?;
111        let reader = BufReader::new(file);
112        let mut events = Vec::new();
113        for line in reader.lines() {
114            let line = line?;
115            if line.trim().is_empty() {
116                continue;
117            }
118            events.push(serde_json::from_str(&line)?);
119        }
120        Ok(events)
121    }
122
123    fn append(&self, event: &AuditEvent) -> Result<(), AuditError> {
124        if let Some(parent) = self.config.ledger_path.parent() {
125            std::fs::create_dir_all(parent)?;
126        }
127        let mut file = OpenOptions::new()
128            .create(true)
129            .append(true)
130            .open(&self.config.ledger_path)?;
131        writeln!(file, "{}", serde_json::to_string(event)?)?;
132        Ok(())
133    }
134}
135
136pub fn hash_payload(value: &serde_json::Value) -> String {
137    let bytes = serde_json::to_vec(value).unwrap_or_default();
138    let digest = Sha256::digest(bytes);
139    hex::encode(digest)
140}
141
142pub fn sign_payload(key: &str, content_hash: &str) -> String {
143    let mut mac =
144        HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length");
145    mac.update(content_hash.as_bytes());
146    hex::encode(mac.finalize().into_bytes())
147}
148
149pub fn default_ledger_path(root: &Path) -> PathBuf {
150    root.join(".cac").join("audit.jsonl")
151}