use std::{error::Error as StdError, fmt, ops::Range};
use bytes::Bytes;
use http::{HeaderName, HeaderValue, header};
use crate::{config::is_python_space, constants::SECRET_HEADERS};
pub(crate) const MAX_SCANNED_LINKS: usize = 32;
const REDACTED: &str = "***";
pub(crate) fn is_secret(name: &HeaderName, value: &HeaderValue) -> bool {
let name = name.as_str();
value.is_sensitive()
|| SECRET_HEADERS.contains(&name)
|| name.contains("token")
|| name.contains("secret")
}
pub(crate) struct Credentials {
variants: Vec<String>,
}
impl Credentials {
pub(crate) fn new<'a>(
headers: impl IntoIterator<Item = (&'a HeaderName, &'a HeaderValue)>,
) -> Self {
let mut variants = Vec::new();
for (name, value) in headers {
if value.is_empty() {
continue;
}
if is_secret(name, value) {
push_variants(&mut variants, value.as_bytes());
}
if (name == header::AUTHORIZATION || name == header::PROXY_AUTHORIZATION)
&& let Some(credential) = after_scheme(value.as_bytes())
{
push_variants(&mut variants, credential);
}
}
variants.sort_unstable_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
variants.dedup();
Self { variants }
}
pub(crate) fn occur_in(&self, text: &str) -> bool {
self.next_match(text, 0).is_some()
}
pub(crate) fn redact(&self, text: &str) -> String {
let mut redacted = String::with_capacity(text.len());
let mut copied = 0;
for found in self.matches(text) {
redacted.push_str(&text[copied..found.start]);
redacted.push_str(REDACTED);
copied = found.end;
}
redacted.push_str(&text[copied..]);
redacted
}
pub(crate) fn matches<'a>(&'a self, text: &'a str) -> impl Iterator<Item = Range<usize>> + 'a {
let mut from = 0;
std::iter::from_fn(move || {
let found = self.next_match(text, from)?;
from = found.end;
Some(found)
})
}
fn next_match(&self, text: &str, from: usize) -> Option<Range<usize>> {
let bytes = text.as_bytes();
(from..bytes.len()).find_map(|start| {
let rest = &bytes[start..];
self.variants
.iter()
.find(|variant| rest.starts_with(variant.as_bytes()))
.map(|variant| start..start + variant.len())
})
}
}
fn after_scheme(value: &[u8]) -> Option<&[u8]> {
let is_space = |byte: &u8| is_python_space(char::from(*byte));
let start = value.iter().position(|byte| !is_space(byte))?;
let value = &value[start..];
let scheme_end = value.iter().position(is_space)?;
let rest = &value[scheme_end..];
let credential_start = rest.iter().position(|byte| !is_space(byte))?;
Some(&rest[credential_start..])
}
fn push_variants(variants: &mut Vec<String>, bytes: &[u8]) {
let text = String::from_utf8_lossy(bytes);
let quoted_debug = format!("{text:?}");
let header_debug = format!(
"{:?}",
HeaderValue::from_bytes(bytes)
.expect("invariant: the bytes are a header value's, or a part of one after a space")
);
let bytes_debug = format!("{:?}", Bytes::copy_from_slice(bytes));
let forms = [
text.to_string(),
unquote("ed_debug, "\"").to_owned(),
text.escape_debug().to_string(),
unquote(&header_debug, "\"").to_owned(),
unquote(&bytes_debug, "b\"").to_owned(),
json_escape(&text),
];
let escaped_again = forms.each_ref().map(|form| {
let quoted = format!("{form:?}");
unquote("ed, "\"").to_owned()
});
variants.extend(forms.into_iter().chain(escaped_again).filter(|form| !form.is_empty()));
}
fn unquote<'a>(text: &'a str, open: &str) -> &'a str {
text.strip_prefix(open).and_then(|inner| inner.strip_suffix('"')).unwrap_or(text)
}
fn json_escape(text: &str) -> String {
use fmt::Write as _;
let mut escaped = String::with_capacity(text.len());
for character in text.chars() {
match character {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\u{8}' => escaped.push_str("\\b"),
'\u{c}' => escaped.push_str("\\f"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
'\u{0}'..='\u{1f}' => write!(escaped, "\\u{:04x}", u32::from(character))
.expect("invariant: writing to a String cannot fail"),
_ => escaped.push(character),
}
}
escaped
}
pub(crate) enum Outcome {
Kept,
MessageOnly,
Replaced(RedactedLink),
}
pub(crate) fn copy_chain(
source: &(dyn StdError + 'static),
message: &str,
credentials: &Credentials,
) -> Outcome {
let mut texts = Vec::new();
let mut found = false;
let mut link = Some(source);
while let Some(current) = link {
if texts.len() == MAX_SCANNED_LINKS {
found = true;
break;
}
let rendered = [current.to_string(), format!("{current:?}"), format!("{current:#?}")];
found |= rendered.iter().any(|text| credentials.occur_in(text));
texts.push(rendered);
link = current.source();
}
if !found {
return if credentials.occur_in(message) { Outcome::MessageOnly } else { Outcome::Kept };
}
let mut below = None;
for [display, debug, alternate_debug] in texts.into_iter().rev() {
below = Some(Box::new(RedactedLink {
display: credentials.redact(&display).into_boxed_str(),
debug: credentials.redact(&debug).into_boxed_str(),
alternate_debug: credentials.redact(&alternate_debug).into_boxed_str(),
source: below,
}));
}
let top = below.expect("invariant: the chain starts with `source`, so it has a link");
Outcome::Replaced(*top)
}
pub(crate) struct RedactedLink {
display: Box<str>,
debug: Box<str>,
alternate_debug: Box<str>,
source: Option<Box<RedactedLink>>,
}
impl fmt::Display for RedactedLink {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.display)
}
}
impl fmt::Debug for RedactedLink {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(if formatter.alternate() { &self.alternate_debug } else { &self.debug })
}
}
impl StdError for RedactedLink {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.source.as_deref().map(|link| link as &(dyn StdError + 'static))
}
}
#[cfg(test)]
#[path = "redact_tests.rs"]
mod tests;