use super::*;
pub const SCHEMA_VERSION: u8 = 4;
pub const HASH_ALGORITHM: &str = "sha256";
pub(crate) const SEQ_KEY: &str = "enforcement:seq";
pub const INSTALLATION_ID_KEY: &str = "system:installation_id";
pub const EVENT_PREFIX: &str = "enforcement:event:";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnforcementEvent {
pub event_id: String,
pub schema_version: u8,
pub seq_no: u64,
pub recorded_at_ms: u64,
pub event_type: EnforcementEventType,
pub event_hash: String,
pub prev_hash: String,
pub installation_id: String,
pub actor_local: Option<ActorLocal>,
pub agent_type: String,
pub subject_kind: SubjectKind,
pub subject_key: String,
pub canonical_subject_hash: Option<String>,
pub receipt_id: Option<String>,
pub decision_reason_code: String,
pub decision_basis_hash: Option<String>,
pub agent_session: Option<String>,
pub agent_id: Option<String>,
pub parent_agent_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActorLocal {
pub username: String,
pub uid: Option<u32>,
pub verified: bool, }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SubjectKind {
File,
Control,
Config,
System,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EnforcementEventType {
Deny,
AllowAfterReceipt,
ReceiptMinted,
BypassDetected,
ControlChanged {
change_kind: ControlChangeKind,
},
EnforcementConfigChanged {
setting: String,
old_value: String,
new_value: String,
},
RecordingGap {
gap_start_ms: u64,
gap_end_ms: u64,
cause: GapCause,
enforcement_mode_during_gap: EnforcementMode,
missed_event_count: MissedEventCount,
certainty: GapCertainty,
},
RetentionPruned {
pruned_count: u64,
oldest_pruned_seq: u64,
newest_pruned_seq: u64,
},
CleanShutdown {
reason: String,
},
SubagentSpawned,
SubagentEdge,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ControlChangeKind {
Created,
Confirmed,
Updated,
Deleted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GapCause {
DaemonUnreachable,
StoreWriteFailure,
StoreLocked,
CorruptionRecovery,
UncleanShutdown,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnforcementMode {
Advisory,
Strict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MissedEventCount {
Known(u64),
Zero,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GapCertainty {
Exact,
Inferred,
}
#[derive(Serialize)]
struct CanonicalEvent<'a> {
event_id: &'a str,
schema_version: u8,
seq_no: u64,
recorded_at_ms: u64,
event_type: &'a EnforcementEventType,
prev_hash: &'a str,
installation_id: &'a str,
actor_local: &'a Option<ActorLocal>,
agent_type: &'a str,
subject_kind: SubjectKind,
subject_key: &'a str,
canonical_subject_hash: Option<&'a str>,
receipt_id: Option<&'a str>,
decision_reason_code: &'a str,
decision_basis_hash: Option<&'a str>,
}
#[derive(Serialize)]
struct CanonicalEventV2<'a> {
event_id: &'a str,
schema_version: u8,
seq_no: u64,
recorded_at_ms: u64,
event_type: &'a EnforcementEventType,
prev_hash: &'a str,
installation_id: &'a str,
actor_local: &'a Option<ActorLocal>,
agent_type: &'a str,
subject_kind: SubjectKind,
subject_key: &'a str,
canonical_subject_hash: Option<&'a str>,
receipt_id: Option<&'a str>,
decision_reason_code: &'a str,
decision_basis_hash: Option<&'a str>,
agent_session: Option<&'a str>,
}
#[derive(Serialize)]
struct CanonicalEventV3<'a> {
event_id: &'a str,
schema_version: u8,
seq_no: u64,
recorded_at_ms: u64,
event_type: &'a EnforcementEventType,
prev_hash: &'a str,
installation_id: &'a str,
actor_local: &'a Option<ActorLocal>,
agent_type: &'a str,
subject_kind: SubjectKind,
subject_key: &'a str,
canonical_subject_hash: Option<&'a str>,
receipt_id: Option<&'a str>,
decision_reason_code: &'a str,
decision_basis_hash: Option<&'a str>,
agent_session: Option<&'a str>,
agent_id: Option<&'a str>,
}
#[derive(Serialize)]
struct CanonicalEventV4<'a> {
event_id: &'a str,
schema_version: u8,
seq_no: u64,
recorded_at_ms: u64,
event_type: &'a EnforcementEventType,
prev_hash: &'a str,
installation_id: &'a str,
actor_local: &'a Option<ActorLocal>,
agent_type: &'a str,
subject_kind: SubjectKind,
subject_key: &'a str,
canonical_subject_hash: Option<&'a str>,
receipt_id: Option<&'a str>,
decision_reason_code: &'a str,
decision_basis_hash: Option<&'a str>,
agent_session: Option<&'a str>,
agent_id: Option<&'a str>,
parent_agent_id: Option<&'a str>,
}
impl EnforcementEvent {
pub fn compute_hash(&self) -> String {
let json = if self.schema_version >= 4 {
let canonical = CanonicalEventV4 {
event_id: &self.event_id,
schema_version: self.schema_version,
seq_no: self.seq_no,
recorded_at_ms: self.recorded_at_ms,
event_type: &self.event_type,
prev_hash: &self.prev_hash,
installation_id: &self.installation_id,
actor_local: &self.actor_local,
agent_type: &self.agent_type,
subject_kind: self.subject_kind,
subject_key: &self.subject_key,
canonical_subject_hash: self.canonical_subject_hash.as_deref(),
receipt_id: self.receipt_id.as_deref(),
decision_reason_code: &self.decision_reason_code,
decision_basis_hash: self.decision_basis_hash.as_deref(),
agent_session: self.agent_session.as_deref(),
agent_id: self.agent_id.as_deref(),
parent_agent_id: self.parent_agent_id.as_deref(),
};
serde_json::to_string(&canonical).expect("canonical serialization must not fail")
} else if self.schema_version == 3 {
let canonical = CanonicalEventV3 {
event_id: &self.event_id,
schema_version: self.schema_version,
seq_no: self.seq_no,
recorded_at_ms: self.recorded_at_ms,
event_type: &self.event_type,
prev_hash: &self.prev_hash,
installation_id: &self.installation_id,
actor_local: &self.actor_local,
agent_type: &self.agent_type,
subject_kind: self.subject_kind,
subject_key: &self.subject_key,
canonical_subject_hash: self.canonical_subject_hash.as_deref(),
receipt_id: self.receipt_id.as_deref(),
decision_reason_code: &self.decision_reason_code,
decision_basis_hash: self.decision_basis_hash.as_deref(),
agent_session: self.agent_session.as_deref(),
agent_id: self.agent_id.as_deref(),
};
serde_json::to_string(&canonical).expect("canonical serialization must not fail")
} else if self.schema_version == 2 {
let canonical = CanonicalEventV2 {
event_id: &self.event_id,
schema_version: self.schema_version,
seq_no: self.seq_no,
recorded_at_ms: self.recorded_at_ms,
event_type: &self.event_type,
prev_hash: &self.prev_hash,
installation_id: &self.installation_id,
actor_local: &self.actor_local,
agent_type: &self.agent_type,
subject_kind: self.subject_kind,
subject_key: &self.subject_key,
canonical_subject_hash: self.canonical_subject_hash.as_deref(),
receipt_id: self.receipt_id.as_deref(),
decision_reason_code: &self.decision_reason_code,
decision_basis_hash: self.decision_basis_hash.as_deref(),
agent_session: self.agent_session.as_deref(),
};
serde_json::to_string(&canonical).expect("canonical serialization must not fail")
} else {
let canonical = CanonicalEvent {
event_id: &self.event_id,
schema_version: self.schema_version,
seq_no: self.seq_no,
recorded_at_ms: self.recorded_at_ms,
event_type: &self.event_type,
prev_hash: &self.prev_hash,
installation_id: &self.installation_id,
actor_local: &self.actor_local,
agent_type: &self.agent_type,
subject_kind: self.subject_kind,
subject_key: &self.subject_key,
canonical_subject_hash: self.canonical_subject_hash.as_deref(),
receipt_id: self.receipt_id.as_deref(),
decision_reason_code: &self.decision_reason_code,
decision_basis_hash: self.decision_basis_hash.as_deref(),
};
serde_json::to_string(&canonical).expect("canonical serialization must not fail")
};
let mut hasher = Sha256::new();
hasher.update(json.as_bytes());
format!("{:x}", hasher.finalize())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChainBreakKind {
Linkage,
Tampered,
UnknownSchema,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainBreak {
pub kind: ChainBreakKind,
pub seq_no: u64,
pub recorded_at_ms: u64,
pub event_type: String,
pub prev_seq_no: Option<u64>,
pub prev_recorded_at_ms: Option<u64>,
pub prev_event_type: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainVerification {
pub checked: usize,
pub tampered_events: usize,
pub linkage_breaks: usize,
pub unknown_schema: usize,
pub breaks: Vec<ChainBreak>,
}
impl ChainVerification {
pub fn is_valid(&self) -> bool {
self.tampered_events == 0 && self.linkage_breaks == 0 && self.unknown_schema == 0
}
}
pub fn verify_chain(events: &[EnforcementEvent]) -> ChainVerification {
verify_chain_with_skips(events, &[])
}
pub fn verify_chain_with_skips(
events: &[EnforcementEvent],
skipped_seqs: &[u64],
) -> ChainVerification {
let mut sorted: Vec<&EnforcementEvent> = events.iter().collect();
sorted.sort_by_key(|e| e.seq_no);
let mut result = ChainVerification::default();
for &seq in skipped_seqs {
result.unknown_schema += 1;
result.breaks.push(ChainBreak {
kind: ChainBreakKind::UnknownSchema,
seq_no: seq,
recorded_at_ms: 0,
event_type: "unreadable".to_string(),
prev_seq_no: None,
prev_recorded_at_ms: None,
prev_event_type: None,
});
}
let mut prev: Option<&EnforcementEvent> = None;
for e in sorted {
if let Some(p) = prev {
if e.prev_hash != p.event_hash {
let gap_start = p.seq_no + 1;
let explained_by_skip = gap_start < e.seq_no
&& (gap_start..e.seq_no).all(|s| skipped_seqs.contains(&s));
if !explained_by_skip {
result.linkage_breaks += 1;
result.breaks.push(ChainBreak {
kind: ChainBreakKind::Linkage,
seq_no: e.seq_no,
recorded_at_ms: e.recorded_at_ms,
event_type: event_type_label(&e.event_type).to_string(),
prev_seq_no: Some(p.seq_no),
prev_recorded_at_ms: Some(p.recorded_at_ms),
prev_event_type: Some(event_type_label(&p.event_type).to_string()),
});
}
}
}
if e.schema_version > SCHEMA_VERSION {
result.unknown_schema += 1;
result.breaks.push(ChainBreak {
kind: ChainBreakKind::UnknownSchema,
seq_no: e.seq_no,
recorded_at_ms: e.recorded_at_ms,
event_type: event_type_label(&e.event_type).to_string(),
prev_seq_no: None,
prev_recorded_at_ms: None,
prev_event_type: None,
});
} else {
result.checked += 1;
if e.event_hash != e.compute_hash() {
result.tampered_events += 1;
result.breaks.push(ChainBreak {
kind: ChainBreakKind::Tampered,
seq_no: e.seq_no,
recorded_at_ms: e.recorded_at_ms,
event_type: event_type_label(&e.event_type).to_string(),
prev_seq_no: None,
prev_recorded_at_ms: None,
prev_event_type: None,
});
}
}
prev = Some(e);
}
result.breaks.sort_by_key(|b| b.seq_no);
result
}