Skip to main content

start_command/
log_sanitizer.rs

1//! Prepares logs and issue text for publication with
2//! @link-foundation/sensitive-data-sanitizer, the sanitizer shared by
3//! start-command, gh-upload-log and hive-mind (issue #250).
4//!
5//! The library CLI runs with `--credentials-only`, `--env-secrets` and
6//! `--gh-config`; it streams the log from stdin, keeps invalid UTF-8 bytes and
7//! writes a new private file atomically (`--output`). Its error messages are
8//! value-free and only their `ERR_*` code is kept here.
9//!
10//! One rule stays here: the library masks an unquoted or single-quoted
11//! `Authorization: <scheme> <value>` to the end of the line, which drops the
12//! rest of a logged one-line JSON prompt (issue #249). The pre-pass below masks
13//! only the scheme and its token first; the library keeps values that already
14//! start with [REDACTED]. Remove it with sensitive-data-sanitizer#45.
15
16use 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";
27/// The library version the JavaScript package depends on.
28pub const PACKAGE: &str = "@link-foundation/sensitive-data-sanitizer@^0.12.0";
29/// Path of the library CLI to use instead of the one on PATH.
30pub const OVERRIDE: &str = "START_SENSITIVE_DATA_SANITIZER";
31const OPTIONS: [&str; 3] = ["--credentials-only", "--env-secrets", "--gh-config"];
32
33// The longest header and scheme prefix is 122 bytes.
34const HOLDBACK: usize = 160;
35// Shell expressions, placeholders and masked values are not credentials.
36const 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/// Masks `Authorization: <scheme> <token>` as `Authorization: [REDACTED]`
63/// byte-wise, so invalid UTF-8 passes through unchanged, holding at most
64/// HOLDBACK pending bytes.
65#[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
131/// Value-free failure message: `code` is a fixed ERR_* name, never content.
132fn 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
144/// The environment the library runs with: inherited, or exactly the given
145/// variables (so tests control which credential-named values it masks).
146enum 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    // npm installs a .cmd shim on Windows next to a POSIX script of the bare
193    // name; Rust runs .exe/.cmd/.bat files, with argument escaping for batch.
194    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
208/// The library CLI: START_SENSITIVE_DATA_SANITIZER, else sensitive-data-sanitizer on PATH.
209pub fn find_sanitizer() -> Option<PathBuf> {
210    resolve(&Environment::Inherit)
211}
212
213/// Install the library CLI with an available JavaScript package manager.
214pub 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
293/// Sanitize a short text, such as the failing command quoted in an issue.
294pub 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
305/// Private temporary copy; dropping it removes its file and directory.
306pub 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    // Static messages keep paths and system error details out of the output.
400    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
457/// Create a private sanitized copy of a log. The source is never modified;
458/// any failure removes the copy and returns a value-free message.
459pub 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}