use kernel::B3Hash;
use serde_json::{Map, Value};
const REDACTED_REALM: &str = "redacted";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Marker {
Plain,
Fingerprinted,
}
impl Marker {
#[must_use]
pub fn spell(self, found: &[u8]) -> String {
match self {
Marker::Plain => format!("secret:{REDACTED_REALM}"),
Marker::Fingerprinted => {
format!("secret:{REDACTED_REALM}/{}", fingerprint(found))
}
}
}
}
#[must_use]
pub fn redact(payload: &Map<String, Value>) -> (Map<String, Value>, u32) {
let mut hits = 0;
let mut out = Map::new();
for (key, value) in payload {
out.insert(key.clone(), walk(value, &mut hits));
}
(out, hits)
}
fn walk(value: &Value, hits: &mut u32) -> Value {
match value {
Value::String(text) => {
let (replaced, found) = redact_text(text, Marker::Fingerprinted);
*hits = hits.saturating_add(found);
Value::String(replaced)
}
Value::Array(items) => Value::Array(items.iter().map(|item| walk(item, hits)).collect()),
Value::Object(map) => {
let mut out = Map::new();
for (key, item) in map {
out.insert(key.clone(), walk(item, hits));
}
Value::Object(out)
}
Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
}
}
#[must_use]
pub fn redact_text(text: &str, marker: Marker) -> (String, u32) {
let bytes = text.as_bytes();
let spans = kernel::secret::scan(bytes);
if spans.is_empty() {
return (text.to_owned(), 0);
}
let mut out = String::with_capacity(text.len());
let mut at = 0usize;
let mut hits: u32 = 0;
for span in spans {
let end = span.start.saturating_add(span.len);
if span.start < at || end > bytes.len() {
continue;
}
let Some(before) = bytes
.get(at..span.start)
.and_then(|b| std::str::from_utf8(b).ok())
else {
continue;
};
let Some(found) = bytes.get(span.start..end) else {
continue;
};
out.push_str(before);
out.push_str(&marker.spell(found));
at = end;
hits = hits.saturating_add(1);
}
if let Some(rest) = bytes.get(at..).and_then(|b| std::str::from_utf8(b).ok()) {
out.push_str(rest);
}
(out, hits)
}
const FINGERPRINT_HEX: usize = 16;
#[must_use]
pub fn fingerprint(found: &[u8]) -> String {
let digest = B3Hash::digest(found).to_string();
digest.get(..FINGERPRINT_HEX).unwrap_or(&digest).to_owned()
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "test code"
)]
mod tests {
use super::*;
fn key_shaped() -> String {
["sk", "9fQ2xZ", "7Lm4Rt", "0Bv8Kd", "3Wp6"].join("")
}
#[test]
fn a_key_shaped_run_of_bytes_does_not_reach_the_payload() {
let key = key_shaped();
let mut payload = Map::new();
payload.insert(
"message".to_owned(),
Value::String(format!("the token is {key} and it works")),
);
let (redacted, hits) = redact(&payload);
assert_eq!(hits, 1);
let text = redacted
.get("message")
.and_then(Value::as_str)
.unwrap()
.to_owned();
assert!(!text.contains(&key), "the value itself is gone");
assert!(text.contains("the token is "), "the sentence survives");
assert!(text.contains("secret:redacted/"));
}
#[test]
fn the_same_value_twice_marks_the_same_way_and_two_values_do_not() {
let key = key_shaped();
let other = ["sk", "2Tg7Yh", "4Nn1Qs", "8Cz5Ud", "6Ke0"].join("");
let (first, _) = redact_text(&key, Marker::Fingerprinted);
let (again, _) = redact_text(&key, Marker::Fingerprinted);
let (different, _) = redact_text(&other, Marker::Fingerprinted);
assert_eq!(first, again, "one value, one marker");
assert_ne!(first, different, "two values are distinguishable");
}
#[test]
fn nested_strings_are_reached_and_ordinary_prose_is_untouched() {
let key = key_shaped();
let mut inner = Map::new();
inner.insert("text".to_owned(), Value::String(key.clone()));
let mut payload = Map::new();
payload.insert(
"content".to_owned(),
Value::Array(vec![Value::Object(inner)]),
);
payload.insert(
"prose".to_owned(),
Value::String("nothing here looks like a key at all".to_owned()),
);
payload.insert("count".to_owned(), Value::Number(3.into()));
let (redacted, hits) = redact(&payload);
assert_eq!(hits, 1);
assert_eq!(
redacted.get("prose").and_then(Value::as_str),
Some("nothing here looks like a key at all"),
"prose is not a shape, and a scanner that trimmed it would be unusable"
);
assert_eq!(redacted.get("count").and_then(Value::as_u64), Some(3));
let text = serde_json::to_string(&redacted).unwrap();
assert!(!text.contains(&key));
}
#[test]
fn a_marker_is_a_reference_a_reader_can_follow_to_an_honest_nothing() {
let key = key_shaped();
let short = fingerprint(key.as_bytes());
assert_eq!(short.len(), 16);
let (marked, hits) = redact_text(&key, Marker::Fingerprinted);
assert_eq!(hits, 1);
assert_eq!(marked, format!("secret:redacted/{short}"));
let reference = kernel::SecretRef::parse(&marked).unwrap();
assert_eq!(reference.realm(), REDACTED_REALM);
assert_eq!(reference.name(), short);
}
#[test]
fn a_plain_marker_says_that_something_was_here_and_nothing_more() {
let key = key_shaped();
let (line, hits) = redact_text(&format!("token {key} used"), Marker::Plain);
assert_eq!(hits, 1);
assert_eq!(line, "token secret:redacted used");
assert!(!line.contains(&fingerprint(key.as_bytes())));
let (kept, _) = redact_text(&format!("token {key} used"), Marker::Fingerprinted);
assert!(kept.contains(&fingerprint(key.as_bytes())));
}
#[test]
fn a_payload_with_nothing_to_hide_comes_back_unchanged() {
let mut payload = Map::new();
payload.insert(
"message".to_owned(),
Value::String("measured in metres".to_owned()),
);
let (redacted, hits) = redact(&payload);
assert_eq!(hits, 0);
assert_eq!(redacted, payload);
}
}