use std::fmt;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::registry::LookupSpan;
pub const REDACTION: &str = "[redacted]";
pub const PATH_REDACTION: &str = "[path]";
pub const ALLOWED_FIELDS: &[&str] = &[
"arch",
"attempt",
"attempt_id",
"attempt_state",
"capacity",
"count",
"demand",
"desired",
"duration_ms",
"elapsed_ms",
"error_kind",
"event",
"exit_code",
"headroom",
"host_id",
"http_status",
"installation_id",
"job_id",
"label",
"lock",
"message",
"mode",
"os",
"outcome",
"pid",
"policy_id",
"policy_state",
"reason",
"retry_in_ms",
"runner_id",
"scope",
"start_mode",
"state",
"target",
"version",
];
const CREDENTIAL_KEYS: &[&str] = &[
"access.token",
"api.key",
"apikey",
"auth",
"authorization",
"client.secret",
"cookie",
"credential",
"encoded.jit.config",
"jit",
"jit.config",
"jitconfig",
"password",
"private.token",
"proxy.authorization",
"refresh.token",
"secret",
"set.cookie",
"token",
"www.authenticate",
"x.api.key",
"x.auth.token",
"x.github.token",
"x.hub.signature",
"x.hub.signature.256",
];
const SCHEME_WORDS: &[&str] = &["basic", "bearer", "digest", "negotiate", "token"];
const TOKEN_PREFIXES: &[&str] = &["ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_", "gh_"];
const OPAQUE_RUN_THRESHOLD: usize = 40;
fn is_opaque_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-')
}
const WRAPPERS: &[char] = &[
'"', '\'', '`', '(', ')', '[', ']', '{', '}', '<', '>', ',', ';', '.', '!', '?',
];
const STRUCTURAL: &[char] = &[',', ';', '{', '}', '[', ']', '<', '>', '&'];
#[must_use]
pub fn is_field_allowed(name: &str) -> bool {
ALLOWED_FIELDS.binary_search(&name).is_ok()
}
fn trim_key(key: &str) -> &str {
key.trim()
.trim_matches(|c: char| c == '\\' || WRAPPERS.contains(&c))
}
fn normalise_key(key: &str) -> String {
trim_key(key).to_ascii_lowercase().replace(['-', '_'], ".")
}
fn is_credential_key(key: &str) -> bool {
let key = normalise_key(key);
CREDENTIAL_KEYS.contains(&key.as_str())
}
fn is_scheme_word(word: &str) -> bool {
SCHEME_WORDS.contains(&word.to_ascii_lowercase().as_str())
}
#[must_use]
pub fn redact(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut pending: u32 = 0;
for chunk in text.split_inclusive(char::is_whitespace) {
let (word, whitespace) = split_trailing_whitespace(chunk);
if word.is_empty() {
out.push_str(whitespace);
continue;
}
if pending > 0 {
pending -= 1;
out.push_str(REDACTION);
out.push_str(whitespace);
if word.ends_with([',', ';']) {
pending = 0;
}
continue;
}
let (rendered, follow_on) = redact_word(word);
out.push_str(&rendered);
out.push_str(whitespace);
pending = follow_on;
}
out
}
fn split_trailing_whitespace(chunk: &str) -> (&str, &str) {
match chunk.char_indices().next_back() {
Some((index, last)) if last.is_whitespace() => chunk.split_at(index),
_ => (chunk, ""),
}
}
fn split_wrappers(fragment: &str) -> (&str, &str, &str) {
let leading = fragment.len() - trim_start_wrappers(fragment).len();
let (prefix, rest) = fragment.split_at(leading);
let core_len = trim_end_wrappers(rest).len();
let (core, suffix) = rest.split_at(core_len);
(prefix, core, suffix)
}
fn trim_start_wrappers(fragment: &str) -> &str {
let mut rest = fragment;
loop {
let trimmed = rest.trim_start_matches(WRAPPERS);
let trimmed = match trimmed.strip_prefix('\\') {
Some(after) if after.starts_with(WRAPPERS) => after,
_ => trimmed,
};
if trimmed.len() == rest.len() {
return rest;
}
rest = trimmed;
}
}
fn trim_end_wrappers(fragment: &str) -> &str {
let mut rest = fragment;
loop {
let trimmed = rest.trim_end_matches(WRAPPERS).trim_end_matches('\\');
if trimmed.len() == rest.len() {
return rest;
}
rest = trimmed;
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Carry {
None,
Expecting,
Unclosed,
}
fn closes_a_value(wrappers: &str) -> bool {
wrappers.contains(['"', '\'', '`', ',', ';', ']', '}', ')'])
}
fn opens_an_unclosed_quote(lead: &str, trail: &str) -> bool {
const QUOTES: [char; 3] = ['"', '\'', '`'];
lead.contains(QUOTES) && !trail.contains(QUOTES)
}
fn is_tag_name(preceding: Option<char>, following: Option<char>) -> bool {
preceding == Some('<') && following == Some('>')
}
fn redact_word(word: &str) -> (String, u32) {
let (prefix, core, suffix) = split_wrappers(word);
if core.is_empty() {
return (word.to_string(), 0);
}
let stem = core.trim_end_matches([':', '=']);
if stem.len() < core.len() && is_credential_key(stem) {
return (word.to_string(), 2);
}
if is_scheme_word(core) {
return (word.to_string(), 1);
}
let (rendered, carry) = redact_core(core, suffix);
let follow_on = if closes_a_value(suffix) {
0
} else {
match carry {
Carry::None => 0,
Carry::Unclosed => 1,
Carry::Expecting => 2,
}
};
(format!("{prefix}{rendered}{suffix}"), follow_on)
}
fn redact_fragment(
fragment: &str,
carry: Carry,
tag_name: bool,
trailing: bool,
) -> (String, Carry) {
let (prefix, core, suffix) = split_wrappers(fragment);
if core.is_empty() {
return (fragment.to_string(), carry);
}
let claimed = match carry {
Carry::None => false,
Carry::Expecting => !tag_name,
Carry::Unclosed => true,
};
if claimed {
let next = if !closes_a_value(suffix) && (carry == Carry::Unclosed || trailing) {
Carry::Unclosed
} else {
Carry::None
};
return (format!("{prefix}{REDACTION}{suffix}"), next);
}
let (rendered, next) = redact_core(core, suffix);
let next = if closes_a_value(suffix) {
Carry::None
} else if next == Carry::None {
carry
} else {
next
};
(format!("{prefix}{rendered}{suffix}"), next)
}
fn redact_core(core: &str, closing: &str) -> (String, Carry) {
if let Some((prefix, scheme, url, remainder)) = split_url(core) {
let mut out = String::with_capacity(core.len());
if !prefix.is_empty() {
out.push_str(&redact_core(prefix, "").0);
}
out.push_str(&redact_url(scheme, url));
let mut rest = remainder;
while let Some((prefix, scheme, url, remainder)) = split_url(rest) {
if !prefix.is_empty() {
out.push_str(&redact_core(prefix, "").0);
}
out.push_str(&redact_url(scheme, url));
rest = remainder;
}
if !rest.is_empty() {
out.push_str(&redact_core(rest, "").0);
}
return (out, Carry::None);
}
if looks_like_path(core) {
return (PATH_REDACTION.to_string(), Carry::None);
}
if core.contains(STRUCTURAL) {
let mut out = String::with_capacity(core.len());
let mut carry = Carry::None;
let mut rest = core;
let mut preceding: Option<char> = None;
while let Some(index) = rest.find(STRUCTURAL) {
let (fragment, tail) = rest.split_at(index);
let separator = char::from(tail.as_bytes()[0]);
if !fragment.is_empty() {
let (rendered, next) = redact_fragment(
fragment,
carry,
is_tag_name(preceding, Some(separator)),
false,
);
out.push_str(&rendered);
carry = next;
}
out.push_str(&tail[..1]);
preceding = Some(separator);
rest = &tail[1..];
}
if !rest.is_empty() {
let following = closing.chars().next();
let (rendered, next) =
redact_fragment(rest, carry, is_tag_name(preceding, following), true);
out.push_str(&rendered);
carry = next;
}
return (out, carry);
}
let mut names_a_credential = false;
for separator in ['=', ':'] {
if let Some((key, raw_value)) = core.split_once(separator)
&& is_credential_key(key)
{
let (lead, value, trail) = split_wrappers(raw_value);
if value.is_empty() {
names_a_credential = true;
continue;
}
let carry = if opens_an_unclosed_quote(lead, trail) {
Carry::Unclosed
} else {
Carry::None
};
return (format!("{key}{separator}{lead}{REDACTION}{trail}"), carry);
}
}
for separator in ['=', ':'] {
if let Some((key, raw_value)) = core.split_once(separator) {
let (lead, value, trail) = split_wrappers(raw_value);
if value.is_empty() {
continue;
}
let redacted = redact_value(value);
if trim_key(key).eq_ignore_ascii_case("sha256")
&& let Some(bare) = redacted.strip_prefix("sha256:")
{
return (format!("{key}{separator}{lead}{bare}{trail}"), Carry::None);
}
return (
format!("{key}{separator}{lead}{redacted}{trail}"),
Carry::None,
);
}
}
let carry = if names_a_credential || is_credential_key(core) {
Carry::Expecting
} else {
Carry::None
};
(redact_value(core), carry)
}
fn is_scheme_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')
}
fn is_url_terminator(c: char) -> bool {
c.is_whitespace() || matches!(c, '"' | ',' | '{' | '}' | '[' | ']' | '<' | '>')
}
fn is_url_head_terminator(c: char) -> bool {
is_url_terminator(c) || matches!(c, ';' | '&')
}
fn split_url(fragment: &str) -> Option<(&str, &str, &str, &str)> {
let separator = fragment.find("://")?;
let before = &fragment[..separator];
let scheme_len = before
.bytes()
.rev()
.take_while(|byte| is_scheme_byte(*byte))
.count();
if scheme_len == 0 {
return None;
}
let (prefix, scheme) = before.split_at(before.len() - scheme_len);
let after = &fragment[separator + "://".len()..];
let end = after.find(is_url_terminator).unwrap_or(after.len());
let url = &after[..end];
let query = url.find(['?', '#']).unwrap_or(url.len());
let end = url[..query].find(is_url_head_terminator).unwrap_or(end);
let (url, remainder) = after.split_at(end);
Some((prefix, scheme, url, remainder))
}
fn redact_url(scheme: &str, rest: &str) -> String {
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let (authority, tail) = rest.split_at(authority_end);
let host = match authority.rsplit_once('@') {
Some((_userinfo, host)) => format!("{REDACTION}@{host}"),
None => authority.to_string(),
};
match tail.find(['?', '#']) {
Some(cut) => {
let (path, query) = tail.split_at(cut);
let separator = &query[..1];
format!(
"{scheme}://{host}{}{separator}{REDACTION}",
redact_path(path)
)
}
None => format!("{scheme}://{host}{}", redact_path(tail)),
}
}
fn redact_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for (index, segment) in path.split('/').enumerate() {
if index > 0 {
out.push('/');
}
if !segment.is_empty() {
out.push_str(&redact_value(segment));
}
}
out
}
fn redact_value(value: &str) -> String {
let lower = value.to_ascii_lowercase();
if TOKEN_PREFIXES
.iter()
.any(|prefix| lower.starts_with(prefix))
{
return REDACTION.to_string();
}
if looks_like_path(value) {
return PATH_REDACTION.to_string();
}
if let Some(digest) = as_sha256_digest(value) {
return digest;
}
if looks_like_jwt(value) {
return REDACTION.to_string();
}
if value.len() >= OPAQUE_RUN_THRESHOLD && value.chars().all(is_opaque_char) {
return REDACTION.to_string();
}
value.to_string()
}
const SHA256_HEX_LEN: usize = 64;
const DIGEST_PREFIX_LEN: usize = 12;
fn as_sha256_digest(value: &str) -> Option<String> {
let is_digest = value.len() == SHA256_HEX_LEN
&& value
.chars()
.all(|c| c.is_ascii_digit() || c.is_ascii_lowercase() && c.is_ascii_hexdigit());
is_digest.then(|| format!("sha256:{}…", &value[..DIGEST_PREFIX_LEN]))
}
const JWT_SEGMENTS: usize = 3;
fn looks_like_jwt(value: &str) -> bool {
if value.len() < OPAQUE_RUN_THRESHOLD || !value.starts_with("eyJ") {
return false;
}
let mut segments = 0usize;
for segment in value.split('.') {
segments += 1;
if !segment.chars().all(is_opaque_char) {
return false;
}
}
(2..=JWT_SEGMENTS).contains(&segments)
}
fn looks_like_path(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
{
return true;
}
if value.starts_with("\\\\") {
return true;
}
if value.starts_with("~/") || value.starts_with("~\\") {
return true;
}
if let Some(rest) = value.strip_prefix('/') {
return rest.contains('/') && !rest.is_empty();
}
false
}
#[derive(Debug, Clone)]
struct SpanFields(Map<String, Value>);
#[derive(Debug, Default)]
struct RedactingVisitor {
fields: Map<String, Value>,
}
impl RedactingVisitor {
fn put_str(&mut self, name: &str, value: &str) {
let rendered = if is_field_allowed(name) {
redact(value)
} else {
REDACTION.to_string()
};
self.fields
.insert(name.to_string(), Value::String(rendered));
}
fn put_value(&mut self, name: &str, value: Value) {
if is_field_allowed(name) {
self.fields.insert(name.to_string(), value);
} else {
self.fields
.insert(name.to_string(), Value::String(REDACTION.to_string()));
}
}
}
impl Visit for RedactingVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.put_str(field.name(), value);
}
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
self.put_str(field.name(), &format!("{value:?}"));
}
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
self.put_str(field.name(), &value.to_string());
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.put_value(field.name(), Value::Bool(value));
}
fn record_i64(&mut self, field: &Field, value: i64) {
self.put_value(field.name(), Value::from(value));
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.put_value(field.name(), Value::from(value));
}
fn record_f64(&mut self, field: &Field, value: f64) {
self.put_value(field.name(), Value::from(value));
}
fn record_i128(&mut self, field: &Field, value: i128) {
self.put_str(field.name(), &value.to_string());
}
fn record_u128(&mut self, field: &Field, value: u128) {
self.put_str(field.name(), &value.to_string());
}
}
#[derive(Debug, Clone)]
pub struct RedactingLayer<W> {
writer: W,
}
impl<W> RedactingLayer<W> {
pub const fn new(writer: W) -> Self {
Self { writer }
}
}
impl<S, W> Layer<S> for RedactingLayer<W>
where
S: Subscriber + for<'a> LookupSpan<'a>,
W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + 'static,
{
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
id: &tracing::span::Id,
ctx: Context<'_, S>,
) {
let mut visitor = RedactingVisitor::default();
attrs.record(&mut visitor);
if let Some(span) = ctx.span(id) {
span.extensions_mut().insert(SpanFields(visitor.fields));
}
}
fn on_record(
&self,
id: &tracing::span::Id,
values: &tracing::span::Record<'_>,
ctx: Context<'_, S>,
) {
let mut visitor = RedactingVisitor::default();
values.record(&mut visitor);
if let Some(span) = ctx.span(id) {
let mut extensions = span.extensions_mut();
if let Some(existing) = extensions.get_mut::<SpanFields>() {
existing.0.extend(visitor.fields);
} else {
extensions.insert(SpanFields(visitor.fields));
}
}
}
fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
let mut visitor = RedactingVisitor::default();
event.record(&mut visitor);
let mut record = Map::new();
record.insert(
"timestamp".to_string(),
Value::String(chrono::Utc::now().to_rfc3339()),
);
record.insert(
"level".to_string(),
Value::String(event.metadata().level().to_string()),
);
record.insert(
"logger".to_string(),
Value::String(event.metadata().target().to_string()),
);
record.insert("fields".to_string(), Value::Object(visitor.fields));
let spans: Vec<Value> = ctx
.event_scope(event)
.into_iter()
.flat_map(tracing_subscriber::registry::Scope::from_root)
.map(|span| {
let mut entry = Map::new();
entry.insert("name".to_string(), Value::String(span.name().to_string()));
if let Some(fields) = span.extensions().get::<SpanFields>() {
entry.insert("fields".to_string(), Value::Object(fields.0.clone()));
}
Value::Object(entry)
})
.collect();
if !spans.is_empty() {
record.insert("spans".to_string(), Value::Array(spans));
}
let mut line = serde_json::to_string(&Value::Object(record)).unwrap_or_else(|_| {
String::from(
r#"{"level":"ERROR","fields":{"message":"a log record could not be encoded"}}"#,
)
});
line.push('\n');
let mut writer = self.writer.make_writer();
let _ = writer.write_all(line.as_bytes());
let _ = writer.flush();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LogRole {
Operator,
Service,
}
pub const OPERATOR_LOG_STEM: &str = "runner-manager.log";
pub const SERVICE_LOG_STEM: &str = "runner-manager.service.log";
impl LogRole {
#[must_use]
pub const fn file_stem(self) -> &'static str {
match self {
Self::Operator => OPERATOR_LOG_STEM,
Self::Service => SERVICE_LOG_STEM,
}
}
}
impl fmt::Display for LogRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Operator => "operator",
Self::Service => "service",
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum LoggingError {
#[error("cannot create the log directory {}: {source}", directory.display())]
Directory {
directory: PathBuf,
#[source]
source: crate::paths::PathsError,
},
#[error("cannot open a diagnostics file in {}: {attempts}", directory.display())]
Appender {
directory: PathBuf,
attempts: String,
},
#[error("a tracing subscriber is already installed for this process: {message}")]
AlreadyInstalled {
message: String,
},
}
#[must_use = "dropping the guard stops the log writer and silently discards later diagnostics"]
#[derive(Debug)]
pub struct LoggingGuard {
_worker: tracing_appender::non_blocking::WorkerGuard,
}
pub fn install(
paths: &crate::paths::AppPaths,
role: LogRole,
default_filter: &str,
) -> Result<LoggingGuard, LoggingError> {
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::util::SubscriberInitExt as _;
let directory = paths.logs_dir().to_path_buf();
paths
.create_all()
.map_err(|source| LoggingError::Directory {
directory: directory.clone(),
source,
})?;
let appender = open_appender(&directory, role)?;
let (writer, worker) = tracing_appender::non_blocking(appender);
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
tracing_subscriber::registry()
.with(filter)
.with(RedactingLayer::new(writer))
.try_init()
.map_err(|error| LoggingError::AlreadyInstalled {
message: error.to_string(),
})?;
Ok(LoggingGuard { _worker: worker })
}
fn open_appender(
directory: &Path,
role: LogRole,
) -> Result<tracing_appender::rolling::RollingFileAppender, LoggingError> {
let stem = role.file_stem();
let first = match build_appender(directory, stem) {
Ok(appender) => return Ok(appender),
Err(error) => error,
};
let qualified = account_qualified_stem(stem);
match build_appender(directory, &qualified) {
Ok(appender) => Ok(appender),
Err(second) => Err(LoggingError::Appender {
directory: directory.to_path_buf(),
attempts: format!(
"neither {stem}.<date> ({first}) nor {qualified}.<date> ({second}) could be \
appended to"
),
}),
}
}
fn build_appender(
directory: &Path,
stem: &str,
) -> Result<tracing_appender::rolling::RollingFileAppender, String> {
tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix(stem)
.build(directory)
.map_err(|error| error.to_string())
}
fn account_qualified_stem(stem: &str) -> String {
let account = account_tag();
match stem.rsplit_once('.') {
Some((head, tail)) => format!("{head}.{account}.{tail}"),
None => format!("{stem}.{account}"),
}
}
#[cfg(unix)]
fn account_tag() -> String {
let uid = unsafe { libc::geteuid() };
format!("uid-{uid}")
}
#[cfg(windows)]
fn account_tag() -> String {
let raw = std::env::var("USERNAME").unwrap_or_default();
let safe: String = raw
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.take(32)
.collect();
if safe.is_empty() {
"other-account".to_string()
} else {
format!("user-{safe}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use tracing_subscriber::layer::SubscriberExt as _;
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl Capture {
fn text(&self) -> String {
String::from_utf8_lossy(&self.0.lock().expect("not poisoned")).into_owned()
}
}
struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
impl Write for CaptureWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("not poisoned").extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
type Writer = CaptureWriter;
fn make_writer(&'a self) -> Self::Writer {
CaptureWriter(Arc::clone(&self.0))
}
}
#[derive(Debug, Clone)]
struct PassthroughLayer<W>(W);
#[derive(Default)]
struct PassthroughVisitor(Map<String, Value>);
impl Visit for PassthroughVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.0
.insert(field.name().to_string(), Value::String(value.to_string()));
}
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
self.0.insert(
field.name().to_string(),
Value::String(format!("{value:?}")),
);
}
}
impl<S, W> Layer<S> for PassthroughLayer<W>
where
S: Subscriber + for<'a> LookupSpan<'a>,
W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + 'static,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let mut visitor = PassthroughVisitor::default();
event.record(&mut visitor);
let mut line = serde_json::to_string(&Value::Object(visitor.0)).unwrap_or_default();
line.push('\n');
let _ = self.0.make_writer().write_all(line.as_bytes());
}
}
const USER_TOKEN: &str = "ghu_16C7e42F292c6912E7710c838347Ae178B4a";
const SERVER_TOKEN: &str = "ghs_1CGGYnBAtn5ov3M0aTHhP7l3ZKuMhIB3pnPd";
const FINE_GRAINED: &str =
"github_pat_11ABCDEFG0abcdefghijkl_ZYXWVUTSRQPONMLKJIHGFEDCBA9876543210zyxwv";
const JIT_BLOB: &str = "eyJhZ2VudE5hbWUiOiJydW5uZXItbWFuYWdlciIsImVuY29kZWQiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLz09In0=";
const WORKSPACE: &str = "/var/lib/runner-manager/runtime/9f2c/attempt-1";
const WINDOWS_WORKSPACE: &str = r"C:\Users\operator\AppData\Local\runner-manager\runtime\9f2c";
const JWT: &str = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.\
eyJpc3MiOiIxMjM0NTYiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMDYwMH0.\
c2lnbmF0dXJlLXRoYXQtaXMtb3BhcXVlLWFuZC1sb25nLWVub3VnaC10by1tYXR0ZXI";
#[derive(Debug)]
struct StoreError {
body: String,
}
fn secrets() -> Vec<&'static str> {
vec![
USER_TOKEN,
SERVER_TOKEN,
FINE_GRAINED,
JIT_BLOB,
JWT,
WORKSPACE,
WINDOWS_WORKSPACE,
]
}
fn scan_for_leaks<L>(layer: L, capture: &Capture) -> Result<String, String>
where
L: Layer<tracing_subscriber::Registry> + Send + Sync + 'static,
{
let subscriber = tracing_subscriber::registry().with(layer);
tracing::subscriber::with_default(subscriber, || {
for secret in secrets() {
tracing::info!("starting runner with {secret}");
tracing::info!("request failed; Authorization: Bearer {secret}");
tracing::warn!("retrying with x-api-key={secret}");
tracing::info!(runner_token = %secret, "registered");
tracing::info!(outcome = %secret, event = "started", "registered");
tracing::info!(message = %secret, "ignored");
tracing::info!(?secret, "debug shaped");
for key in ["encoded_jit_config", "runner_token", "pat"] {
tracing::error!("registration failed: {{\"{key}\":\"{secret}\"}}");
tracing::error!("registration failed: {{ \"{key}\": \"{secret}\" }}");
tracing::error!("registration failed: {key}:{secret}");
}
tracing::error!("response: {{\"authorization\":\"Bearer {secret}\"}}");
tracing::error!("response: {{ \"authorization\": \"Bearer {secret}\" }}");
tracing::error!("response: authorization:Bearer {secret}");
for key in ["encoded_jit_config", "runner_token", "access_token"] {
tracing::error!(
"registration failed: {{\"runner_id\":42,\"{key}\":\"{secret}\"}}"
);
tracing::error!(
"registration failed: {{\"status\":422,\"message\":\"bad\",\"{key}\":\"{secret}\"}}"
);
tracing::error!(
"registration failed: {{ \"runner_id\": 42, \"{key}\": \"{secret}\" }}"
);
}
tracing::error!("response: {{\"body\":{{\"runner_token\":\"{secret}\"}}}}");
tracing::error!(
"response: {{\"error\":{{\"status\":422,\"body\":{{\"encoded_jit_config\":\"{secret}\"}}}}}}"
);
tracing::error!("token exchange failed: scope=repo&access_token={secret}");
tracing::error!(
"token exchange failed: grant_type=refresh&refresh_token={secret}&scope=repo"
);
let failure = StoreError {
body: format!("{{\"runner_token\":\"{secret}\"}}"),
};
tracing::error!(reason = ?failure, "the secret store rejected the request");
tracing::error!(
"api failed: {{\"message\":\"Bad credentials\",\"documentation_url\":\"https://docs.github.com/rest\",\"token\":\"{secret}\"}}"
);
tracing::error!(
"api failed: {{\"token\":\"{secret}\",\"documentation_url\":\"https://docs.github.com/rest\"}}"
);
tracing::error!(
"clone failed: {{\"remote\":\"https://github.com/o/r.git\",\"body\":{{\"password\":\"{secret}\"}}}}"
);
tracing::error!("registration failed: {{\"tokens\":[\"{secret}\",\"x\"]}}");
tracing::error!("registration failed: {{\"tokens\":[\"x\",\"{secret}\"]}}");
tracing::error!("registration failed: [\"x\",\"{secret}\"]");
tracing::error!("registration failed: [{{\"id\":1}},[\"{secret}\"]]");
let listed = StoreError {
body: format!("{{\"tokens\":[\"x\",\"{secret}\"]}}"),
};
tracing::error!(reason = ?listed, "the secret store rejected the list");
tracing::error!("store rejected: Server=host;Database=x;Password={secret};");
tracing::error!("store rejected: user=operator;password={secret}");
tracing::error!("store rejected: theme=dark;session={secret}");
tracing::error!("plist rejected: <string>{secret}</string>");
tracing::error!("plist rejected: <key>token</key><string>{secret}</string>");
tracing::error!(
"store rejected: Server=https://vault.local/api;Password={secret};"
);
tracing::error!("keychain error: url=https://kc.local;secret={secret}");
tracing::error!("unit rejected: Environment=API=https://a.com/v1;TOKEN={secret}");
tracing::error!("token exchange failed: cb=https://a.com/x&access_token={secret}");
tracing::error!(
"dsn rejected: dsn=https://sentry.local/1;password={secret};user=x"
);
tracing::error!(
"callback failed: redirect=https://a.com/cb&state=1&access_token={secret}"
);
let routed = StoreError {
body: format!("Server=https://vault.local/api;Password={secret};"),
};
tracing::error!(reason = ?routed, "the secret store rejected the connection string");
tracing::error!("store rejected: {{\"password\":[\"{secret}\"]}}");
tracing::error!("store rejected: {{\"password\":{{\"v\":\"{secret}\"}}}}");
tracing::error!("store rejected: Password=;{secret}");
tracing::error!("plist rejected: <key>password</key><string>{secret}</string>");
tracing::error!("store rejected: {{\"password\":\"a<{secret}>b\"}}");
tracing::error!("store rejected: {{\"password\":\"a&{secret},b\"}}");
if !looks_like_path(secret) {
tracing::error!("download failed: https://github.com/o/r/raw/{secret}/f");
tracing::error!("download failed: https://github.com/o/r/raw/{secret}");
}
let span = tracing::info_span!("attempt", jit_config = %secret);
let _entered = span.enter();
tracing::info!(event = "inside_span", "in a span");
}
});
let output = capture.text();
for secret in secrets() {
for needle in needles(secret) {
if output.contains(&needle) {
return Err(format!(
"the sink emitted a secret verbatim: {secret}\n\
(found as: {needle})\n--- output ---\n{output}"
));
}
}
}
Ok(output)
}
fn needles(secret: &str) -> Vec<String> {
let escape = |text: &str| {
let json = serde_json::to_string(text).expect("a string is serialisable");
json[1..json.len() - 1].to_string()
};
let once = escape(secret);
let twice = escape(&once);
let mut spellings = vec![secret.to_string(), once, twice];
spellings.dedup();
spellings
}
#[test]
fn the_scan_looks_for_a_needle_that_can_actually_occur() {
let windows = needles(WINDOWS_WORKSPACE);
assert_eq!(
windows.len(),
3,
"a backslash path has three spellings, one per level of escaping it \
can pass through on the way out: {windows:?}"
);
assert!(
windows[1].contains(r"\\Users\\operator"),
"the once-escaped spelling is what an ordinary JSON line contains: {windows:?}"
);
assert!(
windows[2].contains(r"\\\\Users\\\\operator"),
"the twice-escaped spelling is what a Debug rendering inside a JSON \
line contains: {windows:?}"
);
let shape_twelve = serde_json::to_string(&Value::String(format!(
"{:?}",
StoreError {
body: format!("{{\"runner_token\":\"{WINDOWS_WORKSPACE}\"}}"),
}
)))
.expect("serialisable");
assert!(
shape_twelve.contains(&windows[2]),
"the twice-escaped needle must be findable in what shape 12 emits: {shape_twelve}"
);
assert!(
!shape_twelve.contains(&windows[1]),
"and the once-escaped one must not be, or the gap was never there: {shape_twelve}"
);
let rendered = serde_json::to_string(&Value::String(WINDOWS_WORKSPACE.to_string()))
.expect("serialisable");
assert!(
!rendered.contains(WINDOWS_WORKSPACE),
"if this ever contains the raw path, the original scan was fine after all: {rendered}"
);
assert!(rendered.contains(&windows[1]), "{rendered}");
assert_eq!(needles(USER_TOKEN), vec![USER_TOKEN.to_string()]);
}
#[test]
fn the_secret_injection_scan_finds_nothing() {
let capture = Capture::default();
let output = scan_for_leaks(RedactingLayer::new(capture.clone()), &capture)
.unwrap_or_else(|complaint| panic!("{complaint}"));
assert!(!output.trim().is_empty(), "the sink wrote nothing at all");
assert!(
output.contains(REDACTION),
"nothing was redacted, so nothing was routed through the sink:\n{output}"
);
}
#[test]
fn the_scan_catches_a_sink_that_does_not_redact() {
let capture = Capture::default();
let complaint = scan_for_leaks(PassthroughLayer(capture.clone()), &capture)
.expect_err("a sink with no redaction must be caught");
assert!(
complaint.contains("emitted a secret verbatim"),
"the complaint must name the failure mode: {complaint}"
);
}
fn emit(capture: &Capture, body: impl FnOnce()) -> String {
let subscriber = tracing_subscriber::registry().with(RedactingLayer::new(capture.clone()));
tracing::subscriber::with_default(subscriber, body);
capture.text()
}
#[test]
fn a_field_nobody_listed_is_redacted_by_default() {
let capture = Capture::default();
let output = emit(&capture, || {
tracing::info!(
a_field_added_by_a_later_task = "supersecret-value",
"an event"
);
});
assert!(
!output.contains("supersecret-value"),
"an unlisted field leaked its value:\n{output}"
);
assert!(output.contains(REDACTION), "{output}");
assert!(
output.contains("a_field_added_by_a_later_task"),
"the field name should be kept:\n{output}"
);
}
#[test]
fn an_unlisted_numeric_field_is_redacted_too() {
let capture = Capture::default();
let output = emit(&capture, || {
tracing::info!(unlisted_number = 8_675_309_u64, "an event");
});
assert!(!output.contains("8675309"), "{output}");
assert!(output.contains(REDACTION), "{output}");
}
#[test]
fn listed_fields_survive() {
let capture = Capture::default();
let output = emit(&capture, || {
tracing::info!(
event = "reconciled",
policy_id = "9f2c1a44-0000-4000-8000-000000000001",
count = 3,
outcome = "started",
"reconciliation finished"
);
});
for expected in [
"reconciled",
"9f2c1a44-0000-4000-8000-000000000001",
"started",
"reconciliation finished",
] {
assert!(
output.contains(expected),
"{expected} missing from:\n{output}"
);
}
assert!(output.contains("\"count\":3"), "{output}");
}
#[test]
fn the_allowlist_is_sorted_and_has_no_duplicates() {
let mut sorted = ALLOWED_FIELDS.to_vec();
sorted.sort_unstable();
assert_eq!(
ALLOWED_FIELDS,
&sorted[..],
"ALLOWED_FIELDS must stay sorted"
);
let mut deduped = sorted.clone();
deduped.dedup();
assert_eq!(sorted.len(), deduped.len(), "ALLOWED_FIELDS has duplicates");
for name in ALLOWED_FIELDS {
assert!(is_field_allowed(name), "{name} is listed but not allowed");
}
assert!(!is_field_allowed("authorization"));
assert!(!is_field_allowed("runner_token"));
}
#[test]
fn every_record_is_one_parseable_json_object_per_line() {
let capture = Capture::default();
let output = emit(&capture, || {
tracing::info!(event = "one", "first");
tracing::warn!(event = "two", "second");
});
let lines: Vec<&str> = output
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
assert_eq!(lines.len(), 2, "{output}");
for line in lines {
let value: Value = serde_json::from_str(line)
.unwrap_or_else(|error| panic!("not JSON: {error}\n{line}"));
assert!(value.get("timestamp").is_some(), "{line}");
assert!(value.get("level").is_some(), "{line}");
assert!(value.get("logger").is_some(), "{line}");
assert!(value.get("fields").is_some(), "{line}");
}
}
#[test]
fn span_fields_are_redacted_and_carried() {
let capture = Capture::default();
let output = emit(&capture, || {
let span = tracing::info_span!("attempt", attempt_id = "abc-123", jit = %JIT_BLOB);
let _entered = span.enter();
tracing::info!(event = "inside", "in the span");
});
assert!(output.contains("\"name\":\"attempt\""), "{output}");
assert!(
output.contains("abc-123"),
"the listed span field survives:\n{output}"
);
assert!(!output.contains(JIT_BLOB), "a span field leaked:\n{output}");
}
fn assert_install_created_restricted_directories(paths: &crate::paths::AppPaths) {
for (purpose, path) in paths.all() {
assert!(
path.is_dir(),
"install must create the {purpose} directory too: going through \
AppPaths::create_all is what applies the restriction, and creating only \
logs/ is the bug this asserts against ({})",
path.display()
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(path)
.expect("the directory exists")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o700,
"the {purpose} directory is mode {mode:04o}; a diagnostics file under a \
group- or world-readable directory is readable by other local accounts"
);
}
}
}
#[test]
#[serial_test::serial(global_subscriber)]
fn install_writes_redacted_json_into_the_logs_directory() {
if std::env::var_os("RUST_LOG").is_some() {
return;
}
let root = tempfile::tempdir().expect("a temporary directory");
let paths = crate::paths::AppPaths::rooted_at(root.path());
let outcome = install(&paths, LogRole::Operator, "trace");
assert_install_created_restricted_directories(&paths);
let Ok(guard) = outcome else {
return;
};
tracing::info!(event = "installed", runner_token = %USER_TOKEN, "hello from the sink");
drop(guard);
let written: Vec<PathBuf> = std::fs::read_dir(paths.logs_dir())
.expect("the log directory was created")
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
assert_eq!(
written.len(),
1,
"expected one rotating log file: {written:?}"
);
let contents = std::fs::read_to_string(&written[0]).expect("readable");
assert!(contents.contains("hello from the sink"), "{contents}");
assert!(
!contents.contains(USER_TOKEN),
"the file sink must redact exactly like the in-memory one:\n{contents}"
);
assert!(contents.contains(REDACTION), "{contents}");
serde_json::from_str::<Value>(contents.lines().next().expect("a line"))
.expect("each line is a JSON object");
}
#[test]
fn the_daemon_and_the_operator_write_different_files() {
assert_ne!(
LogRole::Service.file_stem(),
LogRole::Operator.file_stem(),
"a boot-mode daemon runs as another account and creates its file 0644; sharing a \
stem hands whichever of them opened it first the day's file and locks the other out"
);
assert_eq!(LogRole::Operator.file_stem(), OPERATOR_LOG_STEM);
assert_eq!(LogRole::Service.file_stem(), SERVICE_LOG_STEM);
}
#[cfg(unix)]
#[test]
fn a_log_file_this_account_cannot_append_to_is_written_beside() {
use std::os::unix::fs::PermissionsExt as _;
if account_tag() == "uid-0" {
return;
}
let root = tempfile::tempdir().expect("a temporary directory");
let paths = crate::paths::AppPaths::rooted_at(root.path());
paths
.create_all()
.expect("the four directories are created");
let logs = paths.logs_dir().to_path_buf();
let today = chrono::Utc::now();
for date in [
(today - chrono::Duration::days(1))
.format("%Y-%m-%d")
.to_string(),
today.format("%Y-%m-%d").to_string(),
(today + chrono::Duration::days(1))
.format("%Y-%m-%d")
.to_string(),
] {
let taken = logs.join(format!("{OPERATOR_LOG_STEM}.{date}"));
std::fs::write(&taken, b"another account's diagnostics").expect("writable");
std::fs::set_permissions(&taken, std::fs::Permissions::from_mode(0o444))
.expect("the mode is applied");
}
let mut appender = open_appender(&logs, LogRole::Operator)
.expect("a diagnostics file is opened beside the one this account may not append to");
appender
.write_all(b"a line\n")
.expect("the line is written");
appender.flush().expect("the line is flushed");
let qualified = account_qualified_stem(OPERATOR_LOG_STEM);
let written: Vec<String> = std::fs::read_dir(&logs)
.expect("the directory is readable")
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
assert!(
written.iter().any(|name| name.starts_with(&qualified)),
"expected a file under {qualified:?} beside the unwritable ones, and found {written:?}"
);
for name in &written {
if name.starts_with(&qualified) {
continue;
}
assert_eq!(
std::fs::read(logs.join(name)).expect("readable"),
b"another account's diagnostics",
"{name} belongs to another account and must not have been touched"
);
}
}
#[test]
fn tokens_are_redacted_wherever_they_appear() {
for token in [USER_TOKEN, SERVER_TOKEN, FINE_GRAINED] {
for shape in [
token.to_string(),
format!("using {token} now"),
format!("(\"{token}\")"),
format!("token={token}"),
format!("Authorization: Bearer {token}"),
format!("authorization={token}"),
format!("https://x-access-token:{token}@github.com/owner/repo.git"),
format!("fatal: could not read from https://{token}@github.com/o/r"),
format!("https://{token}@github.com/o/r.git"),
format!("https://github.com/login/oauth#access_token={token}"),
format!("https://github.com/x?a=1#token={token}"),
] {
let redacted = redact(&shape);
assert!(
!redacted.contains(token),
"{shape:?} survived redaction as {redacted:?}"
);
}
}
}
#[test]
fn a_url_keeps_what_diagnoses_it_and_loses_what_authenticates_it() {
assert_eq!(
redact(
"https://x-access-token:ghu_16C7e42F292c6912E7710c838347Ae178B4a@github.com/owner/repo.git"
),
format!("https://{REDACTION}@github.com/owner/repo.git")
);
assert_eq!(
redact("https://user:hunter2@api.github.com/repos/o/r?page=2"),
format!("https://{REDACTION}@api.github.com/repos/o/r?{REDACTION}")
);
assert_eq!(
redact("https://user:p@ss@github.com/o/r"),
format!("https://{REDACTION}@github.com/o/r")
);
assert_eq!(
redact("https://token@github.com"),
format!("https://{REDACTION}@github.com")
);
assert_eq!(
redact("https://github.com/@owner/repo"),
"https://github.com/@owner/repo"
);
}
#[test]
fn an_encoded_jit_configuration_is_redacted() {
assert_eq!(redact(JIT_BLOB), REDACTION);
let sentence = format!("handing off {JIT_BLOB} to the runner");
let redacted = redact(&sentence);
assert!(!redacted.contains(JIT_BLOB), "{redacted}");
assert!(redacted.starts_with("handing off "), "{redacted}");
assert!(redacted.ends_with(" to the runner"), "{redacted}");
}
#[test]
fn a_secret_embedded_in_a_structured_value_is_redacted() {
for shape in [
format!("{{\"encoded_jit_config\":\"{JIT_BLOB}\"}}"),
format!("{{ \"encoded_jit_config\": \"{JIT_BLOB}\" }}"),
format!("encoded_jit_config:{JIT_BLOB}"),
format!("{{\"runner_token\":\"{USER_TOKEN}\"}}"),
format!("runner_token:{USER_TOKEN}"),
format!("pat:{USER_TOKEN}"),
format!("{{\"authorization\":\"Bearer {USER_TOKEN}\"}}"),
] {
let redacted = redact(&shape);
assert!(
!redacted.contains(JIT_BLOB) && !redacted.contains(USER_TOKEN),
"leaked from {shape}:\n{redacted}"
);
}
for shape in [
"{\"password\":\"hunter2\"}",
"{ \"password\": \"hunter2\" }",
] {
let redacted = redact(shape);
assert!(
!redacted.contains("hunter2"),
"leaked from {shape}: {redacted}"
);
}
let body = format!("registration failed: {{\"encoded_jit_config\":\"{JIT_BLOB}\"}}");
let redacted = redact(&body);
assert!(redacted.starts_with("registration failed: "), "{redacted}");
assert!(redacted.contains("encoded_jit_config"), "{redacted}");
}
#[test]
fn a_credential_key_is_found_wherever_it_sits_in_a_compact_structure() {
for shape in [
format!("{{\"runner_id\":42,\"encoded_jit_config\":\"{JIT_BLOB}\"}}"),
format!("{{\"status\":422,\"message\":\"bad\",\"runner_token\":\"{USER_TOKEN}\"}}"),
format!("{{\"body\":{{\"runner_token\":\"{USER_TOKEN}\"}}}}"),
format!(
"{{\"error\":{{\"status\":422,\"body\":{{\"encoded_jit_config\":\"{JIT_BLOB}\"}}}}}}"
),
format!("[{{\"id\":1}},{{\"access_token\":\"{USER_TOKEN}\"}}]"),
format!("scope=repo&access_token={USER_TOKEN}"),
format!("grant_type=refresh&refresh_token={USER_TOKEN}&scope=repo"),
] {
let redacted = redact(&shape);
assert!(
!redacted.contains(JIT_BLOB) && !redacted.contains(USER_TOKEN),
"leaked from {shape}:\n{redacted}"
);
assert!(redacted.contains(REDACTION), "{shape} -> {redacted}");
}
let redacted = redact(&format!(
"failed: {{\"runner_id\":42,\"encoded_jit_config\":\"{JIT_BLOB}\"}}"
));
assert!(redacted.starts_with("failed: "), "{redacted}");
assert!(redacted.contains("\"runner_id\":42"), "{redacted}");
assert!(redacted.contains("encoded_jit_config"), "{redacted}");
for shape in [
"{\"user\":\"operator\",\"password\":\"hunter2\"}",
"{\"body\":{\"password\":\"hunter2\"}}",
"user=operator&password=hunter2",
] {
assert!(
!redact(shape).contains("hunter2"),
"leaked from {shape}: {}",
redact(shape)
);
}
assert_eq!(
redact("desired 3, active 1, headroom 2"),
"desired 3, active 1, headroom 2"
);
assert_eq!(
redact("labels=[linux,x64,self-hosted]"),
"labels=[linux,x64,self-hosted]"
);
}
#[test]
fn backslash_escaped_json_reaches_the_key_rules_and_the_value_rules() {
let failure = StoreError {
body: "{\"password\":\"hunter2\"}".to_string(),
};
assert!(failure.body.contains("hunter2"), "{}", failure.body);
let rendered = format!("{failure:?}");
assert!(
rendered.contains("\\\"password\\\""),
"the premise is that Debug escapes the quotes: {rendered}"
);
assert!(
!redact(&rendered).contains("hunter2"),
"leaked: {}",
redact(&rendered)
);
for shape in [
"{\\\"password\\\":\\\"hunter2\\\"}",
"Error { body: \"{\\\"access_token\\\":\\\"hunter2\\\"}\" }",
] {
assert!(
!redact(shape).contains("hunter2"),
"leaked from {shape}: {}",
redact(shape)
);
}
let escaped = format!("{{\\\"encoded_jit_config\\\":\\\"{JIT_BLOB}\\\"}}");
assert!(!redact(&escaped).contains(JIT_BLOB), "{}", redact(&escaped));
assert!(
!is_credential_key("runner_token"),
"the premise of this case is an unlisted key; once it is listed, \
this stops exercising the value side at all"
);
let escaped_value = format!("{{\\\"runner_token\\\":\\\"{USER_TOKEN}\\\"}}");
assert!(
!redact(&escaped_value).contains(USER_TOKEN),
"the value side leaked: {}",
redact(&escaped_value)
);
assert_eq!(redact(r"\\fileserver\share\jit"), PATH_REDACTION);
assert_eq!(redact(r"\\?\C:\Users\operator\runtime"), PATH_REDACTION);
assert_eq!(redact(r"\\\\fileserver\\share\\jit"), PATH_REDACTION);
let trailing = redact("C:\\Users\\operator\\runtime\\");
assert!(trailing.starts_with(PATH_REDACTION), "{trailing}");
assert!(!trailing.contains("operator"), "{trailing}");
}
#[test]
fn a_url_does_not_make_the_rest_of_its_word_unredactable() {
let body = format!(
"{{\"message\":\"Bad credentials\",\"documentation_url\":\"https://docs.github.com/rest\",\"token\":\"{USER_TOKEN}\"}}"
);
let redacted = redact(&body);
assert!(!redacted.contains(USER_TOKEN), "leaked: {redacted}");
assert!(
redacted.contains("https://docs.github.com/rest"),
"the URL should survive: {redacted}"
);
let reversed = format!(
"{{\"token\":\"{USER_TOKEN}\",\"documentation_url\":\"https://docs.github.com/rest\"}}"
);
let redacted = redact(&reversed);
assert!(!redacted.contains(USER_TOKEN), "leaked: {redacted}");
let clone = format!(
"{{\"remote\":\"https://x-access-token:{USER_TOKEN}@github.com/o/r.git\",\"body\":{{\"password\":\"hunter2\"}}}}"
);
let redacted = redact(&clone);
assert!(!redacted.contains(USER_TOKEN), "leaked: {redacted}");
assert!(!redacted.contains("hunter2"), "leaked: {redacted}");
assert!(
redacted.contains("@github.com/o/r.git"),
"the remote should stay diagnosable: {redacted}"
);
assert_eq!(
redact(&format!(
"{{\"url\":\"https://api.github.com/x?token={USER_TOKEN}\"}}"
)),
format!("{{\"url\":\"https://api.github.com/x?{REDACTION}\"}}")
);
assert_eq!(
redact("GET https://api.github.com/repos/o/r/actions/runners"),
"GET https://api.github.com/repos/o/r/actions/runners"
);
assert_eq!(
redact("token=https://evil.example/x"),
"token=https://evil.example/x"
);
assert_eq!(
redact("{\"token\":\"https://evil.example/x\"}"),
"{\"token\":\"https://evil.example/x\"}"
);
assert_eq!(redact("{\"password\":\"\"}"), "{\"password\":\"\"}");
}
#[test]
fn an_array_element_is_judged_without_the_quote_that_wraps_it() {
for shape in [
format!("{{\"tokens\":[\"{USER_TOKEN}\",\"x\"]}}"),
format!("{{\"tokens\":[\"x\",\"{USER_TOKEN}\"]}}"),
format!("[\"x\",\"{USER_TOKEN}\"]"),
format!("[{{\"id\":1}},[\"{USER_TOKEN}\"]]"),
format!("{{\\\"tokens\\\":[\\\"x\\\",\\\"{USER_TOKEN}\\\"]}}"),
] {
let redacted = redact(&shape);
assert!(
!redacted.contains(USER_TOKEN),
"leaked from {shape}: {redacted}"
);
assert!(redacted.contains(REDACTION), "{shape} -> {redacted}");
}
let jit = format!("{{\"items\":[\"{JIT_BLOB}\"]}}");
assert!(!redact(&jit).contains(JIT_BLOB), "{}", redact(&jit));
let assertions = format!("{{\"assertions\":[\"{JWT}\"]}}");
assert!(
!redact(&assertions).contains(JWT),
"{}",
redact(&assertions)
);
let opaque = "ZYXWVUTSRQPONMLKJIHGFEDCBA9876543210zyxwvut";
assert!(opaque.len() > OPAQUE_RUN_THRESHOLD, "{}", opaque.len());
let listed = format!("[\"x\",\"{opaque}\"]");
assert!(!redact(&listed).contains(opaque), "{}", redact(&listed));
let roots = format!("{{\"roots\":[\"x\",\"{WINDOWS_WORKSPACE}\"]}}");
let redacted = redact(&roots);
assert!(!redacted.contains(WINDOWS_WORKSPACE), "leaked: {redacted}");
assert!(redacted.contains(PATH_REDACTION), "{redacted}");
assert_eq!(
redact("labels=[\"linux\",\"x64\"]"),
"labels=[\"linux\",\"x64\"]"
);
}
#[test]
fn a_semicolon_cuts_a_word_the_way_a_comma_does() {
for shape in [
"store rejected: Server=host;Database=x;Password=hunter2;",
"store rejected: user=operator;password=hunter2",
"Server=host;Password=hunter2;Database=x",
] {
let redacted = redact(shape);
assert!(
!redacted.contains("hunter2"),
"leaked from {shape}: {redacted}"
);
}
let cookies = format!("theme=dark;session={USER_TOKEN}");
let redacted = redact(&cookies);
assert!(!redacted.contains(USER_TOKEN), "leaked: {redacted}");
assert_eq!(
redact("started; then reconciled; then idled"),
"started; then reconciled; then idled"
);
assert_eq!(
redact("desired 3;active 1;headroom 2"),
"desired 3;active 1;headroom 2"
);
}
#[test]
fn an_element_wrapped_value_is_redacted() {
for shape in [
format!("<string>{USER_TOKEN}</string>"),
format!("<key>token</key><string>{USER_TOKEN}</string>"),
format!("<dict><key>Token</key><string>{USER_TOKEN}</string></dict>"),
] {
let redacted = redact(&shape);
assert!(
!redacted.contains(USER_TOKEN),
"leaked from {shape}: {redacted}"
);
assert!(redacted.contains(REDACTION), "{shape} -> {redacted}");
}
let plist = format!("<key>token</key><string>{JIT_BLOB}</string>");
let redacted = redact(&plist);
assert!(!redacted.contains(JIT_BLOB), "leaked: {redacted}");
assert!(redacted.contains("<key>token</key>"), "{redacted}");
assert_eq!(redact("Custom<Io>"), "Custom<Io>");
}
#[test]
fn a_redacted_value_keeps_the_punctuation_that_wrapped_it() {
assert_eq!(
redact("{\"password\":\"hunter2\"}"),
format!("{{\"password\":\"{REDACTION}\"}}")
);
assert_eq!(
redact(&format!("{{\"access_token\":\"{USER_TOKEN}\"}}")),
format!("{{\"access_token\":\"{REDACTION}\"}}")
);
assert_eq!(
redact(&format!(
"[{{\"id\":1}},{{\"access_token\":\"{USER_TOKEN}\"}}]"
)),
format!("[{{\"id\":1}},{{\"access_token\":\"{REDACTION}\"}}]")
);
assert_eq!(
redact("{\\\"password\\\":\\\"hunter2\\\"}"),
format!("{{\\\"password\\\":\\\"{REDACTION}\\\"}}")
);
let line = redact(&format!(
"{{\"runner_id\":42,\"access_token\":\"{USER_TOKEN}\"}}"
));
serde_json::from_str::<Value>(&line)
.unwrap_or_else(|error| panic!("a redacted body must still parse: {line} ({error})"));
}
#[test]
fn a_bare_jwt_is_redacted_despite_its_dots() {
assert!(
JWT.len() > 100,
"the premise is a long token: {}",
JWT.len()
);
assert_eq!(redact(JWT), REDACTION);
assert_eq!(
redact(&format!("minted {JWT} for the installation")),
format!("minted {REDACTION} for the installation")
);
assert!(!redact(&format!("minted {JWT}.")).contains(JWT));
assert!(!redact(&format!("assertion={JWT}")).contains(JWT));
assert!(!redact(&format!("{{\"assertion\":\"{JWT}\"}}")).contains(JWT));
for ordinary in [
"com.example.runner.manager.platform.process.identity.token",
"runner-manager.2026.08.21.log",
"api.github.com",
"9f2c1a44-0000-4000-8000-000000000001.attempt.json",
] {
assert_eq!(redact(ordinary), ordinary, "over-redacted {ordinary}");
}
}
#[test]
fn the_sink_redacts_a_short_credential_that_only_its_key_gives_away() {
let capture = Capture::default();
let output = emit(&capture, || {
tracing::error!("store rejected: {{\"user\":\"operator\",\"password\":\"hunter2\"}}");
tracing::error!("store rejected: {{\"body\":{{\"password\":\"hunter2\"}}}}");
tracing::error!("store rejected: user=operator&password=hunter2");
let failure = StoreError {
body: "{\"password\":\"hunter2\"}".to_string(),
};
tracing::error!(reason = ?failure, "store rejected the request");
tracing::error!("store rejected: Server=https://vault.local/api;Password=hunter2;");
tracing::error!("keychain error: url=https://kc.local;password=hunter2");
tracing::error!("unit rejected: Environment=API=https://a.com/v1;PASSWORD=hunter2");
tracing::error!("token exchange failed: cb=https://a.com/x&password=hunter2");
tracing::error!("store rejected: {{\"password\":[\"hunter2\"]}}");
tracing::error!("store rejected: {{\"password\":{{\"v\":\"hunter2\"}}}}");
tracing::error!("store rejected: Password=;hunter2");
tracing::error!("plist rejected: <key>password</key><string>hunter2</string>");
tracing::error!(
"plist rejected: <dict><key>password</key><string>hunter2</string></dict>"
);
tracing::error!("store rejected: {{\"password\":\"qq<vv>xx\"}}");
tracing::error!("store rejected: {{\"password\":\"j1&k2,m3\"}}");
tracing::error!("store rejected: {{\"password\":\"n4;p5<q6>r7,t8\"}}");
});
assert!(
!output.contains("hunter2"),
"a short credential leaked through the sink:\n{output}"
);
for piece in [
"qq", "vv", "xx", "j1", "k2", "m3", "n4", "p5", "q6", "r7", "t8",
] {
assert!(
!output.contains(piece),
"a structural character split a secret and the tail survived \
({piece}):\n{output}"
);
}
assert!(output.contains(REDACTION), "{output}");
assert_eq!(
output
.lines()
.filter(|line| !line.trim().is_empty())
.count(),
16,
"{output}"
);
}
#[test]
fn a_credential_value_that_runs_past_its_own_word_is_still_redacted() {
let capture = Capture::default();
let output = emit(&capture, || {
tracing::error!("plist rejected: <key>password</key><string>correct horse</string>");
tracing::error!("plist rejected: <key>password</key> <string>correct horse</string>");
});
for piece in ["correct", "horse"] {
assert!(
!output.contains(piece),
"a multi-word credential value leaked ({piece}):\n{output}"
);
}
}
#[test]
fn the_fragment_carry_stops_where_the_value_does() {
for (input, expected) in [
(
"{\"password\":\"abc\",\"user\":\"bob\"}",
format!("{{\"password\":\"{REDACTION}\",\"user\":\"bob\"}}"),
),
(
"Server=host;Password=hunter2;User=bob",
format!("Server=host;Password={REDACTION};User=bob"),
),
(
"{\"password\":\"\",\"user\":\"bob\"}",
"{\"password\":\"\",\"user\":\"bob\"}".to_string(),
),
(
"<dict><key>password</key><string>hunter2</string></dict>",
format!("<dict><key>password</key><string>{REDACTION}</string></dict>"),
),
(
"{\"password\":\"qq<vv>xx\"}",
format!("{{\"password\":\"{REDACTION}<{REDACTION}>{REDACTION}\"}}"),
),
] {
assert_eq!(redact(input), expected, "from {input}");
}
assert_eq!(
redact("{\"password\":\"x\"} ok"),
format!("{{\"password\":\"{REDACTION}\"}} ok")
);
assert_eq!(
redact("Server=host;Password=hunter2; ok"),
format!("Server=host;Password={REDACTION}; ok")
);
assert_eq!(
redact("{\"password\":\"\"} ok"),
format!("{{\"password\":\"\"}} {REDACTION}")
);
assert_eq!(redact("{\"password\":\"\"}"), "{\"password\":\"\"}");
assert_eq!(
redact("started; then reconciled; then idled"),
"started; then reconciled; then idled"
);
assert_eq!(redact("Custom<Io>"), "Custom<Io>");
}
#[test]
fn paths_are_redacted_on_both_families() {
assert_eq!(redact(WORKSPACE), PATH_REDACTION);
assert_eq!(redact(WINDOWS_WORKSPACE), PATH_REDACTION);
assert_eq!(redact("\\\\fileserver\\share\\jit"), PATH_REDACTION);
assert_eq!(
redact("~/Library/Application Support/x"),
format!("{PATH_REDACTION} Support/x")
);
assert_eq!(
redact("runtime=/var/lib/runner-manager/runtime"),
format!("runtime={PATH_REDACTION}")
);
}
#[test]
fn a_url_survives_but_its_query_string_and_fragment_do_not() {
assert_eq!(
redact("GET https://api.github.com/repos/owner/repo/actions/runners"),
"GET https://api.github.com/repos/owner/repo/actions/runners"
);
assert_eq!(
redact("https://api.github.com/x?access_token=ghu_secret"),
format!("https://api.github.com/x?{REDACTION}")
);
assert_eq!(
redact("https://api.github.com/x#access_token=ghu_secret"),
format!("https://api.github.com/x#{REDACTION}")
);
assert_eq!(
redact("https://api.github.com/x?page=2#access_token=ghu_secret"),
format!("https://api.github.com/x?{REDACTION}")
);
}
#[test]
fn a_separator_after_a_url_still_cuts_the_word() {
for shape in [
format!("store rejected: Server=https://vault.local/api;Password={USER_TOKEN};"),
format!("keychain error: url=https://kc.local;secret={USER_TOKEN}"),
format!("unit rejected: Environment=API=https://a.com/v1;TOKEN={USER_TOKEN}"),
format!("token exchange failed: cb=https://a.com/x&access_token={USER_TOKEN}"),
format!("dsn rejected: dsn=https://sentry.local/1;password={USER_TOKEN};user=x"),
] {
let redacted = redact(&shape);
assert!(
!redacted.contains(USER_TOKEN),
"leaked from {shape}:\n{redacted}"
);
}
assert_eq!(
redact("store rejected: Server=https://vault.local/api;Password=hunter2;"),
format!("store rejected: Server=https://vault.local/api;Password={REDACTION};")
);
assert_eq!(
redact("https://a.com/cb?code=1&state=hunter2"),
format!("https://a.com/cb?{REDACTION}")
);
assert_eq!(
redact("https://a.com/cb#code=1&state=hunter2"),
format!("https://a.com/cb#{REDACTION}")
);
assert_eq!(
redact(&format!(
"https://a.com/cb?a=1;b=2&access_token={USER_TOKEN}"
)),
format!("https://a.com/cb?{REDACTION}")
);
assert_eq!(
redact(r"https://x-access-token:C:\Users\op\p@github.com/o/r.git"),
format!("https://{REDACTION}@github.com/o/r.git")
);
}
#[test]
fn a_secret_in_a_url_path_is_redacted_and_the_rest_of_the_path_is_not() {
for shape in [
format!("https://github.com/o/r/raw/{USER_TOKEN}/f"),
format!("https://api.github.com/{JIT_BLOB}"),
format!("https://a.com/x/{JWT}?page=2"),
] {
let redacted = redact(&shape);
assert!(
!redacted.contains(USER_TOKEN)
&& !redacted.contains(JIT_BLOB)
&& !redacted.contains(JWT),
"leaked from {shape}:\n{redacted}"
);
}
assert_eq!(
redact(&format!("https://github.com/o/r/raw/{USER_TOKEN}/f")),
format!("https://github.com/o/r/raw/{REDACTION}/f")
);
for url in [
"https://api.github.com/repos/owner/repo/actions/runners",
"https://github.com/actions/runner/releases/download/v2.330.0/actions-runner-linux-x64-2.330.0.tar.gz",
"https://github.com/o/r.git",
"https://github.com/@owner/repo",
"https://api.github.com/repos/o/r/actions/runners/42",
] {
assert_eq!(redact(url), url, "over-redacted a diagnosable path: {url}");
}
}
#[test]
fn a_large_message_does_not_overflow_the_stack() {
const ITEMS: usize = 20_000;
let body = r#"{"url":"https://api.github.com/repos/o/r"},"#.repeat(ITEMS);
assert!(body.len() > 800_000, "the premise is a large body");
let redacted = redact(&body);
assert!(
redacted.contains("https://api.github.com/repos/o/r"),
"the URLs are the diagnosable part and must survive"
);
let nested = format!("{{\"error\":{{\"body\":{{\"list\":[{}]}}}}}}", body);
assert!(!redact(&nested).is_empty());
let flat = "{\"a\":1},".repeat(ITEMS);
assert!(!redact(&flat).is_empty());
let long_path = format!("https://a.com/{}", "seg/".repeat(ITEMS));
assert!(!redact(&long_path).is_empty());
}
#[test]
fn a_credential_header_loses_its_scheme_and_its_value() {
assert_eq!(
redact("Authorization: Bearer abc123"),
format!("Authorization: {REDACTION} {REDACTION}")
);
assert_eq!(
redact("Authorization: Bearer abc123, Accept: application/json"),
format!("Authorization: {REDACTION} {REDACTION} Accept: application/json")
);
assert_eq!(
redact("x-api-key=hunter2"),
format!("x-api-key={REDACTION}")
);
assert_eq!(
redact("Cookie: session=abc"),
format!("Cookie: {REDACTION}")
);
let hmac = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
assert_eq!(
redact(&format!("X-Hub-Signature-256: sha256={hmac}")),
format!("X-Hub-Signature-256: {REDACTION}")
);
assert_eq!(
redact(&format!("{{\"x-hub-signature-256\":\"sha256={hmac}\"}}")),
format!("{{\"x-hub-signature-256\":\"{REDACTION}\"}}")
);
assert_eq!(
redact(&format!("X-Hub-Signature: sha1={hmac}")),
format!("X-Hub-Signature: {REDACTION}")
);
assert_eq!(redact(hmac), "sha256:9f86d081884c…");
}
#[test]
fn ordinary_text_survives_intact() {
for text in [
"reconciliation finished",
"runner exited idle without work",
"desired 3, active 1, headroom 2",
"attempt 9f2c1a44-0000-4000-8000-000000000001 is busy",
"http 403 after 2 retries",
"windows/x64 is a documented pair",
] {
assert_eq!(redact(text), text, "redaction damaged an ordinary message");
}
}
#[test]
fn a_digest_renders_as_a_prefix_rather_than_disappearing() {
let digest = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
assert_eq!(redact(digest), "sha256:9f86d081884c…");
let other = "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752";
assert_ne!(
redact(digest),
redact(other),
"two digests must stay distinguishable"
);
assert_eq!(
redact(&format!("checksum mismatch: expected {digest} got {other}")),
"checksum mismatch: expected sha256:9f86d081884c… got sha256:60303ae22b99…"
);
assert_eq!(redact(&digest[..12]), digest[..12].to_string());
assert_eq!(redact(&format!("sha256:{digest}")), "sha256:9f86d081884c…");
assert_eq!(redact(&format!("sha256={digest}")), "sha256=9f86d081884c…");
}
#[test]
fn the_digest_exception_is_exactly_sixty_four_lowercase_hex_and_nothing_else() {
let digest = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
assert_eq!(
redact(&digest.to_ascii_uppercase()),
REDACTION,
"uppercase hex is not the shape this exception recognises"
);
assert_eq!(
redact(&format!("{digest}0")),
REDACTION,
"65 characters is not a SHA-256"
);
let base64ish = "ZYXWVUTSRQPONMLKJIHGFEDCBA9876543210zyxwvutsrqponmlkjihgfedcba98";
assert_eq!(base64ish.len(), 64);
assert_eq!(redact(base64ish), REDACTION);
assert_eq!(redact(JIT_BLOB), REDACTION);
}
#[test]
fn a_uuid_survives() {
let id = "9f2c1a44-0000-4000-8000-000000000001";
assert_eq!(redact(id), id);
assert!(id.len() < OPAQUE_RUN_THRESHOLD);
}
#[test]
fn redaction_preserves_whitespace_and_line_structure() {
let input = "line one\nAuthorization: Bearer x\nline three";
let redacted = redact(input);
assert_eq!(redacted.lines().count(), 3, "{redacted}");
assert!(redacted.starts_with("line one\n"), "{redacted}");
assert!(redacted.ends_with("\nline three"), "{redacted}");
}
}