crates/installer/nordisk-pairing/src/redact.rs
//! **What a collected log line may never carry** (0.3.0). The installer
//! stores every VM's boot log on the operator's machine; a line that carries
//! a secret is withheld there — replaced by its class and its size, never a
//! byte of the content. The rule moved here from private-gunnar-ops'
//! `xtask/src/bootnarr.rs` (the UDP boot narration's filter, retired with the
//! narration on 2026-09-24), so gunnar and holger share one filter.
//!
//! The classes, and why each one is here:
//!
//! * `cmdline` — the kernel command line carries `korp.args` with the
//! monetize public key and `clock_key=` on it, so ONE echoed cmdline
//! discloses the medium's whole configuration.
//! * `token` — any admin or API token, any `Bearer`, a password, a secret. Since 0.5.26
//! a line that only NAMES one is kept with its VALUES withheld in place (test3-holger-rs
//! 2026-10-22: "waiting for holger_console_token" was dropped whole for 40 minutes);
//! withheld whole still when it also carries a Stripe secret, key material, an
//! environment file or an environment record.
//! * `key` — any signing or private key material, and any SSH key line.
//! * `pubkey` — a PUBLIC verifying key. It discloses nothing; it is withheld
//! anyway, on the owner's rule, because it is not progress.
//! * `env` — the shape of a secrets file being read out.
//! * `envrecord` — an environment record (`NAME=value`, an upper-case variable):
//! a kernel `with environment:` line, a process printing its environment.
//! * `highentropy` — a long unbroken base64/hex run: what a key looks like
//! when nobody labelled it.
/// The class a line must be withheld under, or `None` when it may be kept.
pub fn secret_class(line: &str) -> Option<&'static str> {
let lower = line.to_ascii_lowercase();
if lower.contains("kernel command line") || lower.contains("/proc/cmdline") || lower.contains("korp.args") || lower.contains("korp.log_udp=") || lower.contains("korp.env.") || lower.contains("clock_key=") {
return Some("cmdline");
}
if lower.contains("token") || lower.contains("bearer ") || lower.contains("password") || lower.contains("secret") || stripe_secret(&lower) || has_url_userinfo(line) {
return Some("token");
}
if lower.contains("private key") || lower.contains("begin openssh") || lower.contains("ssh-ed25519") || lower.contains("ssh-rsa") || lower.contains("signing.key") || lower.contains("time_ed25519") {
return Some("key");
}
if lower.contains("pubkey") || lower.contains("public key") || lower.contains("sha256:") {
return Some("pubkey");
}
if lower.contains(".env") {
return Some("env");
}
if has_env_record(line) {
return Some("envrecord");
}
if longest_opaque_run(line) >= 40 {
return Some("highentropy");
}
None
}
/// A Stripe secret by its prefix (2026-09-28): a secret or restricted API key
/// (`sk_live_`, `sk_test_`, `rk_live_`, `rk_test_`) or a webhook signing
/// secret (`whsec_`). A real `whsec_` value is 32 base64 characters —
/// shorter than the `highentropy` run — and a test key can be shorter still,
/// so the prefix is the rule: the front logs monetize's lines now
/// (gunnar-front stderr capture), and monetize talks to Stripe.
fn stripe_secret(lower: &str) -> bool {
["sk_live_", "sk_test_", "rk_live_", "rk_test_", "whsec_"].iter().any(|p| lower.contains(p))
}
/// **An environment record**: a `NAME=value` token whose name is an upper-case
/// variable (3+ of `A-Z0-9_`, a letter first) — a kernel `with environment:` record,
/// or a process printing its environment. The value is what must not travel; a short
/// secret-shaped value escapes the `highentropy` run, so the shape is the rule
/// (the same rule gunnar-server's narration door and nordisk-narrate's kmsg follower keep).
fn has_env_record(line: &str) -> bool {
line.split_whitespace().any(|tok| {
let Some((name, value)) = tok.split_once('=') else { return false };
!value.is_empty() && name.len() >= 3 && name.starts_with(|c: char| c.is_ascii_uppercase()) && name.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
})
}
/// The longest unbroken run of base64/hex alphabet.
fn longest_opaque_run(line: &str) -> usize {
let mut best = 0usize;
let mut run = 0usize;
for c in line.chars() {
if c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' {
run += 1;
best = best.max(run);
} else {
run = 0;
}
}
best
}
/// `line` with every opaque base64/hex run of the `highentropy` length (40)
/// replaced by [`VALUE_WITHHELD`].
fn withhold_runs(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut run = String::new();
let flush = |run: &mut String, out: &mut String| {
if run.chars().count() >= 40 {
out.push_str(VALUE_WITHHELD);
} else {
out.push_str(run);
}
run.clear();
};
for c in line.chars() {
if c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' {
run.push(c);
} else {
flush(&mut run, &mut out);
out.push(c);
}
}
flush(&mut run, &mut out);
out
}
/// The text that stands in for a withheld line.
pub fn withheld(class: &str, bytes: usize) -> String {
format!("<<WITHHELD: a {class}-class line of {bytes} bytes was not recorded>>")
}
/// **The text that stands in for a RUN of withheld lines** of one class, in
/// a row (0.5.5): one line with the count, never one line each. A run of one
/// is [`withheld`] exactly.
pub fn withheld_run(class: &str, count: u64, bytes: u64) -> String {
if count == 1 {
return withheld(class, bytes as usize);
}
format!("<<WITHHELD: {count} {class}-class lines in a row, {bytes} bytes, were not recorded>>")
}
/// What stands in for a public key's VALUE inside a kept line (0.5.5).
pub const VALUE_WITHHELD: &str = "<withheld>";
/// Is `line` this module's own marker? A marker is kept as it is: cleaning
/// is idempotent, so the VM and the installer may both clean.
pub fn is_marker(line: &str) -> bool {
line.starts_with("<<WITHHELD: ")
}
/// What cleaning makes of one line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cleaned<'a> {
/// The line may be stored: itself, or itself with its public-key values
/// withheld in place.
Kept(std::borrow::Cow<'a, str>),
/// The whole line is withheld: only its class and its size may be.
Withheld { class: &'static str, bytes: usize },
}
/// **What may be stored of `line`** (0.5.5). A line of the `pubkey` class
/// is KEPT with every public-key value withheld inside it; a line of any
/// other class is withheld whole; a marker is kept as it is.
///
/// Why `pubkey` is redacted inside the line (MEASURED 2026-09-28, test2's
/// main VM: 8 502 of 20 008 held lines read back as a pubkey-class marker —
/// every ssh login and every audited RPC names the key's fingerprint): the
/// line that says "a key logged in" is progress; only the key is not. A
/// whole-line marker per login made the log unreadable and said nothing a
/// withheld value does not.
pub fn cleaned(line: &str) -> Cleaned<'_> {
if is_marker(line) {
return Cleaned::Kept(std::borrow::Cow::Borrowed(line));
}
match secret_class(line) {
None => Cleaned::Kept(std::borrow::Cow::Borrowed(line)),
Some("pubkey") => match redact_values(line) {
Some(kept) => Cleaned::Kept(std::borrow::Cow::Owned(kept)),
None => Cleaned::Kept(std::borrow::Cow::Borrowed(line)),
},
// An unlabelled opaque run (a hash, a digest — or a key nobody
// labelled) is withheld IN PLACE: the run never stored, the line kept.
Some("highentropy") => Cleaned::Kept(std::borrow::Cow::Owned(withhold_runs(line))),
// A line that NAMES a token, a bearer, a password or a secret is progress (0.5.26):
// only the VALUES in it are withheld, in place. Withheld whole still: a line that
// also carries a Stripe secret, key material, an environment file or record.
Some("token") => match token_line_kept(line) {
Some(kept) => Cleaned::Kept(std::borrow::Cow::Owned(kept)),
None => Cleaned::Withheld { class: "token", bytes: line.len() },
},
Some(class) => Cleaned::Withheld { class, bytes: line.len() },
}
}
/// **The line as it may be stored**: itself, itself with its public-key
/// values withheld in place, or the withheld marker. Idempotent.
pub fn clean(line: &str) -> std::borrow::Cow<'_, str> {
match cleaned(line) {
Cleaned::Kept(kept) => kept,
Cleaned::Withheld { class, bytes } => std::borrow::Cow::Owned(withheld(class, bytes)),
}
}
/// **Many lines as they may be stored** (0.5.5): each [`clean`]ed, and a run
/// of withheld lines of one class in a row folded into ONE counted marker
/// ([`withheld_run`]) — so redaction never multiplies lines, also for a VM
/// that holds its lines raw (before 0.5.5).
pub fn clean_lines<'a, I: IntoIterator<Item = &'a str>>(lines: I) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut run: Option<(&'static str, u64, u64)> = None;
for line in lines {
match cleaned(line) {
Cleaned::Kept(kept) => {
out.push(kept.into_owned());
run = None;
}
Cleaned::Withheld { class, bytes } => {
if let Some((c, n, b)) = run.as_mut() {
if *c == class {
*n += 1;
*b += bytes as u64;
let text = withheld_run(class, *n, *b);
if let Some(last) = out.last_mut() {
*last = text;
}
continue;
}
}
out.push(withheld(class, bytes));
run = Some((class, 1, bytes as u64));
}
}
}
out
}
/// A word that names a secret (`token`, `Bearer`, `password`, `secret`, `Authorization`,
/// `credential`, an API key): the word itself is a name, never a value.
fn names_a_secret(word: &str) -> bool {
let w = word.to_ascii_lowercase();
["token", "bearer", "password", "passwd", "secret", "authorization", "credential", "apikey", "api_key", "api-key"].iter().any(|n| w.contains(n))
}
/// **The plain words that may follow a secret's NAME** and are no value (`token is missing`,
/// `password incorrect`, `bearer expired`): after a bare naming word any other word is
/// withheld (the Gunnar session's review of 0.5.26: `password correcthorsebattery`).
const PLAIN_AFTER_A_NAME: &[&str] = &[
"a", "an", "the", "is", "was", "are", "were", "be", "been", "has", "have", "had", "it", "its", "this", "that", "and", "or", "not", "no", "for", "from", "to", "in", "of", "on", "at",
"by", "with", "as", "into", "over", "via", "missing", "incorrect", "accepted", "refused", "rejected", "expired", "unfed", "fed", "declared", "written", "set", "unset", "required", "invalid",
"empty", "ok", "file", "rotated", "revoked", "minted", "read", "loaded", "saved", "changed", "unchanged", "present", "absent", "given", "wrong", "failed", "denied", "mismatch", "too",
"short", "weak", "unknown", "yet", "never", "again", "now", "still", "already", "issued", "verified", "checked", "matched", "kept", "dropped", "cleared", "renewed", "sealed", "derived",
"offered", "waits", "waiting", "until", "after", "before", "since", "when", "while",
];
fn plain_after_a_name(word: &str) -> bool {
let w = word.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_ascii_lowercase();
PLAIN_AFTER_A_NAME.contains(&w.as_str())
}
/// The first `=` or `:` of `seg` that is no URL's `://`.
fn kv_sep(seg: &str) -> Option<usize> {
seg.char_indices().find(|&(i, c)| (c == '=' || c == ':') && !seg[i..].starts_with("://")).map(|(i, _)| i)
}
/// `seg` with the password of a URL's userinfo (`scheme://user:pass@host`) withheld.
fn withhold_userinfo(seg: &str) -> Option<String> {
let at = seg.find("://")? + 3;
let rest = &seg[at..];
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let auth = &rest[..end];
let amp = auth.rfind('@')?;
let colon = auth[..amp].find(':')?;
let pass = &auth[colon + 1..amp];
(!pass.is_empty() && pass != VALUE_WITHHELD).then(|| format!("{}{}{VALUE_WITHHELD}{}", &seg[..at], &auth[..=colon], &seg[at + amp..]))
}
/// Does `line` carry a URL with a password in its userinfo?
fn has_url_userinfo(line: &str) -> bool {
line.split_whitespace().any(|w| withhold_userinfo(w).is_some())
}
/// `w` with every secret-naming `k=v` / `k:v` VALUE withheld, a URL's query (`?`, `&`, `;`
/// parts) included, and a URL's userinfo password; `None` when nothing needed withholding.
fn withhold_kv_parts(w: &str) -> Option<String> {
let mut out = String::with_capacity(w.len());
let mut changed = false;
for (i, seg) in w.split(['?', '&', ';']).enumerate() {
if i > 0 {
// The delimiter this segment followed, as it was.
let at: usize = w.split(['?', '&', ';']).take(i).map(|s| s.len() + 1).sum::<usize>() - 1;
out.push_str(&w[at..at + 1]);
}
let seg = match withhold_userinfo(seg) {
Some(s) => {
changed = true;
std::borrow::Cow::Owned(s)
}
None => std::borrow::Cow::Borrowed(seg),
};
match kv_sep(&seg).filter(|&at| names_a_secret(&seg[..at])) {
Some(at) if !matches!(seg[at + 1..].trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | ',' | '}' | ')')), "" | VALUE_WITHHELD) => {
out.push_str(&seg[..=at]);
out.push_str(VALUE_WITHHELD);
changed = true;
}
_ => out.push_str(&seg),
}
}
changed.then_some(out)
}
/// **A `token`-class line as it may be kept**, or `None` when it must be withheld whole
/// (it also carries a Stripe secret, key material, an environment file or record). Every
/// value is replaced by [`VALUE_WITHHELD`]: the value of a secret-naming `k=v` / `k:v` (also
/// in a URL's query), a URL's userinfo password, the word after `Basic`, the word after a
/// secret-naming `k:`, any word after a bare secret-naming word that is no plain word
/// ([`PLAIN_AFTER_A_NAME`]), and any opaque run of 16 or more; a public key's value as
/// [`cleaned`] withholds it. Words are split on any whitespace, kept as it was. The names
/// stay: "waiting for holger_console_token" is kept.
fn token_line_kept(line: &str) -> Option<String> {
let lower = line.to_ascii_lowercase();
let key_material = lower.contains("private key") || lower.contains("begin openssh") || lower.contains("ssh-ed25519") || lower.contains("ssh-rsa") || lower.contains("signing.key") || lower.contains("time_ed25519");
if stripe_secret(&lower) || key_material || lower.contains(".env") || has_env_record(line) {
return None;
}
// The line as words and the whitespace between them, kept as it was.
let mut pieces: Vec<(bool, &str)> = Vec::new();
let mut start = 0;
let mut in_word = false;
for (i, c) in line.char_indices() {
let word_char = !c.is_whitespace();
if i > start && word_char != in_word {
pieces.push((in_word, &line[start..i]));
start = i;
}
in_word = word_char;
}
if start < line.len() {
pieces.push((in_word, &line[start..]));
}
let mut out = String::with_capacity(line.len());
let mut prev = "";
for (is_word, w) in pieces {
if !is_word {
out.push_str(w);
continue;
}
let bare = |x: &str| x.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_ascii_lowercase();
let has_alnum = w.chars().any(|c| c.is_ascii_alphanumeric());
let auth_scheme = matches!(bare(w).as_str(), "bearer" | "basic");
let prev_bare_name = names_a_secret(prev) && !prev.contains(['=', ':']);
let word = if w == VALUE_WITHHELD {
w.to_string()
} else if let Some(k) = withhold_kv_parts(w) {
k
} else if prev == "Basic" && has_alnum {
VALUE_WITHHELD.to_string()
} else if names_a_secret(prev) && prev.ends_with([':', '=']) && !auth_scheme && !names_a_secret(w) && has_alnum {
VALUE_WITHHELD.to_string()
} else if prev_bare_name && has_alnum && !names_a_secret(w) && !auth_scheme && !plain_after_a_name(w) {
VALUE_WITHHELD.to_string()
} else if longest_opaque_run(w) >= 16 {
VALUE_WITHHELD.to_string()
} else {
w.to_string()
};
out.push_str(&word);
prev = w;
}
Some(redact_values(&out).unwrap_or(out))
}
/// A word that names a public key (`pubkey`, `public_key`, `monetize_pubkey`,
/// `fingerprint`).
fn names_a_key(word: &str) -> bool {
let w = word.to_ascii_lowercase();
w.contains("pubkey") || w.contains("public_key") || w.contains("public-key") || w.contains("fingerprint")
}
/// A value that looks like key material: a `SHA256:` fingerprint or an
/// opaque base64/hex run of 16 or more.
fn looks_like_key(value: &str) -> bool {
value.to_ascii_lowercase().contains("sha256:") || longest_opaque_run(value) >= 16
}
/// A short word that may be a key's value when a key-naming word precedes
/// it: the base64/hex alphabet only, with at least one digit (`eb16`, not
/// `revoked`).
fn short_value(word: &str) -> bool {
!word.is_empty()
&& word.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | ':'))
&& word.chars().any(|c| c.is_ascii_digit())
}
/// The line with every public-key value replaced by [`VALUE_WITHHELD`]: a
/// `SHA256:` fingerprint, any long opaque run, the value of a key-naming
/// `k=v`, and a value-shaped word after a key-naming word or `public key`.
/// `None` when nothing needed withholding.
fn redact_values(line: &str) -> Option<String> {
let words: Vec<&str> = line.split(' ').collect();
let mut out: Vec<String> = Vec::with_capacity(words.len());
let mut after_name = false;
for (i, w) in words.iter().enumerate() {
let word = if *w == VALUE_WITHHELD {
(*w).to_string()
} else if let Some((k, v)) = w.split_once('=') {
let bare = v.trim_matches('"');
if !bare.is_empty() && bare != VALUE_WITHHELD && (names_a_key(k) || looks_like_key(bare)) {
format!("{k}={VALUE_WITHHELD}")
} else {
(*w).to_string()
}
} else if looks_like_key(w) || (after_name && short_value(w)) {
VALUE_WITHHELD.to_string()
} else {
(*w).to_string()
};
let public_key = i > 0 && words[i - 1].eq_ignore_ascii_case("public") && w.eq_ignore_ascii_case("key");
after_name = !w.contains('=') && (names_a_key(w.trim_end_matches(':')) || public_key);
out.push(word);
}
let kept = out.join(" ");
(kept != line).then_some(kept)
}
#[cfg(test)]
mod tests {
/// **A hash is not a secret, and a line is not its hash** (MEASURED
/// estate-box3 2026-09-30): every line with a 40-character run was
/// withheld WHOLE — every git push naming a commit, gunnar's
/// self-registration naming its plugin's blake3 ("67 highentropy-class
/// lines in a row" on main), so the installer never saw the step, and an
/// ERROR naming a hash would never reach the log gate. The run is withheld
/// IN PLACE; the line — its time, level, target and words — is kept.
#[test]
fn an_opaque_run_is_withheld_in_place_and_its_line_kept() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let l = format!("2026-09-30T07:00:00.1Z ERROR gunnar::store: push rejected for {sha}: object missing");
match cleaned(&l) {
Cleaned::Kept(k) => {
assert!(!k.contains(sha), "{k}");
assert!(k.contains("ERROR gunnar::store: push rejected for <withheld>: object missing"), "{k}");
}
w => panic!("the line was withheld whole: {w:?}"),
}
let digest = "dba8e30fe8af4a881572f52144afa386df347e80f5029066f695810784e751dd";
let reg = format!("2026-09-30T07:00:00.1Z INFO gunnar_server::self_register: plugin: ADDED — gunnar blake3 {digest} event=\"plugin.self_register\"");
let kept = clean(®);
assert!(kept.contains("plugin: ADDED — gunnar blake3 <withheld>") && !kept.contains(digest), "{kept}");
// An unlabelled key run is still never stored, whatever surrounds it.
let key = "AAAAC3NzaC1lZDI1NTE5AAAAIGx8r8b4v8v5r7q2Zk1pQ3aQ";
assert!(!clean(&format!("x {key} y")).contains(key));
assert_eq!(clean(&clean(®)), clean(®), "idempotent");
}
use super::*;
/// **A line that NAMES a token is progress; only a token's VALUE is withheld**
/// (test3-holger-rs 2026-10-22: the main stood 40+ minutes at `TICK stage=pairing`
/// and its whole installer log read `<<WITHHELD: 2 token-class lines in a row, 618
/// bytes, were not recorded>>` every 30 s — the box said what it waited for, by
/// name, and the filter dropped the line because the name has `token` in it).
#[test]
fn a_line_that_names_a_token_is_kept_with_only_the_values_withheld() {
let w = VALUE_WITHHELD;
for (line, kept) in [
// Names only: kept word for word.
("pairing: waiting for holger_console_token from the shop".to_string(), "pairing: waiting for holger_console_token from the shop".to_string()),
(
"pair status: main: step serve (try 81: waiting for the server: the boot waits for the installer to feed `holger_console_token` (/data/holger-console-token is declared and not written yet)) · not paired".to_string(),
"pair status: main: step serve (try 81: waiting for the server: the boot waits for the installer to feed `holger_console_token` (/data/holger-console-token is declared and not written yet)) · not paired".to_string(),
),
("REFUSED [holger-console-token-unfed] the installer never fed the token in 3600 s".to_string(), "REFUSED [holger-console-token-unfed] the installer never fed the token in 3600 s".to_string()),
("password incorrect for user admin".to_string(), "password incorrect for user admin".to_string()),
// Values: masked in place, the line kept.
("console: token=Q7zP2xY9abc accepted".to_string(), format!("console: token={w} accepted")),
("auth: Authorization: Bearer eyJhbGciOiJFZERTQSJ9 from 10.44.0.1".to_string(), format!("auth: Authorization: Bearer {w} from 10.44.0.1")),
("login: password: hunter2 refused".to_string(), format!("login: password: {w} refused")),
(r#"feed {"admin_token":"0123456789abcdef0123"} kept"#.to_string(), format!(r#"feed {{"admin_token":{w} kept"#)),
("fed the console token 9f8e7d6c5b4a39281706f5e4d3c2b1a0 to main".to_string(), format!("fed the console token {w} to main")),
] {
assert_eq!(secret_class(&line), Some("token"), "{line}");
assert_eq!(clean(&line), kept, "{line}");
assert_eq!(clean(&clean(&line)), clean(&line), "idempotent: {line}");
}
// Still withheld WHOLE: a Stripe secret, a key, an environment record, a cmdline.
for line in [
"monetize-server: stripe: POST /v1/checkout/sessions with sk_live_51HabcDEF token",
"GUNNAR_UI_ADMIN_TOKEN=xyz",
"reading GUNNAR_UI_ADMIN_TOKEN=abc from the env",
"token in -----BEGIN OPENSSH PRIVATE KEY-----",
"the secret is in /etc/monetize/monetize.env",
] {
assert!(clean(line).starts_with("<<WITHHELD: a token-class"), "{line} -> {}", clean(line));
}
// And a run of them is still one counted marker; a kept line breaks the run.
let out = clean_lines(["GUNNAR_UI_ADMIN_TOKEN=a", "GUNNAR_UI_ADMIN_TOKEN=b", "waiting for the token", "GUNNAR_UI_ADMIN_TOKEN=c"]);
assert_eq!(out, vec![withheld_run("token", 2, 46), "waiting for the token".to_string(), withheld("token", 23)]);
}
/// **The Gunnar session's review of 0.5.26** (2026-10-22): a secret in a URL's query or
/// userinfo, and a lower-case value with no digit after a bare naming word, were kept; a
/// tab did not separate words. Each is withheld now; a plain word after a name is kept.
#[test]
fn a_url_secret_a_plain_value_after_a_name_and_a_tab_separated_value_are_withheld() {
let w = VALUE_WITHHELD;
for (line, kept) in [
("GET https://x.example/cb?token=ab12cd&state=1 200".to_string(), format!("GET https://x.example/cb?token={w}&state=1 200")),
("redirect https://x.example/cb?state=1&access_token=Zz&client_secret=q;password=p".to_string(), format!("redirect https://x.example/cb?state=1&access_token={w}&client_secret={w};password={w}")),
("clone https://user:hunter@git.example/r.git".to_string(), format!("clone https://user:{w}@git.example/r.git")),
("login: password correcthorsebattery refused".to_string(), format!("login: password {w} refused")),
("secret letmein".to_string(), format!("secret {w}")),
("console token\tq7zp2xy9abc\tok".to_string(), format!("console token\t{w}\tok")),
("password\tincorrect for admin".to_string(), "password\tincorrect for admin".to_string()),
// Plain words after a name stay: the progress a person reads.
("the console token is missing: holger_console_token was never fed".to_string(), "the console token is missing: holger_console_token was never fed".to_string()),
("REFUSED the bearer expired at 18:12Z".to_string(), "REFUSED the bearer expired at 18:12Z".to_string()),
// Gunnar's UI admin token and console bearer (hex / base64 runs) are withheld; the
// request id and its short value are kept.
(
"INFO gunnar_ui::auth: admin token 3f9a8b7c6d5e4f30a1b2c3d4e5f60718 issued x-gunnar-request-id=r-4711".to_string(),
format!("INFO gunnar_ui::auth: admin token {w} issued x-gunnar-request-id=r-4711"),
),
(
"WARN gunnar_front: console bearer dGhpc2lzYXZlcnlsb25nYmFzZTY0c2VjcmV0 refused x-gunnar-request-id: r-4712".to_string(),
format!("WARN gunnar_front: console bearer {w} refused x-gunnar-request-id: r-4712"),
),
] {
assert_eq!(clean(&line), kept, "{line}");
assert_eq!(clean(&clean(&line)), clean(&line), "idempotent: {line}");
}
}
#[test]
fn progress_is_kept_and_every_secret_class_is_withheld_without_its_content() {
assert_eq!(clean("korp-start: stage net up at 6.34 s"), "korp-start: stage net up at 6.34 s");
for (line, class) in [
("[ 0.000000] Kernel command line: korp.args=monetize_pubkey=abc", "cmdline"),
("GUNNAR_UI_ADMIN_TOKEN=xyz", "token"),
("-----BEGIN OPENSSH PRIVATE KEY-----", "key"),
("reading /etc/monetize/monetize.env", "env"),
// Stripe's secrets by prefix: a whsec_ value is shorter than the
// highentropy run, a test key shorter still.
("monetize-server: stripe: POST /v1/checkout/sessions with sk_live_51Habc", "token"),
("monetize-server: hmac key whsec_5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH", "token"),
("monetize-server: vendor key rk_test_abc", "token"),
] {
assert_eq!(secret_class(line), Some(class), "{line}");
let c = clean(line);
assert!(c.starts_with("<<WITHHELD: a ") && c.contains(class) && !c.contains(line), "{c}");
assert_eq!(clean(&c), c, "a marker cleans to itself");
}
// An unlabelled key run: classed highentropy, and withheld IN PLACE —
// nothing of it stored, the line (here: nothing else) kept.
let run = "AAAAC3NzaC1lZDI1NTE5AAAAIGx8r8b4v8v5r7q2Zk1pQ3a";
assert_eq!(secret_class(run), Some("highentropy"));
assert_eq!(clean(run), VALUE_WITHHELD);
}
/// **An environment record is withheld by the SHARED filter** (release-3 DEV-NARRATE
/// review, redact-no-envrecord): gunnar-server's own narration door withheld
/// `envrecord` and `korp.env.` lines, this filter — the one the log door and every
/// other path use — kept them: a kernel `with environment:` record, or any line that
/// prints `NAME=value` for an upper-case variable, went out with its value. A short,
/// secret-shaped value passes the `highentropy` run, so the shape is the rule.
#[test]
fn an_environment_record_is_withheld_and_names_no_value() {
for (line, class) in [
("kmsg 6,368,90210,-;with environment: HOME=/ TERM=linux KORP_INSTALLER_ROOT=/run/installer", "envrecord"),
("kmsg 6,369,90211,-;with environment: MONETIZE_VERIFY_KEY=MCowBQYDK2VwAyEA", "envrecord"),
("box: STRIPE_ACCOUNT=acct_1Qx9 MONETIZE_SEAL=q7Zp2", "envrecord"),
("app @4s korp.env.GUNNAR_DATA_ROOT applied", "cmdline"),
] {
assert_eq!(secret_class(line), Some(class), "{line}");
let c = clean(line);
assert!(c.starts_with("<<WITHHELD: a ") && c.contains(class) && !c.contains("q7Zp2") && !c.contains("MCowBQ"), "{c}");
}
// Lower-case structured fields are a tracing line's own, not an environment.
assert_eq!(secret_class(r#"INFO gunnar::audit: served event="grpc.call" decision="allow" elapsed_ms=3"#), None);
assert_eq!(secret_class("HTTP/1.1 200 OK"), None);
}
#[test]
fn a_pubkey_line_is_kept_with_its_values_withheld_in_place() {
let w = VALUE_WITHHELD;
for (line, kept) in [
("fed monetize pubkey eb16".to_string(), format!("fed monetize pubkey {w}")),
(
"WARN gunnar::audit: event=\"ssh.auth.accepted\" fingerprint=SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU principal=admin".to_string(),
format!("WARN gunnar::audit: event=\"ssh.auth.accepted\" fingerprint={w} principal=admin"),
),
("feed: monetize_pubkey=abc public key 1f2e3d4c".to_string(), format!("feed: monetize_pubkey={w} public key {w}")),
("public key revoked".to_string(), "public key revoked".to_string()),
] {
assert_eq!(secret_class(&line), Some("pubkey"), "{line}");
let c = clean(&line);
assert_eq!(c, kept);
assert_eq!(clean(&c), c, "idempotent: the VM and the installer both clean");
}
}
#[test]
fn a_run_of_withheld_lines_is_one_counted_marker() {
// Lines withheld WHOLE (an environment record naming a token): a line that only
// names a token is kept since 0.5.26.
let lines = ["boot", "A_TOKEN=1", "B_TOKEN=22", "reading x.env", "up", "C_TOKEN=3", "D_TOKEN=4"];
let out = clean_lines(lines);
assert_eq!(
out,
vec![
"boot".to_string(),
withheld_run("token", 2, 19),
withheld("env", 13),
"up".to_string(),
withheld_run("token", 2, 18),
]
);
assert!(out.iter().all(|l| clean(l) == l.as_str()), "markers clean to themselves");
assert_eq!(withheld_run("key", 1, 7), withheld("key", 7));
}
}