Skip to main content

start_command/
log_sanitizer.rs

1//! Bounded byte-wise sanitization for logs prepared for publication.
2
3use regex::bytes::Regex;
4use std::fs::{self, DirBuilder, File, OpenOptions};
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7use std::sync::OnceLock;
8
9pub const BLOCK_SIZE: usize = 64 * 1024;
10const MARKER: &[u8] = b"[REDACTED]";
11const BLOCKED: &str = "Log sanitization failed; upload blocked.";
12
13#[derive(Clone, Copy)]
14enum Continuation {
15    Token,
16    Jwt,
17    Line,
18    Value,
19    Url,
20    AnyValue,
21}
22
23impl Continuation {
24    fn accepts(self, byte: u8) -> bool {
25        match self {
26            Self::Token => byte.is_ascii_alphanumeric() || b"_-".contains(&byte),
27            Self::Jwt => byte.is_ascii_alphanumeric() || b"_.-".contains(&byte),
28            Self::Line => !b"\r\n".contains(&byte),
29            Self::Value => !byte.is_ascii_whitespace() && !b"\"';,".contains(&byte),
30            Self::Url => !byte.is_ascii_whitespace() && byte != b'@',
31            Self::AnyValue => !byte.is_ascii_whitespace(),
32        }
33    }
34
35    fn union(self, other: Self) -> Self {
36        // Nested tokens must not narrow a containing header/URL's suffix.
37        match (self, other) {
38            (Self::Line, _) | (_, Self::Line) => Self::Line,
39            (Self::AnyValue, _) | (_, Self::AnyValue) => Self::AnyValue,
40            (Self::Value, Self::Url) | (Self::Url, Self::Value) => Self::AnyValue,
41            (Self::Value, _) | (_, Self::Value) => Self::Value,
42            (Self::Url, _) | (_, Self::Url) => Self::Url,
43            (Self::Jwt, _) | (_, Self::Jwt) => Self::Jwt,
44            _ => Self::Token,
45        }
46    }
47}
48
49fn rules() -> &'static [(Regex, Option<Continuation>)] {
50    static RULES: OnceLock<Vec<(Regex, Option<Continuation>)>> = OnceLock::new();
51    RULES.get_or_init(|| {
52        [
53            (
54                r"\b[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{0,512}",
55                Some(Continuation::Jwt),
56            ),
57            (
58                r"[A-Za-z0-9_-]{512,}[A-Za-z0-9_.-]*",
59                Some(Continuation::Jwt),
60            ),
61            (
62                r"(?:gh[pousr]_|github_pat_|sk-)[A-Za-z0-9_-]*",
63                Some(Continuation::Token),
64            ),
65            (r"(?:AKIA|ASIA)[A-Z0-9]{16}", None),
66            (r"eyJ[A-Za-z0-9_.-]*", Some(Continuation::Jwt)),
67            (
68                r"(?i:(?:Proxy-)?Authorization)[ \t]{0,32}:[^\r\n]*",
69                Some(Continuation::Line),
70            ),
71            (
72                r#"(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)[ \t]{0,32}[=:][ \t]*["']?[^\s"';,]*"#,
73                Some(Continuation::Value),
74            ),
75            (
76                r"(?:https?|git|ssh)://[^\s:/@]{1,256}:[^\s@]*",
77                Some(Continuation::Url),
78            ),
79        ]
80        .into_iter()
81        .map(|(pattern, continuation)| {
82            (
83                regex::bytes::RegexBuilder::new(pattern)
84                    .unicode(false)
85                    .build()
86                    .expect("static credential rule"),
87                continuation,
88            )
89        })
90        .collect()
91    })
92}
93
94fn is_jwt(value: &[u8]) -> bool {
95    use base64::Engine;
96    let header = value.split(|byte| *byte == b'.').next().unwrap_or_default();
97    let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(header) else {
98        return false;
99    };
100    serde_json::from_slice::<serde_json::Value>(&bytes)
101        .ok()
102        .and_then(|header| {
103            header
104                .get("alg")
105                .and_then(serde_json::Value::as_str)
106                .map(str::to_owned)
107        })
108        .is_some()
109}
110
111/// Holds at most one block of pending bytes, independent of source file size.
112/// Very long credential-like words are conservatively redacted as well.
113pub struct StreamingSanitizer {
114    secrets: Vec<Vec<u8>>,
115    holdback: usize,
116    pending: Vec<u8>,
117    continuation: Option<Continuation>,
118    pub redactions: u64,
119}
120
121impl StreamingSanitizer {
122    pub fn new(env: impl IntoIterator<Item = (String, String)>) -> Result<Self, String> {
123        Self::new_bytes(
124            env.into_iter()
125                .map(|(name, value)| (name, value.into_bytes())),
126        )
127    }
128
129    fn new_bytes(env: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self, String> {
130        let env_name = Regex::new(r"(?i)(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|PAT)(?:_|$)")
131            .map_err(|_| BLOCKED.to_string())?;
132        let mut secrets: Vec<Vec<u8>> = env
133            .into_iter()
134            .filter(|(name, value)| env_name.is_match(name.as_bytes()) && !value.is_empty())
135            .map(|(_, value)| value)
136            .collect();
137        if secrets.iter().any(|value| value.len() > BLOCK_SIZE) {
138            return Err(
139                "Known environment secret is too large for bounded sanitization.".to_string(),
140            );
141        }
142        secrets.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
143        secrets.dedup();
144        let holdback = secrets.iter().map(Vec::len).max().unwrap_or(0).max(2048);
145        Ok(Self {
146            secrets,
147            holdback,
148            pending: Vec::new(),
149            continuation: None,
150            redactions: 0,
151        })
152    }
153
154    pub fn pending_bytes(&self) -> usize {
155        self.pending.len()
156    }
157
158    fn find_matches(&self, input: &[u8]) -> Vec<(usize, usize, Option<Continuation>)> {
159        let mut matches = Vec::new();
160        for (index, (rule, continuation)) in rules().iter().enumerate() {
161            for found in rule.find_iter(input) {
162                if index != 0 || is_jwt(&input[found.start()..found.end()]) {
163                    matches.push((found.start(), found.end(), *continuation));
164                }
165            }
166        }
167        for secret in &self.secrets {
168            for (index, _) in input
169                .windows(secret.len())
170                .enumerate()
171                .filter(|(_, window)| *window == secret)
172            {
173                matches.push((index, index + secret.len(), None));
174            }
175        }
176        matches.sort_unstable_by_key(|(index, end, _)| (*index, std::cmp::Reverse(*end)));
177        let mut merged: Vec<(usize, usize, Option<Continuation>)> = Vec::new();
178        for (index, end, continuation) in matches {
179            if let Some(previous) = merged.last_mut().filter(|previous| index <= previous.1) {
180                if end > previous.1 {
181                    previous.1 = end;
182                    previous.2 = continuation;
183                } else if end == previous.1 && continuation.is_some() {
184                    previous.2 = match (previous.2, continuation) {
185                        (Some(retained), Some(additional)) => Some(retained.union(additional)),
186                        _ => continuation,
187                    };
188                }
189            } else {
190                merged.push((index, end, continuation));
191            }
192        }
193        merged
194    }
195
196    pub fn push(&mut self, block: &[u8], final_block: bool) -> Vec<u8> {
197        let mut input = std::mem::take(&mut self.pending);
198        input.extend_from_slice(block);
199        let mut beginning = 0;
200        if let Some(continuation) = self.continuation {
201            beginning = input
202                .iter()
203                .take_while(|byte| continuation.accepts(**byte))
204                .count();
205            if beginning == input.len() && !final_block {
206                return Vec::new();
207            }
208            self.continuation = None;
209        }
210        let input = &input[beginning..];
211        let safe_end = if final_block {
212            input.len()
213        } else {
214            input.len().saturating_sub(self.holdback)
215        };
216        let mut offset = 0;
217        let mut output = Vec::new();
218        for (start, end, continuation) in self.find_matches(input) {
219            if start >= safe_end {
220                break;
221            }
222            output.extend_from_slice(&input[offset..start]);
223            output.extend_from_slice(MARKER);
224            self.redactions += 1;
225            offset = end;
226            if offset == input.len() && continuation.is_some() && !final_block {
227                self.continuation = continuation;
228                break;
229            }
230        }
231        let end = offset.max(safe_end);
232        output.extend_from_slice(&input[offset..end]);
233        self.pending.extend_from_slice(&input[end..]);
234        output
235    }
236}
237
238/// Private temporary copy; dropping it removes its file and directory.
239pub struct PreparedLog {
240    pub path: PathBuf,
241    directory: PathBuf,
242}
243
244impl Drop for PreparedLog {
245    fn drop(&mut self) {
246        let _ = fs::remove_dir_all(&self.directory);
247    }
248}
249
250pub fn sanitize_log_to_temp(source_path: &Path, verbose: bool) -> Result<PreparedLog, String> {
251    let values = std::env::vars_os().map(|(name, value)| {
252        #[cfg(unix)]
253        let bytes = {
254            use std::os::unix::ffi::OsStringExt;
255            value.into_vec()
256        };
257        #[cfg(not(unix))]
258        let bytes = value.to_string_lossy().into_owned().into_bytes();
259        (name.to_string_lossy().into_owned(), bytes)
260    });
261    sanitize_log_to_temp_with_bytes(source_path, values, verbose)
262}
263
264pub fn sanitize_log_to_temp_with_env(
265    source_path: &Path,
266    env: impl IntoIterator<Item = (String, String)>,
267    verbose: bool,
268) -> Result<PreparedLog, String> {
269    sanitize_log_to_temp_with_bytes(
270        source_path,
271        env.into_iter()
272            .map(|(name, value)| (name, value.into_bytes())),
273        verbose,
274    )
275}
276
277fn sanitize_log_to_temp_with_bytes(
278    source_path: &Path,
279    env: impl IntoIterator<Item = (String, Vec<u8>)>,
280    verbose: bool,
281) -> Result<PreparedLog, String> {
282    let blocked_at = |stage| {
283        if verbose {
284            eprintln!("Log sanitization failed at stage: {}.", stage);
285        }
286        BLOCKED.to_string()
287    };
288    // Static errors prevent paths and system error details from leaking secrets.
289    let mut sanitizer =
290        StreamingSanitizer::new_bytes(env).map_err(|_| blocked_at("environment"))?;
291    let mut source = File::open(source_path).map_err(|_| blocked_at("source"))?;
292    if !source
293        .metadata()
294        .map_err(|_| blocked_at("source"))?
295        .is_file()
296    {
297        return Err(blocked_at("source"));
298    }
299    let directory = std::env::temp_dir().join(format!("start-sanitized-{}", uuid::Uuid::new_v4()));
300    let builder = DirBuilder::new();
301    #[cfg(unix)]
302    let builder = {
303        use std::os::unix::fs::DirBuilderExt;
304        let mut builder = builder;
305        builder.mode(0o700);
306        builder
307    };
308    builder
309        .create(&directory)
310        .map_err(|_| blocked_at("private-file"))?;
311    let prepared = PreparedLog {
312        path: directory.join("execution.log"),
313        directory,
314    };
315    let mut options = OpenOptions::new();
316    options.write(true).create_new(true);
317    #[cfg(unix)]
318    {
319        use std::os::unix::fs::OpenOptionsExt;
320        options.mode(0o600);
321    }
322    let mut destination = options
323        .open(&prepared.path)
324        .map_err(|_| blocked_at("private-file"))?;
325    #[cfg(unix)]
326    {
327        use std::os::unix::fs::PermissionsExt;
328        destination
329            .set_permissions(fs::Permissions::from_mode(0o600))
330            .map_err(|_| blocked_at("private-file"))?;
331    }
332    let mut buffer = [0; BLOCK_SIZE];
333    let mut bytes = 0_u64;
334    loop {
335        let length = source.read(&mut buffer).map_err(|_| blocked_at("read"))?;
336        if length == 0 {
337            break;
338        }
339        bytes += length as u64;
340        destination
341            .write_all(&sanitizer.push(&buffer[..length], false))
342            .map_err(|_| blocked_at("write"))?;
343    }
344    destination
345        .write_all(&sanitizer.push(&[], true))
346        .map_err(|_| blocked_at("write"))?;
347    destination.sync_all().map_err(|_| blocked_at("sync"))?;
348    drop(destination);
349    if verbose {
350        println!(
351            "Prepared private sanitized log: {} bytes scanned, {} redactions.",
352            bytes, sanitizer.redactions
353        );
354    }
355    Ok(prepared)
356}