1use 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 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
111pub 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
238pub 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 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}