Skip to main content

majordomus_cli/execution/
redact.rs

1//! One redaction, driven by the schema.
2//!
3//! A value a client sent is stored on the execution, published in `execution.created`,
4//! rendered in the Cockpit and printed by the command line. That is four places a secret
5//! could leak from, so it is removed once — here, before the value is stored — and every
6//! consumer downstream is reading an already-safe value rather than remembering to be
7//! careful.
8//!
9//! What counts as sensitive is the input type's own schema, never a guess from a field's
10//! name: `format: "password"`, `writeOnly: true`, or the explicit
11//! `x-majordomus-sensitive: true` extension. A Rust type declares it where it declares
12//! everything else a projection reads:
13//!
14//! ```rust
15//! # use schemars::JsonSchema;
16//! /// Credentials for a provider.
17//! #[derive(JsonSchema)]
18//! struct Credentials {
19//!     /// Who.
20//!     user: String,
21//!     /// The secret; never stored, never published, never logged.
22//!     #[schemars(extend("format" = "password"))]
23//!     token: String,
24//! }
25//! ```
26
27use serde_json::{Map, Value};
28
29/// What a sensitive value is replaced with. A marker rather than an empty string, so that
30/// a reader can tell "withheld" from "not given".
31pub const REDACTED: &str = "«redacted»";
32
33/// The deepest a value is walked. A schema and a value that recur past this are copied as
34/// they are rather than examined, which cannot leak more than the input already carried.
35const MAX_DEPTH: usize = 32;
36
37/// The input as it may be stored: every value the schema marks sensitive replaced.
38///
39/// ```
40/// use majordomus_cli::execution::redact::{redact, REDACTED};
41/// use serde_json::json;
42/// let schema = json!({
43///     "type": "object",
44///     "properties": {
45///         "user": { "type": "string" },
46///         "token": { "type": "string", "format": "password" }
47///     }
48/// });
49/// let safe = redact(&schema, &json!({ "user": "ada", "token": "hunter2" }));
50/// assert_eq!(safe["user"], "ada");
51/// assert_eq!(safe["token"], REDACTED);
52/// ```
53pub fn redact(schema: &Value, value: &Value) -> Value {
54    walk(schema, schema, value, 0)
55}
56
57/// Is a value described by this schema sensitive?
58fn sensitive(schema: &Value) -> bool {
59    schema.get("format").and_then(Value::as_str) == Some("password")
60        || schema.get("writeOnly") == Some(&Value::Bool(true))
61        || schema.get("x-majordomus-sensitive") == Some(&Value::Bool(true))
62}
63
64/// Follow one `$ref` into the root's `$defs`, once; anything else is returned unchanged.
65fn resolve<'a>(root: &'a Value, schema: &'a Value) -> &'a Value {
66    let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
67        return schema;
68    };
69    reference
70        .strip_prefix("#/$defs/")
71        .and_then(|name| root.get("$defs").and_then(|d| d.get(name)))
72        .unwrap_or(schema)
73}
74
75fn walk(root: &Value, schema: &Value, value: &Value, depth: usize) -> Value {
76    if depth >= MAX_DEPTH {
77        return value.clone();
78    }
79    let schema = resolve(root, schema);
80    if sensitive(schema) {
81        return Value::String(REDACTED.to_string());
82    }
83    match value {
84        Value::Object(members) => {
85            let properties = schema.get("properties").and_then(Value::as_object);
86            let additional = schema.get("additionalProperties");
87            let mut out = Map::with_capacity(members.len());
88            for (key, member) in members {
89                let property = properties
90                    .and_then(|p| p.get(key))
91                    .or(additional.filter(|a| a.is_object()));
92                match property {
93                    Some(p) => out.insert(key.clone(), walk(root, p, member, depth + 1)),
94                    None => out.insert(key.clone(), member.clone()),
95                };
96            }
97            Value::Object(out)
98        }
99        Value::Array(items) => match schema.get("items") {
100            Some(item_schema) => Value::Array(
101                items
102                    .iter()
103                    .map(|i| walk(root, item_schema, i, depth + 1))
104                    .collect(),
105            ),
106            None => value.clone(),
107        },
108        other => other.clone(),
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serde_json::json;
116
117    #[test]
118    fn every_way_a_schema_can_say_sensitive_is_honoured() {
119        for marker in [
120            json!({ "type": "string", "format": "password" }),
121            json!({ "type": "string", "writeOnly": true }),
122            json!({ "type": "string", "x-majordomus-sensitive": true }),
123        ] {
124            let schema = json!({ "type": "object", "properties": { "s": marker } });
125            assert_eq!(redact(&schema, &json!({ "s": "secret" }))["s"], REDACTED);
126        }
127        let plain = json!({ "type": "object", "properties": { "s": { "type": "string" } } });
128        assert_eq!(redact(&plain, &json!({ "s": "public" }))["s"], "public");
129    }
130
131    #[test]
132    fn a_name_that_looks_like_a_secret_is_not_one() {
133        // the rule is the schema, never the field name: a "password_policy" that is a
134        // document name would be destroyed by a name-matching redactor
135        let schema = json!({
136            "type": "object",
137            "properties": { "password_policy": { "type": "string" } }
138        });
139        let safe = redact(&schema, &json!({ "password_policy": "docs/PASSWORDS.md" }));
140        assert_eq!(safe["password_policy"], "docs/PASSWORDS.md");
141    }
142
143    #[test]
144    fn nesting_refs_arrays_and_maps_are_all_reached() {
145        let schema = json!({
146            "type": "object",
147            "properties": {
148                "creds": { "$ref": "#/$defs/Credentials" },
149                "many": { "type": "array", "items": { "$ref": "#/$defs/Credentials" } },
150                "map": { "type": "object", "additionalProperties": { "$ref": "#/$defs/Credentials" } }
151            },
152            "$defs": {
153                "Credentials": {
154                    "type": "object",
155                    "properties": {
156                        "user": { "type": "string" },
157                        "token": { "type": "string", "format": "password" }
158                    }
159                }
160            }
161        });
162        let value = json!({
163            "creds": { "user": "a", "token": "t1" },
164            "many": [{ "user": "b", "token": "t2" }],
165            "map": { "k": { "user": "c", "token": "t3" } },
166            "unknown": "kept"
167        });
168        let safe = redact(&schema, &value);
169        assert_eq!(safe["creds"]["token"], REDACTED);
170        assert_eq!(safe["creds"]["user"], "a");
171        assert_eq!(safe["many"][0]["token"], REDACTED);
172        assert_eq!(safe["map"]["k"]["token"], REDACTED);
173        assert_eq!(
174            safe["unknown"], "kept",
175            "a value the schema does not describe is kept"
176        );
177        assert!(!serde_json::to_string(&safe).unwrap().contains("t1"));
178        assert!(!serde_json::to_string(&safe).unwrap().contains("t3"));
179    }
180
181    #[test]
182    fn a_value_that_is_not_an_object_and_a_schema_that_says_nothing_survive() {
183        assert_eq!(redact(&json!({}), &json!(5)), json!(5));
184        assert_eq!(redact(&json!(true), &json!("x")), json!("x"));
185        assert_eq!(redact(&json!({}), &json!([1, 2])), json!([1, 2]));
186        // a broken reference is not followed and does not panic
187        let schema =
188            json!({ "type": "object", "properties": { "a": { "$ref": "#/$defs/Missing" } } });
189        assert_eq!(redact(&schema, &json!({ "a": "x" }))["a"], "x");
190    }
191
192    #[test]
193    fn deep_recursion_terminates() {
194        let mut schema =
195            json!({ "type": "object", "properties": { "next": { "$ref": "#/$defs/Node" } } });
196        schema["$defs"] = json!({ "Node": { "type": "object", "properties": { "next": { "$ref": "#/$defs/Node" } } } });
197        let mut value = json!({ "leaf": true });
198        for _ in 0..64 {
199            value = json!({ "next": value });
200        }
201        let safe = redact(&schema, &value);
202        assert!(safe.is_object());
203    }
204}