use crate::error::WITHHELD;
use crate::json::OValue;
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
#[derive(Clone, Default)]
pub struct SecretSet {
forms: Arc<Mutex<Forms>>,
}
#[derive(Default)]
struct Forms {
set: BTreeSet<String>,
exact: Option<Matcher>,
folded: Option<Matcher>,
steps: Steps,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct Steps(#[cfg(test)] u64);
#[cfg(not(test))]
const _: () = assert!(std::mem::size_of::<Steps>() == 0);
impl Steps {
#[inline]
fn tick(&mut self) {
#[cfg(test)]
{
self.0 += 1;
}
}
#[inline]
fn add(&mut self, other: Self) {
#[cfg(test)]
{
self.0 += other.0;
}
#[cfg(not(test))]
let _ = other;
}
}
impl Forms {
fn found_in(&mut self, text: &str, folded: bool) -> bool {
let set = &self.set;
let matcher = if folded {
self.folded.get_or_insert_with(|| {
Matcher::new(
set.iter()
.map(|form| form.to_ascii_lowercase().into_bytes()),
)
})
} else {
self.exact.get_or_insert_with(|| {
Matcher::new(set.iter().map(|form| form.as_bytes().to_vec()))
})
};
let (found, steps) = matcher.find(text.as_bytes());
self.steps.add(steps);
found
}
}
struct Matcher {
nodes: Vec<Node>,
}
#[derive(Default)]
struct Node {
next: Vec<(u8, usize)>,
fail: usize,
end: bool,
}
impl Matcher {
fn new(patterns: impl Iterator<Item = Vec<u8>>) -> Self {
let mut nodes = vec![Node::default()];
for pattern in patterns {
let mut at = 0;
for byte in pattern {
at = match nodes[at].next.binary_search_by_key(&byte, |(b, _)| *b) {
Ok(found) => nodes[at].next[found].1,
Err(slot) => {
nodes.push(Node::default());
let child = nodes.len() - 1;
nodes[at].next.insert(slot, (byte, child));
child
}
};
}
nodes[at].end = true;
}
let mut queue = std::collections::VecDeque::from([0]);
while let Some(at) = queue.pop_front() {
for (byte, child) in nodes[at].next.clone() {
let fail = if at == 0 {
0
} else {
let mut link = nodes[at].fail;
loop {
if let Some(target) = Self::child(&nodes, link, byte) {
break target;
}
if link == 0 {
break 0;
}
link = nodes[link].fail;
}
};
nodes[child].fail = fail;
nodes[child].end |= nodes[fail].end;
queue.push_back(child);
}
}
Self { nodes }
}
fn child(nodes: &[Node], at: usize, byte: u8) -> Option<usize> {
let next = &nodes[at].next;
next.binary_search_by_key(&byte, |(b, _)| *b)
.ok()
.map(|found| next[found].1)
}
fn find(&self, text: &[u8]) -> (bool, Steps) {
let mut steps = Steps::default();
let mut at = 0;
if self.nodes[0].end {
return (true, steps);
}
for &byte in text {
loop {
steps.tick();
if let Some(next) = Self::child(&self.nodes, at, byte) {
at = next;
break;
}
if at == 0 {
break;
}
at = self.nodes[at].fail;
}
if self.nodes[at].end {
return (true, steps);
}
}
(false, steps)
}
}
impl SecretSet {
fn forms(&self) -> MutexGuard<'_, Forms> {
self.forms.lock().unwrap_or_else(PoisonError::into_inner)
}
fn push(&self, candidate: String) {
let mut forms = self.forms();
if forms.set.insert(candidate) {
forms.exact = None;
forms.folded = None;
}
}
#[cfg(test)]
pub fn form_count(&self) -> usize {
self.forms().set.len()
}
#[cfg(test)]
pub fn steps(&self) -> u64 {
self.forms().steps.0
}
pub fn add(&self, value: &str) {
for candidate in [value, crate::tenant::config::js_trim(value)] {
if candidate.is_empty() {
continue;
}
let encoded: String =
url::form_urlencoded::byte_serialize(candidate.as_bytes()).collect();
for form in [
candidate.to_owned(),
encoded,
base64(candidate.as_bytes(), STANDARD_ALPHABET),
base64(candidate.as_bytes(), URL_SAFE_ALPHABET),
] {
self.push(form);
}
}
}
pub fn add_basic_credentials(&self, client_id: &str, client_secret: &str) {
let form = |text: &str| -> String {
url::form_urlencoded::byte_serialize(text.as_bytes()).collect()
};
for (id, secret) in [
(client_id, client_secret),
(
crate::tenant::config::js_trim(client_id),
crate::tenant::config::js_trim(client_secret),
),
] {
if secret.is_empty() {
continue;
}
for pair in [
format!("{id}:{secret}"),
format!("{}:{}", form(id), form(secret)),
] {
for candidate in [
base64(pair.as_bytes(), STANDARD_ALPHABET),
base64(pair.as_bytes(), URL_SAFE_ALPHABET),
pair,
] {
self.push(candidate);
}
}
}
}
pub fn register_config(&self, config: &crate::tenant::config::TenantConfig) {
for secret in config.secret_values() {
self.add(secret);
}
if let (Some(id), Some(secret)) = (
config.effective_str("mgmtClientId"),
config.effective_str("mgmtClientSecret"),
) {
self.add_basic_credentials(id, secret);
}
}
pub fn taints(&self, text: &str) -> bool {
let mut forms = self.forms();
forms.found_in(text, false)
|| (text.len() >= 4 && forms.set.iter().any(|form| form.contains(text)))
}
pub fn carried_by(&self, text: &str) -> bool {
self.forms().found_in(text, false)
}
pub fn appears_in_ignoring_case(&self, text: &str) -> bool {
self.forms().found_in(&text.to_ascii_lowercase(), true)
}
pub fn snapshot(&self) -> Vec<String> {
self.forms().set.iter().cloned().collect()
}
pub fn redact(&self, text: &str) -> String {
let mut forms = self.snapshot();
forms.sort_by_key(|form| std::cmp::Reverse(form.len()));
let mut out = text.to_owned();
for form in forms {
if !form.is_empty() && out.contains(form.as_str()) {
out = out.replace(form.as_str(), WITHHELD);
}
}
out
}
pub fn scrub<'a>(&self, text: &'a str) -> &'a str {
if self.taints(text) { WITHHELD } else { text }
}
}
const STANDARD_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const URL_SAFE_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
pub(crate) fn base64_standard(bytes: &[u8]) -> String {
base64(bytes, STANDARD_ALPHABET)
}
fn base64(bytes: &[u8], alphabet: &[u8; 64]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [
chunk[0],
chunk.get(1).copied().unwrap_or(0),
chunk.get(2).copied().unwrap_or(0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
let symbols = chunk.len() + 1;
for index in 0..symbols {
let shift = 18 - 6 * index;
out.push(char::from(alphabet[((n >> shift) & 0x3f) as usize]));
}
}
out
}
pub fn withhold(value: &mut OValue, secrets: &SecretSet) {
match value {
OValue::String(text) if secrets.carried_by(text) => *text = WITHHELD.to_owned(),
OValue::Object(entries) => {
entries.retain(|(key, _)| !secrets.carried_by(key));
for (_, member) in entries {
withhold(member, secrets);
}
}
OValue::Array(items) => {
for item in items {
withhold(item, secrets);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_member_named_by_a_secret_is_dropped() {
let secrets = SecretSet::default();
secrets.add("FAKE-K1-SECRET");
let mut doc = crate::json::parse(
r#"{"FAKE-K1-SECRET":"v","a":[{"x FAKE-K1-SECRET":{"deep":1},"b":"FAKE-K1-SECRET","K1":2}],"name":"n"}"#,
)
.unwrap();
super::withhold(&mut doc, &secrets);
assert_eq!(
crate::json::to_compact(&doc).unwrap(),
r#"{"a":[{"b":"[withheld]","K1":2}],"name":"n"}"#
);
}
use super::*;
#[test]
fn the_matcher_agrees_with_contains_per_form() {
let forms = ["abcd", "bcde", "cd", "xbc", "aab", "é€x", "zzzzzz"];
let matcher = Matcher::new(forms.iter().map(|form| form.as_bytes().to_vec()));
let texts = [
"", "a", "ab", "abc", "abce", "xabcd", "xbcde", "aaab", "aaaa", "c d", "acd", "xbx",
"xxbc", "é€", "aé€xb", "zzzzz", "zzzzzzz", "bcdf", "abxbcd",
];
for text in texts {
let expected = forms.iter().any(|form| text.contains(form));
let (found, steps) = matcher.find(text.as_bytes());
assert_eq!(found, expected, "{text:?}");
assert!(
steps.0 <= 2 * text.len() as u64,
"{text:?}: {steps:?} steps"
);
}
assert!(!Matcher::new(std::iter::empty()).find(b"anything").0);
}
#[test]
fn forms_join_once_and_every_read_sees_the_latest() {
let secrets = SecretSet::default();
secrets.add("s3cr3t-value");
let count = secrets.form_count();
secrets.add("s3cr3t-value");
assert_eq!(secrets.form_count(), count, "no duplicate forms");
assert!(secrets.carried_by("x s3cr3t-value y"));
assert!(!secrets.carried_by("another-value"));
assert!(!secrets.appears_in_ignoring_case("ANOTHER-VALUE"));
secrets.add("another-value");
assert!(secrets.carried_by("another-value"));
assert!(secrets.appears_in_ignoring_case("ANOTHER-VALUE"));
}
#[test]
fn the_forms_are_one_ordered_set() {
let secrets = SecretSet::default();
for form in ["zz-secret-2", "aa-secret-1", "zz-secret-2", "mm-secret-3"] {
secrets.push(form.to_owned());
}
assert_eq!(
secrets.snapshot(),
["aa-secret-1", "mm-secret-3", "zz-secret-2"]
);
assert_eq!(secrets.form_count(), 3);
secrets.push("aa-secret-1-longer".to_owned());
assert_eq!(
secrets.redact("x aa-secret-1-longer y"),
format!("x {WITHHELD} y")
);
}
#[test]
fn only_taints_reads_fragments() {
let secrets = SecretSet::default();
secrets.add("FAKE-FRAGMENT-SECRET-1");
assert!(secrets.taints("FRAGMENT"));
assert!(!secrets.carried_by("FRAGMENT"));
assert!(!secrets.taints("FRA"));
}
#[test]
fn the_step_counter_counts_reads() {
let secrets = SecretSet::default();
secrets.add("FAKE-STEP-SECRET-1");
let before = secrets.steps();
assert!(!secrets.carried_by("abcdefgh"));
let steps = secrets.steps() - before;
assert!((8..=16).contains(&steps), "{steps}");
}
#[test]
fn the_secret_set_is_its_own_module() {
let root = env!("CARGO_MANIFEST_DIR");
let error = std::fs::read_to_string(format!("{root}/src/error.rs")).unwrap();
for item in [
"struct SecretSet",
"struct Forms",
"struct Matcher",
"fn base64(",
] {
assert!(!error.contains(item), "error.rs still defines {item}");
}
assert!(error.contains("pub use crate::secrets::"));
let design = std::fs::read_to_string(format!("{root}/docs/DESIGN.md")).unwrap();
assert!(
design.contains("\nsrc/secrets.rs "),
"DESIGN §11 names src/secrets.rs"
);
}
#[test]
fn secret_forms() {
let secrets = SecretSet::default();
secrets.add(" a b/c+ ");
assert!(secrets.taints("x a b/c+ y"));
assert!(secrets.taints("a+b%2Fc%2B"));
assert!(secrets.taints(&base64(b"a b/c+", STANDARD_ALPHABET)));
assert!(!secrets.taints("unrelated"));
assert_eq!(
base64(b"FAKE-SECRET-100", STANDARD_ALPHABET),
"RkFLRS1TRUNSRVQtMTAw"
);
assert_eq!(base64(b"ab", STANDARD_ALPHABET), "YWI");
assert_eq!(base64(b"\xfb\xff", URL_SAFE_ALPHABET), "-_8");
}
#[test]
fn line_check_ignores_case() {
let secrets = SecretSet::default();
secrets.add("FAKE-Secret-100");
assert!(secrets.appears_in_ignoring_case(r#"{"fake-secret-100":"***"}"#));
assert!(secrets.appears_in_ignoring_case("x FAKE-SECRET-100 y"));
assert!(secrets.appears_in_ignoring_case(
&base64(b"FAKE-Secret-100", STANDARD_ALPHABET).to_lowercase()
));
assert!(!secrets.appears_in_ignoring_case("fake-secret-10"));
}
#[test]
fn basic_credential_forms() {
let secrets = SecretSet::default();
secrets.add_basic_credentials("client 100", "FAKE/SECRET 100");
assert!(secrets.taints(&format!(
"x{}y",
base64(b"client 100:FAKE/SECRET 100", STANDARD_ALPHABET)
)));
assert!(secrets.taints(&base64(b"client+100:FAKE%2FSECRET+100", STANDARD_ALPHABET)));
assert!(secrets.taints(&base64(b"client+100:FAKE%2FSECRET+100", URL_SAFE_ALPHABET)));
assert!(!secrets.taints("unrelated"));
let empty = SecretSet::default();
empty.add_basic_credentials("client-100", "");
assert!(!empty.taints(&base64(b"client-100:", STANDARD_ALPHABET)));
}
}