use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::Utc;
use tsafe_core::run_evidence::RunEvidence;
use crate::events::{
project_enforce_completed_cloudevent, project_lifecycle_cloudevent, AuditActor,
AuditArtifactRef, AuditOutcome, AuditResource, AuditTouchedResource, CloudEvent,
EnforceCompletedInput, LifecycleEventInput, DEFAULT_CLOUDEVENT_SOURCE,
DEFAULT_PROVENANCE_KIND_OTHER, EVENT_AUDIT_FAILED, EVENT_AUDIT_RENDERED,
EVENT_CONTRACT_CREATED, EVENT_CONTRACT_REJECTED, EVENT_ENFORCE_COMPLETED,
EVENT_ENFORCE_REJECTED, EVENT_SCAN_COMPLETED, EVENT_SCAN_FAILED,
};
use crate::hash::blake3_hash;
use crate::model::SCAN_SCHEMA;
use tsafe_core::attest_contract::ATTEST_CONTRACT_SCHEMA;
pub const TSAFE_ATTEST_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone)]
pub struct EventLog {
path: PathBuf,
}
impl EventLog {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn append(&self, event: &CloudEvent) -> Result<()> {
event
.ensure_valid()
.map_err(|errors| anyhow::anyhow!("invalid audit event: {errors}"))?;
ensure_parent_dir(&self.path)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.with_context(|| format!("open audit events: {}", self.path.display()))?;
let mut line = serde_json::to_string(event)
.with_context(|| format!("serialize audit event: {}", self.path.display()))?;
line.push('\n');
file.write_all(line.as_bytes())
.with_context(|| format!("write audit event: {}", self.path.display()))?;
Ok(())
}
pub fn path(&self) -> &Path {
&self.path
}
}
pub fn enforce_completed_event(evidence: &RunEvidence, run_output: &Path) -> Result<CloudEvent> {
let run_bytes = fs::read(run_output)
.with_context(|| format!("read run evidence: {}", run_output.display()))?;
let run_hash = blake3_hash(&run_bytes);
let event = project_enforce_completed_cloudevent(&EnforceCompletedInput {
event_id: stable_hash("event", EVENT_ENFORCE_COMPLETED, &run_hash),
source: String::new(),
time: evidence.finished_at,
subject: run_hash.clone(),
actor: local_actor(Some(evidence.machine.username_hash.clone())),
resource: AuditResource {
kind: "run".to_string(),
id: run_hash.clone(),
},
correlation_id: run_hash.clone(),
idempotency_key: stable_hash("idempotency", EVENT_ENFORCE_COMPLETED, &run_hash),
fingerprint: Some(stable_hash(
"fingerprint",
EVENT_ENFORCE_COMPLETED,
&format!("{}:{}", evidence.contract.hash, evidence.command.join("\0")),
)),
artifact_refs: vec![AuditArtifactRef {
schema: evidence.schema.clone(),
path: run_output.display().to_string(),
hash: run_hash.clone(),
}],
touched_resources: touched_env_refs(evidence),
artifact_hash: Some(run_hash),
tsafe_attest_version: evidence.tsafe_attest_version.clone(),
});
Ok(event)
}
pub fn lifecycle_event(
event_type: &str,
operation: &str,
outcome: AuditOutcome,
resource_kind: &str,
resource_id: impl AsRef<str>,
reason_code: Option<String>,
artifact: Option<AuditArtifactRef>,
) -> CloudEvent {
let resource_id = resource_id.as_ref();
let event_id = stable_hash("event", event_type, resource_id);
let artifact_hash = artifact.as_ref().map(|item| item.hash.clone());
let artifact_refs = artifact.into_iter().collect::<Vec<_>>();
project_lifecycle_cloudevent(&LifecycleEventInput {
event_type: event_type.to_string(),
operation: operation.to_string(),
outcome,
reason_code,
event_id: event_id.clone(),
source: DEFAULT_CLOUDEVENT_SOURCE.to_string(),
time: Utc::now(),
subject: resource_id.to_string(),
actor: local_actor(None),
resource: AuditResource {
kind: resource_kind.to_string(),
id: resource_id.to_string(),
},
correlation_id: event_id.clone(),
idempotency_key: stable_hash("idempotency", event_type, resource_id),
fingerprint: Some(stable_hash("fingerprint", event_type, resource_id)),
artifact_refs,
touched_resources: Vec::new(),
artifact_hash,
tsafe_attest_version: TSAFE_ATTEST_VERSION.to_string(),
provenancekind: DEFAULT_PROVENANCE_KIND_OTHER.to_string(),
})
}
pub fn scan_completed_event(path: &Path) -> CloudEvent {
lifecycle_event(
EVENT_SCAN_COMPLETED,
"scan.completed",
AuditOutcome::Success,
"scan",
blake3_hash(path.display().to_string()),
None,
artifact_ref(SCAN_SCHEMA, path).ok(),
)
}
pub fn scan_failed_event(repo: &Path, reason: &str) -> CloudEvent {
lifecycle_event(
EVENT_SCAN_FAILED,
"scan.failed",
AuditOutcome::Failure,
"repo",
blake3_hash(repo.display().to_string()),
Some(reason_code(reason)),
None,
)
}
pub fn contract_created_event(path: &Path) -> CloudEvent {
lifecycle_event(
EVENT_CONTRACT_CREATED,
"contract.created",
AuditOutcome::Success,
"contract",
blake3_hash(path.display().to_string()),
None,
artifact_ref(ATTEST_CONTRACT_SCHEMA, path).ok(),
)
}
pub fn contract_rejected_event(path: &Path, reason: &str) -> CloudEvent {
lifecycle_event(
EVENT_CONTRACT_REJECTED,
"contract.rejected",
AuditOutcome::Rejected,
"contract",
blake3_hash(path.display().to_string()),
Some(reason_code(reason)),
None,
)
}
pub fn enforce_rejected_event(path: &Path, reason: &str) -> CloudEvent {
lifecycle_event(
EVENT_ENFORCE_REJECTED,
"enforce.rejected",
AuditOutcome::Rejected,
"contract",
blake3_hash(path.display().to_string()),
Some(reason_code(reason)),
None,
)
}
pub fn audit_rendered_event(path: &Path) -> CloudEvent {
lifecycle_event(
EVENT_AUDIT_RENDERED,
"audit.rendered",
AuditOutcome::Success,
"audit",
blake3_hash(path.display().to_string()),
None,
None,
)
}
pub fn audit_failed_event(path: &Path, reason: &str) -> CloudEvent {
lifecycle_event(
EVENT_AUDIT_FAILED,
"audit.failed",
AuditOutcome::Failure,
"run",
blake3_hash(path.display().to_string()),
Some(reason_code(reason)),
None,
)
}
fn local_actor(id_hash: Option<String>) -> AuditActor {
AuditActor {
kind: "local-user".to_string(),
id_hash,
}
}
fn artifact_ref(schema: &str, path: &Path) -> Result<AuditArtifactRef> {
let bytes = fs::read(path).with_context(|| format!("read artifact: {}", path.display()))?;
Ok(AuditArtifactRef {
schema: schema.to_string(),
path: path.display().to_string(),
hash: blake3_hash(bytes),
})
}
fn touched_env_refs(evidence: &RunEvidence) -> Vec<AuditTouchedResource> {
evidence
.environment
.secrets_injected
.iter()
.map(|item| &item.name)
.chain(
evidence
.environment
.sensitive_env_denied
.iter()
.map(|item| &item.name),
)
.map(|name| AuditTouchedResource {
kind: "env".to_string(),
name_ref: Some(blake3_hash(format!("env:{name}"))),
id_ref: None,
})
.collect()
}
fn stable_hash(prefix: &str, event_type: &str, subject: &str) -> String {
blake3_hash(format!("{prefix}:{event_type}:{subject}"))
}
fn reason_code(reason: &str) -> String {
let raw = reason
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.chars()
.take(80)
.collect::<String>();
let mut collapsed = String::new();
for ch in raw.chars() {
if ch == '_' && collapsed.ends_with('_') {
continue;
}
collapsed.push(ch);
}
collapsed
}
fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)
.with_context(|| format!("create output directory: {}", parent.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reason_code_removes_sensitive_shape() {
assert_eq!(
reason_code("Contract violation: missing API_TOKEN!"),
"contract_violation_missing_api_token"
);
}
}