1use 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
27const MANIFEST_GATED_REASON: &str = "manifest-gated host call";
31
32const MAX_AUDIT_STR_BYTES: usize = 1024;
46
47#[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 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
221pub struct AuditSinkHealth {
222 pub accepted: u64,
224 pub persisted: u64,
226 pub failed: u64,
228 pub queue_full: u64,
230 pub queue_depth: u64,
232 pub collapsed_repeats: u64,
234 pub omitted_path_probes: u64,
236 pub worker_alive: bool,
238 pub degraded: bool,
240 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 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
524fn truncate_guest_str(s: &str) -> String {
531 if s.len() <= MAX_AUDIT_STR_BYTES {
532 return s.to_owned();
533 }
534 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#[derive(Clone)]
550pub struct KernelAuditSink {
551 session_id: SessionId,
553 queue: Arc<AuditQueue>,
555 policy: HostAuditPolicy,
556}
557
558impl KernelAuditSink {
559 #[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 #[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 #[must_use]
583 pub fn health(&self) -> AuditSinkHealth {
584 self.queue.health()
585 }
586
587 pub fn shutdown(&self) {
589 self.queue.shutdown();
590 }
591
592 fn to_action(event: HostAuditEvent<'_>) -> AuditAction {
599 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: 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 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;