use std::{collections::BTreeMap, error::Error, fmt};
use serde_json::{Map, Value};
use crate::secret::{Secret, SecretBytes};
pub const REDACTION_MARKER: &str = "<redacted>";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OutputStream {
Stdout,
Stderr,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RedactionError {
EmptySecret,
}
impl fmt::Display for RedactionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptySecret => formatter.write_str("cannot register an empty secret"),
}
}
}
impl Error for RedactionError {}
pub struct SecretRedactor {
secrets: Vec<SecretBytes>,
}
impl SecretRedactor {
pub fn new() -> Self {
Self {
secrets: Vec::new(),
}
}
pub fn register_secret(&mut self, secret: &Secret) -> Result<(), RedactionError> {
self.register_bytes(secret.expose_secret().as_bytes())
}
pub fn register_secret_bytes(&mut self, secret: &SecretBytes) -> Result<(), RedactionError> {
self.register_bytes(secret.expose_secret_bytes())
}
fn register_bytes(&mut self, secret: &[u8]) -> Result<(), RedactionError> {
if secret.is_empty() {
return Err(RedactionError::EmptySecret);
}
if !self
.secrets
.iter()
.any(|known| known.expose_secret_bytes() == secret)
{
self.secrets.push(SecretBytes::new(secret.to_vec()));
}
Ok(())
}
pub fn stream(&self) -> StreamingRedactor<'_> {
StreamingRedactor {
redactor: self,
pending: Vec::new(),
finished: false,
}
}
pub fn command_output(&self) -> CommandOutputRedactor<'_> {
CommandOutputRedactor {
stdout: self.stream(),
stderr: self.stream(),
}
}
pub fn redact_bytes(&self, input: &[u8]) -> Vec<u8> {
let mut stream = self.stream();
let mut output = stream.push(input);
output.extend(stream.finish());
output
}
pub fn redact_text(&self, input: &str) -> String {
String::from_utf8_lossy(&self.redact_bytes(input.as_bytes())).into_owned()
}
pub fn redact_arguments(&self, arguments: &[String]) -> Vec<String> {
let mut redact_next = false;
arguments
.iter()
.map(|argument| {
if redact_next {
redact_next = false;
return REDACTION_MARKER.to_owned();
}
if let Some((option, _value)) = argument.split_once('=')
&& is_sensitive_name(option)
{
return format!("{}={REDACTION_MARKER}", self.redact_text(option));
}
if argument.starts_with('-') && is_sensitive_name(argument) {
redact_next = true;
return self.redact_text(argument);
}
self.redact_text(argument)
})
.collect()
}
pub fn redact_environment(
&self,
environment: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
environment
.iter()
.map(|(name, value)| {
let value = if is_sensitive_name(name) {
REDACTION_MARKER.to_owned()
} else {
self.redact_text(value)
};
(self.redact_text(name), value)
})
.collect()
}
pub fn redact_json_value(&self, value: &mut Value) {
match value {
Value::String(text) => *text = self.redact_text(text),
Value::Array(items) => {
for item in items {
self.redact_json_value(item);
}
}
Value::Object(object) => self.redact_json_object(object),
Value::Null | Value::Bool(_) | Value::Number(_) => {}
}
}
fn redact_json_object(&self, object: &mut Map<String, Value>) {
let original = std::mem::take(object);
for (name, mut value) in original {
let redacted_name = self.redact_text(&name);
if is_sensitive_name(&name) {
value = Value::String(REDACTION_MARKER.to_owned());
} else {
self.redact_json_value(&mut value);
}
object.insert(redacted_name, value);
}
}
}
impl Default for SecretRedactor {
fn default() -> Self {
Self::new()
}
}
pub struct StreamingRedactor<'a> {
redactor: &'a SecretRedactor,
pending: Vec<u8>,
finished: bool,
}
impl StreamingRedactor<'_> {
pub fn push(&mut self, chunk: &[u8]) -> Vec<u8> {
assert!(
!self.finished,
"cannot push after redaction stream finished"
);
self.pending.extend_from_slice(chunk);
self.drain_safe(false)
}
pub fn finish(&mut self) -> Vec<u8> {
if self.finished {
return Vec::new();
}
self.finished = true;
self.drain_safe(true)
}
fn drain_safe(&mut self, finishing: bool) -> Vec<u8> {
let mut output = Vec::new();
let mut consumed = 0;
while consumed < self.pending.len() {
let remaining = &self.pending[consumed..];
let is_partial_secret = self.redactor.secrets.iter().any(|secret| {
let secret = secret.expose_secret_bytes();
secret.len() > remaining.len() && secret.starts_with(remaining)
});
let must_wait = !finishing && is_partial_secret;
if must_wait {
break;
}
if finishing && is_partial_secret {
output.extend_from_slice(REDACTION_MARKER.as_bytes());
consumed += remaining.len();
continue;
}
let full_match = self
.redactor
.secrets
.iter()
.map(SecretBytes::expose_secret_bytes)
.filter(|secret| remaining.starts_with(secret))
.max_by_key(|secret| secret.len());
if let Some(secret) = full_match {
output.extend_from_slice(REDACTION_MARKER.as_bytes());
consumed += secret.len();
} else {
output.push(remaining[0]);
consumed += 1;
}
}
self.pending.drain(..consumed);
output
}
}
impl Drop for StreamingRedactor<'_> {
fn drop(&mut self) {
self.pending.fill(0);
let _ = std::hint::black_box(self.pending.as_mut_slice());
}
}
pub struct CommandOutputRedactor<'a> {
stdout: StreamingRedactor<'a>,
stderr: StreamingRedactor<'a>,
}
impl CommandOutputRedactor<'_> {
pub fn push(&mut self, stream: OutputStream, chunk: &[u8]) -> Vec<u8> {
match stream {
OutputStream::Stdout => self.stdout.push(chunk),
OutputStream::Stderr => self.stderr.push(chunk),
}
}
pub fn finish(&mut self, stream: OutputStream) -> Vec<u8> {
match stream {
OutputStream::Stdout => self.stdout.finish(),
OutputStream::Stderr => self.stderr.finish(),
}
}
}
fn is_sensitive_name(name: &str) -> bool {
let normalized: String = name
.trim_start_matches('-')
.chars()
.filter(char::is_ascii_alphanumeric)
.map(|character| character.to_ascii_lowercase())
.collect();
[
"authorization",
"credential",
"githubtoken",
"apikey",
"privatekey",
"password",
"passphrase",
"secret",
"token",
"jwt",
"p12",
"p8",
"profilebase64",
"signedurl",
"keychainpassword",
"sshkey",
]
.iter()
.any(|sensitive| normalized.contains(sensitive))
}