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}