Skip to main content

tsafe_core/
audit.rs

1//! Append-only structured audit logging for vault operations.
2//!
3//! Each vault operation (get, set, delete, rotate, …) appends a [`AuditEntry`]
4//! as a single JSON line to the profile's `.audit.jsonl` file.  The log file is
5//! created with `0o600` permissions on Unix so other local users cannot read it.
6//!
7//! ## HMAC chain integrity (v2)
8//!
9//! Each entry carries a `prev_entry_hmac` field: the HMAC-SHA256 of the
10//! immediately preceding entry's canonical JSON, keyed by a per-session ephemeral
11//! key generated at [`AuditLog::new()`] time.
12//!
13//! **What this provides:**
14//! - Within-session tamper detection: any modification, insertion, or deletion of
15//!   entries is detectable via [`AuditLog::verify_chain()`] as long as the
16//!   `AuditLog` handle that wrote the entries is still in scope.
17//!
18//! **Explicit ceiling:**
19//! - The chain key is ephemeral — it is generated fresh on each [`AuditLog::new()`]
20//!   and is never persisted.  Cross-session verification is therefore not possible
21//!   (the key used to write a previous session's entries is gone).  Entries written
22//!   by a previous session have `prev_entry_hmac = None` from the perspective of the
23//!   new session's chain.  This is an accepted v2 residual; cross-session
24//!   verification would require persisting the chain key (e.g. in the vault's
25//!   keyring), which is deferred to a post-v2 upgrade.
26//! - Filesystem-level tamper (deleting the entire log file, swapping it for another)
27//!   is not detectable without an external root of trust.
28
29use std::io::Write;
30use std::path::{Path, PathBuf};
31
32use chrono::{DateTime, Utc};
33use hmac::{Hmac, Mac};
34use rand::RngCore;
35use serde::{Deserialize, Serialize};
36use sha2::Sha256;
37use thiserror::Error;
38use uuid::Uuid;
39
40fn bytes_to_hex(bytes: &[u8]) -> String {
41    bytes.iter().map(|b| format!("{b:02x}")).collect()
42}
43
44use crate::contracts::{
45    AuthorityContract, AuthorityInheritMode, AuthorityNetworkPolicy, AuthorityTargetDecision,
46    AuthorityTargetEvaluation, AuthorityTrustLevel,
47};
48use crate::deny_reason::DenyReason;
49use crate::errors::{SafeError, SafeResult};
50use crate::rbac::RbacProfile;
51
52type HmacSha256 = Hmac<Sha256>;
53
54/// Error type returned by [`AuditLog::verify_chain`].
55#[derive(Debug, Error, PartialEq, Eq)]
56pub enum AuditVerifyError {
57    /// An entry's `prev_entry_hmac` does not match the HMAC computed from the
58    /// previous entry's canonical JSON using the session chain key.
59    #[error("audit chain broken at entry index {at_entry} (id: {entry_id})")]
60    ChainBroken {
61        /// Zero-based index of the entry whose `prev_entry_hmac` is wrong.
62        at_entry: usize,
63        /// The UUID of the offending entry, for human correlation.
64        entry_id: String,
65    },
66
67    /// The log file could not be read.
68    #[error("could not read audit log: {0}")]
69    Io(String),
70}
71
72/// Outcome of an audited vault operation.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum AuditStatus {
76    /// The operation completed without error.
77    Success,
78    /// The operation was attempted but failed (reason in [`AuditEntry::message`]).
79    Failure,
80}
81
82/// Optional structured context for an audit entry.
83///
84/// This keeps the local audit log compatible with older entries while giving
85/// higher layers a place to attach non-plaintext execution metadata such as the
86/// chosen authority contract, target, and projected secret set.
87#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
88pub struct AuditContext {
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub exec: Option<AuditExecContext>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub cellos: Option<AuditCellosContext>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub clipboard: Option<AuditClipboardContext>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub reveal: Option<AuditRevealContext>,
97}
98
99/// CellOS-specific audit context for resolve and revoke operations.
100///
101/// Enables `tsafe audit --cell-id` to return the full resolve/revoke chain for
102/// a given cell without correlating two separate audit systems manually.
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
104pub struct AuditCellosContext {
105    /// CellOS cell identifier, e.g. `doom-cell-001`.
106    pub cellos_cell_id: String,
107    /// The 32-byte hex cell_token registered at supervisor spawn.
108    /// Logged for correlation; safe to store (random nonce, not a secret value).
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub cell_token: Option<String>,
111}
112
113/// Clipboard-copy audit context for TUI yank operations.
114///
115/// Recorded on `clipboard.copy`, `clipboard.copy_failed`, `clipboard.cleared`,
116/// and `clipboard.preserved` entries. Never carries the secret value — only
117/// metadata about what the daemon did and what the OS guaranteed.
118#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
119pub struct AuditClipboardContext {
120    /// Auto-clear TTL in seconds (e.g. 30).
121    pub ttl_secs: u64,
122    /// Backend error string when the operation failed; absent on success.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub reason: Option<String>,
125    /// `Some(true)` when the producer set `org.nspasteboard.ConcealedType`
126    /// (macOS, via arboard's `exclude_from_history`) so conformant clipboard
127    /// managers skip archiving the value. `Some(false)` when concealment was
128    /// attempted but failed. `None` on platforms with no convention (current
129    /// Linux/Windows). Distinguishes "we asked the OS to hide it" from
130    /// "we just wrote and hoped".
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub excluded_from_history: Option<bool>,
133    /// On `clipboard.cleared` / `clipboard.preserved` entries: `Some(true)`
134    /// when the daemon read the clipboard back and the contents matched what
135    /// it had written (so the clear / preservation reflects ground truth).
136    /// `Some(false)` when the contents differed (user copied something else).
137    /// `None` on the original `clipboard.copy` entry (verification hadn't
138    /// happened yet).
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub cleared_verified: Option<bool>,
141}
142
143impl AuditContext {
144    pub fn from_exec(exec: AuditExecContext) -> Self {
145        Self {
146            exec: Some(exec),
147            ..Default::default()
148        }
149    }
150
151    pub fn from_cellos(cellos: AuditCellosContext) -> Self {
152        Self {
153            cellos: Some(cellos),
154            ..Default::default()
155        }
156    }
157
158    pub fn from_clipboard(clipboard: AuditClipboardContext) -> Self {
159        Self {
160            clipboard: Some(clipboard),
161            ..Default::default()
162        }
163    }
164
165    pub fn from_reveal(reveal: AuditRevealContext) -> Self {
166        Self {
167            reveal: Some(reveal),
168            ..Default::default()
169        }
170    }
171}
172
173/// Reveal audit context for TUI `'r'` operations.
174///
175/// Recorded on `secret.reveal_started` (user pressed `r`) and
176/// `secret.reveal_expired` (auto-conceal fired). User-initiated toggle-off
177/// is intentionally not audited — silence implies deliberate user action.
178/// Never carries the secret value — only the TTL.
179#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
180pub struct AuditRevealContext {
181    /// Auto-conceal TTL in seconds (e.g. 60).
182    pub ttl_secs: u64,
183}
184
185/// One `--env ENV_VAR=VAULT_KEY` mapping recorded in the audit trail.
186///
187/// This lets auditors see which vault key was sourced for each renamed env var
188/// without the audit log exposing secret values.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct AuditEnvMapping {
191    /// The environment variable name that the child process received.
192    pub env: String,
193    /// The vault key whose value was injected under `env`.
194    pub vault_key: String,
195}
196
197/// Execution-specific audit context for `tsafe exec`-style operations.
198#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
199pub struct AuditExecContext {
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub contract_name: Option<String>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub target: Option<String>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub authority_profile: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub authority_namespace: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub trust_level: Option<AuthorityTrustLevel>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub access_profile: Option<RbacProfile>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub inherit: Option<AuthorityInheritMode>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub deny_dangerous_env: Option<bool>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub redact_output: Option<bool>,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub network: Option<AuthorityNetworkPolicy>,
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub allowed_secrets: Vec<String>,
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub required_secrets: Vec<String>,
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub injected_secrets: Vec<String>,
226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
227    pub missing_required_secrets: Vec<String>,
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub dropped_env_names: Vec<String>,
230    /// Records each `--env ENV_VAR=VAULT_KEY` mapping so the audit trail shows
231    /// which vault key was sourced for each renamed env var injection.
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub env_mappings: Vec<AuditEnvMapping>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub target_allowed: Option<bool>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub target_decision: Option<AuthorityTargetDecision>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub matched_target: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub deny_reason: Option<DenyReason>,
242}
243
244impl AuditExecContext {
245    /// Seed an exec context from an authority contract without committing to a
246    /// concrete target or the final injected/dropped sets yet.
247    pub fn from_contract(contract: &AuthorityContract) -> Self {
248        let resolved = contract.resolved_exec_policy();
249        Self {
250            contract_name: Some(contract.name.clone()),
251            target: None,
252            authority_profile: contract.profile.clone(),
253            authority_namespace: contract.namespace.clone(),
254            trust_level: Some(resolved.trust_level),
255            access_profile: Some(resolved.access_profile),
256            inherit: Some(resolved.inherit),
257            deny_dangerous_env: Some(resolved.deny_dangerous_env),
258            redact_output: Some(resolved.redact_output),
259            network: Some(contract.network),
260            allowed_secrets: contract.allowed_secrets.clone(),
261            required_secrets: contract.required_secrets.clone(),
262            injected_secrets: Vec::new(),
263            missing_required_secrets: Vec::new(),
264            dropped_env_names: Vec::new(),
265            env_mappings: Vec::new(),
266            target_allowed: None,
267            target_decision: None,
268            matched_target: None,
269            deny_reason: None,
270        }
271    }
272
273    pub fn with_target(mut self, target: impl Into<String>) -> Self {
274        self.target = Some(target.into());
275        self
276    }
277
278    pub fn with_injected_secrets<I, S>(mut self, names: I) -> Self
279    where
280        I: IntoIterator<Item = S>,
281        S: AsRef<str>,
282    {
283        self.injected_secrets = normalize_names(names);
284        self
285    }
286
287    pub fn with_missing_required_secrets<I, S>(mut self, names: I) -> Self
288    where
289        I: IntoIterator<Item = S>,
290        S: AsRef<str>,
291    {
292        self.missing_required_secrets = normalize_names(names);
293        self
294    }
295
296    pub fn with_dropped_env_names<I, S>(mut self, names: I) -> Self
297    where
298        I: IntoIterator<Item = S>,
299        S: AsRef<str>,
300    {
301        self.dropped_env_names = normalize_names(names);
302        self
303    }
304
305    pub fn with_target_allowed(mut self, allowed: bool) -> Self {
306        self.target_allowed = Some(allowed);
307        self
308    }
309
310    pub fn with_target_evaluation(mut self, evaluation: &AuthorityTargetEvaluation) -> Self {
311        self.target_allowed = Some(evaluation.decision.is_allowed());
312        self.target_decision = Some(evaluation.decision);
313        self.matched_target = evaluation.matched_allowlist_entry.clone();
314        self
315    }
316}
317
318fn normalize_names<I, S>(names: I) -> Vec<String>
319where
320    I: IntoIterator<Item = S>,
321    S: AsRef<str>,
322{
323    let mut out = names
324        .into_iter()
325        .map(|name| name.as_ref().trim().to_string())
326        .filter(|name| !name.is_empty())
327        .collect::<Vec<_>>();
328    out.sort();
329    out.dedup();
330    out
331}
332
333/// One structured audit event. Written as a single JSON line (JSONL).
334///
335/// ## Audit integrity contract (v2)
336///
337/// Entries are **chronologically ordered** (append-only JSONL) and each entry
338/// carries a `prev_entry_hmac` field: the hex-encoded HMAC-SHA256 of the
339/// immediately preceding entry's canonical JSON, keyed by the session's
340/// ephemeral chain key held in [`AuditLog`].
341///
342/// **What this provides:**
343/// - Within-session tamper detection: modification, insertion, or deletion of
344///   entries written during a single session can be detected via
345///   [`AuditLog::verify_chain`].
346///
347/// **Explicit ceiling:**
348/// - The chain key is ephemeral (generated at [`AuditLog::new()`], never
349///   persisted). Cross-session verification is not possible. Old entries
350///   written before v2 (no `prev_entry_hmac`) are treated as chain anchors
351///   and do not cause verification failures on their own.
352/// - Filesystem-level attacks (deleting the file, swapping it entirely) are
353///   not detectable without an external root of trust.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct AuditEntry {
356    pub id: String,
357    pub timestamp: DateTime<Utc>,
358    pub profile: String,
359    pub operation: String,
360    pub key: Option<String>,
361    pub status: AuditStatus,
362    pub message: Option<String>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub context: Option<AuditContext>,
365    /// HMAC-SHA256 of the previous entry's canonical JSON, hex-encoded.
366    ///
367    /// `None` for the first entry in a session, for entries written before the
368    /// v2 chain was introduced, or after a cross-session chain break.
369    /// Verification is only meaningful within a single [`AuditLog`] session.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub prev_entry_hmac: Option<String>,
372}
373
374impl AuditEntry {
375    /// Build a success entry with a new UUID and the current UTC timestamp.
376    pub fn success(profile: &str, operation: &str, key: Option<&str>) -> Self {
377        Self {
378            id: Uuid::new_v4().to_string(),
379            timestamp: Utc::now(),
380            profile: profile.to_string(),
381            operation: operation.to_string(),
382            key: key.map(str::to_string),
383            status: AuditStatus::Success,
384            message: None,
385            context: None,
386            prev_entry_hmac: None,
387        }
388    }
389
390    /// Build a failure entry with a new UUID, the current UTC timestamp, and an error message.
391    pub fn failure(profile: &str, operation: &str, key: Option<&str>, message: &str) -> Self {
392        Self {
393            id: Uuid::new_v4().to_string(),
394            timestamp: Utc::now(),
395            profile: profile.to_string(),
396            operation: operation.to_string(),
397            key: key.map(str::to_string),
398            status: AuditStatus::Failure,
399            message: Some(message.to_string()),
400            context: None,
401            prev_entry_hmac: None,
402        }
403    }
404
405    /// Attach optional structured context without changing the legacy fields.
406    pub fn with_context(mut self, context: AuditContext) -> Self {
407        self.context = Some(context);
408        self
409    }
410}
411
412/// Compute HMAC-SHA256 of `entry`'s canonical JSON serialization using `key`.
413///
414/// The canonical form is `serde_json::to_string(entry)` — the same bytes
415/// written to the log file for that entry (before the trailing newline).
416/// Returns a lowercase hex-encoded digest.
417pub fn compute_entry_hmac(entry: &AuditEntry, key: &[u8; 32]) -> String {
418    let json = serde_json::to_string(entry).expect("AuditEntry is always serializable");
419    let mut mac =
420        HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts any key length via padding");
421    mac.update(json.as_bytes());
422    let result = mac.finalize().into_bytes();
423    bytes_to_hex(&result)
424}
425
426/// Generate a cryptographically random 32-byte chain key.
427fn derive_chain_key() -> [u8; 32] {
428    let mut key = [0u8; 32];
429    rand::rngs::OsRng.fill_bytes(&mut key);
430    key
431}
432
433/// Append-only JSONL audit log with within-session HMAC chain integrity.
434///
435/// The chain key is generated fresh at [`AuditLog::new()`] and is held only
436/// in memory for the lifetime of this handle. All entries appended through
437/// this handle form a verifiable chain via [`AuditLog::verify_chain()`].
438/// Entries from prior sessions (different `AuditLog` instances) have
439/// `prev_entry_hmac = None` and act as chain anchors for the new session.
440pub struct AuditLog {
441    path: PathBuf,
442    /// Per-session ephemeral HMAC key. Never persisted.
443    chain_key: [u8; 32],
444    /// HMAC of the last entry written through this handle. Seeded from the
445    /// file's existing last line on the first append of a new session, then
446    /// updated after every subsequent append.
447    prev_hmac: std::cell::Cell<Option<String>>,
448    /// Whether `prev_hmac` has been bootstrapped from the file.
449    bootstrapped: std::cell::Cell<bool>,
450}
451
452impl AuditLog {
453    /// Create a handle to the audit log at `path`. The file is created lazily on first [`Self::append`].
454    pub fn new(path: &Path) -> Self {
455        Self {
456            path: path.to_path_buf(),
457            chain_key: derive_chain_key(),
458            prev_hmac: std::cell::Cell::new(None),
459            bootstrapped: std::cell::Cell::new(false),
460        }
461    }
462
463    /// Read the last line of the audit log and return the `prev_entry_hmac`
464    /// of that entry (if any). Used to bootstrap the chain on the first append
465    /// of a new session: the new session starts fresh (no prior HMAC), so
466    /// the first entry always has `prev_entry_hmac = None`.
467    fn bootstrap_if_needed(&self) {
468        if self.bootstrapped.get() {
469            return;
470        }
471        self.bootstrapped.set(true);
472        // The new session's chain starts with None — we do not inherit the
473        // previous session's HMAC because that key is gone. The first entry
474        // of this session is a chain anchor.
475        self.prev_hmac.set(None);
476    }
477
478    /// Append an entry. The entry's `prev_entry_hmac` is set to the HMAC of
479    /// the previously written entry (within this session), then the entry is
480    /// written and this handle's chain state is advanced.
481    ///
482    /// Errors here are non-fatal (caller should `.ok()` if audit is best-effort).
483    pub fn append(&self, entry: &AuditEntry) -> SafeResult<()> {
484        self.bootstrap_if_needed();
485
486        if let Some(parent) = self.path.parent() {
487            std::fs::create_dir_all(parent)?;
488            #[cfg(unix)]
489            {
490                use std::os::unix::fs::PermissionsExt;
491                let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
492            }
493        }
494
495        // Clone and set the chain link on our local copy.
496        let mut chained = entry.clone();
497        chained.prev_entry_hmac = self.prev_hmac.take();
498
499        let mut line = serde_json::to_string(&chained).map_err(SafeError::Serialization)?;
500        line.push('\n');
501
502        let mut opts = std::fs::OpenOptions::new();
503        opts.create(true).append(true);
504        #[cfg(unix)]
505        {
506            use std::os::unix::fs::OpenOptionsExt;
507            opts.mode(0o600);
508        }
509        let mut file = opts.open(&self.path)?;
510        file.write_all(line.as_bytes())?;
511
512        // Advance chain: compute HMAC of what we just wrote (without the newline).
513        let next_hmac = compute_entry_hmac(&chained, &self.chain_key);
514        self.prev_hmac.set(Some(next_hmac));
515
516        Ok(())
517    }
518
519    /// Verify the HMAC chain for all entries written by this session.
520    ///
521    /// Reads the log file and replays the chain using this session's key.
522    /// Entries with `prev_entry_hmac = None` are treated as chain anchors
523    /// (session boundaries or pre-v2 entries) — they reset the expected HMAC
524    /// to `None` and verification continues from there.
525    ///
526    /// Returns `Ok(())` if every non-anchor entry's `prev_entry_hmac` matches
527    /// the HMAC computed from the previous entry. Returns
528    /// [`AuditVerifyError::ChainBroken`] at the first mismatch.
529    pub fn verify_chain(&self) -> Result<(), AuditVerifyError> {
530        verify_chain_with_key(&self.path, &self.chain_key)
531    }
532
533    /// Read entries, most recent first. Silently skip malformed lines.
534    pub fn read(&self, limit: Option<usize>) -> SafeResult<Vec<AuditEntry>> {
535        if !self.path.exists() {
536            return Ok(Vec::new());
537        }
538        let content = std::fs::read_to_string(&self.path)?;
539        let mut entries: Vec<AuditEntry> = content
540            .lines()
541            .filter(|l| !l.trim().is_empty())
542            .filter_map(|l| serde_json::from_str(l).ok())
543            .collect();
544        entries.reverse(); // most recent first
545        if let Some(n) = limit {
546            entries.truncate(n);
547        }
548        Ok(entries)
549    }
550
551    /// Read and project entries into plaintext-free explanation sessions.
552    pub fn explain(&self, limit: Option<usize>) -> SafeResult<crate::audit_explain::AuditTimeline> {
553        Ok(crate::audit_explain::explain_entries(&self.read(limit)?))
554    }
555
556    /// Most recent successful audit entry for this profile and operation, if any.
557    /// Entries are scanned newest-first (up to `scan_limit` lines worth of entries).
558    pub fn last_successful_operation(
559        &self,
560        profile: &str,
561        operation: &str,
562        scan_limit: usize,
563    ) -> SafeResult<Option<DateTime<Utc>>> {
564        let entries = self.read(Some(scan_limit))?;
565        Ok(entries
566            .into_iter()
567            .find(|e| {
568                e.profile == profile
569                    && e.operation == operation
570                    && matches!(e.status, AuditStatus::Success)
571            })
572            .map(|e| e.timestamp))
573    }
574
575    /// Return filtered audit entries.
576    ///
577    /// All parameters are optional:
578    /// - `since`   — only entries with `timestamp >= since` are returned.
579    /// - `until`   — only entries with `timestamp <= until` are returned.
580    /// - `command` — only entries whose `operation` exactly matches this string.
581    ///
582    /// Results are returned newest-first (matching `read()`).
583    pub fn filter_audit(
584        &self,
585        since: Option<DateTime<Utc>>,
586        until: Option<DateTime<Utc>>,
587        command: Option<&str>,
588    ) -> SafeResult<Vec<AuditEntry>> {
589        let entries = self.read(None)?;
590        Ok(entries
591            .into_iter()
592            .filter(|e| {
593                if let Some(s) = since {
594                    if e.timestamp < s {
595                        return false;
596                    }
597                }
598                if let Some(u) = until {
599                    if e.timestamp > u {
600                        return false;
601                    }
602                }
603                if let Some(cmd) = command {
604                    if e.operation != cmd {
605                        return false;
606                    }
607                }
608                true
609            })
610            .collect())
611    }
612
613    /// Remove all audit entries with `timestamp < before` (i.e. older than the cutoff).
614    ///
615    /// Rewrites the log file atomically. Entries at or after `before` are kept.
616    /// Returns the number of entries that were removed.
617    ///
618    /// Note: pruning breaks the HMAC chain because it removes entries; the
619    /// retained entries' `prev_entry_hmac` values become invalid relative to
620    /// the new session's key. This is expected — prune is a privileged
621    /// administrative operation.
622    pub fn prune_audit_before(&self, before: DateTime<Utc>) -> SafeResult<usize> {
623        if !self.path.exists() {
624            return Ok(0);
625        }
626        let content = std::fs::read_to_string(&self.path)?;
627        let mut kept: Vec<&str> = Vec::new();
628        let mut removed = 0usize;
629        for line in content.lines() {
630            if line.trim().is_empty() {
631                continue;
632            }
633            match serde_json::from_str::<AuditEntry>(line) {
634                Ok(entry) if entry.timestamp < before => {
635                    removed += 1;
636                }
637                _ => {
638                    kept.push(line);
639                }
640            }
641        }
642        if removed == 0 {
643            return Ok(0);
644        }
645        let new_content = kept.join("\n") + if kept.is_empty() { "" } else { "\n" };
646        let tmp = self.path.with_extension("jsonl.tmp");
647        std::fs::write(&tmp, &new_content)?;
648        // On Windows rename may fail if the log is open by another process.
649        if let Err(e) = std::fs::rename(&tmp, &self.path) {
650            let _ = std::fs::remove_file(&tmp); // clean up temp on failure
651            return Err(std::io::Error::other(format!(
652                "audit prune: failed to rename temp file — log unchanged: {e}"
653            ))
654            .into());
655        }
656        // Append a sentinel entry so HMAC-chain readers can distinguish a
657        // legitimate prune from tampering. The sentinel carries no secret data.
658        // Derive the profile name from the log file stem (e.g. prod.audit.jsonl → prod).
659        let profile_name = self
660            .path
661            .file_stem()
662            .and_then(|s| s.to_str())
663            .unwrap_or("unknown");
664        let mut sentinel = AuditEntry::success(profile_name, "audit-prune", None);
665        sentinel.message = Some(format!("pruned {removed} entries older than {before}"));
666        self.append(&sentinel)?;
667        Ok(removed)
668    }
669}
670
671/// Return the size in bytes of the audit log file at `path`.
672///
673/// Returns `Ok(0)` when the file does not exist (no log yet is not an error).
674pub fn audit_log_size_bytes(path: &Path) -> SafeResult<u64> {
675    match std::fs::metadata(path) {
676        Ok(meta) => Ok(meta.len()),
677        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
678        Err(e) => Err(SafeError::Io(e)),
679    }
680}
681
682/// Verify the HMAC chain in a log file using the provided key.
683///
684/// This is the inner implementation used by [`AuditLog::verify_chain`].
685/// Entries whose `prev_entry_hmac` is `None` are treated as chain anchors
686/// (session starts or pre-v2 entries) and do not cause verification failures.
687fn verify_chain_with_key(path: &Path, key: &[u8; 32]) -> Result<(), AuditVerifyError> {
688    if !path.exists() {
689        return Ok(());
690    }
691    let content = std::fs::read_to_string(path).map_err(|e| AuditVerifyError::Io(e.to_string()))?;
692
693    let entries: Vec<AuditEntry> = content
694        .lines()
695        .filter(|l| !l.trim().is_empty())
696        .filter_map(|l| serde_json::from_str(l).ok())
697        .collect();
698
699    // Walk entries in append order. prev_computed tracks the HMAC of the last
700    // entry we processed. For entries with prev_entry_hmac = None we treat
701    // them as anchors and reset our expected value to None.
702    let mut prev_computed: Option<String> = None;
703
704    for (idx, entry) in entries.iter().enumerate() {
705        match &entry.prev_entry_hmac {
706            None => {
707                // Chain anchor: reset the running HMAC and compute this entry's HMAC
708                // for the next iteration.
709                prev_computed = Some(compute_entry_hmac(entry, key));
710            }
711            Some(stored_hmac) => {
712                // Verify the stored HMAC matches what we expect.
713                match &prev_computed {
714                    Some(expected) if expected == stored_hmac => {
715                        prev_computed = Some(compute_entry_hmac(entry, key));
716                    }
717                    _ => {
718                        return Err(AuditVerifyError::ChainBroken {
719                            at_entry: idx,
720                            entry_id: entry.id.clone(),
721                        });
722                    }
723                }
724            }
725        }
726    }
727
728    Ok(())
729}
730
731// ── tests ────────────────────────────────────────────────────────────────────
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
737    use tempfile::tempdir;
738
739    #[test]
740    fn append_and_read_roundtrip() {
741        let dir = tempdir().unwrap();
742        let log = AuditLog::new(&dir.path().join("t.jsonl"));
743        log.append(&AuditEntry::success("dev", "set", Some("DB_PASS")))
744            .unwrap();
745        log.append(&AuditEntry::success("dev", "get", Some("DB_PASS")))
746            .unwrap();
747        log.append(&AuditEntry::failure(
748            "dev",
749            "get",
750            Some("MISSING"),
751            "not found",
752        ))
753        .unwrap();
754        let entries = log.read(None).unwrap();
755        assert_eq!(entries.len(), 3);
756        assert_eq!(entries[0].status, AuditStatus::Failure); // most recent first
757    }
758
759    #[test]
760    fn limit_truncates() {
761        let dir = tempdir().unwrap();
762        let log = AuditLog::new(&dir.path().join("t.jsonl"));
763        for i in 0..10 {
764            log.append(&AuditEntry::success("dev", "op", Some(&format!("K{i}"))))
765                .unwrap();
766        }
767        assert_eq!(log.read(Some(3)).unwrap().len(), 3);
768    }
769
770    #[test]
771    fn nonexistent_log_returns_empty() {
772        let dir = tempdir().unwrap();
773        let log = AuditLog::new(&dir.path().join("does-not-exist.jsonl"));
774        assert!(log.read(None).unwrap().is_empty());
775    }
776
777    #[test]
778    fn ids_are_unique() {
779        let e1 = AuditEntry::success("p", "op", None);
780        let e2 = AuditEntry::success("p", "op", None);
781        assert_ne!(e1.id, e2.id);
782    }
783
784    #[test]
785    fn last_successful_operation_finds_rotate() {
786        let dir = tempdir().unwrap();
787        let log = AuditLog::new(&dir.path().join("a.jsonl"));
788        log.append(&AuditEntry::success("dev", "set", Some("K")))
789            .unwrap();
790        log.append(&AuditEntry::success("dev", "rotate", None))
791            .unwrap();
792        log.append(&AuditEntry::success("dev", "get", Some("K")))
793            .unwrap();
794        assert!(log
795            .last_successful_operation("dev", "rotate", 100)
796            .unwrap()
797            .is_some());
798        assert!(log
799            .last_successful_operation("dev", "missing-op", 100)
800            .unwrap()
801            .is_none());
802    }
803
804    /// Verifies the HMAC chain integrity contract introduced in v2.
805    ///
806    /// Three entries are written through a single `AuditLog` handle (one session).
807    /// The chain must be intact. Entry 2's JSON is then tampered with on disk
808    /// and re-verification must report `ChainBroken { at_entry: 2 }`.
809    #[test]
810    fn hmac_chain_intact_and_detects_tampering() {
811        let dir = tempdir().unwrap();
812        let path = dir.path().join("chain.jsonl");
813        let log = AuditLog::new(&path);
814
815        log.append(&AuditEntry::success("dev", "set", Some("A")))
816            .unwrap();
817        log.append(&AuditEntry::success("dev", "get", Some("A")))
818            .unwrap();
819        log.append(&AuditEntry::failure(
820            "dev",
821            "get",
822            Some("MISSING"),
823            "not found",
824        ))
825        .unwrap();
826
827        // Chain must be intact immediately after writing.
828        log.verify_chain()
829            .expect("chain must be intact after write");
830
831        // Tamper with entry at index 1 (second line, 0-based) on disk.
832        let content = std::fs::read_to_string(&path).unwrap();
833        let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
834
835        // Deserialize line 1, modify its operation field, re-serialize.
836        let mut tampered: AuditEntry = serde_json::from_str(&lines[1]).unwrap();
837        tampered.operation = "TAMPERED".to_string();
838        // Preserve the original prev_entry_hmac — attacker does not know the key.
839        lines[1] = serde_json::to_string(&tampered).unwrap();
840
841        let tampered_content = lines.join("\n") + "\n";
842        std::fs::write(&path, tampered_content).unwrap();
843
844        // Verification must detect the broken chain at entry index 2 (entry 2's
845        // prev_entry_hmac now points to a digest that no longer matches line 1).
846        let err = log
847            .verify_chain()
848            .expect_err("tampered log must fail verification");
849        match err {
850            AuditVerifyError::ChainBroken { at_entry, .. } => {
851                assert_eq!(
852                    at_entry, 2,
853                    "chain should break at the entry after the tampered one"
854                );
855            }
856            other => panic!("unexpected error: {other}"),
857        }
858    }
859
860    /// Chain starts fresh (no inherited HMAC) for the first entry in a session.
861    #[test]
862    fn first_entry_has_no_prev_hmac() {
863        let dir = tempdir().unwrap();
864        let path = dir.path().join("first.jsonl");
865        let log = AuditLog::new(&path);
866        log.append(&AuditEntry::success("dev", "set", Some("K")))
867            .unwrap();
868
869        let content = std::fs::read_to_string(&path).unwrap();
870        let entry: AuditEntry = serde_json::from_str(content.trim()).unwrap();
871        assert!(
872            entry.prev_entry_hmac.is_none(),
873            "first entry must have no prev_entry_hmac"
874        );
875    }
876
877    /// Subsequent entries within a session carry the chain HMAC.
878    #[test]
879    fn subsequent_entries_carry_prev_hmac() {
880        let dir = tempdir().unwrap();
881        let path = dir.path().join("chain2.jsonl");
882        let log = AuditLog::new(&path);
883        log.append(&AuditEntry::success("dev", "set", Some("A")))
884            .unwrap();
885        log.append(&AuditEntry::success("dev", "get", Some("A")))
886            .unwrap();
887
888        let content = std::fs::read_to_string(&path).unwrap();
889        let lines: Vec<&str> = content.lines().collect();
890        let second: AuditEntry = serde_json::from_str(lines[1]).unwrap();
891        assert!(
892            second.prev_entry_hmac.is_some(),
893            "second entry must carry prev_entry_hmac"
894        );
895    }
896
897    /// Old entries without `prev_entry_hmac` deserialize cleanly (backward compat).
898    #[test]
899    fn old_entries_deserialize_without_prev_hmac() {
900        let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
901        let entry: AuditEntry = serde_json::from_str(raw).unwrap();
902        assert!(entry.prev_entry_hmac.is_none());
903        assert!(entry.context.is_none());
904    }
905
906    /// Documents the current audit integrity contract (v2).
907    #[test]
908    fn audit_integrity_contract_v2() {
909        let dir = tempdir().unwrap();
910        let log = AuditLog::new(&dir.path().join("integrity.jsonl"));
911
912        log.append(&AuditEntry::success("dev", "set", Some("A")))
913            .unwrap();
914        log.append(&AuditEntry::success("dev", "get", Some("A")))
915            .unwrap();
916        log.append(&AuditEntry::failure(
917            "dev",
918            "get",
919            Some("MISSING"),
920            "not found",
921        ))
922        .unwrap();
923
924        let entries = log.read(None).unwrap();
925        assert_eq!(entries.len(), 3, "all appended entries must be retained");
926
927        // Most-recent-first order (failure was last appended).
928        assert_eq!(entries[0].status, AuditStatus::Failure);
929        assert_eq!(entries[1].operation, "get");
930        assert_eq!(entries[2].operation, "set");
931
932        // Every entry carries a unique UUID.
933        let ids: std::collections::HashSet<_> = entries.iter().map(|e| &e.id).collect();
934        assert_eq!(ids.len(), 3, "every entry must have a distinct UUID");
935
936        // Timestamps are monotonically non-decreasing in append order.
937        let mut ordered = entries.clone();
938        ordered.reverse();
939        for w in ordered.windows(2) {
940            assert!(
941                w[0].timestamp <= w[1].timestamp,
942                "timestamps should be non-decreasing in append order"
943            );
944        }
945
946        // Chain must be intact.
947        log.verify_chain()
948            .expect("integrity contract: chain must be intact");
949    }
950
951    #[test]
952    fn old_entries_deserialize_without_context() {
953        let raw = r#"{"id":"1","timestamp":"2026-04-08T20:30:00Z","profile":"dev","operation":"exec","key":null,"status":"success","message":null}"#;
954        let entry: AuditEntry = serde_json::from_str(raw).unwrap();
955        assert!(entry.context.is_none());
956    }
957
958    // ── Task 1.9: filter_audit and prune_audit_before ─────────────────────────
959
960    #[test]
961    fn filter_audit_by_command() {
962        let dir = tempdir().unwrap();
963        let log = AuditLog::new(&dir.path().join("t.jsonl"));
964        log.append(&AuditEntry::success("dev", "get", Some("A")))
965            .unwrap();
966        log.append(&AuditEntry::success("dev", "set", Some("B")))
967            .unwrap();
968        log.append(&AuditEntry::success("dev", "get", Some("C")))
969            .unwrap();
970
971        let gets = log.filter_audit(None, None, Some("get")).unwrap();
972        assert_eq!(gets.len(), 2);
973        assert!(gets.iter().all(|e| e.operation == "get"));
974
975        let sets = log.filter_audit(None, None, Some("set")).unwrap();
976        assert_eq!(sets.len(), 1);
977    }
978
979    #[test]
980    fn filter_audit_by_time_range() {
981        use chrono::Duration;
982        let dir = tempdir().unwrap();
983        let log = AuditLog::new(&dir.path().join("t.jsonl"));
984
985        let now = Utc::now();
986        let old = now - Duration::hours(2);
987        let recent = now - Duration::minutes(30);
988
989        // Inject entries with specific timestamps.
990        let mut e_old = AuditEntry::success("dev", "get", Some("OLD"));
991        e_old.timestamp = old;
992        let mut e_recent = AuditEntry::success("dev", "get", Some("RECENT"));
993        e_recent.timestamp = recent;
994        let mut e_now = AuditEntry::success("dev", "set", Some("NOW"));
995        e_now.timestamp = now;
996        log.append(&e_old).unwrap();
997        log.append(&e_recent).unwrap();
998        log.append(&e_now).unwrap();
999
1000        // since = 1 hour ago → excludes old
1001        let since_cutoff = now - Duration::hours(1);
1002        let results = log.filter_audit(Some(since_cutoff), None, None).unwrap();
1003        assert_eq!(results.len(), 2);
1004
1005        // until = 1 hour ago → includes only old
1006        let results = log
1007            .filter_audit(None, Some(now - Duration::hours(1)), None)
1008            .unwrap();
1009        assert_eq!(results.len(), 1);
1010        assert_eq!(results[0].key.as_deref(), Some("OLD"));
1011    }
1012
1013    #[test]
1014    fn filter_audit_combined_since_and_command() {
1015        use chrono::Duration;
1016        let dir = tempdir().unwrap();
1017        let log = AuditLog::new(&dir.path().join("t.jsonl"));
1018
1019        let now = Utc::now();
1020        let mut old_get = AuditEntry::success("dev", "get", Some("OLD"));
1021        old_get.timestamp = now - Duration::hours(3);
1022        let mut new_get = AuditEntry::success("dev", "get", Some("NEW"));
1023        new_get.timestamp = now - Duration::minutes(5);
1024        let mut new_set = AuditEntry::success("dev", "set", Some("S"));
1025        new_set.timestamp = now - Duration::minutes(5);
1026        log.append(&old_get).unwrap();
1027        log.append(&new_get).unwrap();
1028        log.append(&new_set).unwrap();
1029
1030        let results = log
1031            .filter_audit(Some(now - Duration::hours(1)), None, Some("get"))
1032            .unwrap();
1033        assert_eq!(results.len(), 1);
1034        assert_eq!(results[0].key.as_deref(), Some("NEW"));
1035    }
1036
1037    #[test]
1038    fn filter_audit_empty_log_returns_empty() {
1039        let dir = tempdir().unwrap();
1040        let log = AuditLog::new(&dir.path().join("missing.jsonl"));
1041        let results = log.filter_audit(None, None, Some("get")).unwrap();
1042        assert!(results.is_empty());
1043    }
1044
1045    #[test]
1046    fn prune_audit_before_removes_old_entries() {
1047        use chrono::Duration;
1048        let dir = tempdir().unwrap();
1049        let log = AuditLog::new(&dir.path().join("t.jsonl"));
1050
1051        let now = Utc::now();
1052        let mut old = AuditEntry::success("dev", "get", Some("A"));
1053        old.timestamp = now - Duration::hours(48);
1054        let mut recent = AuditEntry::success("dev", "set", Some("B"));
1055        recent.timestamp = now - Duration::hours(1);
1056        log.append(&old).unwrap();
1057        log.append(&recent).unwrap();
1058
1059        let cutoff = now - Duration::hours(24);
1060        let removed = log.prune_audit_before(cutoff).unwrap();
1061        assert_eq!(removed, 1);
1062
1063        let remaining = log.read(None).unwrap();
1064        assert_eq!(remaining.len(), 1);
1065        assert_eq!(remaining[0].key.as_deref(), Some("B"));
1066    }
1067
1068    #[test]
1069    fn prune_audit_before_noop_on_empty_log() {
1070        let dir = tempdir().unwrap();
1071        let log = AuditLog::new(&dir.path().join("missing.jsonl"));
1072        let removed = log.prune_audit_before(Utc::now()).unwrap();
1073        assert_eq!(removed, 0);
1074    }
1075
1076    #[test]
1077    fn prune_audit_before_keeps_all_if_none_old() {
1078        use chrono::Duration;
1079        let dir = tempdir().unwrap();
1080        let log = AuditLog::new(&dir.path().join("t.jsonl"));
1081        log.append(&AuditEntry::success("dev", "get", Some("A")))
1082            .unwrap();
1083        log.append(&AuditEntry::success("dev", "set", Some("B")))
1084            .unwrap();
1085
1086        // Prune entries older than 1 day — all entries are recent.
1087        let removed = log
1088            .prune_audit_before(Utc::now() - Duration::days(1))
1089            .unwrap();
1090        assert_eq!(removed, 0);
1091        assert_eq!(log.read(None).unwrap().len(), 2);
1092    }
1093
1094    #[test]
1095    fn exec_context_from_contract_seeds_trust_shape() {
1096        let contract = AuthorityContract {
1097            name: "deploy".into(),
1098            profile: Some("work".into()),
1099            namespace: Some("infra".into()),
1100            access_profile: RbacProfile::ReadOnly,
1101            allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
1102            required_secrets: vec!["DB_PASSWORD".into()],
1103            allowed_targets: vec!["terraform".into()],
1104            trust: AuthorityTrust::Hardened,
1105            network: AuthorityNetworkPolicy::Restricted,
1106        };
1107
1108        let exec = AuditExecContext::from_contract(&contract)
1109            .with_target("terraform")
1110            .with_injected_secrets(["DB_PASSWORD", "DB_PASSWORD", "API_KEY"])
1111            .with_missing_required_secrets(["DB_PASSWORD"])
1112            .with_dropped_env_names(["OPENAI_API_KEY", "OPENAI_API_KEY"])
1113            .with_target_evaluation(&contract.evaluate_target(Some("terraform")));
1114
1115        assert_eq!(exec.contract_name.as_deref(), Some("deploy"));
1116        assert_eq!(exec.authority_profile.as_deref(), Some("work"));
1117        assert_eq!(exec.authority_namespace.as_deref(), Some("infra"));
1118        assert_eq!(exec.access_profile, Some(RbacProfile::ReadOnly));
1119        assert_eq!(exec.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
1120        assert_eq!(exec.required_secrets, vec!["DB_PASSWORD"]);
1121        assert_eq!(exec.injected_secrets, vec!["API_KEY", "DB_PASSWORD"]);
1122        assert_eq!(exec.missing_required_secrets, vec!["DB_PASSWORD"]);
1123        assert_eq!(exec.dropped_env_names, vec!["OPENAI_API_KEY"]);
1124        assert_eq!(exec.target_allowed, Some(true));
1125        assert_eq!(
1126            exec.target_decision,
1127            Some(AuthorityTargetDecision::AllowedExact)
1128        );
1129        assert_eq!(exec.matched_target.as_deref(), Some("terraform"));
1130    }
1131}