Skip to main content

astrid_kernel/audit_sink/
mod.rs

1//! Kernel implementation of the capsule host-audit sink.
2//!
3//! The WASM host engine (`astrid-capsule`) reports sensitive per-action host
4//! calls — fs read/write/delete, net connect/bind, process spawn — to the
5//! [`HostAuditSink`](astrid_capsule::HostAuditSink) trait. The kernel holds
6//! both the durable audit log and the runtime ed25519 signing key, so it is
7//! the side that can map those neutral events onto a signed, hash-chained
8//! [`AuditEntry`](astrid_audit::AuditEntry). A bounded writer coalesces
9//! concurrent reports and collapses identical events in the window; host
10//! calls return after enqueue. Producers never block WASM/tokio workers.
11
12use std::collections::HashMap;
13use std::sync::{
14    Arc, Mutex,
15    mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel},
16};
17use std::thread::{self, JoinHandle};
18use std::time::{Duration, Instant};
19
20use astrid_audit::{AuditAction, AuditLog, AuditOutcome, AuthorizationProof};
21use astrid_capsule::{HostAuditEvent, HostAuditOutcome, HostAuditSink};
22use astrid_config::types::AuditConfig;
23use astrid_core::{PrincipalId, SessionId};
24use astrid_crypto::ContentHash;
25use tracing::warn;
26
27/// Authorization reason stamped on an allowed or failed manifest-gated host
28/// call — the capsule's declared manifest allowlist is what authorized the
29/// effect (there is no per-call user/capability token at this seam).
30const MANIFEST_GATED_REASON: &str = "manifest-gated host call";
31
32/// Byte cap applied to every guest-controlled string (path / host / addr /
33/// command) before it is signed and persisted onto the audit chain.
34///
35/// # Amplification threat
36///
37/// These strings are chosen by the guest and are otherwise unbounded. Every
38/// sensitive host call records one entry — INCLUDING gate-denied calls from a
39/// zero-capability capsule, which pay nothing to be denied. A capsule can
40/// therefore drive unbounded disk growth and per-append signing/hashing CPU by
41/// passing multi-megabyte paths/hosts/commands to host fns it isn't even
42/// allowed to use. Capping each field at a small constant removes that
43/// amplification while preserving enough of the value to be forensically
44/// useful.
45const MAX_AUDIT_STR_BYTES: usize = 1024;
46
47/// Operator policy for the host-audit writer. Built from [`AuditConfig`].
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct HostAuditPolicy {
50    coalesce: Duration,
51    max_batch: usize,
52    queue_capacity: usize,
53    persist_path_probes: bool,
54}
55
56impl Default for HostAuditPolicy {
57    fn default() -> Self {
58        Self::from(&AuditConfig::default())
59    }
60}
61
62impl From<&AuditConfig> for HostAuditPolicy {
63    fn from(config: &AuditConfig) -> Self {
64        Self {
65            coalesce: Duration::from_millis(config.host_coalesce_ms),
66            max_batch: usize::try_from(config.host_batch_max)
67                .unwrap_or(128)
68                .clamp(8, 128),
69            queue_capacity: usize::try_from(config.host_queue_capacity)
70                .unwrap_or(4096)
71                .clamp(64, 65_536),
72            persist_path_probes: config.host_path_probes,
73        }
74    }
75}
76
77struct AuditWork {
78    session_id: SessionId,
79    principal: PrincipalId,
80    action: AuditAction,
81    authorization: AuthorizationProof,
82    outcome: AuditOutcome,
83    repeats: u32,
84}
85
86impl AuditWork {
87    fn collapse_key(&self) -> String {
88        // Allowed host calls with distinct payloads still pin ed25519 if
89        // each row is unique. Fold allowed File/Net/Process by class per
90        // principal and session; keep the first payload and stamp
91        // `repeats=N`. Denials stay exact (principal + path/host/command).
92        let kind = match &self.action {
93            AuditAction::FileRead { .. } => Some("fileread"),
94            AuditAction::FileWrite { .. } => Some("filewrite"),
95            AuditAction::FileDelete { .. } => Some("filedelete"),
96            AuditAction::NetConnect { .. } => Some("netconnect"),
97            AuditAction::NetBind { .. } => Some("netbind"),
98            AuditAction::NetAccept { .. } => Some("netaccept"),
99            AuditAction::ProcessSpawn { .. } => Some("proc"),
100            _ => None,
101        };
102        if let Some(kind) = kind {
103            if matches!(self.authorization, AuthorizationProof::Denied { .. }) {
104                return format!(
105                    "{kind}|fail|{:?}|{}|{:?}",
106                    self.session_id, self.principal, self.action
107                );
108            }
109            return format!(
110                "{kind}|{}|{:?}|{}",
111                outcome_class(&self.outcome),
112                self.session_id,
113                self.principal
114            );
115        }
116        format!(
117            "{:?}|{}|{:?}|{:?}|{}",
118            self.session_id,
119            self.principal,
120            self.action,
121            self.authorization,
122            outcome_class(&self.outcome)
123        )
124    }
125
126    fn into_request(
127        self,
128    ) -> (
129        SessionId,
130        PrincipalId,
131        AuditAction,
132        AuthorizationProof,
133        AuditOutcome,
134    ) {
135        let outcome = with_repeat_count(self.outcome, self.repeats);
136        (
137            self.session_id,
138            self.principal,
139            self.action,
140            self.authorization,
141            outcome,
142        )
143    }
144}
145
146fn outcome_class(outcome: &AuditOutcome) -> &'static str {
147    match outcome {
148        AuditOutcome::Success { .. } => "ok",
149        AuditOutcome::Failure { .. } => "fail",
150    }
151}
152
153fn with_repeat_count(outcome: AuditOutcome, repeats: u32) -> AuditOutcome {
154    if repeats <= 1 {
155        return outcome;
156    }
157    let stamp = format!("repeats={repeats}");
158    match outcome {
159        AuditOutcome::Success { details } => AuditOutcome::Success {
160            details: Some(match details {
161                Some(existing) if existing.contains("repeats=") => existing,
162                Some(existing) => format!("{existing}; {stamp}"),
163                None => stamp,
164            }),
165        },
166        AuditOutcome::Failure { error } => AuditOutcome::Failure {
167            error: if error.contains("repeats=") {
168                error
169            } else {
170                format!("{error}; {stamp}")
171            },
172        },
173    }
174}
175
176fn fold_work(map: &mut HashMap<String, Box<AuditWork>>, work: Box<AuditWork>) -> u32 {
177    let extra = work.repeats.saturating_sub(1);
178    let key = work.collapse_key();
179    if let Some(existing) = map.get_mut(&key) {
180        existing.repeats = existing.repeats.saturating_add(work.repeats);
181        work.repeats.saturating_add(extra)
182    } else {
183        map.insert(key, work);
184        extra
185    }
186}
187
188#[allow(clippy::vec_box)]
189fn collapse_batch(batch: Vec<Box<AuditWork>>) -> (Vec<Box<AuditWork>>, u64) {
190    let mut index: HashMap<String, usize> = HashMap::new();
191    let mut out: Vec<Box<AuditWork>> = Vec::new();
192    let mut collapsed = 0_u64;
193    for work in batch {
194        let key = work.collapse_key();
195        if let Some(&i) = index.get(&key) {
196            collapsed = collapsed.saturating_add(u64::from(work.repeats));
197            out[i].repeats = out[i].repeats.saturating_add(work.repeats);
198        } else {
199            index.insert(key, out.len());
200            out.push(work);
201        }
202    }
203    (out, collapsed)
204}
205
206#[derive(Default)]
207struct AuditHealthState {
208    accepted: u64,
209    persisted: u64,
210    failed: u64,
211    queue_full: u64,
212    queued: u64,
213    collapsed_repeats: u64,
214    omitted_path_probes: u64,
215    worker_alive: bool,
216    last_error: Option<String>,
217}
218
219/// Operator-visible health for the bounded host-audit ingestion queue.
220#[derive(Clone, Debug, Default, PartialEq, Eq)]
221pub struct AuditSinkHealth {
222    /// Events accepted into the bounded queue (including folded repeats).
223    pub accepted: u64,
224    /// Events acknowledged after durable append.
225    pub persisted: u64,
226    /// Events whose durable append failed.
227    pub failed: u64,
228    /// Number of times producers observed a full queue.
229    pub queue_full: u64,
230    /// Events accepted but not yet removed from the writer queue.
231    pub queue_depth: u64,
232    /// Identical events folded into a single signed row.
233    pub collapsed_repeats: u64,
234    /// Allowed path probes omitted from the signed chain.
235    pub omitted_path_probes: u64,
236    /// Whether the dedicated writer thread is alive.
237    pub worker_alive: bool,
238    /// Whether a failure or dead writer has degraded ingestion.
239    pub degraded: bool,
240    /// Most recent persistence/worker error, if degraded.
241    pub last_error: Option<String>,
242}
243
244struct AuditQueue {
245    sender: Mutex<Option<SyncSender<()>>>,
246    pending: Arc<Mutex<HashMap<String, Box<AuditWork>>>>,
247    health: Arc<Mutex<AuditHealthState>>,
248    worker: Mutex<Option<JoinHandle<()>>>,
249    capacity: usize,
250}
251
252impl AuditQueue {
253    fn new(audit_log: Arc<AuditLog>, policy: HostAuditPolicy) -> Arc<Self> {
254        let (sender, receiver) = sync_channel(1);
255        let health = Arc::new(Mutex::new(AuditHealthState::default()));
256        let pending = Arc::new(Mutex::new(HashMap::new()));
257        let worker_health = Arc::clone(&health);
258        let worker_pending = Arc::clone(&pending);
259        let worker = thread::Builder::new()
260            .name("astrid-audit-writer".to_owned())
261            .spawn(move || {
262                audit_writer(
263                    &audit_log,
264                    &receiver,
265                    &worker_pending,
266                    &worker_health,
267                    policy,
268                );
269            })
270            .ok();
271        let queue = Arc::new(Self {
272            sender: Mutex::new(Some(sender)),
273            pending,
274            health,
275            worker: Mutex::new(worker),
276            capacity: policy.queue_capacity,
277        });
278        if queue
279            .worker
280            .lock()
281            .ok()
282            .and_then(|worker| worker.as_ref().map(|_| ()))
283            .is_none()
284        {
285            queue.record_failure("failed to spawn bounded audit writer".to_owned());
286            if let Ok(mut sender) = queue.sender.lock() {
287                sender.take();
288            }
289        }
290        queue
291    }
292
293    fn submit(&self, work: Box<AuditWork>) -> Result<(), Box<AuditWork>> {
294        let sender = self
295            .sender
296            .lock()
297            .ok()
298            .and_then(|sender| sender.as_ref().cloned());
299        let Some(sender) = sender else {
300            return Err(work);
301        };
302        self.fold_overflow(work);
303        // Capacity 1: Full means the writer is already awake.
304        let _ = sender.try_send(());
305        Ok(())
306    }
307
308    fn fold_overflow(&self, work: Box<AuditWork>) {
309        let repeats = work.repeats;
310        if let Ok(mut pending) = self.pending.lock() {
311            let key = work.collapse_key();
312            if !pending.contains_key(&key) && pending.len() >= self.capacity {
313                if let Ok(mut health) = self.health.lock() {
314                    health.queue_full = health.queue_full.saturating_add(1);
315                }
316                return;
317            }
318            let extra = fold_work(&mut pending, work);
319            self.note_accepted(u64::from(repeats));
320            if extra > 0
321                && let Ok(mut health) = self.health.lock()
322            {
323                health.collapsed_repeats =
324                    health.collapsed_repeats.saturating_add(u64::from(extra));
325            }
326            return;
327        }
328        self.note_accepted(u64::from(repeats));
329    }
330
331    fn note_accepted(&self, n: u64) {
332        if let Ok(mut health) = self.health.lock() {
333            health.accepted = health.accepted.saturating_add(n);
334            health.queued = health.queued.saturating_add(n);
335        }
336    }
337
338    fn record_failure(&self, error: String) {
339        if let Ok(mut health) = self.health.lock() {
340            health.failed = health.failed.saturating_add(1);
341            health.worker_alive = false;
342            health.last_error = Some(error);
343        }
344    }
345
346    fn omit_path_probe(&self) {
347        if let Ok(mut health) = self.health.lock() {
348            health.omitted_path_probes = health.omitted_path_probes.saturating_add(1);
349        }
350    }
351
352    fn health(&self) -> AuditSinkHealth {
353        self.health.lock().map_or_else(
354            |_| AuditSinkHealth {
355                failed: 1,
356                worker_alive: false,
357                degraded: true,
358                queue_depth: 0,
359                last_error: Some("audit health mutex poisoned".to_owned()),
360                ..AuditSinkHealth::default()
361            },
362            |health| AuditSinkHealth {
363                accepted: health.accepted,
364                persisted: health.persisted,
365                failed: health.failed,
366                queue_full: health.queue_full,
367                queue_depth: health.queued,
368                collapsed_repeats: health.collapsed_repeats,
369                omitted_path_probes: health.omitted_path_probes,
370                worker_alive: health.worker_alive,
371                degraded: health.failed > 0 || !health.worker_alive,
372                last_error: health.last_error.clone(),
373            },
374        )
375    }
376
377    fn shutdown(&self) {
378        self.sender.lock().ok().and_then(|mut sender| sender.take());
379        if let Ok(mut worker) = self.worker.lock()
380            && let Some(worker) = worker.take()
381        {
382            let _ = worker.join();
383        }
384    }
385}
386
387impl Drop for AuditQueue {
388    fn drop(&mut self) {
389        if let Ok(sender) = self.sender.get_mut() {
390            sender.take();
391        }
392        if let Ok(worker) = self.worker.get_mut()
393            && let Some(worker) = worker.take()
394        {
395            let _ = worker.join();
396        }
397    }
398}
399
400#[allow(clippy::vec_box)]
401fn take_pending(
402    pending: &Mutex<HashMap<String, Box<AuditWork>>>,
403    limit: usize,
404) -> Vec<Box<AuditWork>> {
405    let Ok(mut map) = pending.lock() else {
406        return Vec::new();
407    };
408    let mut out = Vec::new();
409    let keys: Vec<String> = map.keys().take(limit).cloned().collect();
410    for key in keys {
411        if let Some(work) = map.remove(&key) {
412            out.push(work);
413        }
414        if out.len() >= limit {
415            break;
416        }
417    }
418    out
419}
420
421#[allow(clippy::vec_box)]
422fn persist_batch(
423    runtime: &tokio::runtime::Runtime,
424    audit_log: &Arc<AuditLog>,
425    health: &Arc<Mutex<AuditHealthState>>,
426    batch: Vec<Box<AuditWork>>,
427) {
428    let drained: u64 = batch.iter().map(|work| u64::from(work.repeats)).sum();
429    let (batch, collapsed) = collapse_batch(batch);
430    if batch.len() > 16 {
431        warn!(
432            count = batch.len(),
433            "host-audit persist still has many unique rows after collapse"
434        );
435    }
436    if let Ok(mut state) = health.lock() {
437        state.queued = state.queued.saturating_sub(drained);
438        if collapsed > 0 {
439            state.collapsed_repeats = state.collapsed_repeats.saturating_add(collapsed);
440        }
441    }
442    let requests = batch.into_iter().map(|work| work.into_request()).collect();
443    let results = runtime.block_on(audit_log.append_batch_with_principal(requests));
444    let mut persisted = 0_u64;
445    let mut failed = 0_u64;
446    let mut last_error = None;
447    for result in results {
448        if result.is_ok() {
449            persisted = persisted.saturating_add(1);
450        } else if let Err(error) = result {
451            failed = failed.saturating_add(1);
452            last_error = Some(error.to_string());
453        }
454    }
455    if let Ok(mut state) = health.lock() {
456        state.persisted = state.persisted.saturating_add(persisted);
457        state.failed = state.failed.saturating_add(failed);
458        if let Some(error) = last_error {
459            state.last_error = Some(error);
460        }
461    }
462}
463
464fn audit_writer(
465    audit_log: &Arc<AuditLog>,
466    receiver: &Receiver<()>,
467    pending: &Mutex<HashMap<String, Box<AuditWork>>>,
468    health: &Arc<Mutex<AuditHealthState>>,
469    policy: HostAuditPolicy,
470) {
471    if let Ok(mut state) = health.lock() {
472        state.worker_alive = true;
473    }
474    let runtime = match tokio::runtime::Builder::new_current_thread()
475        .enable_all()
476        .build()
477    {
478        Ok(runtime) => runtime,
479        Err(error) => {
480            let reason = format!("failed to create audit writer runtime: {error}");
481            while receiver.recv().is_ok() {}
482            let _ = take_pending(pending, usize::MAX);
483            if let Ok(mut state) = health.lock() {
484                state.failed = state.failed.saturating_add(1);
485                state.worker_alive = false;
486                state.last_error = Some(reason);
487            }
488            return;
489        },
490    };
491
492    loop {
493        if receiver.recv().is_err() {
494            let rest = take_pending(pending, policy.max_batch);
495            if !rest.is_empty() {
496                persist_batch(&runtime, audit_log, health, rest);
497                continue;
498            }
499            break;
500        }
501        let deadline = Instant::now()
502            .checked_add(policy.coalesce)
503            .unwrap_or_else(Instant::now);
504        loop {
505            let timeout = deadline.saturating_duration_since(Instant::now());
506            if timeout.is_zero() {
507                break;
508            }
509            match receiver.recv_timeout(timeout) {
510                Ok(()) | Err(RecvTimeoutError::Timeout) => {},
511                Err(RecvTimeoutError::Disconnected) => break,
512            }
513        }
514        let batch = take_pending(pending, policy.max_batch);
515        if !batch.is_empty() {
516            persist_batch(&runtime, audit_log, health, batch);
517        }
518    }
519    if let Ok(mut state) = health.lock() {
520        state.worker_alive = false;
521    }
522}
523
524/// Truncate a guest-controlled string to at most [`MAX_AUDIT_STR_BYTES`],
525/// snapping to a UTF-8 char boundary so the stored value is always valid UTF-8.
526///
527/// See the [`MAX_AUDIT_STR_BYTES`] amplification threat: guest strings are
528/// unbounded and are signed+persisted per call, so they must be bounded at this
529/// sink boundary before `to_owned`.
530fn truncate_guest_str(s: &str) -> String {
531    if s.len() <= MAX_AUDIT_STR_BYTES {
532        return s.to_owned();
533    }
534    // Snap down to the largest char boundary at or below the cap so slicing
535    // never splits a multi-byte code point (which would panic). Index 0 is
536    // always a boundary, so the search always yields.
537    let end = (0..=MAX_AUDIT_STR_BYTES)
538        .rev()
539        .find(|&i| s.is_char_boundary(i))
540        .unwrap_or(0);
541    s[..end].to_owned()
542}
543
544/// Persists capsule per-action host calls onto the kernel's signed audit
545/// chain.
546///
547/// Moved into a `dyn HostAuditSink` handed to every capsule engine at load. One
548/// per kernel boot, bound to the kernel's single `session_id`.
549#[derive(Clone)]
550pub struct KernelAuditSink {
551    /// The kernel session every entry is chained under.
552    session_id: SessionId,
553    /// Bounded writer queue. Producers never block on a full queue.
554    queue: Arc<AuditQueue>,
555    policy: HostAuditPolicy,
556}
557
558impl KernelAuditSink {
559    /// Construct a sink over the kernel's audit log + session using default
560    /// [`HostAuditPolicy`].
561    #[must_use]
562    pub fn new(audit_log: impl Into<Arc<AuditLog>>, session_id: impl Into<SessionId>) -> Self {
563        Self::with_policy(audit_log, session_id, HostAuditPolicy::default())
564    }
565
566    /// Construct a sink with an operator policy from [`AuditConfig`].
567    #[must_use]
568    pub fn with_policy(
569        audit_log: impl Into<Arc<AuditLog>>,
570        session_id: impl Into<SessionId>,
571        policy: HostAuditPolicy,
572    ) -> Self {
573        let audit_log = audit_log.into();
574        Self {
575            session_id: session_id.into(),
576            queue: AuditQueue::new(audit_log, policy),
577            policy,
578        }
579    }
580
581    /// Return operator-visible queue and persistence health.
582    #[must_use]
583    pub fn health(&self) -> AuditSinkHealth {
584        self.queue.health()
585    }
586
587    /// Stop the bounded writer after draining all accepted events.
588    pub fn shutdown(&self) {
589        self.queue.shutdown();
590    }
591
592    /// Map a neutral host event onto the internal audit action.
593    ///
594    /// `FileWrite` content hashing is not captured at this per-action seam
595    /// yet (the host fn reports the path, not the written bytes); a
596    /// zero hash is recorded as a documented placeholder pending a
597    /// content-addressed follow-up.
598    fn to_action(event: HostAuditEvent<'_>) -> AuditAction {
599        // Every guest-controlled string is bounded here (see
600        // `truncate_guest_str` / `MAX_AUDIT_STR_BYTES`) before it is signed and
601        // persisted, closing the disk/CPU amplification path.
602        match event {
603            HostAuditEvent::FileRead { path } | HostAuditEvent::FileProbe { path } => {
604                AuditAction::FileRead {
605                    path: truncate_guest_str(path),
606                }
607            },
608            HostAuditEvent::FileWrite { path } => AuditAction::FileWrite {
609                path: truncate_guest_str(path),
610                // Content hash not captured at the per-action seam yet.
611                content_hash: ContentHash::zero(),
612            },
613            HostAuditEvent::FileDelete { path } => AuditAction::FileDelete {
614                path: truncate_guest_str(path),
615            },
616            HostAuditEvent::NetConnect { host, port } => AuditAction::NetConnect {
617                host: truncate_guest_str(host),
618                port,
619            },
620            HostAuditEvent::NetBind { addr } => AuditAction::NetBind {
621                addr: truncate_guest_str(addr),
622            },
623            HostAuditEvent::ProcessSpawn { command } => AuditAction::ProcessSpawn {
624                command: truncate_guest_str(command),
625            },
626            HostAuditEvent::NetAccept {
627                local_addr,
628                peer_addr,
629            } => AuditAction::NetAccept {
630                local_addr: truncate_guest_str(local_addr),
631                peer_addr: truncate_guest_str(peer_addr),
632            },
633        }
634    }
635
636    fn record_action(
637        &self,
638        principal: &PrincipalId,
639        action: AuditAction,
640        outcome: HostAuditOutcome<'_>,
641    ) {
642        let (proof, audit_outcome) = Self::to_proof_outcome(outcome);
643        let work = Box::new(AuditWork {
644            session_id: self.session_id.clone(),
645            principal: principal.clone(),
646            action,
647            authorization: proof,
648            outcome: audit_outcome,
649            repeats: 1,
650        });
651        if self.queue.submit(work).is_err() {
652            self.queue
653                .record_failure("audit writer unavailable".to_owned());
654            warn!(
655                security_event = true,
656                %principal,
657                "Failed to enqueue per-action audit entry"
658            );
659        }
660    }
661
662    /// Build the authorization proof + outcome pair for an outcome.
663    fn to_proof_outcome(outcome: HostAuditOutcome<'_>) -> (AuthorizationProof, AuditOutcome) {
664        match outcome {
665            HostAuditOutcome::Allowed => (
666                AuthorizationProof::System {
667                    reason: MANIFEST_GATED_REASON.into(),
668                },
669                AuditOutcome::success(),
670            ),
671            HostAuditOutcome::Failed(e) => (
672                AuthorizationProof::System {
673                    reason: MANIFEST_GATED_REASON.into(),
674                },
675                AuditOutcome::failure(e),
676            ),
677            HostAuditOutcome::Denied(r) => (
678                AuthorizationProof::Denied {
679                    reason: r.to_owned(),
680                },
681                AuditOutcome::failure(r),
682            ),
683        }
684    }
685}
686
687impl HostAuditSink for KernelAuditSink {
688    fn record(
689        &self,
690        principal: &PrincipalId,
691        event: HostAuditEvent<'_>,
692        outcome: HostAuditOutcome<'_>,
693    ) {
694        if matches!(event, HostAuditEvent::FileProbe { .. })
695            && matches!(outcome, HostAuditOutcome::Allowed)
696            && !self.policy.persist_path_probes
697        {
698            self.queue.omit_path_probe();
699            return;
700        }
701        let action = Self::to_action(event);
702        self.record_action(principal, action, outcome);
703    }
704}
705
706#[cfg(test)]
707mod tests;