mirror of
https://github.com/kristoferssolo/captra.git
synced 2025-12-20 11:04:39 +00:00
feat: add custom errors
This commit is contained in:
parent
8f1bbc88cf
commit
51f07beb4d
22
Cargo.lock
generated
22
Cargo.lock
generated
@ -80,7 +80,9 @@ dependencies = [
|
|||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@ -642,6 +644,26 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.9"
|
version = "1.1.9"
|
||||||
|
|||||||
@ -17,6 +17,8 @@ glob = "0.3"
|
|||||||
rand = "0.8" # ed25519-dalek depends on rand_core v0.6
|
rand = "0.8" # ed25519-dalek depends on rand_core v0.6
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
thiserror = "2.0"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
|||||||
197
src/host.rs
197
src/host.rs
@ -1,80 +1,191 @@
|
|||||||
use color_eyre::Result;
|
|
||||||
use ed25519_dalek::{PUBLIC_KEY_LENGTH, SigningKey};
|
|
||||||
use glob::Pattern;
|
|
||||||
use rand::{Rng, SeedableRng, rngs::StdRng};
|
|
||||||
use std::path::Path;
|
|
||||||
use tracing::Level;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
manifest::{CapabilityManifest, PRIME_MULTIPLIER},
|
manifest::{CapabilityManifest, PRIME_MULTIPLIER},
|
||||||
trace::{TraceEvent, finalize_trace, log_trace_event, save_trace},
|
trace::{
|
||||||
|
CapEventSubtype, EventType, SignedTrace, TraceError, TraceEvent, finalize_trace,
|
||||||
|
log_trace_event, save_trace,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
use ed25519_dalek::{PUBLIC_KEY_LENGTH, SigningKey, ed25519::signature::SignerMut};
|
||||||
|
use glob::Pattern;
|
||||||
|
use rand::{Rng, SeedableRng, rngs::StdRng};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::Path;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::Level;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct HostState {
|
pub struct HostState {
|
||||||
pub manifest: CapabilityManifest,
|
manifest: CapabilityManifest,
|
||||||
pub trace: Vec<TraceEvent>,
|
trace: Vec<TraceEvent>,
|
||||||
pub seed: u64,
|
seed: u64,
|
||||||
pub keypair: SigningKey,
|
keypair: SigningKey,
|
||||||
pub pubkey: [u8; PUBLIC_KEY_LENGTH],
|
pubkey: [u8; PUBLIC_KEY_LENGTH],
|
||||||
|
run_id: String,
|
||||||
|
manifest_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from capability enforcement.
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum CapError {
|
||||||
|
#[error("No FS capability declared")]
|
||||||
|
NoFsCapability,
|
||||||
|
|
||||||
|
#[error("No read patterns defined")]
|
||||||
|
NoReadPatterns,
|
||||||
|
|
||||||
|
#[error("Path does not match any glob pattern")]
|
||||||
|
GlobMismatch,
|
||||||
|
|
||||||
|
#[error("Invalid path provided (empty or invalid UTF-8)")]
|
||||||
|
InvalidPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostState {
|
impl HostState {
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Should not panic
|
||||||
pub fn new(manifest: CapabilityManifest, seed: u64, keypair: SigningKey) -> Self {
|
pub fn new(manifest: CapabilityManifest, seed: u64, keypair: SigningKey) -> Self {
|
||||||
let pubkey = keypair.verifying_key().to_bytes();
|
let pubkey = keypair.verifying_key().to_bytes();
|
||||||
|
let run_id = format!("captra-run-{seed}");
|
||||||
|
let manifest_json = serde_json::to_string(&manifest).expect("Manifest serializes"); // Safe: validated earlier
|
||||||
|
let mut hashser = Sha256::default();
|
||||||
|
hashser.update(manifest_json.as_bytes());
|
||||||
|
let manifest_hash = format!("{:x}", hashser.finalize());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
manifest,
|
manifest,
|
||||||
trace: Vec::new(),
|
trace: Vec::new(),
|
||||||
seed,
|
seed,
|
||||||
keypair,
|
keypair,
|
||||||
pubkey,
|
pubkey,
|
||||||
|
run_id,
|
||||||
|
manifest_hash,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simulate "plugin execution": check if path is allowed via FS read cap.
|
/// Get `pubkey`
|
||||||
/// Returns `true` if allowed, `false` if denied.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn execute_plugin<P: AsRef<Path>>(&mut self, path: P) -> bool {
|
pub const fn pubkey(&self) -> &[u8; PUBLIC_KEY_LENGTH] {
|
||||||
let Some(fs_cap) = &self.manifest.capabilities.fs else {
|
&self.pubkey
|
||||||
return false;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let Some(read_patterns) = &fs_cap.read else {
|
/// Get `run_id`
|
||||||
return false;
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub fn run_id(&self) -> &str {
|
||||||
|
&self.run_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get `trace`
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub fn trace(&self) -> &[TraceEvent] {
|
||||||
|
&self.trace
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulate "plugin execution": check if path is allowed via FS read cap.
|
||||||
|
/// Logs to trace on success/error (outcome=false for errors).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`CapError`] if enforcement fails (e.g., no caps or mismatch).
|
||||||
|
pub fn execute_plugin<P: AsRef<Path>>(&mut self, path: P) -> Result<bool, CapError> {
|
||||||
|
let path_str = path.as_ref().to_string_lossy();
|
||||||
|
if path_str.is_empty() {
|
||||||
|
return Err(CapError::InvalidPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.manifest.capabilities.fs.is_none() {
|
||||||
|
self.log_cap_error(CapEventSubtype::NoFsCapability, "missing fs cap", &path_str);
|
||||||
|
return Err(CapError::NoFsCapability);
|
||||||
|
}
|
||||||
|
|
||||||
|
let read_patterns_ops = self
|
||||||
|
.manifest
|
||||||
|
.capabilities
|
||||||
|
.fs
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|fs| fs.read.clone());
|
||||||
|
|
||||||
|
let read_patterns = match read_patterns_ops {
|
||||||
|
Some(v) if !v.is_empty() => v,
|
||||||
|
_ => {
|
||||||
|
self.log_cap_error(
|
||||||
|
CapEventSubtype::NoReadPatterns,
|
||||||
|
"empty read patterns",
|
||||||
|
&path_str,
|
||||||
|
);
|
||||||
|
return Err(CapError::NoReadPatterns);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let seq = u64::try_from(self.trace.len()).map_or(1, |len| len + 1);
|
let seq = u64::try_from(self.trace.len()).map_or(1, |len| len + 1);
|
||||||
let path_str = path.as_ref().to_string_lossy();
|
|
||||||
|
|
||||||
let mut rng = StdRng::seed_from_u64(self.seed.wrapping_mul(PRIME_MULTIPLIER + seq));
|
let mut rng = StdRng::seed_from_u64(self.seed.wrapping_mul(PRIME_MULTIPLIER + seq));
|
||||||
let ts_seed = rng.r#gen();
|
let ts_seed = rng.r#gen();
|
||||||
|
|
||||||
let is_allowed = read_patterns.iter().any(|pattern| {
|
let is_allowed = read_patterns.iter().any(|pattern| {
|
||||||
Pattern::new(pattern)
|
Pattern::new(pattern).map_or_else(
|
||||||
.map(|p| p.matches(&path_str))
|
|_| {
|
||||||
.unwrap_or(false)
|
self.log_cap_error(CapEventSubtype::InvalidGlob, pattern, &path_str);
|
||||||
|
false
|
||||||
|
},
|
||||||
|
|p| p.matches(&path_str),
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if !is_allowed {
|
||||||
|
self.log_cap_error(
|
||||||
|
CapEventSubtype::GlobMismatch,
|
||||||
|
"no matching pattern",
|
||||||
|
&path_str,
|
||||||
|
);
|
||||||
|
return Err(CapError::GlobMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
log_trace_event(
|
log_trace_event(
|
||||||
seq,
|
seq,
|
||||||
"cap.call",
|
EventType::CapCall,
|
||||||
&path_str,
|
&path_str,
|
||||||
is_allowed,
|
true,
|
||||||
ts_seed,
|
ts_seed,
|
||||||
&self.manifest.plugin,
|
&self.manifest.plugin,
|
||||||
);
|
);
|
||||||
|
|
||||||
self.trace.push(TraceEvent {
|
self.trace.push(TraceEvent {
|
||||||
|
run_id: self.run_id.clone(),
|
||||||
seq,
|
seq,
|
||||||
event_type: "cap.call".into(),
|
event_type: EventType::CapCall,
|
||||||
input: path_str.into(),
|
input: path_str.into(),
|
||||||
outcome: is_allowed,
|
outcome: is_allowed,
|
||||||
ts_seed,
|
ts_seed,
|
||||||
});
|
});
|
||||||
|
|
||||||
is_allowed
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs the current trace JSON with the host keypair.
|
||||||
|
/// Computes SHA256 hash of trace for integrity.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`TraceError`] (serialization).
|
||||||
|
pub fn sign_current_trace(&mut self) -> Result<SignedTrace, TraceError> {
|
||||||
|
let trace_json = finalize_trace(&self.trace);
|
||||||
|
let mut hasher = Sha256::default();
|
||||||
|
hasher.update(trace_json.as_bytes());
|
||||||
|
let trace_hash = format!("{:x}", hasher.finalize());
|
||||||
|
|
||||||
|
let signature = self.keypair.sign(trace_hash.as_bytes()).to_bytes().to_vec();
|
||||||
|
|
||||||
|
Ok(SignedTrace::new(
|
||||||
|
self.run_id.clone(),
|
||||||
|
self.manifest_hash.clone(),
|
||||||
|
trace_json,
|
||||||
|
signature,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize trace to pretty JSON string
|
/// Serialize trace to pretty JSON string
|
||||||
@ -89,9 +200,35 @@ impl HostState {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// If file write fails (e.g., I/O error) or JSON serialization fails.
|
/// If file write fails (e.g., I/O error) or JSON serialization fails.
|
||||||
pub fn save_current_trace<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
pub fn save_current_trace<P: AsRef<Path>>(&self, path: P) -> Result<(), TraceError> {
|
||||||
save_trace(&self.trace, path)
|
save_trace(&self.trace, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn log_cap_error(&mut self, event_subtype: CapEventSubtype, reason: &str, path_str: &str) {
|
||||||
|
let seq = u64::try_from(self.trace.len()).map_or(1, |len| len + 1);
|
||||||
|
let mut rng = StdRng::seed_from_u64(self.seed.wrapping_mul(PRIME_MULTIPLIER + seq));
|
||||||
|
let ts_seed = rng.r#gen();
|
||||||
|
|
||||||
|
let event_type = EventType::from(event_subtype);
|
||||||
|
|
||||||
|
log_trace_event(
|
||||||
|
seq,
|
||||||
|
event_type,
|
||||||
|
path_str,
|
||||||
|
false,
|
||||||
|
ts_seed,
|
||||||
|
&self.manifest.plugin,
|
||||||
|
);
|
||||||
|
|
||||||
|
self.trace.push(TraceEvent {
|
||||||
|
run_id: self.run_id.clone(),
|
||||||
|
seq,
|
||||||
|
event_type,
|
||||||
|
input: format!("{event_subtype}: {reason}"),
|
||||||
|
outcome: false,
|
||||||
|
ts_seed,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_tracing() {
|
pub fn init_tracing() {
|
||||||
|
|||||||
108
src/lib.rs
108
src/lib.rs
@ -1,111 +1,3 @@
|
|||||||
pub mod host;
|
pub mod host;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod trace;
|
pub mod trace;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::{
|
|
||||||
host::{HostState, init_tracing},
|
|
||||||
manifest::load_manifest,
|
|
||||||
trace::{TraceEvent, load_trace},
|
|
||||||
};
|
|
||||||
use claims::{assert_ok, assert_some};
|
|
||||||
use ed25519_dalek::SigningKey;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use tempfile::tempdir;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tracing_init() {
|
|
||||||
init_tracing();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn manifest_loader() {
|
|
||||||
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
|
||||||
assert_eq!(manifest.plugin, "formatter-v1");
|
|
||||||
assert_eq!(manifest.version, "0.1");
|
|
||||||
assert_eq!(manifest.issued_by, "dev-team");
|
|
||||||
|
|
||||||
let caps = manifest.capabilities;
|
|
||||||
let fs_cap = assert_some!(caps.fs);
|
|
||||||
let read_patterns = assert_some!(fs_cap.read);
|
|
||||||
assert_eq!(read_patterns, vec!["./workspace/*"]);
|
|
||||||
let write_patterns = assert_some!(fs_cap.write);
|
|
||||||
assert!(write_patterns.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn host_enforcement_with_trace() {
|
|
||||||
init_tracing();
|
|
||||||
|
|
||||||
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
|
||||||
let fixed_seed = 12345;
|
|
||||||
let mut csprng = OsRng;
|
|
||||||
let keypair = SigningKey::generate(&mut csprng);
|
|
||||||
let mut host = HostState::new(manifest, fixed_seed, keypair);
|
|
||||||
|
|
||||||
// allowed - expect a tracing event
|
|
||||||
let out1 = host.execute_plugin("./workspace/config.toml");
|
|
||||||
assert!(out1);
|
|
||||||
// denied - event + entry
|
|
||||||
let out2 = host.execute_plugin("/etc/passwd");
|
|
||||||
assert!(!out2);
|
|
||||||
|
|
||||||
assert_eq!(host.trace.len(), 2);
|
|
||||||
|
|
||||||
let trace1 = assert_some!(host.trace.first());
|
|
||||||
assert_eq!(trace1.seq, 1);
|
|
||||||
assert_eq!(trace1.event_type, "cap.call");
|
|
||||||
assert_eq!(trace1.input, "./workspace/config.toml");
|
|
||||||
assert!(trace1.outcome);
|
|
||||||
assert_eq!(trace1.ts_seed, 8_166_419_713_379_829_776);
|
|
||||||
assert_ne!(trace1.ts_seed, 0);
|
|
||||||
|
|
||||||
let trace2 = assert_some!(host.trace.get(1));
|
|
||||||
assert_eq!(trace2.seq, 2);
|
|
||||||
assert_eq!(trace2.input, "/etc/passwd");
|
|
||||||
assert!(!trace2.outcome);
|
|
||||||
assert_eq!(trace2.ts_seed, 10_553_447_931_939_622_718);
|
|
||||||
assert_ne!(trace1.ts_seed, trace2.ts_seed);
|
|
||||||
|
|
||||||
let tmp_dir = tempdir().expect("Temp dir failed");
|
|
||||||
let tmp_path = tmp_dir.as_ref().join("trace.json");
|
|
||||||
|
|
||||||
assert_ok!(host.save_current_trace(&tmp_path), "Save failed");
|
|
||||||
|
|
||||||
let loaded_trace = assert_ok!(load_trace(tmp_path), "Load failed");
|
|
||||||
assert_eq!(loaded_trace.len(), 2);
|
|
||||||
|
|
||||||
let loaded_trace1 = assert_some!(loaded_trace.first());
|
|
||||||
assert_eq!(trace1, loaded_trace1);
|
|
||||||
|
|
||||||
let loaded_trace2 = assert_some!(loaded_trace.get(1));
|
|
||||||
assert_eq!(trace2, loaded_trace2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn trace_reproducibility() {
|
|
||||||
init_tracing();
|
|
||||||
|
|
||||||
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
|
||||||
|
|
||||||
let fixed_seed = 12345;
|
|
||||||
let mut csprng1 = OsRng;
|
|
||||||
let keypair1 = SigningKey::generate(&mut csprng1);
|
|
||||||
|
|
||||||
let mut host1 = HostState::new(manifest.clone(), fixed_seed, keypair1);
|
|
||||||
assert!(!host1.execute_plugin("./allowed.txt"));
|
|
||||||
let trace1 = host1.get_trace_json();
|
|
||||||
|
|
||||||
let mut csprng2 = OsRng;
|
|
||||||
let keypair2 = SigningKey::generate(&mut csprng2);
|
|
||||||
let mut host2 = HostState::new(manifest, fixed_seed, keypair2);
|
|
||||||
assert!(!host2.execute_plugin("./allowed.txt"));
|
|
||||||
let trace2 = host2.get_trace_json();
|
|
||||||
|
|
||||||
let parsed1 = assert_ok!(serde_json::from_str::<Vec<TraceEvent>>(&trace1));
|
|
||||||
let parsed2 = assert_ok!(serde_json::from_str::<Vec<TraceEvent>>(&trace2));
|
|
||||||
assert_eq!(parsed1, parsed2);
|
|
||||||
assert_eq!(parsed1[0].ts_seed, parsed2[0].ts_seed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use color_eyre::eyre::Result;
|
use glob::Pattern;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs::read_to_string;
|
use std::{fs::read_to_string, path::Path};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Prime for seq hashing to derive per-event RNG state
|
/// Prime for seq hashing to derive per-event RNG state
|
||||||
pub const PRIME_MULTIPLIER: u64 = 314_159;
|
pub const PRIME_MULTIPLIER: u64 = 314_159;
|
||||||
@ -31,13 +32,80 @@ pub struct CapabilityManifest {
|
|||||||
// TODO: add signature
|
// TODO: add signature
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads a capability manifest from a JSON file.
|
/// Errors from manifest loading/validation.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ManifestError {
|
||||||
|
#[error("IO error reading manifest: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("JSON deserialization failed: {0}")]
|
||||||
|
Deserialize(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Invalid plugin name: must be non-empty")]
|
||||||
|
InvalidPlugin,
|
||||||
|
|
||||||
|
#[error("Invalid version: must be non-empty")]
|
||||||
|
InvalidVersion,
|
||||||
|
|
||||||
|
#[error("Invalid issuer: must be non-empty")]
|
||||||
|
InvalidIssuer,
|
||||||
|
|
||||||
|
#[error("Invalid glob pattern at index {idx}: {pattern} - {err}")]
|
||||||
|
InvalidGlob {
|
||||||
|
idx: usize,
|
||||||
|
pattern: String,
|
||||||
|
err: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapabilityManifest {
|
||||||
|
/// Validates the manifest: non-empty fields and compilable glob patterns.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`ManifestError`] if invalid.
|
||||||
|
pub fn validate(&self) -> Result<(), ManifestError> {
|
||||||
|
if self.plugin.is_empty() {
|
||||||
|
return Err(ManifestError::InvalidPlugin);
|
||||||
|
}
|
||||||
|
if self.version.is_empty() {
|
||||||
|
return Err(ManifestError::InvalidVersion);
|
||||||
|
}
|
||||||
|
if self.issued_by.is_empty() {
|
||||||
|
return Err(ManifestError::InvalidIssuer);
|
||||||
|
}
|
||||||
|
if let Some(fs_cap) = &self.capabilities.fs
|
||||||
|
&& let Some(read_patterns) = &fs_cap.read
|
||||||
|
{
|
||||||
|
for (idx, pattern) in read_patterns.iter().enumerate() {
|
||||||
|
Pattern::new(pattern).map_err(|err| ManifestError::InvalidGlob {
|
||||||
|
idx,
|
||||||
|
pattern: pattern.clone(),
|
||||||
|
err: err.to_string(),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a capability manifest from a JSON file and validates it.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`ManifestError`] (IO, JSON, or validation failures).
|
||||||
|
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ManifestError> {
|
||||||
|
let json_str = read_to_string(path)?;
|
||||||
|
let manifest = serde_json::from_str::<Self>(&json_str)?;
|
||||||
|
manifest.validate()?;
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A think wrapper around `CapabilityManifest::load()`
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// - bubbles up `std::fs::read_to_string` and `serde_json::from_str` errors;
|
/// [`ManifestError`] (IO, JSON, or validation failures).
|
||||||
pub fn load_manifest(path: &str) -> Result<CapabilityManifest> {
|
pub fn load_manifest<P: AsRef<Path>>(path: P) -> Result<CapabilityManifest, ManifestError> {
|
||||||
let json_str = read_to_string(path)?;
|
CapabilityManifest::load(path)
|
||||||
let manifest = serde_json::from_str(&json_str)?;
|
|
||||||
Ok(manifest)
|
|
||||||
}
|
}
|
||||||
|
|||||||
126
src/trace.rs
126
src/trace.rs
@ -1,12 +1,14 @@
|
|||||||
use color_eyre::eyre::Result;
|
use base64::{Engine, engine::general_purpose};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{fs, path::Path};
|
use std::{fmt::Display, fs, path::Path, str::FromStr};
|
||||||
|
use thiserror::Error;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct TraceEvent {
|
pub struct TraceEvent {
|
||||||
|
pub run_id: String,
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
pub event_type: String,
|
pub event_type: EventType,
|
||||||
pub input: String,
|
pub input: String,
|
||||||
pub outcome: bool,
|
pub outcome: bool,
|
||||||
pub ts_seed: u64,
|
pub ts_seed: u64,
|
||||||
@ -20,12 +22,61 @@ pub struct SignedTrace {
|
|||||||
pub signature: String,
|
pub signature: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Errors from trace serialization/IO.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum TraceError {
|
||||||
|
#[error("JSON serialization failed: {0}")]
|
||||||
|
Serialize(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("Base64 encoding failed: {0}")]
|
||||||
|
Base64(#[from] base64::DecodeError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EventType {
|
||||||
|
CapCall,
|
||||||
|
CapError,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CapEventSubtype {
|
||||||
|
InvalidPath,
|
||||||
|
NoFsCapability,
|
||||||
|
NoReadPatterns,
|
||||||
|
GlobMismatch,
|
||||||
|
InvalidGlob,
|
||||||
|
// TODO: NetConnect, NetDeny, CpuQuotaExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignedTrace {
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
run_id: String,
|
||||||
|
manifest_hash: String,
|
||||||
|
trace_json: String,
|
||||||
|
signature: Vec<u8>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
run_id,
|
||||||
|
manifest_hash,
|
||||||
|
trace_json,
|
||||||
|
signature: general_purpose::STANDARD.encode(signature),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Save the current trace to a file as pretty JSON.
|
/// Save the current trace to a file as pretty JSON.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// If file write fails (e.g., I/O error) or JSON serialization fails.
|
/// [`TraceError`] (JSON or IO).
|
||||||
pub fn save_trace<P: AsRef<Path>>(trace: &[TraceEvent], path: P) -> Result<()> {
|
pub fn save_trace<P: AsRef<Path>>(trace: &[TraceEvent], path: P) -> Result<(), TraceError> {
|
||||||
let json_str = serde_json::to_string_pretty(trace)?;
|
let json_str = serde_json::to_string_pretty(trace)?;
|
||||||
fs::write(path, json_str)?;
|
fs::write(path, json_str)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -35,8 +86,8 @@ pub fn save_trace<P: AsRef<Path>>(trace: &[TraceEvent], path: P) -> Result<()> {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// If file read fails (e.g., I/O error) or JSON parsing fails.
|
/// [`TraceError`] (JSON or IO).
|
||||||
pub fn load_trace<P: AsRef<Path>>(path: P) -> Result<Vec<TraceEvent>> {
|
pub fn load_trace<P: AsRef<Path>>(path: P) -> Result<Vec<TraceEvent>, TraceError> {
|
||||||
let json_str = fs::read_to_string(path)?;
|
let json_str = fs::read_to_string(path)?;
|
||||||
let trace = serde_json::from_str(&json_str)?;
|
let trace = serde_json::from_str(&json_str)?;
|
||||||
Ok(trace)
|
Ok(trace)
|
||||||
@ -52,7 +103,7 @@ pub fn finalize_trace(trace: &[TraceEvent]) -> String {
|
|||||||
/// Log a trace event
|
/// Log a trace event
|
||||||
pub fn log_trace_event(
|
pub fn log_trace_event(
|
||||||
seq: u64,
|
seq: u64,
|
||||||
event_type: &str,
|
event_type: EventType,
|
||||||
input: &str,
|
input: &str,
|
||||||
outcome: bool,
|
outcome: bool,
|
||||||
ts_seed: u64,
|
ts_seed: u64,
|
||||||
@ -61,9 +112,66 @@ pub fn log_trace_event(
|
|||||||
info!(
|
info!(
|
||||||
seq = seq,
|
seq = seq,
|
||||||
ts_seed = ts_seed,
|
ts_seed = ts_seed,
|
||||||
event_type = event_type,
|
event_type = %event_type,
|
||||||
input = %input,
|
input = %input,
|
||||||
outcome = outcome,
|
outcome = outcome,
|
||||||
plugin = plugin,
|
plugin = plugin,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FromStr for EventType {
|
||||||
|
type Err = &'static str;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"cap.call" => Ok(Self::CapCall),
|
||||||
|
"cap.error" => Ok(Self::CapError),
|
||||||
|
_ => Err("Unknown event type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for EventType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let s = match self {
|
||||||
|
Self::CapCall => "cap.call",
|
||||||
|
Self::CapError => "cap.error",
|
||||||
|
};
|
||||||
|
f.write_str(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for CapEventSubtype {
|
||||||
|
type Err = &'static str;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"invalid_path" => Ok(Self::InvalidPath),
|
||||||
|
"no_fs_capability" => Ok(Self::NoFsCapability),
|
||||||
|
"no_read_patterns" => Ok(Self::NoReadPatterns),
|
||||||
|
"glob_mismatch" => Ok(Self::GlobMismatch),
|
||||||
|
"invalid_glob" => Ok(Self::InvalidGlob),
|
||||||
|
_ => Err("Unknown cap event subtype"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for CapEventSubtype {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let s = match self {
|
||||||
|
Self::InvalidPath => "invalid_path",
|
||||||
|
Self::NoFsCapability => "no_fs_capability",
|
||||||
|
Self::NoReadPatterns => "no_read_patterns",
|
||||||
|
Self::GlobMismatch => "glob_mismatch",
|
||||||
|
Self::InvalidGlob => "invalid_glob",
|
||||||
|
};
|
||||||
|
f.write_str(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CapEventSubtype> for EventType {
|
||||||
|
fn from(subtype: CapEventSubtype) -> Self {
|
||||||
|
match subtype {
|
||||||
|
CapEventSubtype::GlobMismatch => Self::CapCall,
|
||||||
|
_ => Self::CapError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
176
tests/captra.rs
Normal file
176
tests/captra.rs
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||||
|
use captra::{
|
||||||
|
host::{CapError, HostState, init_tracing},
|
||||||
|
manifest::{ManifestError, load_manifest},
|
||||||
|
trace::{EventType, TraceEvent, load_trace},
|
||||||
|
};
|
||||||
|
use claims::{assert_err, assert_matches, assert_ok, assert_some};
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use std::{fs::File, io::Write};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tracing_init() {
|
||||||
|
init_tracing();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_loader() {
|
||||||
|
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
||||||
|
assert_eq!(manifest.plugin, "formatter-v1");
|
||||||
|
assert_eq!(manifest.version, "0.1");
|
||||||
|
assert_eq!(manifest.issued_by, "dev-team");
|
||||||
|
|
||||||
|
let caps = manifest.capabilities;
|
||||||
|
let fs_cap = assert_some!(caps.fs);
|
||||||
|
let read_patterns = assert_some!(fs_cap.read);
|
||||||
|
assert_eq!(read_patterns, vec!["./workspace/*"]);
|
||||||
|
let write_patterns = assert_some!(fs_cap.write);
|
||||||
|
assert!(write_patterns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_validation_invalid() {
|
||||||
|
// Mock invalid manifest (empty plugin).
|
||||||
|
let invalid_json = r#"
|
||||||
|
{
|
||||||
|
"plugin": "",
|
||||||
|
"version": "0.1",
|
||||||
|
"capabilities": {
|
||||||
|
"fs": {
|
||||||
|
"read": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"issued_by": "dev"
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let tmp_dir = tempdir().expect("Temp dir failed");
|
||||||
|
let tmp_path = tmp_dir.as_ref().join("invalid.json");
|
||||||
|
{
|
||||||
|
let mut file = File::create(&tmp_path).expect("File creation failed");
|
||||||
|
file.write_all(invalid_json.as_bytes())
|
||||||
|
.expect("Write failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_err!(load_manifest(tmp_path), "Expected validation error");
|
||||||
|
|
||||||
|
let glob_invalid_json = r#"
|
||||||
|
{
|
||||||
|
"plugin": "test",
|
||||||
|
"version": "0.1",
|
||||||
|
"capabilities": {
|
||||||
|
"fs": {
|
||||||
|
"read": [
|
||||||
|
"["
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"issued_by": "dev"
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let tmp_path2 = tmp_dir.as_ref().join("invalid.json");
|
||||||
|
{
|
||||||
|
let mut file = File::create(&tmp_path2).expect("File creation failed");
|
||||||
|
file.write_all(glob_invalid_json.as_bytes())
|
||||||
|
.expect("Write failed");
|
||||||
|
}
|
||||||
|
let err = assert_err!(load_manifest(tmp_path2), "Expected validation error");
|
||||||
|
assert_matches!(
|
||||||
|
&err,
|
||||||
|
ManifestError::InvalidGlob { .. },
|
||||||
|
"Expected InvalidGlob, got: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_enforcement_with_trace() {
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
||||||
|
let fixed_seed = 12345;
|
||||||
|
let mut csprng = OsRng;
|
||||||
|
let keypair = SigningKey::generate(&mut csprng);
|
||||||
|
let mut host = HostState::new(manifest, fixed_seed, keypair);
|
||||||
|
|
||||||
|
assert_eq!(host.run_id(), "captra-run-12345");
|
||||||
|
|
||||||
|
// allowed - expect a tracing event
|
||||||
|
let out1 = assert_ok!(host.execute_plugin("./workspace/config.toml"));
|
||||||
|
assert!(out1);
|
||||||
|
// denied - event + entry
|
||||||
|
let out2 = assert_err!(host.execute_plugin("/etc/passwd"));
|
||||||
|
assert_eq!(out2, CapError::GlobMismatch);
|
||||||
|
|
||||||
|
{
|
||||||
|
assert_eq!(host.trace().len(), 2); // Both allowed/denied log to trace.
|
||||||
|
|
||||||
|
let trace1 = assert_some!(host.trace().get(0));
|
||||||
|
assert_eq!(trace1.seq, 1);
|
||||||
|
assert_eq!(trace1.event_type, EventType::CapCall);
|
||||||
|
assert_eq!(trace1.input, "./workspace/config.toml");
|
||||||
|
assert!(trace1.outcome);
|
||||||
|
assert_eq!(trace1.ts_seed, 8_166_419_713_379_829_776);
|
||||||
|
assert_ne!(trace1.ts_seed, 0);
|
||||||
|
|
||||||
|
let trace2 = assert_some!(host.trace().get(1));
|
||||||
|
assert_eq!(trace2.run_id, "captra-run-12345");
|
||||||
|
assert_eq!(trace2.seq, 2);
|
||||||
|
assert_eq!(trace2.event_type, EventType::CapCall);
|
||||||
|
assert!(!trace2.outcome);
|
||||||
|
assert!(trace2.input.starts_with("glob_mismatch: "));
|
||||||
|
assert_eq!(trace2.ts_seed, 10_553_447_931_939_622_718);
|
||||||
|
assert_ne!(trace1.ts_seed, trace2.ts_seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let signed = assert_ok!(host.sign_current_trace());
|
||||||
|
assert_eq!(signed.run_id, "captra-run-12345");
|
||||||
|
|
||||||
|
let sig_bytes = STANDARD.decode(&signed.signature).expect("Base64 decode");
|
||||||
|
assert_eq!(sig_bytes.len(), 64);
|
||||||
|
assert!(!signed.trace_json.is_empty()); // Nonempty JSON
|
||||||
|
|
||||||
|
// Save/load roundtrip
|
||||||
|
let tmp_dir = tempdir().expect("Temp dir failed");
|
||||||
|
let tmp_path = tmp_dir.as_ref().join("trace.json");
|
||||||
|
assert_ok!(host.save_current_trace(&tmp_path), "Save failed");
|
||||||
|
|
||||||
|
let loaded_trace = assert_ok!(load_trace(tmp_path), "Load failed");
|
||||||
|
assert_eq!(loaded_trace.len(), 2);
|
||||||
|
|
||||||
|
let trace1_refetched = assert_some!(host.trace().get(0));
|
||||||
|
let trace2_refetched = assert_some!(host.trace().get(1));
|
||||||
|
let loaded_trace1 = assert_some!(loaded_trace.get(0));
|
||||||
|
let loaded_trace2 = assert_some!(loaded_trace.get(1));
|
||||||
|
|
||||||
|
assert_eq!(trace1_refetched, loaded_trace1);
|
||||||
|
assert_eq!(trace2_refetched, loaded_trace2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_reproducibility() {
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
||||||
|
|
||||||
|
let fixed_seed = 12345;
|
||||||
|
let mut csprng1 = OsRng;
|
||||||
|
let keypair1 = SigningKey::generate(&mut csprng1);
|
||||||
|
|
||||||
|
let mut host1 = HostState::new(manifest.clone(), fixed_seed, keypair1);
|
||||||
|
let out1 = assert_err!(host1.execute_plugin("./allowed.txt"));
|
||||||
|
assert_eq!(out1, CapError::GlobMismatch);
|
||||||
|
let trace1 = host1.get_trace_json();
|
||||||
|
|
||||||
|
let mut csprng2 = OsRng;
|
||||||
|
let keypair2 = SigningKey::generate(&mut csprng2);
|
||||||
|
let mut host2 = HostState::new(manifest, fixed_seed, keypair2);
|
||||||
|
let out2 = assert_err!(host2.execute_plugin("./allowed.txt"));
|
||||||
|
assert_eq!(out2, CapError::GlobMismatch);
|
||||||
|
let trace2 = host2.get_trace_json();
|
||||||
|
|
||||||
|
let parsed1 = assert_ok!(serde_json::from_str::<Vec<TraceEvent>>(&trace1));
|
||||||
|
let parsed2 = assert_ok!(serde_json::from_str::<Vec<TraceEvent>>(&trace2));
|
||||||
|
assert_eq!(parsed1, parsed2);
|
||||||
|
assert_eq!(parsed1[0].ts_seed, parsed2[0].ts_seed);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user