1use regex::bytes::{Regex, RegexBuilder};
17use std::ffi::OsString;
18use std::fs::{self, DirBuilder, File, OpenOptions};
19use std::io::{Read, Write};
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22use std::sync::OnceLock;
23
24pub const BLOCK_SIZE: usize = 64 * 1024;
25const MARKER: &[u8] = b"[REDACTED]";
26const COMMAND: &str = "sensitive-data-sanitizer";
27pub const PACKAGE: &str = "@link-foundation/sensitive-data-sanitizer@^0.12.0";
29pub const OVERRIDE: &str = "START_SENSITIVE_DATA_SANITIZER";
31const OPTIONS: [&str; 3] = ["--credentials-only", "--env-secrets", "--gh-config"];
32
33const HOLDBACK: usize = 160;
35const NOT_A_VALUE: &[u8] = b"$<[{";
37
38fn header() -> &'static Regex {
39 static HEADER: OnceLock<Regex> = OnceLock::new();
40 HEADER.get_or_init(|| {
41 RegexBuilder::new(
42 r"(?i)((?:Proxy-)?Authorization[ \t]{0,32}:[ \t]{0,32})(?:Bearer|Basic|Digest|token)[ \t]{1,32}",
43 )
44 .unicode(false)
45 .build()
46 .expect("static Authorization rule")
47 })
48}
49
50fn is_value_byte(byte: u8) -> bool {
51 byte > 0x20 && !b"\"',\\".contains(&byte)
52}
53
54fn value_end(text: &[u8], start: usize) -> usize {
55 start
56 + text[start..]
57 .iter()
58 .take_while(|byte| is_value_byte(**byte))
59 .count()
60}
61
62#[derive(Default)]
66pub struct AuthorizationHeaderMask {
67 pending: Vec<u8>,
68 in_value: bool,
69 pub redactions: u64,
70}
71
72impl AuthorizationHeaderMask {
73 pub fn new() -> Self {
74 Self::default()
75 }
76
77 pub fn pending_bytes(&self) -> usize {
78 self.pending.len()
79 }
80
81 pub fn push(&mut self, block: &[u8], final_block: bool) -> Vec<u8> {
82 let mut text = std::mem::take(&mut self.pending);
83 text.extend_from_slice(block);
84 let mut output = Vec::new();
85 let mut offset = 0;
86 if self.in_value {
87 offset = value_end(&text, 0);
88 if offset == text.len() && !final_block {
89 return output;
90 }
91 self.in_value = false;
92 }
93 let mut search = offset;
94 while let Some(found) = header().captures_at(&text, search) {
95 let whole = found.get(0).expect("whole match");
96 let prefix_end = found.get(1).expect("header group").end();
97 let value_start = whole.end();
98 search = value_start;
99 if value_start == text.len() && !final_block {
100 output.extend_from_slice(&text[offset..whole.start()]);
101 self.pending = text[whole.start()..].to_vec();
102 return output;
103 }
104 if value_start == text.len()
105 || !is_value_byte(text[value_start])
106 || NOT_A_VALUE.contains(&text[value_start])
107 {
108 continue;
109 }
110 output.extend_from_slice(&text[offset..prefix_end]);
111 output.extend_from_slice(MARKER);
112 self.redactions += 1;
113 offset = value_end(&text, value_start);
114 if offset == text.len() && !final_block {
115 self.in_value = true;
116 return output;
117 }
118 search = offset;
119 }
120 let safe_end = if final_block {
121 text.len()
122 } else {
123 offset.max(text.len().saturating_sub(HOLDBACK))
124 };
125 output.extend_from_slice(&text[offset..safe_end]);
126 self.pending = text[safe_end..].to_vec();
127 output
128 }
129}
130
131fn blocked(code: &str, subject: &str) -> String {
133 let action = if subject == "Log" {
134 "upload"
135 } else {
136 "publication"
137 };
138 format!(
139 "{} sanitization failed ({}); {} blocked.",
140 subject, code, action
141 )
142}
143
144enum Environment {
147 Inherit,
148 Explicit(Vec<(String, String)>),
149}
150
151impl Environment {
152 fn get(&self, name: &str) -> Option<OsString> {
153 match self {
154 Self::Inherit => std::env::var_os(name),
155 Self::Explicit(values) => values
156 .iter()
157 .find(|(key, _)| {
158 if cfg!(windows) {
159 key.eq_ignore_ascii_case(name)
160 } else {
161 key == name
162 }
163 })
164 .map(|(_, value)| value.into()),
165 }
166 }
167
168 fn apply(&self, command: &mut Command) {
169 if let Self::Explicit(values) = self {
170 command.env_clear().envs(values.iter().cloned());
171 }
172 }
173}
174
175fn is_executable(path: &Path) -> bool {
176 let Ok(metadata) = fs::metadata(path) else {
177 return false;
178 };
179 #[cfg(unix)]
180 {
181 use std::os::unix::fs::PermissionsExt;
182 metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
183 }
184 #[cfg(not(unix))]
185 metadata.is_file()
186}
187
188fn resolve(env: &Environment) -> Option<PathBuf> {
189 if let Some(command) = env.get(OVERRIDE).filter(|value| !value.is_empty()) {
190 return Some(PathBuf::from(command));
191 }
192 let names: &[&str] = if cfg!(windows) {
195 &[".exe", ".cmd", ".bat"]
196 } else {
197 &[""]
198 };
199 let path = env.get("PATH")?;
200 std::env::split_paths(&path).find_map(|directory| {
201 names
202 .iter()
203 .map(|extension| directory.join(format!("{}{}", COMMAND, extension)))
204 .find(|candidate| is_executable(candidate))
205 })
206}
207
208pub fn find_sanitizer() -> Option<PathBuf> {
210 resolve(&Environment::Inherit)
211}
212
213pub fn ensure_sanitizer_available() -> Result<(), String> {
215 if find_sanitizer().is_some() {
216 return Ok(());
217 }
218 let npm = if cfg!(windows) { "npm.cmd" } else { "npm" };
219 for command in ["bun", npm] {
220 let available = Command::new(command)
221 .arg("--version")
222 .stdout(Stdio::null())
223 .stderr(Stdio::null())
224 .status()
225 .map(|status| status.success())
226 .unwrap_or(false);
227 if !available {
228 continue;
229 }
230 let args = ["install", "-g", PACKAGE];
231 println!(
232 "{} not found; installing with: {} {}",
233 COMMAND,
234 command,
235 args.join(" ")
236 );
237 let installed = Command::new(command)
238 .args(args)
239 .status()
240 .map(|status| status.success())
241 .unwrap_or(false);
242 if installed && find_sanitizer().is_some() {
243 return Ok(());
244 }
245 }
246 Err(format!(
247 "{} is not installed and automatic installation did not make it available on PATH.",
248 COMMAND
249 ))
250}
251
252fn error_code(stderr: &[u8]) -> String {
253 static CODE: OnceLock<Regex> = OnceLock::new();
254 CODE.get_or_init(|| Regex::new(r"\bERR_[A-Z_]+\b").expect("static code rule"))
255 .find(stderr)
256 .map(|code| String::from_utf8_lossy(code.as_bytes()).into_owned())
257 .unwrap_or_else(|| "ERR_SANITIZER".to_string())
258}
259
260fn sanitizer_command(env: &Environment, subject: &str) -> Result<Command, String> {
261 let program = resolve(env).ok_or_else(|| blocked("ERR_UNAVAILABLE", subject))?;
262 let mut command = Command::new(program);
263 env.apply(&mut command);
264 command.arg("redact").arg("-");
265 Ok(command)
266}
267
268fn text_with(text: &str, env: &Environment) -> Result<String, String> {
269 let fail = |code: &str| blocked(code, "Text");
270 let input = AuthorizationHeaderMask::new().push(text.as_bytes(), true);
271 let mut child = sanitizer_command(env, "Text")?
272 .args(OPTIONS)
273 .stdin(Stdio::piped())
274 .stdout(Stdio::piped())
275 .stderr(Stdio::piped())
276 .spawn()
277 .map_err(|_| fail("ERR_UNAVAILABLE"))?;
278 let mut stdin = child.stdin.take().ok_or_else(|| fail("ERR_SANITIZER"))?;
279 let writer = std::thread::spawn(move || stdin.write_all(&input));
280 let output = child
281 .wait_with_output()
282 .map_err(|_| fail("ERR_SANITIZER"))?;
283 let written = writer.join().map(|result| result.is_ok()).unwrap_or(false);
284 if !output.status.success() {
285 return Err(fail(&error_code(&output.stderr)));
286 }
287 if !written {
288 return Err(fail("ERR_SANITIZER"));
289 }
290 String::from_utf8(output.stdout).map_err(|_| fail("ERR_SANITIZER"))
291}
292
293pub fn sanitize_text(text: &str) -> Result<String, String> {
295 text_with(text, &Environment::Inherit)
296}
297
298pub fn sanitize_text_with_env(
299 text: &str,
300 env: impl IntoIterator<Item = (String, String)>,
301) -> Result<String, String> {
302 text_with(text, &Environment::Explicit(env.into_iter().collect()))
303}
304
305pub struct PreparedLog {
307 pub path: PathBuf,
308 directory: PathBuf,
309}
310
311impl Drop for PreparedLog {
312 fn drop(&mut self) {
313 let _ = fs::remove_dir_all(&self.directory);
314 }
315}
316
317fn count_markers(path: &Path) -> std::io::Result<u64> {
318 let mut file = File::open(path)?;
319 let mut buffer = vec![0; BLOCK_SIZE];
320 let mut text = Vec::new();
321 let mut count = 0;
322 loop {
323 let length = file.read(&mut buffer)?;
324 text.extend_from_slice(&buffer[..length]);
325 let mut next = 0;
326 while let Some(index) = text[next..]
327 .windows(MARKER.len())
328 .position(|window| window == MARKER)
329 {
330 count += 1;
331 next += index + MARKER.len();
332 }
333 if length == 0 {
334 return Ok(count);
335 }
336 let keep = next.max(text.len().saturating_sub(MARKER.len() - 1));
337 text.drain(..keep);
338 }
339}
340
341fn create_private_file(path: &Path) -> std::io::Result<File> {
342 let mut options = OpenOptions::new();
343 options.write(true).create_new(true);
344 #[cfg(unix)]
345 {
346 use std::os::unix::fs::OpenOptionsExt;
347 options.mode(0o600);
348 }
349 let file = options.open(path)?;
350 #[cfg(unix)]
351 {
352 use std::os::unix::fs::PermissionsExt;
353 file.set_permissions(fs::Permissions::from_mode(0o600))?;
354 }
355 Ok(file)
356}
357
358fn mask_authorization_headers(source_path: &Path, stage_path: &Path) -> Result<u64, String> {
359 let failed = |_| blocked("ERR_SOURCE", "Log");
360 let mut source = File::open(source_path).map_err(failed)?;
361 if !source.metadata().map_err(failed)?.is_file() {
362 return Err(blocked("ERR_SOURCE", "Log"));
363 }
364 let mut stage = create_private_file(stage_path).map_err(failed)?;
365 let mut mask = AuthorizationHeaderMask::new();
366 let mut buffer = vec![0; BLOCK_SIZE];
367 let mut bytes = 0_u64;
368 loop {
369 let length = source.read(&mut buffer).map_err(failed)?;
370 stage
371 .write_all(&mask.push(&buffer[..length], length == 0))
372 .map_err(failed)?;
373 if length == 0 {
374 break;
375 }
376 bytes += length as u64;
377 }
378 stage.sync_all().map_err(failed)?;
379 Ok(bytes)
380}
381
382fn private_directory() -> Result<PathBuf, String> {
383 let directory = std::env::temp_dir().join(format!("start-sanitized-{}", uuid::Uuid::new_v4()));
384 let builder = DirBuilder::new();
385 #[cfg(unix)]
386 let builder = {
387 use std::os::unix::fs::DirBuilderExt;
388 let mut builder = builder;
389 builder.mode(0o700);
390 builder
391 };
392 builder
393 .create(&directory)
394 .map_err(|_| blocked("ERR_PRIVATE_FILE", "Log"))?;
395 Ok(directory)
396}
397
398fn log_with(source_path: &Path, env: &Environment, verbose: bool) -> Result<PreparedLog, String> {
399 let directory = private_directory()?;
401 let prepared = PreparedLog {
402 path: directory.join("execution.log"),
403 directory,
404 };
405 let target = prepared.path.clone();
406 let stage_path = prepared.directory.join("authorization-masked.log");
407 let bytes = mask_authorization_headers(source_path, &stage_path)?;
408 let private_file = |_| blocked("ERR_PRIVATE_FILE", "Log");
409 let stage = File::open(&stage_path).map_err(private_file)?;
410 let size = stage.metadata().map_err(private_file)?.len().max(1);
411 let output = sanitizer_command(env, "Log")?
412 .arg("--stream")
413 .args(OPTIONS)
414 .arg("--max-bytes")
415 .arg(size.to_string())
416 .arg("--output")
417 .arg(&target)
418 .stdin(Stdio::from(stage))
419 .stdout(Stdio::null())
420 .stderr(Stdio::piped())
421 .output()
422 .map_err(|_| blocked("ERR_UNAVAILABLE", "Log"))?;
423 if !output.status.success() {
424 return Err(blocked(&error_code(&output.stderr), "Log"));
425 }
426 let metadata = fs::symlink_metadata(&target).map_err(|_| blocked("ERR_OUTPUT", "Log"))?;
427 #[cfg(unix)]
428 let private = {
429 use std::os::unix::fs::PermissionsExt;
430 metadata.permissions().mode() & 0o077 == 0
431 };
432 #[cfg(not(unix))]
433 let private = true;
434 if !metadata.is_file() || !private {
435 return Err(blocked("ERR_OUTPUT", "Log"));
436 }
437 if verbose {
438 let redactions = count_markers(&target)
439 .unwrap_or(0)
440 .saturating_sub(count_markers(source_path).unwrap_or(0));
441 println!(
442 "Prepared private sanitized log: {} bytes scanned, {} redactions.",
443 bytes, redactions
444 );
445 }
446 let _ = fs::remove_file(&stage_path);
447 Ok(prepared)
448}
449
450fn report(result: Result<PreparedLog, String>, verbose: bool) -> Result<PreparedLog, String> {
451 if let (Err(message), true) = (&result, verbose) {
452 eprintln!("{}", message);
453 }
454 result
455}
456
457pub fn sanitize_log_to_temp(source_path: &Path, verbose: bool) -> Result<PreparedLog, String> {
460 report(
461 log_with(source_path, &Environment::Inherit, verbose),
462 verbose,
463 )
464}
465
466pub fn sanitize_log_to_temp_with_env(
467 source_path: &Path,
468 env: impl IntoIterator<Item = (String, String)>,
469 verbose: bool,
470) -> Result<PreparedLog, String> {
471 let env = Environment::Explicit(env.into_iter().collect());
472 report(log_with(source_path, &env, verbose), verbose)
473}