feat(cli/org): status + audit (verified-signer attribution, TAMPERED flag, committer-date framing)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2172,6 +2172,7 @@ dependencies = [
|
|||||||
"predicates",
|
"predicates",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"rand",
|
"rand",
|
||||||
|
"regex",
|
||||||
"relicario-core",
|
"relicario-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ rqrr = "0.7"
|
|||||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
qrcode = { version = "0.14", features = ["svg"] }
|
qrcode = { version = "0.14", features = ["svg"] }
|
||||||
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||||
|
regex = "1"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
predicates = "3"
|
predicates = "3"
|
||||||
tempfile = "3"
|
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
ed25519-dalek = "2"
|
ed25519-dalek = "2"
|
||||||
|
|||||||
@@ -441,11 +441,261 @@ pub fn run_rotate_key(dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn run_status(dir: &Path) -> Result<()> {
|
||||||
|
let root = crate::org_session::org_dir(Some(dir))?;
|
||||||
|
|
||||||
|
let meta: relicario_core::OrgMeta = {
|
||||||
|
let s = fs::read_to_string(root.join("org.json")).context("read org.json")?;
|
||||||
|
serde_json::from_str(&s)?
|
||||||
|
};
|
||||||
|
let members: OrgMembers = {
|
||||||
|
let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
|
||||||
|
serde_json::from_str(&s)?
|
||||||
|
};
|
||||||
|
let collections: OrgCollections = {
|
||||||
|
let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?;
|
||||||
|
serde_json::from_str(&s)?
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Org: {} ({})", meta.display_name, meta.org_id.as_str());
|
||||||
|
println!();
|
||||||
|
println!("Members ({}):", members.members.len());
|
||||||
|
for m in &members.members {
|
||||||
|
let colls = if m.collections.is_empty() {
|
||||||
|
"(no collections)".to_string()
|
||||||
|
} else {
|
||||||
|
m.collections.join(", ")
|
||||||
|
};
|
||||||
|
println!(" {:?} {} {} [{}]", m.role, m.member_id.as_str(), m.display_name, colls);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!("Collections ({}):", collections.collections.len());
|
||||||
|
for c in &collections.collections {
|
||||||
|
println!(" {} — {}", c.slug, c.display_name);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct AuditEvent {
|
||||||
|
pub commit: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
/// Actor as resolved from the VERIFIED signing key (authoritative).
|
||||||
|
pub actor_name: Option<String>,
|
||||||
|
pub actor_id: Option<String>,
|
||||||
|
/// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
|
||||||
|
pub trailer_actor_id: Option<String>,
|
||||||
|
pub action: Option<String>,
|
||||||
|
pub collection: Option<String>,
|
||||||
|
pub item_id: Option<String>,
|
||||||
|
pub device_id: Option<String>,
|
||||||
|
/// True when the trailer's claimed actor disagrees with the verified signer,
|
||||||
|
/// or when no current member matches the signing key.
|
||||||
|
pub tampered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent {
|
||||||
|
let mut ev = AuditEvent {
|
||||||
|
commit: commit.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
actor_name: None,
|
||||||
|
actor_id: None,
|
||||||
|
trailer_actor_id: None,
|
||||||
|
action: None,
|
||||||
|
collection: None,
|
||||||
|
item_id: None,
|
||||||
|
device_id: None,
|
||||||
|
tampered: false,
|
||||||
|
};
|
||||||
|
for line in trailers.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if let Some(rest) = line.strip_prefix("Relicario-Actor:") {
|
||||||
|
// Contract format: "<name> <member_id>" (member_id is the last token).
|
||||||
|
let rest = rest.trim();
|
||||||
|
if let Some((_name, id)) = rest.rsplit_once(' ') {
|
||||||
|
ev.trailer_actor_id = Some(id.trim().to_string());
|
||||||
|
} else if !rest.is_empty() {
|
||||||
|
ev.trailer_actor_id = Some(rest.to_string());
|
||||||
|
}
|
||||||
|
} else if let Some(v) = line.strip_prefix("Relicario-Action:") {
|
||||||
|
ev.action = Some(v.trim().to_string());
|
||||||
|
} else if let Some(v) = line.strip_prefix("Relicario-Collection:") {
|
||||||
|
ev.collection = Some(v.trim().to_string());
|
||||||
|
} else if let Some(v) = line.strip_prefix("Relicario-Item:") {
|
||||||
|
ev.item_id = Some(v.trim().to_string());
|
||||||
|
} else if let Some(v) = line.strip_prefix("Relicario-Device:") {
|
||||||
|
ev.device_id = Some(v.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ev
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a commit's SSH signature fingerprint to a current member, mirroring
|
||||||
|
/// the pre-receive hook: build an allowed_signers from members.json, inject it
|
||||||
|
/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from
|
||||||
|
/// stderr. Returns None if the commit is unsigned or the signer is not a member.
|
||||||
|
fn resolve_signer<'m>(
|
||||||
|
root: &Path,
|
||||||
|
commit: &str,
|
||||||
|
members: &'m relicario_core::OrgMembers,
|
||||||
|
) -> Option<&'m relicario_core::OrgMember> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut tmp = tempfile::NamedTempFile::new().ok()?;
|
||||||
|
for m in &members.members {
|
||||||
|
let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim());
|
||||||
|
}
|
||||||
|
let allowed_path = tmp.path();
|
||||||
|
|
||||||
|
let output = std::process::Command::new("git")
|
||||||
|
.current_dir(root)
|
||||||
|
.args(["verify-commit", "--raw", commit])
|
||||||
|
.env("GIT_CONFIG_COUNT", "1")
|
||||||
|
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
||||||
|
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|
||||||
|
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
|
||||||
|
let fp = re.captures(&stderr)?.get(1)?.as_str().to_string();
|
||||||
|
|
||||||
|
members.members.iter().find(|m| {
|
||||||
|
relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_audit(
|
||||||
|
dir: &Path,
|
||||||
|
since: Option<&str>,
|
||||||
|
member_filter: Option<&str>,
|
||||||
|
collection_filter: Option<&str>,
|
||||||
|
action_filter: Option<&str>,
|
||||||
|
format: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Spec surface is `--format <table|json>` (default table). Accept only those.
|
||||||
|
let json = match format {
|
||||||
|
"json" => true,
|
||||||
|
"table" => false,
|
||||||
|
other => anyhow::bail!("unknown --format `{other}` — use table or json"),
|
||||||
|
};
|
||||||
|
let root = crate::org_session::org_dir(Some(dir))?;
|
||||||
|
|
||||||
|
// members.json — needed to resolve each commit's verified signer to a member.
|
||||||
|
let members: relicario_core::OrgMembers = {
|
||||||
|
let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
|
||||||
|
serde_json::from_str(&s).context("parse members.json")?
|
||||||
|
};
|
||||||
|
|
||||||
|
// git log framed with a record separator (%x1e, U+001E) PER COMMIT and a
|
||||||
|
// field separator (%x1f, U+001F) between fields, so multi-line trailer
|
||||||
|
// values cannot misalign record boundaries. Committer date (%cI), not
|
||||||
|
// author date: it is what revocation/audit is anchored to.
|
||||||
|
let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)";
|
||||||
|
let mut args: Vec<String> = vec!["log".into(), format!("--format={fmt}")];
|
||||||
|
if let Some(s) = since {
|
||||||
|
args.push(format!("--since={s}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = std::process::Command::new("git")
|
||||||
|
.current_dir(&root)
|
||||||
|
.args(&args)
|
||||||
|
.output()
|
||||||
|
.context("git log")?;
|
||||||
|
let log = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
let mut events: Vec<AuditEvent> = Vec::new();
|
||||||
|
for record in log.split('\u{1e}') {
|
||||||
|
let record = record.trim_start_matches('\n');
|
||||||
|
if record.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut fields = record.splitn(3, '\u{1f}');
|
||||||
|
let commit = fields.next().unwrap_or("").trim();
|
||||||
|
let ts = fields.next().unwrap_or("").trim();
|
||||||
|
let trailers = fields.next().unwrap_or("");
|
||||||
|
if commit.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ev = parse_trailer_block(commit, ts, trailers);
|
||||||
|
if ev.action.is_none() {
|
||||||
|
continue; // not an org commit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the VERIFIED signer and attribute it as the authoritative actor.
|
||||||
|
match resolve_signer(&root, commit, &members) {
|
||||||
|
Some(m) => {
|
||||||
|
ev.actor_name = Some(m.display_name.clone());
|
||||||
|
ev.actor_id = Some(m.member_id.as_str().to_string());
|
||||||
|
// Tampered if the trailer claims a different actor than the signer.
|
||||||
|
if let Some(claimed) = ev.trailer_actor_id.as_deref() {
|
||||||
|
if claimed != m.member_id.as_str() {
|
||||||
|
ev.tampered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// No current member matched the signature -> cannot trust the
|
||||||
|
// trailer's claimed actor.
|
||||||
|
ev.tampered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mid) = member_filter {
|
||||||
|
// Filter on the VERIFIED actor id, not the spoofable trailer.
|
||||||
|
if ev.actor_id.as_deref() != Some(mid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(col) = collection_filter {
|
||||||
|
if ev.collection.as_deref() != Some(col) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(act) = action_filter {
|
||||||
|
if ev.action.as_deref() != Some(act) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events.push(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&events)?);
|
||||||
|
} else {
|
||||||
|
println!("{:<44} {:<26} {:<20} {:<18} {}", "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG");
|
||||||
|
for ev in &events {
|
||||||
|
println!("{:<44} {:<26} {:<20} {:<18} {}",
|
||||||
|
ev.commit,
|
||||||
|
ev.timestamp,
|
||||||
|
ev.action.as_deref().unwrap_or("-"),
|
||||||
|
ev.actor_name.as_deref().unwrap_or("<unverified>"),
|
||||||
|
if ev.tampered { "TAMPERED" } else { "" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};
|
use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_trailers_extracts_relicario_fields() {
|
||||||
|
// Contract trailer shape: "Relicario-Actor: <name> <member_id>".
|
||||||
|
let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n";
|
||||||
|
let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw);
|
||||||
|
assert_eq!(event.action.as_deref(), Some("item-create"));
|
||||||
|
assert_eq!(event.collection.as_deref(), Some("prod"));
|
||||||
|
// The verified actor_id is resolved later from the signature, not the trailer;
|
||||||
|
// the trailer only populates trailer_actor_id here.
|
||||||
|
assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
|
||||||
|
assert_eq!(event.actor_id, None);
|
||||||
|
assert!(!event.tampered);
|
||||||
|
}
|
||||||
|
|
||||||
fn alice() -> OrgMember {
|
fn alice() -> OrgMember {
|
||||||
OrgMember {
|
OrgMember {
|
||||||
member_id: MemberId::new(),
|
member_id: MemberId::new(),
|
||||||
|
|||||||
Reference in New Issue
Block a user