use regex::bytes::{Regex, RegexBuilder};
use std::ffi::OsString;
use std::fs::{self, DirBuilder, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
pub const BLOCK_SIZE: usize = 64 * 1024;
const MARKER: &[u8] = b"[REDACTED]";
const COMMAND: &str = "sensitive-data-sanitizer";
pub const PACKAGE: &str = "@link-foundation/sensitive-data-sanitizer@^0.12.0";
pub const OVERRIDE: &str = "START_SENSITIVE_DATA_SANITIZER";
const OPTIONS: [&str; 3] = ["--credentials-only", "--env-secrets", "--gh-config"];
const HOLDBACK: usize = 160;
const NOT_A_VALUE: &[u8] = b"$<[{";
fn header() -> &'static Regex {
static HEADER: OnceLock<Regex> = OnceLock::new();
HEADER.get_or_init(|| {
RegexBuilder::new(
r"(?i)((?:Proxy-)?Authorization[ \t]{0,32}:[ \t]{0,32})(?:Bearer|Basic|Digest|token)[ \t]{1,32}",
)
.unicode(false)
.build()
.expect("static Authorization rule")
})
}
fn is_value_byte(byte: u8) -> bool {
byte > 0x20 && !b"\"',\\".contains(&byte)
}
fn value_end(text: &[u8], start: usize) -> usize {
start
+ text[start..]
.iter()
.take_while(|byte| is_value_byte(**byte))
.count()
}
#[derive(Default)]
pub struct AuthorizationHeaderMask {
pending: Vec<u8>,
in_value: bool,
pub redactions: u64,
}
impl AuthorizationHeaderMask {
pub fn new() -> Self {
Self::default()
}
pub fn pending_bytes(&self) -> usize {
self.pending.len()
}
pub fn push(&mut self, block: &[u8], final_block: bool) -> Vec<u8> {
let mut text = std::mem::take(&mut self.pending);
text.extend_from_slice(block);
let mut output = Vec::new();
let mut offset = 0;
if self.in_value {
offset = value_end(&text, 0);
if offset == text.len() && !final_block {
return output;
}
self.in_value = false;
}
let mut search = offset;
while let Some(found) = header().captures_at(&text, search) {
let whole = found.get(0).expect("whole match");
let prefix_end = found.get(1).expect("header group").end();
let value_start = whole.end();
search = value_start;
if value_start == text.len() && !final_block {
output.extend_from_slice(&text[offset..whole.start()]);
self.pending = text[whole.start()..].to_vec();
return output;
}
if value_start == text.len()
|| !is_value_byte(text[value_start])
|| NOT_A_VALUE.contains(&text[value_start])
{
continue;
}
output.extend_from_slice(&text[offset..prefix_end]);
output.extend_from_slice(MARKER);
self.redactions += 1;
offset = value_end(&text, value_start);
if offset == text.len() && !final_block {
self.in_value = true;
return output;
}
search = offset;
}
let safe_end = if final_block {
text.len()
} else {
offset.max(text.len().saturating_sub(HOLDBACK))
};
output.extend_from_slice(&text[offset..safe_end]);
self.pending = text[safe_end..].to_vec();
output
}
}
fn blocked(code: &str, subject: &str) -> String {
let action = if subject == "Log" {
"upload"
} else {
"publication"
};
format!(
"{} sanitization failed ({}); {} blocked.",
subject, code, action
)
}
enum Environment {
Inherit,
Explicit(Vec<(String, String)>),
}
impl Environment {
fn get(&self, name: &str) -> Option<OsString> {
match self {
Self::Inherit => std::env::var_os(name),
Self::Explicit(values) => values
.iter()
.find(|(key, _)| {
if cfg!(windows) {
key.eq_ignore_ascii_case(name)
} else {
key == name
}
})
.map(|(_, value)| value.into()),
}
}
fn apply(&self, command: &mut Command) {
if let Self::Explicit(values) = self {
command.env_clear().envs(values.iter().cloned());
}
}
}
fn is_executable(path: &Path) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
metadata.is_file()
}
fn resolve(env: &Environment) -> Option<PathBuf> {
if let Some(command) = env.get(OVERRIDE).filter(|value| !value.is_empty()) {
return Some(PathBuf::from(command));
}
let names: &[&str] = if cfg!(windows) {
&[".exe", ".cmd", ".bat"]
} else {
&[""]
};
let path = env.get("PATH")?;
std::env::split_paths(&path).find_map(|directory| {
names
.iter()
.map(|extension| directory.join(format!("{}{}", COMMAND, extension)))
.find(|candidate| is_executable(candidate))
})
}
pub fn find_sanitizer() -> Option<PathBuf> {
resolve(&Environment::Inherit)
}
pub fn ensure_sanitizer_available() -> Result<(), String> {
if find_sanitizer().is_some() {
return Ok(());
}
let npm = if cfg!(windows) { "npm.cmd" } else { "npm" };
for command in ["bun", npm] {
let available = Command::new(command)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false);
if !available {
continue;
}
let args = ["install", "-g", PACKAGE];
println!(
"{} not found; installing with: {} {}",
COMMAND,
command,
args.join(" ")
);
let installed = Command::new(command)
.args(args)
.status()
.map(|status| status.success())
.unwrap_or(false);
if installed && find_sanitizer().is_some() {
return Ok(());
}
}
Err(format!(
"{} is not installed and automatic installation did not make it available on PATH.",
COMMAND
))
}
fn error_code(stderr: &[u8]) -> String {
static CODE: OnceLock<Regex> = OnceLock::new();
CODE.get_or_init(|| Regex::new(r"\bERR_[A-Z_]+\b").expect("static code rule"))
.find(stderr)
.map(|code| String::from_utf8_lossy(code.as_bytes()).into_owned())
.unwrap_or_else(|| "ERR_SANITIZER".to_string())
}
fn sanitizer_command(env: &Environment, subject: &str) -> Result<Command, String> {
let program = resolve(env).ok_or_else(|| blocked("ERR_UNAVAILABLE", subject))?;
let mut command = Command::new(program);
env.apply(&mut command);
command.arg("redact").arg("-");
Ok(command)
}
fn text_with(text: &str, env: &Environment) -> Result<String, String> {
let fail = |code: &str| blocked(code, "Text");
let input = AuthorizationHeaderMask::new().push(text.as_bytes(), true);
let mut child = sanitizer_command(env, "Text")?
.args(OPTIONS)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|_| fail("ERR_UNAVAILABLE"))?;
let mut stdin = child.stdin.take().ok_or_else(|| fail("ERR_SANITIZER"))?;
let writer = std::thread::spawn(move || stdin.write_all(&input));
let output = child
.wait_with_output()
.map_err(|_| fail("ERR_SANITIZER"))?;
let written = writer.join().map(|result| result.is_ok()).unwrap_or(false);
if !output.status.success() {
return Err(fail(&error_code(&output.stderr)));
}
if !written {
return Err(fail("ERR_SANITIZER"));
}
String::from_utf8(output.stdout).map_err(|_| fail("ERR_SANITIZER"))
}
pub fn sanitize_text(text: &str) -> Result<String, String> {
text_with(text, &Environment::Inherit)
}
pub fn sanitize_text_with_env(
text: &str,
env: impl IntoIterator<Item = (String, String)>,
) -> Result<String, String> {
text_with(text, &Environment::Explicit(env.into_iter().collect()))
}
pub struct PreparedLog {
pub path: PathBuf,
directory: PathBuf,
}
impl Drop for PreparedLog {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.directory);
}
}
fn count_markers(path: &Path) -> std::io::Result<u64> {
let mut file = File::open(path)?;
let mut buffer = vec![0; BLOCK_SIZE];
let mut text = Vec::new();
let mut count = 0;
loop {
let length = file.read(&mut buffer)?;
text.extend_from_slice(&buffer[..length]);
let mut next = 0;
while let Some(index) = text[next..]
.windows(MARKER.len())
.position(|window| window == MARKER)
{
count += 1;
next += index + MARKER.len();
}
if length == 0 {
return Ok(count);
}
let keep = next.max(text.len().saturating_sub(MARKER.len() - 1));
text.drain(..keep);
}
}
fn create_private_file(path: &Path) -> std::io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
Ok(file)
}
fn mask_authorization_headers(source_path: &Path, stage_path: &Path) -> Result<u64, String> {
let failed = |_| blocked("ERR_SOURCE", "Log");
let mut source = File::open(source_path).map_err(failed)?;
if !source.metadata().map_err(failed)?.is_file() {
return Err(blocked("ERR_SOURCE", "Log"));
}
let mut stage = create_private_file(stage_path).map_err(failed)?;
let mut mask = AuthorizationHeaderMask::new();
let mut buffer = vec![0; BLOCK_SIZE];
let mut bytes = 0_u64;
loop {
let length = source.read(&mut buffer).map_err(failed)?;
stage
.write_all(&mask.push(&buffer[..length], length == 0))
.map_err(failed)?;
if length == 0 {
break;
}
bytes += length as u64;
}
stage.sync_all().map_err(failed)?;
Ok(bytes)
}
fn private_directory() -> Result<PathBuf, String> {
let directory = std::env::temp_dir().join(format!("start-sanitized-{}", uuid::Uuid::new_v4()));
let builder = DirBuilder::new();
#[cfg(unix)]
let builder = {
use std::os::unix::fs::DirBuilderExt;
let mut builder = builder;
builder.mode(0o700);
builder
};
builder
.create(&directory)
.map_err(|_| blocked("ERR_PRIVATE_FILE", "Log"))?;
Ok(directory)
}
fn log_with(source_path: &Path, env: &Environment, verbose: bool) -> Result<PreparedLog, String> {
let directory = private_directory()?;
let prepared = PreparedLog {
path: directory.join("execution.log"),
directory,
};
let target = prepared.path.clone();
let stage_path = prepared.directory.join("authorization-masked.log");
let bytes = mask_authorization_headers(source_path, &stage_path)?;
let private_file = |_| blocked("ERR_PRIVATE_FILE", "Log");
let stage = File::open(&stage_path).map_err(private_file)?;
let size = stage.metadata().map_err(private_file)?.len().max(1);
let output = sanitizer_command(env, "Log")?
.arg("--stream")
.args(OPTIONS)
.arg("--max-bytes")
.arg(size.to_string())
.arg("--output")
.arg(&target)
.stdin(Stdio::from(stage))
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|_| blocked("ERR_UNAVAILABLE", "Log"))?;
if !output.status.success() {
return Err(blocked(&error_code(&output.stderr), "Log"));
}
let metadata = fs::symlink_metadata(&target).map_err(|_| blocked("ERR_OUTPUT", "Log"))?;
#[cfg(unix)]
let private = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o077 == 0
};
#[cfg(not(unix))]
let private = true;
if !metadata.is_file() || !private {
return Err(blocked("ERR_OUTPUT", "Log"));
}
if verbose {
let redactions = count_markers(&target)
.unwrap_or(0)
.saturating_sub(count_markers(source_path).unwrap_or(0));
println!(
"Prepared private sanitized log: {} bytes scanned, {} redactions.",
bytes, redactions
);
}
let _ = fs::remove_file(&stage_path);
Ok(prepared)
}
fn report(result: Result<PreparedLog, String>, verbose: bool) -> Result<PreparedLog, String> {
if let (Err(message), true) = (&result, verbose) {
eprintln!("{}", message);
}
result
}
pub fn sanitize_log_to_temp(source_path: &Path, verbose: bool) -> Result<PreparedLog, String> {
report(
log_with(source_path, &Environment::Inherit, verbose),
verbose,
)
}
pub fn sanitize_log_to_temp_with_env(
source_path: &Path,
env: impl IntoIterator<Item = (String, String)>,
verbose: bool,
) -> Result<PreparedLog, String> {
let env = Environment::Explicit(env.into_iter().collect());
report(log_with(source_path, &env, verbose), verbose)
}