Skip to main content

scour_secrets/processor/
command_output.rs

1//! Command-output processor for support-dump files.
2//!
3//! Handles the `> command` + output-block shape that vendor diagnostic
4//! bundles use (Dataiku `diag.txt`, Elastic diagnostics, MongoDB `mdiag`,
5//! sosreport-style dumps):
6//!
7//! ```text
8//! Mon Jun 22 04:01:46 AM UTC 2026
9//! > hostname --fqdn
10//! dss-prod-01.corp.example.com
11//!
12//! ----------------------------------------------------------
13//!
14//! > printenv
15//! HOSTNAME=dss-prod-01
16//! HTTPS_PROXY=http://user:secret@proxy:3128
17//! ```
18//!
19//! A line starting with the prompt prefix opens a block: the rest of that
20//! line is the *command string*, and the block's content is every following
21//! line until the next prompt line, a separator line (four or more dashes),
22//! or end of input. Field rules are matched against the command string with
23//! the usual glob syntax (`hostname*` matches `hostname --fqdn`).
24//!
25//! - A matching rule **with** `sub_processor` delegates the block content to
26//!   that processor with the rule's `sub_fields` — e.g. a `printenv` block to
27//!   the `env` processor.
28//! - A matching rule **without** `sub_processor` treats the trimmed block as
29//!   a single value and replaces it with the rule's category (fits
30//!   single-line outputs like `hostname --fqdn`); surrounding whitespace and
31//!   blank lines are preserved.
32//! - Blocks with no matching rule, prompt lines, separators, timestamps, and
33//!   any text outside a block are preserved byte-for-byte.
34//!
35//! # Profile Options
36//!
37//! | Key             | Default | Description                                 |
38//! |-----------------|---------|---------------------------------------------|
39//! | `prompt_prefix` | `"> "`  | Line prefix that opens a command block.     |
40
41use 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
48/// Default line prefix that opens a command block.
49const DEFAULT_PROMPT_PREFIX: &str = "> ";
50
51/// Minimum run of `-` for a line to count as a block separator.
52const SEPARATOR_MIN_DASHES: usize = 4;
53
54/// Processor for `> command` + output-block support dumps.
55pub struct CommandOutputProcessor;
56
57/// Whether `line` (terminator stripped) is a separator: nothing but dashes,
58/// at least [`SEPARATOR_MIN_DASHES`] of them.
59fn is_separator(line: &str) -> bool {
60    let t = line.trim();
61    t.len() >= SEPARATOR_MIN_DASHES && t.bytes().all(|b| b == b'-')
62}
63
64/// Replace the trimmed core of `block` via `rule`, keeping leading/trailing
65/// whitespace (blank output lines, final newline) byte-for-byte. Blocks that
66/// are all whitespace pass through unchanged.
67fn 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    /// Flush a completed block: apply the matching rule (if any) and append
82    /// the result to `out`.
83    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        // A prompt line somewhere in the content (start of input or of a line).
111        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        // Rule for the block currently being collected, and its raw content.
135        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                // New prompt terminates any open block.
144                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        // Everything not covered by the matched rule is byte-identical,
223        // including the prompt line, separators, timestamps, and the
224        // unmatched uname block.
225        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        // Trailing blank line of the block survives the value replacement.
234        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}