Skip to main content

kovra_wrapper/
sanitize.rs

1//! Output sanitization — the **margin defense** of §5.1.
2//!
3//! After a child runs, the Wrapper may mask any verbatim occurrence of an
4//! injected secret value in the child's stdout/stderr before returning it to the
5//! caller (and thence, possibly, to the agent). This catches *naive*
6//! exfiltration — `print(os.environ['DB_PASSWORD'])` — and nothing more.
7//!
8//! **This is a net, never a boundary.** It does not catch obfuscated
9//! exfiltration (base64, reversal, splitting, encryption) and must never be
10//! presented as security. The real containment for `high`/`prod` is the executor
11//! allowlist (§5.1, I15) plus the attended prompt that shows the resolved
12//! command — not this masking.
13
14/// The replacement written in place of a matched secret value.
15pub const MASK: &[u8] = b"***";
16
17/// Return a copy of `data` with every verbatim occurrence of each value in
18/// `secrets` replaced by [`MASK`]. Empty secrets are skipped (they would match
19/// everywhere).
20pub fn mask_secrets(data: &[u8], secrets: &[&[u8]]) -> Vec<u8> {
21    let mut out = data.to_vec();
22    for secret in secrets {
23        if secret.is_empty() {
24            continue;
25        }
26        out = replace_bytes(&out, secret, MASK);
27    }
28    out
29}
30
31/// Replace every non-overlapping occurrence of `needle` in `haystack` with `rep`.
32fn replace_bytes(haystack: &[u8], needle: &[u8], rep: &[u8]) -> Vec<u8> {
33    if needle.is_empty() || needle.len() > haystack.len() {
34        return haystack.to_vec();
35    }
36    let mut out = Vec::with_capacity(haystack.len());
37    let mut i = 0;
38    while i < haystack.len() {
39        if i + needle.len() <= haystack.len() && &haystack[i..i + needle.len()] == needle {
40            out.extend_from_slice(rep);
41            i += needle.len();
42        } else {
43            out.push(haystack[i]);
44            i += 1;
45        }
46    }
47    out
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn masks_naive_occurrences() {
56        let out = mask_secrets(b"connecting with hunter2 now", &[b"hunter2"]);
57        assert_eq!(out, b"connecting with *** now");
58        assert!(!String::from_utf8_lossy(&out).contains("hunter2"));
59    }
60
61    #[test]
62    fn masks_multiple_secrets_and_repeats() {
63        let out = mask_secrets(b"a=AAA b=BBB a=AAA", &[b"AAA", b"BBB"]);
64        assert_eq!(out, b"a=*** b=*** a=***");
65    }
66
67    #[test]
68    fn empty_secret_is_ignored() {
69        let out = mask_secrets(b"untouched", &[b""]);
70        assert_eq!(out, b"untouched");
71    }
72
73    #[test]
74    fn does_not_catch_obfuscated_exfiltration() {
75        // Documents the limitation: base64 of the secret slips through (by design;
76        // this is a net, not a boundary).
77        let out = mask_secrets(b"aHVudGVyMg==", &[b"hunter2"]);
78        assert_eq!(out, b"aHVudGVyMg==");
79    }
80}