scour_secrets/processor/
command_output.rs1use crate::error::Result;
42use crate::processor::profile::FieldRule;
43use crate::processor::{
44 find_matching_rule, process_sub_content, replace_value, FileTypeProfile, Processor,
45};
46use crate::store::MappingStore;
47
48const DEFAULT_PROMPT_PREFIX: &str = "> ";
50
51const SEPARATOR_MIN_DASHES: usize = 4;
53
54pub struct CommandOutputProcessor;
56
57fn is_separator(line: &str) -> bool {
60 let t = line.trim();
61 t.len() >= SEPARATOR_MIN_DASHES && t.bytes().all(|b| b == b'-')
62}
63
64fn replace_block_value(block: &str, rule: &FieldRule, store: &MappingStore) -> Result<String> {
68 let trimmed = block.trim();
69 if trimmed.is_empty() {
70 return Ok(block.to_string());
71 }
72 let start = block
73 .find(trimmed)
74 .expect("trim of a str is always a substring of it");
75 let end = start + trimmed.len();
76 let replaced = replace_value(trimmed, rule, store)?;
77 Ok(format!("{}{}{}", &block[..start], replaced, &block[end..]))
78}
79
80impl CommandOutputProcessor {
81 fn flush_block(
84 block: &str,
85 rule: Option<&FieldRule>,
86 store: &MappingStore,
87 out: &mut String,
88 ) -> Result<()> {
89 match rule {
90 Some(rule) if rule.sub_processor.is_some() => {
91 out.push_str(&process_sub_content(block, rule, store)?);
92 }
93 Some(rule) => out.push_str(&replace_block_value(block, rule, store)?),
94 None => out.push_str(block),
95 }
96 Ok(())
97 }
98}
99
100impl Processor for CommandOutputProcessor {
101 fn name(&self) -> &'static str {
102 "command_output"
103 }
104
105 fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
106 let prompt = profile
107 .options
108 .get("prompt_prefix")
109 .map_or(DEFAULT_PROMPT_PREFIX, |s| s.as_str());
110 content.starts_with(prompt.as_bytes())
112 || content
113 .windows(prompt.len() + 1)
114 .any(|w| w[0] == b'\n' && &w[1..] == prompt.as_bytes())
115 }
116
117 fn process(
118 &self,
119 content: &[u8],
120 profile: &FileTypeProfile,
121 store: &MappingStore,
122 ) -> Result<Vec<u8>> {
123 let text =
124 std::str::from_utf8(content).map_err(|e| crate::error::SanitizeError::ParseError {
125 format: "command_output".into(),
126 message: format!("requires UTF-8 input: {e}"),
127 })?;
128 let prompt = profile
129 .options
130 .get("prompt_prefix")
131 .map_or(DEFAULT_PROMPT_PREFIX, |s| s.as_str());
132
133 let mut out = String::with_capacity(text.len());
134 let mut active_rule: Option<Option<&FieldRule>> = None;
136 let mut block = String::new();
137
138 for line in text.split_inclusive('\n') {
139 let body = line.strip_suffix('\n').unwrap_or(line);
140 let body = body.strip_suffix('\r').unwrap_or(body);
141
142 if let Some(command) = body.strip_prefix(prompt) {
143 if let Some(rule) = active_rule.take() {
145 Self::flush_block(&block, rule, store, &mut out)?;
146 block.clear();
147 }
148 out.push_str(line);
149 active_rule = Some(find_matching_rule(command.trim(), profile));
150 } else if is_separator(body) {
151 if let Some(rule) = active_rule.take() {
152 Self::flush_block(&block, rule, store, &mut out)?;
153 block.clear();
154 }
155 out.push_str(line);
156 } else if active_rule.is_some() {
157 block.push_str(line);
158 } else {
159 out.push_str(line);
160 }
161 }
162 if let Some(rule) = active_rule {
163 Self::flush_block(&block, rule, store, &mut out)?;
164 }
165
166 Ok(out.into_bytes())
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::category::Category;
174 use crate::generator::HmacGenerator;
175 use crate::processor::profile::{FieldRule, FileTypeProfile};
176 use std::sync::Arc;
177
178 fn store() -> MappingStore {
179 MappingStore::new(Arc::new(HmacGenerator::new([42u8; 32])), None)
180 }
181
182 fn profile(fields: Vec<FieldRule>) -> FileTypeProfile {
183 FileTypeProfile::new("command_output", fields)
184 }
185
186 fn hostname_rule() -> FieldRule {
187 FieldRule::new("hostname*")
188 .with_category(Category::Hostname)
189 .with_min_length(2)
190 }
191
192 const DUMP: &str = "DSS Diagnosis\n\
193 Diagnosis started at Mon Jun 22 04:01:42 AM UTC 2026\n\
194 \n\
195 ----------------------------------------------------------\n\
196 \n\
197 Mon Jun 22 04:01:42 AM UTC 2026\n\
198 > uname -a\n\
199 Linux dss-prod-01 6.12.76-linuxkit #1 SMP x86_64 GNU/Linux\n\
200 \n\
201 ----------------------------------------------------------\n\
202 \n\
203 Mon Jun 22 04:01:46 AM UTC 2026\n\
204 > hostname --fqdn\n\
205 dss-prod-01.corp.example.com\n\
206 \n\
207 ----------------------------------------------------------\n";
208
209 #[test]
210 fn replaces_matched_command_output_and_preserves_the_rest() {
211 let store = store();
212 let profile = profile(vec![hostname_rule()]);
213 let out = CommandOutputProcessor
214 .process(DUMP.as_bytes(), &profile, &store)
215 .unwrap();
216 let out = String::from_utf8(out).unwrap();
217
218 assert!(
219 !out.contains("dss-prod-01.corp.example.com"),
220 "fqdn must be replaced: {out}"
221 );
222 assert!(out.contains("> hostname --fqdn\n"));
226 assert!(out.contains("Linux dss-prod-01 6.12.76-linuxkit #1 SMP x86_64 GNU/Linux\n"));
227 assert!(out.contains("Diagnosis started at Mon Jun 22 04:01:42 AM UTC 2026\n"));
228 assert_eq!(
229 out.matches("----------------------------------------------------------\n")
230 .count(),
231 3
232 );
233 assert_eq!(out.lines().count(), DUMP.lines().count());
235 }
236
237 #[test]
238 fn unmatched_input_is_byte_identical() {
239 let store = store();
240 let profile = profile(vec![
241 FieldRule::new("nomatch*").with_category(Category::Hostname)
242 ]);
243 let out = CommandOutputProcessor
244 .process(DUMP.as_bytes(), &profile, &store)
245 .unwrap();
246 assert_eq!(out, DUMP.as_bytes());
247 }
248
249 #[test]
250 fn delegates_block_to_sub_processor() {
251 let store = store();
252 let profile = profile(vec![FieldRule::new("printenv")
253 .with_sub_processor("env")
254 .with_sub_fields(vec![FieldRule::new("*PASSWORD*")
255 .with_category(Category::Custom("password".into()))
256 .with_min_length(1)])]);
257 let dump = "> printenv\nHOME=/home/dataiku\nDB_PASSWORD=hunter2secret\n\n> id\nuid=1000\n";
258 let out = CommandOutputProcessor
259 .process(dump.as_bytes(), &profile, &store)
260 .unwrap();
261 let out = String::from_utf8(out).unwrap();
262
263 assert!(
264 !out.contains("hunter2secret"),
265 "env credential replaced: {out}"
266 );
267 assert!(
268 out.contains("HOME=/home/dataiku\n"),
269 "other vars preserved: {out}"
270 );
271 assert!(
272 out.contains("> id\nuid=1000\n"),
273 "next block preserved: {out}"
274 );
275 }
276
277 #[test]
278 fn custom_prompt_prefix_option() {
279 let store = store();
280 let mut profile = profile(vec![hostname_rule()]);
281 profile.options.insert("prompt_prefix".into(), "$ ".into());
282 let dump = "$ hostname\nweb-42.internal\n";
283 assert!(CommandOutputProcessor.can_handle(dump.as_bytes(), &profile));
284 let out = CommandOutputProcessor
285 .process(dump.as_bytes(), &profile, &store)
286 .unwrap();
287 let out = String::from_utf8(out).unwrap();
288 assert!(!out.contains("web-42.internal"));
289 assert!(out.starts_with("$ hostname\n"));
290 }
291
292 #[test]
293 fn can_handle_requires_a_prompt_line() {
294 let profile = profile(vec![]);
295 assert!(CommandOutputProcessor.can_handle(b"> uname -a\nLinux\n", &profile));
296 assert!(CommandOutputProcessor.can_handle(b"header\n> id\nuid=0\n", &profile));
297 assert!(!CommandOutputProcessor.can_handle(b"plain text, no prompts\n", &profile));
298 }
299
300 #[test]
301 fn empty_command_output_passes_through() {
302 let store = store();
303 let profile = profile(vec![hostname_rule()]);
304 let dump = "> hostname --fqdn\n\n\n----\n";
305 let out = CommandOutputProcessor
306 .process(dump.as_bytes(), &profile, &store)
307 .unwrap();
308 assert_eq!(out, dump.as_bytes());
309 }
310}