From the independent review of task 23. A torn last line followed by an empty later file had its recovery written into the later file, which broke the chain for good; the line is now ended in its own file. The log-name rule takes months 01 to 12 and days 01 to 31 only. [approvals] ttl_ms is limited to a day, the longest loopd waits after a pending frame. Running out of file descriptors or memory pauses the listener instead of stopping brokerd (the errors the previous fix skipped do not occur on Linux). args.rs's doc fixed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
113 lines
3.3 KiB
Rust
113 lines
3.3 KiB
Rust
//! Audit log record types. One JSON object per line, hash-chained by the exact bytes of each line.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{CallId, DataClass, DenyReason, Hash32, SessionId, Timestamp};
|
|
|
|
pub use crate::chain::{ChainFailure, ChainReport, ChainVerifier, Location, TornTail};
|
|
|
|
// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
|
|
pub enum DecisionRecord {
|
|
Allowed {},
|
|
Ask {},
|
|
Denied { reason: DenyReason },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ApprovalAnswer {
|
|
Approved,
|
|
Refused,
|
|
Expired,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ResultStatus {
|
|
Result,
|
|
Failed,
|
|
}
|
|
|
|
// JSON: {"type":"decision","session":"…",…} ; the tag sits beside the fields
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
|
pub enum AuditEvent {
|
|
Decision {
|
|
session: SessionId,
|
|
call: CallId,
|
|
tool: String,
|
|
arguments: String,
|
|
outcome: DecisionRecord,
|
|
grant: Option<String>,
|
|
grant_sha256: Option<Hash32>,
|
|
taint: DataClass,
|
|
untrusted: bool,
|
|
},
|
|
Approval {
|
|
session: SessionId,
|
|
call: CallId,
|
|
decision: u64,
|
|
answer: ApprovalAnswer,
|
|
by: Option<String>,
|
|
post: Option<String>,
|
|
reason: Option<String>,
|
|
outcome: DecisionRecord,
|
|
grant: Option<String>,
|
|
grant_sha256: Option<Hash32>,
|
|
taint: DataClass,
|
|
untrusted: bool,
|
|
},
|
|
Result {
|
|
session: SessionId,
|
|
call: CallId,
|
|
decision: u64,
|
|
status: ResultStatus,
|
|
class: DataClass,
|
|
untrusted: bool,
|
|
truncated: bool,
|
|
bytes: u64,
|
|
sha256: Hash32,
|
|
taint_after: DataClass,
|
|
},
|
|
Recovery {
|
|
torn_bytes: u64,
|
|
torn_sha256: Hash32,
|
|
},
|
|
AcceptedBreak {
|
|
file: String,
|
|
line: u64,
|
|
last_good: Hash32,
|
|
},
|
|
}
|
|
|
|
// JSON: {"seq":0,"time":"…","prev":"…","event":{"type":"decision",…}} ; `event` is a nested object.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct AuditRecord {
|
|
pub seq: u64,
|
|
pub time: Timestamp,
|
|
pub prev: Hash32,
|
|
pub event: AuditEvent,
|
|
}
|
|
|
|
/// True if `name` is an audit log file: `YYYY-MM-DD.jsonl`, ASCII digits with `-` at positions 4
|
|
/// and 7, a month from 01 to 12 and a day from 01 to 31. `brokerd` and `bxctl audit verify` both
|
|
/// decide with this, so they read the same files.
|
|
pub fn is_audit_log_name(name: &str) -> bool {
|
|
let Some(date) = name.strip_suffix(".jsonl") else {
|
|
return false;
|
|
};
|
|
let shaped = date.len() == 10
|
|
&& date.bytes().enumerate().all(|(i, b)| match i {
|
|
4 | 7 => b == b'-',
|
|
_ => b.is_ascii_digit(),
|
|
});
|
|
if !shaped {
|
|
return false;
|
|
}
|
|
let number = |range: std::ops::Range<usize>| date.get(range).and_then(|s| s.parse::<u8>().ok());
|
|
matches!(number(5..7), Some(1..=12)) && matches!(number(8..10), Some(1..=31))
|
|
}
|