Skip to main content

scour_secrets/processor/
columns.rs

1//! Whitespace-columns processor for fixed-header command output.
2//!
3//! Handles `ps aux` / `top -b` style listings that support bundles capture
4//! verbatim: a header line of column names followed by rows aligned on
5//! whitespace runs:
6//!
7//! ```text
8//! USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
9//! root           1  0.0  0.5 167576 11132 ?        Ss   Jun24   0:11 /sbin/init
10//! jdoe        1234  2.0  1.1 981432 22208 ?        Sl   Jun24   3:02 ruby puma
11//! ```
12//!
13//! Field rules match by **header column name** (exact or glob, like other
14//! processors' key matching). Behaviour:
15//!
16//! - A line containing a token that matches a field rule is treated as the
17//!   header: it fixes the column positions and is emitted unchanged. A later
18//!   matching line re-keys the columns (repeated `top -b` iterations).
19//! - Lines before the first header, and lines with fewer tokens than the
20//!   header (`top` preamble, wrapped output), pass through unchanged.
21//! - In data rows, the token at each matched column index is replaced via the
22//!   rule's category. The final column is matched only up to its own token —
23//!   trailing free-text (a `COMMAND` with arguments) is one token per rule
24//!   match, so match trailing columns deliberately.
25//!
26//! `min_length` on the rule is the practical guard here: preamble lines that
27//! happen to reach the header's token count carry short tokens (`-`, counts)
28//! at the matched index, and service-account rows (`git`, `sshd`) are usually
29//! shorter than real usernames.
30
31use crate::error::Result;
32use crate::processor::limits::DEFAULT_INPUT_SIZE;
33use crate::processor::{
34    apply_edits, check_size_and_decode, edit_token, find_matching_rule, FileTypeProfile, Processor,
35    Replacement,
36};
37use crate::store::MappingStore;
38
39/// Structured processor for whitespace-aligned columnar command output.
40pub struct ColumnsProcessor;
41
42/// `(start, end)` byte spans of the whitespace-separated tokens of `line`.
43fn token_spans(line: &str) -> Vec<(usize, usize)> {
44    let bytes = line.as_bytes();
45    let mut spans = Vec::new();
46    let mut i = 0usize;
47    while i < bytes.len() {
48        if bytes[i].is_ascii_whitespace() {
49            i += 1;
50            continue;
51        }
52        let start = i;
53        while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
54            i += 1;
55        }
56        spans.push((start, i));
57    }
58    spans
59}
60
61/// Column indices of header tokens that match a field rule, with the matched
62/// column's name. Empty when `line` is not a header for this profile.
63fn matched_header_columns(line: &str, profile: &FileTypeProfile) -> Vec<(usize, String)> {
64    token_spans(line)
65        .iter()
66        .enumerate()
67        .filter_map(|(idx, &(s, e))| {
68            let name = &line[s..e];
69            find_matching_rule(name, profile).map(|_| (idx, name.to_string()))
70        })
71        .collect()
72}
73
74/// Compute the span edits for `content`: for every data row under a detected
75/// header, replace the token in each rule-matched column.
76fn compute_edits(
77    text: &str,
78    profile: &FileTypeProfile,
79    store: &MappingStore,
80) -> Result<Vec<Replacement>> {
81    let mut edits = Vec::new();
82    // (column index, column name) pairs of the active header, plus its width.
83    let mut header: Option<(Vec<(usize, String)>, usize)> = None;
84
85    let mut line_start = 0usize;
86    for line in text.split_inclusive('\n') {
87        let body = line.trim_end_matches(['\n', '\r']);
88        let matched = matched_header_columns(body, profile);
89        if !matched.is_empty() {
90            header = Some((matched, token_spans(body).len()));
91            line_start += line.len();
92            continue;
93        }
94        if let Some((columns, width)) = &header {
95            let spans = token_spans(body);
96            // Preamble/wrapped lines are narrower than the header; skip them.
97            if spans.len() >= *width {
98                for (idx, name) in columns {
99                    let (s, e) = spans[*idx];
100                    let value = &body[s..e];
101                    if let Some(token) = edit_token(name, name, value, profile, store)? {
102                        edits.push(Replacement::new(line_start + s, line_start + e, token));
103                    }
104                }
105            }
106        }
107        line_start += line.len();
108    }
109    Ok(edits)
110}
111
112impl Processor for ColumnsProcessor {
113    fn name(&self) -> &'static str {
114        "columns"
115    }
116
117    fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
118        profile.processor == "columns"
119    }
120
121    fn process(
122        &self,
123        content: &[u8],
124        profile: &FileTypeProfile,
125        store: &MappingStore,
126    ) -> Result<Vec<u8>> {
127        let text = check_size_and_decode(content, "columns", DEFAULT_INPUT_SIZE)?;
128        let edits = compute_edits(text, profile, store)?;
129        Ok(apply_edits(content, edits))
130    }
131
132    fn process_to_edits(
133        &self,
134        content: &[u8],
135        profile: &FileTypeProfile,
136        store: &MappingStore,
137    ) -> Result<Option<Vec<Replacement>>> {
138        let text = check_size_and_decode(content, "columns", DEFAULT_INPUT_SIZE)?;
139        Ok(Some(compute_edits(text, profile, store)?))
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::category::Category;
147    use crate::generator::HmacGenerator;
148    use crate::processor::profile::FieldRule;
149    use std::sync::Arc;
150
151    fn store() -> MappingStore {
152        MappingStore::new(Arc::new(HmacGenerator::new([42u8; 32])), None)
153    }
154
155    fn user_profile(min_length: usize) -> FileTypeProfile {
156        FileTypeProfile::new(
157            "columns",
158            vec![FieldRule::new("USER")
159                .with_category(Category::Name)
160                .with_min_length(min_length)],
161        )
162    }
163
164    #[test]
165    fn ps_user_column_is_replaced() {
166        let input = b"USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND\n\
167                      git            1  0.0  0.5 167576 11132 ?        Ss   Jun24   0:11 /sbin/init\n\
168                      jdoeworth   1234  2.0  1.1 981432 22208 ?        Sl   Jun24   3:02 ruby puma\n";
169        let st = store();
170        let out = ColumnsProcessor
171            .process(input, &user_profile(4), &st)
172            .unwrap();
173        let out = String::from_utf8(out).unwrap();
174        assert!(!out.contains("jdoeworth"), "username replaced: {out}");
175        assert!(out.contains("git "), "min_length keeps 'git': {out}");
176        assert!(
177            out.contains("USER") && out.contains("/sbin/init") && out.contains("ruby puma"),
178            "header and commands preserved: {out}"
179        );
180    }
181
182    #[test]
183    fn top_preamble_lines_pass_through() {
184        // top -b preamble reaches the header's width on some lines; the
185        // min_length guard plus width check must leave it untouched.
186        let input = b"top - 04:01:46 up 5 days,  3:22,  1 user,  load average: 0.10, 0.20, 0.30\n\
187                      Tasks: 270 total,   1 running, 269 sleeping,   0 stopped,   0 zombie\n\
188                      \n\
189                      \x20   PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND\n\
190                      \x20  1234 jdoeworth 20   0  981432  22208  11132 S   2.0   1.1   3:02.11 ruby\n";
191        let st = store();
192        let out = ColumnsProcessor
193            .process(input, &user_profile(4), &st)
194            .unwrap();
195        let out = String::from_utf8(out).unwrap();
196        assert!(!out.contains("jdoeworth"), "username replaced: {out}");
197        assert!(
198            out.contains("load average: 0.10, 0.20, 0.30") && out.contains("Tasks: 270 total"),
199            "preamble untouched: {out}"
200        );
201    }
202
203    #[test]
204    fn lines_before_header_are_untouched() {
205        let input = b"no header yet alice bob\nUSER PID\nalice 12\n";
206        let st = store();
207        let out = ColumnsProcessor
208            .process(input, &user_profile(4), &st)
209            .unwrap();
210        let out = String::from_utf8(out).unwrap();
211        assert!(
212            out.starts_with("no header yet alice bob\n"),
213            "pre-header lines pass through: {out}"
214        );
215        assert!(!out.contains("\nalice 12"), "data row replaced: {out}");
216    }
217
218    #[test]
219    fn repeated_headers_rekey_columns() {
220        // top -b -n 2: the header repeats; the second block must still match.
221        let input = b"USER PID\nalice 12\nUSER PID\nbobby 34\n";
222        let st = store();
223        let out = ColumnsProcessor
224            .process(input, &user_profile(4), &st)
225            .unwrap();
226        let out = String::from_utf8(out).unwrap();
227        assert!(
228            !out.contains("alice") && !out.contains("bobby"),
229            "both blocks replaced: {out}"
230        );
231    }
232}