use super::names::{Leaf, registered, registrable};
pub const CREDENTIAL_LOOK: usize = 16;
pub const PASSWORD_LOOK: usize = 8;
const WORD_SEPARATORS: [char; 5] = ['-', '_', '.', '/', ':'];
pub fn looks_like_credential(text: &str) -> bool {
text.chars().count() >= CREDENTIAL_LOOK
&& !text.chars().any(char::is_whitespace)
&& !is_date(text)
}
pub fn looks_like_password(text: &str) -> bool {
text.chars().count() >= PASSWORD_LOOK && password_shaped(text)
}
fn password_shaped(text: &str) -> bool {
if text.chars().any(char::is_whitespace) || text.contains("://") || is_date(text) {
return false;
}
text.chars()
.any(|c| !c.is_alphanumeric() && !WORD_SEPARATORS.contains(&c))
|| text.split(WORD_SEPARATORS).any(|part| {
part.chars().any(|c| c.is_ascii_digit()) && part.chars().any(char::is_alphabetic)
})
}
fn digits(text: &str, len: usize) -> bool {
text.len() == len && text.bytes().all(|byte| byte.is_ascii_digit())
}
pub fn is_date(text: &str) -> bool {
let Some(date) = text.get(..10) else {
return false;
};
let mut parts = date.split('-');
let date_ok = matches!(
(parts.next(), parts.next(), parts.next(), parts.next()),
(Some(year), Some(month), Some(day), None)
if digits(year, 4) && digits(month, 2) && digits(day, 2)
);
if !date_ok {
return false;
}
match text[10..].strip_prefix('T') {
None => text.len() == 10,
Some(time) => is_time(time),
}
}
fn is_time(text: &str) -> bool {
let (clock, zone) = match text.find(['Z', '+', '-']) {
Some(at) => text.split_at(at),
None => (text, ""),
};
let (clock, fraction) = match clock.split_once('.') {
Some((clock, fraction)) => (clock, Some(fraction)),
None => (clock, None),
};
let fields: Vec<&str> = clock.split(':').collect();
let clock_ok = matches!(fields.len(), 2 | 3) && fields.iter().all(|field| digits(field, 2));
let fraction_ok = fraction.is_none_or(|fraction| {
!fraction.is_empty() && fraction.bytes().all(|byte| byte.is_ascii_digit())
});
let zone_ok = zone.is_empty()
|| zone == "Z"
|| zone
.strip_prefix(['+', '-'])
.and_then(|offset| offset.split_once(':'))
.is_some_and(|(hours, minutes)| digits(hours, 2) && digits(minutes, 2));
clock_ok && fraction_ok && zone_ok
}
pub const AUTH_SCHEMES: [&str; 3] = ["basic", "bearer", "token"];
const SCHEME_JOINERS: [char; 2] = [':', '='];
fn scheme_of(word: &str) -> Option<(&str, &str)> {
let (joined, part) = match word.rfind(SCHEME_JOINERS) {
Some(at) => (&word[..at], &word[at + 1..]),
None => ("", word),
};
let scheme = part.trim_matches(|c: char| !c.is_alphanumeric());
AUTH_SCHEMES
.contains(&scheme.to_ascii_lowercase().as_str())
.then_some((scheme, joined))
}
fn names_authorization(name: &str) -> bool {
name.trim_matches(SCHEME_WORD_TRIM)
.to_ascii_lowercase()
.ends_with("authorization")
}
pub fn scheme_words(text: &str) -> Vec<&str> {
let words: Vec<&str> = text.split_whitespace().collect();
words
.windows(2)
.filter(|pair| scheme_of(pair[0]).is_some())
.map(|pair| pair[1])
.collect()
}
const SCHEME_WORD_TRIM: &[char] = &['"', '\'', '`', '(', ')', '{', '}', '<', '>', ',', ';'];
pub fn is_basic_credential(word: &str) -> bool {
let bytes = word.as_bytes();
if bytes.len() < 8 || !bytes.len().is_multiple_of(4) {
return false;
}
let body = word.trim_end_matches('=');
if bytes.len() - body.len() > 2 {
return false;
}
let value = |byte: u8| -> Option<u32> {
match byte {
b'A'..=b'Z' => Some(u32::from(byte - b'A')),
b'a'..=b'z' => Some(u32::from(byte - b'a') + 26),
b'0'..=b'9' => Some(u32::from(byte - b'0') + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
};
let mut decoded = Vec::with_capacity(body.len() * 3 / 4);
let (mut bits, mut count) = (0_u32, 0_u32);
for byte in body.bytes() {
let Some(sextet) = value(byte) else {
return false;
};
bits = (bits << 6) | sextet;
count += 6;
if count >= 8 {
count -= 8;
decoded.extend(u8::try_from((bits >> count) & 0xFF).ok());
}
}
decoded.contains(&b':') && decoded.iter().all(|byte| (0x20..0x7F).contains(byte))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextCredential {
pub span: std::ops::Range<usize>,
pub registered: Option<String>,
}
impl TextCredential {
fn at(text: &str, span: std::ops::Range<usize>, registered: bool) -> Self {
let value = &text[span.clone()];
Self {
registered: (registered && registrable(value)).then(|| value.to_owned()),
span,
}
}
}
pub fn scheme_credential_spans(text: &str) -> Vec<TextCredential> {
let words = words_at(text);
let mut spans = Vec::new();
for (index, pair) in words.windows(2).enumerate() {
let ((_, word_before), (at, word)) = (pair[0], pair[1]);
let Some((scheme, joined)) = scheme_of(word_before) else {
continue;
};
let core = word.trim_matches(SCHEME_WORD_TRIM);
if core.is_empty() || core == super::WITHHELD {
continue;
}
let basic = scheme.eq_ignore_ascii_case("basic");
if !(looks_like_credential(core)
|| looks_like_password(core)
|| (basic && is_basic_credential(core)))
{
continue;
}
let header_named = if joined.is_empty() {
index > 0 && {
let name = words[index - 1].1.trim_matches(SCHEME_WORD_TRIM);
name.strip_suffix(SCHEME_JOINERS)
.is_some_and(names_authorization)
}
} else {
names_authorization(joined)
};
let header_value =
words.len() == 2 && joined.is_empty() && scheme.starts_with(char::is_uppercase);
let bearer = scheme.eq_ignore_ascii_case("bearer");
let start = at + (word.len() - word.trim_start_matches(SCHEME_WORD_TRIM).len());
spans.push(TextCredential::at(
text,
start..start + core.len(),
header_named || header_value || bearer,
));
}
spans
}
pub fn scheme_credentials(text: &str) -> Vec<&str> {
scheme_credential_spans(text)
.into_iter()
.map(|found| &text[found.span])
.collect()
}
fn ends_key_value(c: char) -> bool {
c == '&' || ends_parameters(c)
}
fn ends_parameters(c: char) -> bool {
c.is_whitespace() || matches!(c, ';' | '"' | '\'' | '`' | ')' | '}' | '>')
}
fn ends_segment(c: char) -> bool {
matches!(c, ';' | '\n' | '\r')
}
const KEY_VALUE_SPACES: [char; 2] = [' ', '\t'];
fn credential_value_registered(value: &str, delimited: bool) -> bool {
looks_like_credential(value)
|| looks_like_password(value)
|| value
.chars()
.any(|c| !(c.is_alphanumeric() || c.is_whitespace() || WORD_SEPARATORS.contains(&c)))
|| (delimited && value.contains(char::is_whitespace))
}
pub fn registers_whole(text: &str) -> bool {
!is_date(text)
&& (credential_value_registered(text, true)
|| (text.trim().chars().count() >= PASSWORD_LOOK && super::names::registrable(text)))
}
pub fn unprinted_credentials(text: &str) -> Vec<String> {
let mut out = Vec::new();
if looks_like_json_text(text) {
out.extend(unreadable_json_strings(text));
}
if let Some((_, parts)) = text_withheld(text) {
out.extend(parts);
}
for word in text.split_whitespace() {
let run = word.contains('&') || word.trim_end_matches('=').contains('=');
if looks_like_credential(word) && !run && !word.contains("://") {
out.push(word.to_owned());
}
}
let mut seen = std::collections::HashSet::new();
out.retain(|found| registrable(found) && seen.insert(found.clone()));
out
}
fn braced_value(inner: &str) -> Option<(usize, String)> {
let mut value = String::new();
let mut chars = inner.char_indices().peekable();
while let Some((at, c)) = chars.next() {
if c == '}' {
if chars.peek().is_some_and(|&(_, next)| next == '}') {
chars.next();
} else {
return Some((at, value));
}
}
value.push(c);
}
None
}
pub fn key_value_credential_spans(text: &str) -> Vec<TextCredential> {
use super::names::{Class, classify};
let key_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
let mut spans = Vec::new();
let mut from = 0;
while let Some(offset) = text[from..].find('=') {
let mark = from + offset;
from = mark + 1;
let after = &text[mark + 1..];
if after.starts_with(['=', '>']) {
from += 1;
continue;
}
let before = text[..mark].trim_end_matches(KEY_VALUE_SPACES);
let key_start = before
.char_indices()
.rev()
.take_while(|&(_, c)| key_char(c))
.last()
.map_or(before.len(), |(at, _)| at);
let key = before[key_start..].trim_start_matches(|c: char| !c.is_alphabetic());
if key.is_empty() {
continue;
}
let class = classify(key);
let credential = class >= Class::Credential;
let value_at = mark + 1 + (after.len() - after.trim_start_matches(KEY_VALUE_SPACES).len());
let spaced = before.len() < mark || value_at > mark + 1;
if class < Class::Count || (spaced && !credential) {
continue;
}
let after = &text[value_at..];
let segment = {
let lead = text[..key_start].trim_end();
lead.ends_with(';') || (lead.is_empty() && after.contains(';'))
};
let (span, value, delimited) = match after.chars().next() {
Some(quote @ ('"' | '\'')) => {
let inner = &after[1..];
let end = inner.find(quote).unwrap_or(inner.len());
let start = value_at + 1;
(start..start + end, inner[..end].to_owned(), true)
}
Some('{') if credential => match braced_value(&after[1..]) {
Some((end, value)) => (value_at..value_at + end + 2, value, true),
None => (value_at..text.len(), after[1..].to_owned(), true),
},
_ if credential && segment => {
let end = after.find(ends_segment).unwrap_or(after.len());
let value = after[..end].trim_end();
(value_at..value_at + value.len(), value.to_owned(), true)
}
_ => {
let mut end = after.find(ends_key_value).unwrap_or(after.len());
if credential && after[..end].trim_end_matches('=').contains('=') {
end = after.find(ends_parameters).unwrap_or(after.len());
}
(value_at..value_at + end, after[..end].to_owned(), false)
}
};
let first_word = value.split_whitespace().next().unwrap_or_default();
if value.trim().is_empty()
|| value.starts_with(super::WITHHELD)
|| scheme_of(first_word).is_some_and(|(_, joined)| joined.is_empty())
{
continue;
}
let credential_look = looks_like_credential(&value);
if credential || credential_look || looks_like_password(&value) {
let registered = if credential {
credential_value_registered(&value, delimited)
} else {
credential_look
};
from = span.end;
spans.push(TextCredential {
span,
registered: (registered && registrable(&value)).then_some(value),
});
}
}
spans
}
fn names_credential_header(name: &str) -> bool {
use super::names::{Class, HEADER_CREDENTIAL_WORDS, classify, normalised};
let name = name.trim_matches(SCHEME_WORD_TRIM);
if !looks_like_header_name(name) {
return false;
}
let normal = normalised(name);
(name.contains('-') || matches!(normal.as_str(), "authorization" | "cookie"))
&& (classify(name) >= Class::Credential
|| normal
.split('_')
.any(|word| HEADER_CREDENTIAL_WORDS.contains(&word)))
}
pub fn header_credential_spans(text: &str) -> Vec<TextCredential> {
let words = words_at(text);
let mut spans = Vec::new();
for (index, &(at, word)) in words.iter().enumerate() {
let lead = word.len() - word.trim_start_matches(SCHEME_WORD_TRIM).len();
let Some(colon) = word[lead..].find(':').map(|colon| lead + colon) else {
continue;
};
if !names_credential_header(&word[lead..colon]) {
continue;
}
let rest = &word[colon + 1..];
let (value_at, value) = if rest.trim_matches(SCHEME_WORD_TRIM).is_empty() {
match words.get(index + 1) {
Some(&next) => next,
None => continue,
}
} else {
(at + colon + 1, rest)
};
let core = value.trim_matches(SCHEME_WORD_TRIM);
if core.is_empty()
|| core.starts_with(super::WITHHELD)
|| core.contains("://")
|| scheme_of(core).is_some()
|| !(looks_like_credential(core) || looks_like_password(core))
{
continue;
}
let start = value_at + (value.len() - value.trim_start_matches(SCHEME_WORD_TRIM).len());
if spans
.last()
.is_some_and(|last: &TextCredential| start < last.span.end)
{
continue;
}
spans.push(TextCredential::at(text, start..start + core.len(), true));
}
spans
}
fn key_value_registrations(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for found in key_value_credential_spans(text) {
let value = &text[found.span];
let mut add = |part: &str| {
if !out.iter().any(|known| known == part) {
out.push(part.to_owned());
}
};
if let Some(registered) = &found.registered {
add(registered);
}
if value.len() < text.len() {
for nested in key_value_registrations(value) {
add(&nested);
}
}
}
out
}
fn words_at(text: &str) -> Vec<(usize, &str)> {
let mut words = Vec::new();
let mut start = None;
for (at, c) in text.char_indices() {
match (c.is_whitespace(), start) {
(true, Some(from)) => {
words.push((from, &text[from..at]));
start = None;
}
(false, None) => start = Some(at),
_ => {}
}
}
if let Some(from) = start {
words.push((from, &text[from..]));
}
words
}
fn replaced(text: &str, found: Vec<TextCredential>) -> Option<(String, Vec<String>)> {
if found.is_empty() {
return None;
}
let mut shown = String::with_capacity(text.len());
let mut copied = 0;
let mut parts = Vec::new();
for TextCredential { span, registered } in found {
shown.push_str(&text[copied..span.start]);
shown.push_str(super::WITHHELD);
parts.extend(registered);
copied = span.end;
}
shown.push_str(&text[copied..]);
Some((shown, parts))
}
pub fn scheme_withheld(text: &str) -> Option<(String, Vec<String>)> {
replaced(text, scheme_credential_spans(text))
}
pub fn header_withheld(text: &str) -> Option<(String, Vec<String>)> {
replaced(text, header_credential_spans(text))
}
pub fn key_value_withheld(text: &str) -> Option<(String, Vec<String>)> {
let found = key_value_credential_spans(text);
let nested: Vec<String> = found
.iter()
.flat_map(|found| key_value_registrations(&text[found.span.clone()]))
.collect();
replaced(text, found).map(|(shown, mut parts)| {
for part in nested {
if !parts.contains(&part) {
parts.push(part);
}
}
(shown, parts)
})
}
type TextRule = fn(&str) -> Option<(String, Vec<String>)>;
const TEXT_RULES: [TextRule; 4] = [
url_withheld,
scheme_withheld,
header_withheld,
key_value_withheld,
];
const MODEL_OUTPUT_RULES: [TextRule; 2] = [url_withheld, scheme_withheld];
fn in_order(text: &str, rules: &[TextRule]) -> Option<(String, Vec<String>)> {
let mut shown: Option<String> = None;
let mut parts = Vec::new();
for rule in rules {
if let Some((next, found)) = rule(shown.as_deref().unwrap_or(text)) {
parts.extend(found);
shown = Some(next);
}
}
shown.map(|shown| (shown, parts))
}
pub fn text_withheld(text: &str) -> Option<(String, Vec<String>)> {
in_order(text, &TEXT_RULES)
}
pub fn model_output_withheld(text: &str) -> Option<String> {
in_order(text, &MODEL_OUTPUT_RULES).map(|(shown, _)| shown)
}
const HEADER_NAME_PART: usize = 20;
pub fn looks_like_header_name(text: &str) -> bool {
(1..=64).contains(&text.len())
&& text.starts_with(|c: char| c.is_ascii_alphabetic())
&& text.split(['-', '_']).all(|part| {
!part.is_empty()
&& part.len() <= HEADER_NAME_PART
&& (part.bytes().all(|byte| byte.is_ascii_alphabetic())
|| part.bytes().all(|byte| byte.is_ascii_digit()))
})
}
pub fn shows_credential(text: &str) -> bool {
let token_word = |word: &str| {
let word = word.trim_matches(|c: char| !c.is_alphanumeric());
looks_like_credential(word)
&& !word.contains("://")
&& word
.chars()
.any(|c| !(c.is_alphabetic() || c == '-' || c == '_'))
};
text.split_whitespace().any(token_word)
|| !scheme_credentials(text).is_empty()
|| has_private_key(text)
}
pub fn looks_like_json_text(text: &str) -> bool {
let text = text.trim_start();
if let Some(rest) = text.strip_prefix('{') {
return rest.trim_start().starts_with(['"', '}']);
}
let Some(rest) = text.strip_prefix('[') else {
return false;
};
let rest = rest.trim_start();
if rest.starts_with(['{', '[', '"', ']']) {
return true;
}
let end = rest
.find(|c: char| c.is_whitespace() || c == ',' || c == ']')
.unwrap_or(rest.len());
let (scalar, after) = rest.split_at(end);
is_json_scalar(scalar) && after.trim_start().starts_with([',', ']'])
}
pub fn is_json_scalar(text: &str) -> bool {
is_json_number(text) || matches!(text, "true" | "false" | "null")
}
fn is_json_number(text: &str) -> bool {
let digits_at = |text: &str| text.bytes().take_while(u8::is_ascii_digit).count();
let text = text.strip_prefix('-').unwrap_or(text);
let int = digits_at(text);
if int == 0 || (int > 1 && text.starts_with('0')) {
return false;
}
let mut rest = &text[int..];
if let Some(fraction) = rest.strip_prefix('.') {
let count = digits_at(fraction);
if count == 0 {
return false;
}
rest = &fraction[count..];
}
if let Some(exponent) = rest.strip_prefix(['e', 'E']) {
let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
let count = digits_at(exponent);
if count == 0 {
return false;
}
rest = &exponent[count..];
}
rest.is_empty()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UrlParts {
pub span: std::ops::Range<usize>,
pub userinfo: Option<std::ops::Range<usize>>,
pub query: Option<std::ops::Range<usize>>,
pub fragment: Option<std::ops::Range<usize>>,
}
pub fn urls_in(text: &str) -> Vec<UrlParts> {
let bytes = text.as_bytes();
let scheme_byte = |byte: u8| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'.' | b'-');
let mut found = Vec::new();
let mut from = 0;
while let Some(offset) = text[from..].find("://") {
let mark = from + offset;
let after = mark + 3;
let end = text[after..]
.find(char::is_whitespace)
.map_or(text.len(), |at| after + at);
let mut start = mark;
while start > from && scheme_byte(bytes[start - 1]) {
start -= 1;
}
while start < mark && !bytes[start].is_ascii_alphabetic() {
start += 1;
}
if start == mark {
from = after;
continue;
}
let url = &text[after..end];
let (userinfo, host) = match url.rfind('@') {
Some(at) => (Some(after..after + at), after + at + 1),
None => (None, after),
};
let path = host + text[host..end].find(['/', '?', '#']).unwrap_or(end - host);
let fragment_mark = text[path..end].find('#').map(|at| path + at);
let query_end = fragment_mark.unwrap_or(end);
let query = text[path..query_end]
.find('?')
.map(|at| path + at + 1..query_end);
let fragment = fragment_mark.map(|at| at + 1..end);
found.push(UrlParts {
span: start..end,
userinfo,
query,
fragment,
});
from = end;
}
found
}
pub fn has_private_key(text: &str) -> bool {
let mut rest = text;
while let Some(at) = rest.find("-----BEGIN ") {
let after = &rest[at + "-----BEGIN ".len()..];
if let Some(end) = after.find("-----")
&& after[..end].contains("PRIVATE KEY")
{
return true;
}
rest = after;
}
false
}
pub fn printable_member_name(name: &str) -> bool {
!shows_credential(name)
}
pub fn unreadable_json_strings(text: &str) -> Vec<String> {
let mut withheld = Vec::new();
let withheld = &mut withheld;
let leaf = |text: &str, withheld: &mut Vec<String>| {
if urls_in(text).is_empty() {
withheld.extend(registered(Leaf::Inner, text));
} else if let Some((_, parts)) = url_withheld(text) {
withheld.extend(parts);
}
};
let mut chars = text.chars().peekable();
let mut bare = String::new();
let flush = |bare: &mut String, withheld: &mut Vec<String>| {
if !bare.is_empty() && !is_json_scalar(bare) {
leaf(bare, withheld);
}
bare.clear();
};
while let Some(c) = chars.next() {
if c != '"' {
if c.is_whitespace() || matches!(c, '{' | '}' | '[' | ']' | ',' | ':') {
flush(&mut bare, withheld);
} else {
bare.push(c);
}
continue;
}
flush(&mut bare, withheld);
let pieces = json_string_pieces(&mut chars);
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
let name = chars.peek() == Some(&':');
for piece in pieces.iter().filter(|piece| !piece.is_empty()) {
if !name || !printable_member_name(piece) {
leaf(piece, withheld);
}
}
}
flush(&mut bare, withheld);
std::mem::take(withheld)
}
fn json_string_pieces(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Vec<String> {
enum Unit {
Char(char),
Code(u32),
}
let mut pieces = vec![String::new()];
let mut high: Option<u32> = None;
while let Some(c) = chars.next() {
let unit = match c {
'"' => break,
'\\' => match chars.next() {
None => break,
Some('u') => {
let code = (0..4).try_fold(0, |code, _| {
chars
.next()
.and_then(|c| c.to_digit(16))
.map(|d| code * 16 + d)
});
match code {
Some(code) => Unit::Code(code),
None => continue,
}
}
Some(escaped) => Unit::Char(match escaped {
'n' => '\n',
't' => '\t',
'r' => '\r',
'b' => '\u{8}',
'f' => '\u{c}',
other => other,
}),
},
other => Unit::Char(other),
};
let decoded = match (high.take(), unit) {
(Some(high), Unit::Code(low @ 0xDC00..=0xDFFF)) => {
char::from_u32(0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00))
}
(pending, unit) => {
if pending.is_some() {
pieces.push(String::new());
}
match unit {
Unit::Code(code @ 0xD800..=0xDBFF) => {
high = Some(code);
None
}
Unit::Code(0xDC00..=0xDFFF) => {
pieces.push(String::new());
None
}
Unit::Code(code) => char::from_u32(code),
Unit::Char(c) => Some(c),
}
}
};
if let (Some(c), Some(piece)) = (decoded, pieces.last_mut()) {
piece.push(c);
}
}
pieces
}
pub fn url_withheld(text: &str) -> Option<(String, Vec<String>)> {
let mut parts = Vec::new();
let mut shown = String::with_capacity(text.len());
let mut copied = 0;
let mut changed = false;
for url in urls_in(text) {
let ranges = [
(url.userinfo, true),
(url.query, false),
(url.fragment, false),
];
for (range, userinfo) in ranges {
let Some(range) = range.filter(|range| !range.is_empty()) else {
continue;
};
let part = &text[range.clone()];
shown.push_str(&text[copied..range.start]);
shown.push_str(super::WITHHELD);
copied = range.end;
if part == super::WITHHELD {
continue;
}
changed = true;
if (!userinfo || part.contains(':') || !part.contains(['/', '?', '#']))
&& registrable(part)
{
parts.push(part.to_owned());
}
for nested in key_value_registrations(part) {
if !parts.contains(&nested) {
parts.push(nested);
}
}
}
}
shown.push_str(&text[copied..]);
changed.then_some((shown, parts))
}
#[cfg(test)]
mod tests {
use super::super::check::{schemes_withheld, urls_withheld};
use super::*;
#[test]
fn private_keys_are_found_by_their_armour() {
for text in [
"-----BEGIN PRIVATE KEY-----\nMIIE\n-----END PRIVATE KEY-----",
"x -----BEGIN RSA PRIVATE KEY----- y",
"-----BEGIN OPENSSH PRIVATE KEY-----",
"-----BEGIN ENCRYPTED PRIVATE KEY-----",
"-----BEGIN PGP PRIVATE KEY BLOCK-----",
"-----BEGIN CERTIFICATE-----\n-----BEGIN EC PRIVATE KEY-----",
] {
assert!(has_private_key(text), "{text}");
assert!(shows_credential(text), "{text}");
}
for text in [
"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
"-----BEGIN PUBLIC KEY-----",
"BEGIN PRIVATE KEY",
"-----BEGIN PRIVATE KEY",
] {
assert!(!has_private_key(text), "{text}");
}
}
#[test]
fn scheme_credentials_are_withheld_in_place() {
for (text, shown, part) in [
(
"Bearer sk-proj-abcdef0123456789",
"Bearer [withheld]",
"sk-proj-abcdef0123456789",
),
("Basic dXNlcjpwYXNz", "Basic [withheld]", "dXNlcjpwYXNz"),
("Token hunter2x", "Token [withheld]", "hunter2x"),
(
"curl -H 'Authorization: Bearer abcdefghij0123456789' https://h",
"curl -H 'Authorization: Bearer [withheld]' https://h",
"abcdefghij0123456789",
),
(
"-H \"Authorization: Basic dXNlcjpwYXNzd29yZA==\"",
"-H \"Authorization: Basic [withheld]\"",
"dXNlcjpwYXNzd29yZA==",
),
(
"(Token abcdefghij0123456789),",
"(Token [withheld]),",
"abcdefghij0123456789",
),
] {
assert_eq!(
scheme_withheld(text),
Some((shown.to_owned(), vec![part.to_owned()])),
"{text}"
);
assert!(!schemes_withheld(text) && schemes_withheld(shown), "{text}");
}
for text in [
"a basic introduction",
"Use a Bearer token",
"Bearer [withheld]",
"basic auth",
"Token",
] {
assert_eq!(scheme_withheld(text), None, "{text}");
}
assert!(is_basic_credential("dXNlcjpwYXNz"));
assert!(!is_basic_credential("introduction"));
assert!(!is_basic_credential("dXNlcjpwYXN"));
}
#[test]
fn scheme_registration_follows_the_header_form() {
for (text, shown, registered) in [
(
"curl -H Authorization:Bearer abcdefghij0123456789 https://h",
"curl -H Authorization:Bearer [withheld] https://h",
true,
),
(
"-H \"Proxy-Authorization:Basic dXNlcjpwYXNz\"",
"-H \"Proxy-Authorization:Basic [withheld]\"",
true,
),
(
"Authorization=Token abcdefghij0123456789",
"Authorization=Token [withheld]",
true,
),
(
"authorization: token abcdefghij0123456789 sent",
"authorization: token [withheld] sent",
true,
),
(
"retry with bearer abcdefghij0123456789 later",
"retry with bearer [withheld] later",
true,
),
(
"token gpt-4o-mini-2024-07-18 quota exceeded",
"token [withheld] quota exceeded",
false,
),
(
"basic claude-3-5-sonnet-20241022 routing",
"basic [withheld] routing",
false,
),
("token gpt-4o-mini-2024-07-18", "token [withheld]", false),
("token hunter2x", "token [withheld]", false),
("Basic dXNlcjpwYXNzd29yZA==", "Basic [withheld]", true),
(
"x-api-key: Token abcdefghij0123456789 sent",
"x-api-key: Token [withheld] sent",
false,
),
] {
let parts: Vec<String> = if registered {
scheme_credentials(text)
.into_iter()
.map(str::to_owned)
.collect()
} else {
Vec::new()
};
assert_eq!(
scheme_withheld(text),
Some((shown.to_owned(), parts)),
"{text}"
);
assert_eq!(scheme_credentials(text).len(), 1, "{text}");
}
assert_eq!(scheme_words("Authorization:Bearer hunter2x"), ["hunter2x"]);
for text in [
"x:bearers abcdefghij0123456789",
"Bearer-ish abcdefghij0123456789",
] {
assert_eq!(scheme_withheld(text), None, "{text}");
}
}
#[test]
fn key_value_credentials_are_withheld_in_place() {
for (text, shown, parts) in [
(
"Server=x;User Id=sa;Password=hunter2x!;",
"Server=x;User Id=sa;Password=[withheld];",
vec!["hunter2x!"],
),
("Uid=a;Pwd=pw;", "Uid=a;Pwd=[withheld];", vec![]),
("Set auth=none here", "Set auth=[withheld] here", vec![]),
("Password=true;", "Password=[withheld];", vec![]),
("Pwd=********;", "Pwd=[withheld];", vec![]),
("Pwd=1234;", "Pwd=[withheld];", vec![]),
("Pwd=hunter2;", "Pwd=[withheld];", vec![]),
("Pwd=hunter2x;", "Pwd=[withheld];", vec!["hunter2x"]),
("Pwd=p@ss;", "Pwd=[withheld];", vec!["p@ss"]),
(
"Use auth=oauth2 for remote",
"Use auth=[withheld] for remote",
vec![],
),
(
"secret=sha256 and pwd=v2.0",
"secret=[withheld] and pwd=[withheld]",
vec![],
),
(
"SharedAccessSignature=sv=2022-11-02&sig=abcdefghij0123456789;x=y",
"SharedAccessSignature=[withheld];x=y",
vec![
"sv=2022-11-02&sig=abcdefghij0123456789",
"abcdefghij0123456789",
],
),
(
"client_secret=abc+def/ghij==&grant_type=client_credentials",
"client_secret=[withheld]&grant_type=client_credentials",
vec!["abc+def/ghij=="],
),
(
"AccountName=a;AccountKey=abc+/==;EndpointSuffix=x",
"AccountName=a;AccountKey=[withheld];EndpointSuffix=x",
vec!["abc+/=="],
),
(
"Password=\"a b;c\";Server=x",
"Password=\"[withheld]\";Server=x",
vec!["a b;c"],
),
(
"run --token=sk-abcdefghij0123456 now",
"run --token=[withheld] now",
vec!["sk-abcdefghij0123456"],
),
("key=hunter2x!", "key=[withheld]", vec![]),
] {
let parts = parts.into_iter().map(str::to_owned).collect();
assert_eq!(
key_value_withheld(text),
Some((shown.to_owned(), parts)),
"{text}"
);
assert!(!schemes_withheld(text) && schemes_withheld(shown), "{text}");
}
for text in [
"max_tokens=100 prompt_tokens=12",
"AccountName=acct;EndpointSuffix=core.windows.net",
"sort key=name",
"Password=;",
"Password=[withheld];",
"Authorization=Bearer [withheld]",
"auth=basic",
"a == b",
"=x",
] {
assert_eq!(key_value_withheld(text), None, "{text}");
}
}
#[test]
fn connection_string_values_run_to_their_segment_end() {
for (text, shown, parts) in [
(
"Server=db;User Id=sa;Password=p@ss w0rd;",
"Server=db;User Id=sa;Password=[withheld];",
vec!["p@ss w0rd"],
),
(
"Server=db; Password=my long pass phrase; Encrypt=true",
"Server=db; Password=[withheld]; Encrypt=true",
vec!["my long pass phrase"],
),
(
"Password=p@ss w0rd;Server=db",
"Password=[withheld];Server=db",
vec!["p@ss w0rd"],
),
(
"Server=db;Password=p@ss w0rd\nnext line",
"Server=db;Password=[withheld]\nnext line",
vec!["p@ss w0rd"],
),
(
"Driver={ODBC Driver 18};Server=db;UID=app;PWD={p@ss;w0rd};",
"Driver={ODBC Driver 18};Server=db;UID=app;PWD=[withheld];",
vec!["p@ss;w0rd"],
),
("PWD={a}}b;c d};x=y", "PWD=[withheld];x=y", vec!["a}b;c d"]),
(
"note PWD={p@ss;w0rd} end",
"note PWD=[withheld] end",
vec!["p@ss;w0rd"],
),
("PWD={never closed", "PWD=[withheld]", vec!["never closed"]),
(
"note: Password=p@ss w0rd;",
"note: Password=[withheld] w0rd;",
vec!["p@ss"],
),
(
"password=hunter2x! is rejected",
"password=[withheld] is rejected",
vec!["hunter2x!"],
),
] {
let parts = parts.into_iter().map(str::to_owned).collect();
assert_eq!(
key_value_withheld(text),
Some((shown.to_owned(), parts)),
"{text}"
);
assert!(!schemes_withheld(text) && schemes_withheld(shown), "{text}");
assert_eq!(text_withheld(shown), None, "{text}: idempotent");
}
for text in [
"Driver={ODBC Driver 18};Server=db;Database=app",
"Server=db;Password=;Uid=app",
"Server=db;Password=[withheld];",
"Server=db;Authorization=Bearer [withheld]",
] {
assert_eq!(key_value_withheld(text), None, "{text}");
}
}
#[test]
fn spaced_key_values_are_read_under_credential_names_only() {
for (text, shown, parts) in [
(
"aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"aws_secret_access_key = [withheld]",
vec!["wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"],
),
(
"set password = hunter2x! in the config",
"set password = [withheld] in the config",
vec!["hunter2x!"],
),
(
"[default]\naws_access_key_id = AKIAIOSFODNN7EXAMPLE\naws_secret_access_key\t=\tabc+def/ghij",
"[default]\naws_access_key_id = AKIAIOSFODNN7EXAMPLE\naws_secret_access_key\t=\t[withheld]",
vec!["abc+def/ghij"],
),
("then pwd= v2.0 here", "then pwd= [withheld] here", vec![]),
] {
let parts = parts.into_iter().map(str::to_owned).collect();
assert_eq!(
key_value_withheld(text),
Some((shown.to_owned(), parts)),
"{text}"
);
assert_eq!(text_withheld(shown), None, "{text}: idempotent");
}
for text in [
"if a = b then sort key = name",
"max_tokens = 100 and token = abcdefghij0123456789xyz",
"password == expected",
"password => hunter2x!",
"password =",
] {
assert_eq!(key_value_withheld(text), None, "{text}");
}
}
#[test]
fn model_output_gets_the_url_and_scheme_rules_only() {
assert_eq!(
model_output_withheld(
"see https://u:p4ssw0rd@h.example/x?k=v then Bearer abcdefghij0123456789; \
password=hunter2x! and X-Api-Key: abcdefghij0123456789"
),
Some(
"see https://[withheld]@h.example/x?[withheld] then Bearer [withheld]; \
password=hunter2x! and X-Api-Key: abcdefghij0123456789"
.to_owned()
)
);
let shown =
model_output_withheld("Bearer abcdefghij0123456789 at https://h/x?sig=abcd").unwrap();
assert_eq!(shown, "Bearer [withheld] at https://h/x?[withheld]");
assert_eq!(model_output_withheld(&shown), None, "idempotent");
assert_eq!(model_output_withheld("password=os.environ['PW']"), None);
}
#[test]
fn credential_header_values_are_withheld_in_place() {
for (text, shown, part) in [
(
"curl -H 'X-Api-Key: abcdefghij0123456789' https://h",
"curl -H 'X-Api-Key: [withheld]' https://h",
"abcdefghij0123456789",
),
(
"-H \"api-key:abcdefghij0123456789\"",
"-H \"api-key:[withheld]\"",
"abcdefghij0123456789",
),
(
"Ocp-Apim-Subscription-Key: 0123456789abcdef0123",
"Ocp-Apim-Subscription-Key: [withheld]",
"0123456789abcdef0123",
),
(
"X-Auth-Token: hunter2x!",
"X-Auth-Token: [withheld]",
"hunter2x!",
),
(
"Authorization: sk-proj-abcdef0123456789",
"Authorization: [withheld]",
"sk-proj-abcdef0123456789",
),
] {
assert_eq!(
header_withheld(text),
Some((shown.to_owned(), vec![part.to_owned()])),
"{text}"
);
assert!(!schemes_withheld(text) && schemes_withheld(shown), "{text}");
assert_eq!(text_withheld(shown), None, "{text}");
}
for text in [
"Authorization: Bearer abcdefghij0123456789",
"X-Api-Key: [withheld]",
"the x-api-key: header is required",
"x-ratelimit-remaining-tokens: 1999",
"x-request-id: req-abc123def456gh",
"description: see hunter2x!",
"https://h:8443/x",
"x-api-key: https://vault.example/k",
] {
assert_eq!(header_withheld(text), None, "{text}");
}
}
#[test]
fn text_rules_apply_in_order() {
assert_eq!(
text_withheld(
"jdbc:postgresql://h/app?user=app&password=hunter2x! and Bearer abcdefghij0123456789"
),
Some((
"jdbc:postgresql://h/app?[withheld] and Bearer [withheld]".to_owned(),
vec![
"user=app&password=hunter2x!".to_owned(),
"hunter2x!".to_owned(),
"abcdefghij0123456789".to_owned()
]
))
);
assert_eq!(text_withheld("nothing here"), None);
}
#[test]
fn header_names_are_word_shaped() {
for name in [
"x-trace",
"Authorization",
"x-portkey-trace-id",
"X_Env",
"x-01",
] {
assert!(looks_like_header_name(name), "{name}");
}
for text in [
"sk-proj-ab12cd34",
"hunter2x",
"x-b3-traceid",
"abcdefghijklmnopqrstuvwxyz",
"Bearer x",
"-x",
"",
] {
assert!(!looks_like_header_name(text), "{text}");
}
}
#[test]
fn urls_are_withheld_when_every_part_is() {
assert!(urls_withheld(
"https://[withheld]@h/p?[withheld]#[withheld]"
));
assert!(urls_withheld("http://h?"));
assert!(!urls_withheld("see https://u:p@h/"));
}
}