switchyard/logging/
redact.rs

1use crate::types::plan::ApplyMode;
2use serde_json::Value;
3use time::format_description::well_known::Rfc3339;
4use time::OffsetDateTime;
5
6pub const TS_ZERO: &str = "1970-01-01T00:00:00Z";
7
8#[must_use]
9pub fn now_iso() -> String {
10    OffsetDateTime::now_utc()
11        .format(&Rfc3339)
12        .unwrap_or_else(|_| TS_ZERO.to_string())
13}
14
15/// Return a timestamp for facts emission based on mode.
16/// - `DryRun`: constant zero timestamp for determinism.
17/// - Commit: real, current timestamp in RFC3339.
18#[must_use]
19pub fn ts_for_mode(mode: &ApplyMode) -> String {
20    match mode {
21        ApplyMode::DryRun => TS_ZERO.to_string(),
22        ApplyMode::Commit => now_iso(),
23    }
24}
25
26/// Apply redactions to a fact event for comparison and safe logging.
27/// Currently zeroes timestamps to `TS_ZERO` and removes volatile fields that
28/// could leak secrets in tests. Extend as policy evolves.
29#[must_use]
30pub fn redact_event(mut v: Value) -> Value {
31    if let Some(obj) = v.as_object_mut() {
32        obj.insert("ts".into(), Value::String(TS_ZERO.to_string()));
33        // Remove or normalize volatile timings (keep lock_wait_ms for assertions)
34        obj.remove("duration_ms");
35        // Normalize fsync_ms for deterministic comparisons across DryRun and Commit
36        if obj.contains_key("fsync_ms") {
37            obj.insert("fsync_ms".into(), Value::from(0));
38        }
39        // Remove volatile flags derived from runtime conditions (keep degraded for assertions)
40        obj.remove("severity");
41        // Keep topology descriptors for assertions in tests (do not remove)
42        // Remove content-hash fields for determinism gating (kept in raw logs)
43        obj.remove("before_hash");
44        obj.remove("after_hash");
45        obj.remove("hash_alg");
46        // Keep dry_run/redacted flags; these are asserted in audit tests
47        // Placeholder secret masking: if provenance.helper exists, replace with "***"
48        if let Some(p) = obj.get_mut("provenance") {
49            if let Some(pobj) = p.as_object_mut() {
50                if pobj.contains_key("helper") {
51                    pobj.insert("helper".into(), Value::String("***".into()));
52                }
53            }
54        }
55        // Attestations are preserved, but bundle_hash/public_key_id may vary; mask if present
56        if let Some(att) = obj.get_mut("attestation") {
57            if let Some(aobj) = att.as_object_mut() {
58                if aobj.contains_key("bundle_hash") {
59                    aobj.insert("bundle_hash".into(), Value::String("***".into()));
60                }
61                if aobj.contains_key("public_key_id") {
62                    aobj.insert("public_key_id".into(), Value::String("***".into()));
63                }
64                if aobj.contains_key("signature") {
65                    aobj.insert("signature".into(), Value::String("***".into()));
66                }
67            }
68        }
69    }
70    v
71}
72
73#[cfg(test)]
74#[allow(clippy::panic)]
75mod tests {
76    use super::*;
77    use serde_json::json;
78
79    #[test]
80    fn redact_masks_and_removes_expected_fields() {
81        let input = json!({
82            "ts": "2025-01-01T12:00:00Z",
83            "duration_ms": 123,
84            "lock_wait_ms": 45,
85            "severity": "warn",
86            "degraded": true,
87            "before_hash": "abc",
88            "after_hash": "def",
89            "hash_alg": "sha256",
90            "provenance": {"helper":"paru", "uid": 0, "gid": 0, "pkg": "coreutils"},
91            "attestation": {"signature":"sig","bundle_hash":"bh","public_key_id":"pk"}
92        });
93        let out = redact_event(input);
94        assert_eq!(out.get("ts").and_then(|v| v.as_str()), Some(TS_ZERO));
95        assert!(out.get("duration_ms").is_none());
96        // We keep lock_wait_ms and degraded in redacted output for test assertions
97        assert_eq!(out.get("lock_wait_ms").and_then(Value::as_i64), Some(45));
98        assert!(out.get("severity").is_none());
99        assert_eq!(out.get("degraded").and_then(Value::as_bool), Some(true));
100        assert!(out.get("before_hash").is_none());
101        assert!(out.get("after_hash").is_none());
102        assert!(out.get("hash_alg").is_none());
103        let prov = out
104            .get("provenance")
105            .and_then(|v| v.as_object())
106            .unwrap_or_else(|| panic!("provenance should be an object"));
107        assert_eq!(prov.get("helper").and_then(|v| v.as_str()), Some("***"));
108        let att = out
109            .get("attestation")
110            .and_then(|v| v.as_object())
111            .unwrap_or_else(|| panic!("attestation should be an object"));
112        assert_eq!(att.get("signature").and_then(|v| v.as_str()), Some("***"));
113        assert_eq!(att.get("bundle_hash").and_then(|v| v.as_str()), Some("***"));
114        assert_eq!(
115            att.get("public_key_id").and_then(|v| v.as_str()),
116            Some("***")
117        );
118    }
119}