Skip to main content

scour_secrets/processor/
yaml_proc.rs

1//! YAML structured processor.
2//!
3//! The CLI uses [`process_to_edits`](YamlProcessor::process_to_edits): it drives
4//! a span-aware event parser (`saphyr-parser`) and replaces each matched scalar
5//! at its exact source span, preserving comments, anchors, key order, and quote
6//! style byte-for-byte (escaped/quoted scalars are hit as written, so they never
7//! leak). `process` is the re-serializing fallback (which normalizes some
8//! whitespace).
9//!
10//! Key paths use the same dot-separated convention as the JSON processor.
11
12use crate::error::{Result, SanitizeError};
13use crate::processor::limits::{DEFAULT_DEPTH, YAML_INPUT_SIZE, YAML_NODE_COUNT};
14use crate::processor::{
15    build_path, edit_token, walk_tree, FileTypeProfile, Processor, Replacement, TreeNode,
16};
17use crate::store::MappingStore;
18use saphyr_parser::{Event, Parser, ScalarStyle};
19use serde_yaml_ng::Value;
20
21/// Byte length of a leading quoted scalar (`"..."` or `'...'`) within `src` —
22/// the index just past its closing quote — or `None` for non-quoted styles or
23/// when no closing quote is found (then the caller keeps saphyr's span).
24///
25/// saphyr's reported span for a quoted scalar can extend to end-of-line
26/// (including a trailing inline comment), so we re-scan to the actual closing
27/// quote to avoid clobbering comments and trailing formatting.
28fn quoted_scalar_end(src: &[u8], style: ScalarStyle) -> Option<usize> {
29    let quote = match style {
30        ScalarStyle::DoubleQuoted => b'"',
31        ScalarStyle::SingleQuoted => b'\'',
32        _ => return None,
33    };
34    if src.first() != Some(&quote) {
35        return None;
36    }
37    let mut i = 1;
38    while i < src.len() {
39        let b = src[i];
40        // Double-quoted YAML uses backslash escapes; skip the escaped byte.
41        if style == ScalarStyle::DoubleQuoted && b == b'\\' {
42            i += 2;
43            continue;
44        }
45        if b == quote {
46            // Single-quoted YAML escapes a quote by doubling it (`''`).
47            if style == ScalarStyle::SingleQuoted && src.get(i + 1) == Some(&quote) {
48                i += 2;
49                continue;
50            }
51            return Some(i + 1);
52        }
53        i += 1;
54    }
55    None
56}
57
58/// A container frame for the YAML event-stream walk (span-based editing).
59enum YamlFrame {
60    /// Inside a mapping. `path`/`key` locate the mapping itself; `current_key`
61    /// is the key whose value is being read; `expecting_key` alternates.
62    Mapping {
63        path: String,
64        expecting_key: bool,
65        current_key: Option<String>,
66    },
67    /// Inside a sequence. Items are path-transparent (keep the parent key/path).
68    Sequence { path: String, key: String },
69}
70
71/// The (dot-path, bare-key) of the value about to be read, given the frame stack.
72fn yaml_value_position(frames: &[YamlFrame]) -> (String, String) {
73    match frames.last() {
74        Some(YamlFrame::Mapping {
75            path, current_key, ..
76        }) => {
77            let key = current_key.clone().unwrap_or_default();
78            (build_path(path, &key), key)
79        }
80        Some(YamlFrame::Sequence { path, key }) => (path.clone(), key.clone()),
81        None => (String::new(), String::new()),
82    }
83}
84
85/// After a value (scalar, or a container that just ended) is consumed, a parent
86/// mapping should expect the next key.
87fn yaml_note_value_consumed(frames: &mut [YamlFrame]) {
88    if let Some(YamlFrame::Mapping { expecting_key, .. }) = frames.last_mut() {
89        *expecting_key = true;
90    }
91}
92
93/// Replacement text for a matched YAML scalar, given its source bytes (`span_src`)
94/// and scalar style.
95///
96/// Flow scalars (plain, single/double-quoted) become a double-quoted token.
97/// **Block** scalars (`|` literal, `>` folded) must stay block-valid: their value
98/// spans the indented content lines, so replacing it with an inline `"token"`
99/// would collapse the block and absorb the following keys. Instead, emit a single
100/// indented line — `<indent>token` plus the trailing newline the span consumed —
101/// keeping the block structure intact.
102fn yaml_scalar_replacement(token: &str, style: ScalarStyle, span_src: &[u8]) -> String {
103    match style {
104        ScalarStyle::Literal | ScalarStyle::Folded => {
105            let indent: String = span_src
106                .iter()
107                .take_while(|&&b| b == b' ' || b == b'\t')
108                .map(|&b| b as char)
109                .collect();
110            let trailing_nl = if span_src.last() == Some(&b'\n') {
111                "\n"
112            } else {
113                ""
114            };
115            format!("{indent}{token}{trailing_nl}")
116        }
117        _ => format!("\"{token}\""),
118    }
119}
120
121/// Structured processor for YAML files.
122pub struct YamlProcessor;
123
124impl Processor for YamlProcessor {
125    fn name(&self) -> &'static str {
126        "yaml"
127    }
128
129    fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
130        if profile.processor == "yaml" {
131            return true;
132        }
133        // Heuristic: starts with `---` or a YAML-ish key: value.
134        let text = String::from_utf8_lossy(content);
135        let trimmed = text.trim_start();
136        trimmed.starts_with("---")
137            || trimmed.starts_with("- ")
138            || trimmed.starts_with('{')
139            || trimmed.contains(": ")
140    }
141
142    fn process(
143        &self,
144        content: &[u8],
145        profile: &FileTypeProfile,
146        store: &MappingStore,
147    ) -> Result<Vec<u8>> {
148        // Guard against alias bombs: reject inputs above YAML_INPUT_SIZE.
149        let text = crate::processor::check_size_and_decode(content, "YAML", YAML_INPUT_SIZE)?;
150
151        let mut value: Value =
152            serde_yaml_ng::from_str(text).map_err(|e| SanitizeError::ParseError {
153                format: "YAML".into(),
154                message: format!("YAML parse error: {}", e),
155            })?;
156
157        // F-06 fix: count total nodes in the deserialized tree to detect
158        // alias bombs. After expansion, aliased subtrees become
159        // independent copies in memory, so the node count reflects the
160        // true memory footprint.
161        let node_count = count_yaml_nodes(&value);
162        if node_count > YAML_NODE_COUNT {
163            return Err(SanitizeError::InputTooLarge {
164                size: node_count,
165                limit: YAML_NODE_COUNT,
166            });
167        }
168
169        walk_yaml(&mut value, "", profile, store, 0)?;
170
171        let output = serde_yaml_ng::to_string(&value).map_err(|e| {
172            SanitizeError::IoError(std::io::Error::other(format!("YAML serialize error: {e}")))
173        })?;
174
175        Ok(output.into_bytes())
176    }
177
178    /// Span-based redaction: drive `saphyr-parser` (which yields each event with
179    /// a byte `Span`) over the document, tracking the mapping/sequence path, and
180    /// emit an edit replacing each matched value scalar's exact source span with
181    /// a quoted token. Comments, anchors, key order, block/flow style, and the
182    /// exact escaping of unrelated content are preserved; the value is hit in the
183    /// source as written, so quoted/escaped scalars never leak.
184    fn process_to_edits(
185        &self,
186        content: &[u8],
187        profile: &FileTypeProfile,
188        store: &MappingStore,
189    ) -> Result<Option<Vec<Replacement>>> {
190        let text = crate::processor::check_size_and_decode(content, "YAML", YAML_INPUT_SIZE)?;
191        let mut edits = Vec::new();
192        let mut frames: Vec<YamlFrame> = Vec::new();
193
194        // saphyr reports span markers as CHARACTER counts, not byte offsets
195        // (its `Marker::index()` "in bytes" doc is wrong — every char advances
196        // the index by 1). For multi-byte UTF-8 we must translate char index →
197        // byte offset before slicing `content`, or a scalar that follows
198        // multi-byte content is sliced at the wrong position and the output is
199        // corrupted. ASCII needs no map (char index == byte offset).
200        let char_to_byte: Option<Vec<usize>> = if text.is_ascii() {
201            None
202        } else {
203            Some(
204                text.char_indices()
205                    .map(|(b, _)| b)
206                    .chain(std::iter::once(text.len()))
207                    .collect(),
208            )
209        };
210        let to_byte = |char_idx: usize| -> usize {
211            char_to_byte
212                .as_ref()
213                .map_or(char_idx, |m| m.get(char_idx).copied().unwrap_or(text.len()))
214        };
215
216        for event in Parser::new_from_str(text) {
217            let (event, span) = event.map_err(|e| SanitizeError::ParseError {
218                format: "YAML".into(),
219                message: format!("YAML parse error: {e}"),
220            })?;
221            match event {
222                Event::Scalar(value, style, _aid, _tag) => {
223                    let is_key = matches!(
224                        frames.last(),
225                        Some(YamlFrame::Mapping {
226                            expecting_key: true,
227                            ..
228                        })
229                    );
230                    if is_key {
231                        if let Some(YamlFrame::Mapping {
232                            expecting_key,
233                            current_key,
234                            ..
235                        }) = frames.last_mut()
236                        {
237                            *current_key = Some(value.into_owned());
238                            *expecting_key = false;
239                        }
240                    } else {
241                        let (path, key) = yaml_value_position(&frames);
242                        if let Some(token) = edit_token(&key, &path, &value, profile, store)? {
243                            let start = to_byte(span.start.index());
244                            let mut end = to_byte(span.end.index());
245                            // saphyr's span for a *quoted* scalar can run to the
246                            // end of the line, swallowing trailing whitespace and
247                            // an inline `# comment`. Clamp to the real closing
248                            // quote so comments/formatting survive byte-for-byte.
249                            if let Some(real) = quoted_scalar_end(&content[start..end], style) {
250                                end = start + real;
251                            }
252                            let repl = yaml_scalar_replacement(&token, style, &content[start..end]);
253                            edits.push(Replacement {
254                                start,
255                                end,
256                                value: repl,
257                            });
258                        }
259                        yaml_note_value_consumed(&mut frames);
260                    }
261                }
262                Event::MappingStart(..) => {
263                    let (path, _key) = yaml_value_position(&frames);
264                    frames.push(YamlFrame::Mapping {
265                        path,
266                        expecting_key: true,
267                        current_key: None,
268                    });
269                }
270                Event::SequenceStart(..) => {
271                    let (path, key) = yaml_value_position(&frames);
272                    frames.push(YamlFrame::Sequence { path, key });
273                }
274                Event::MappingEnd | Event::SequenceEnd => {
275                    frames.pop();
276                    yaml_note_value_consumed(&mut frames);
277                }
278                Event::Alias(_) => {
279                    // Alias references an anchored value (redacted at its
280                    // definition); the alias node itself isn't a literal.
281                    yaml_note_value_consumed(&mut frames);
282                }
283                _ => {}
284            }
285        }
286        Ok(Some(edits))
287    }
288}
289
290/// Count the total number of nodes in a YAML value tree (F-06 fix).
291/// Used to detect alias bombs that produce a small source document
292/// but expand to millions of nodes after alias resolution.
293fn count_yaml_nodes(value: &Value) -> usize {
294    count_yaml_nodes_inner(value, 0)
295}
296
297/// Inner recursive counter with depth guard to prevent stack overflow
298/// on deeply nested YAML before `walk_yaml`'s depth check is reached.
299fn count_yaml_nodes_inner(value: &Value, depth: usize) -> usize {
300    if depth > DEFAULT_DEPTH {
301        return 1; // Stop counting deeper; walk_yaml will catch depth violations
302    }
303    match value {
304        Value::Mapping(map) => {
305            1 + map
306                .iter()
307                .map(|(k, v)| {
308                    count_yaml_nodes_inner(k, depth + 1) + count_yaml_nodes_inner(v, depth + 1)
309                })
310                .sum::<usize>()
311        }
312        Value::Sequence(seq) => {
313            1 + seq
314                .iter()
315                .map(|v| count_yaml_nodes_inner(v, depth + 1))
316                .sum::<usize>()
317        }
318        Value::Tagged(tagged) => 1 + count_yaml_nodes_inner(&tagged.value, depth + 1),
319        _ => 1, // Null, Bool, Number, String
320    }
321}
322
323impl TreeNode for Value {
324    fn for_each_map_entry<F>(&mut self, mut f: F) -> Result<()>
325    where
326        F: FnMut(&str, &mut Self) -> Result<()>,
327    {
328        if let Self::Mapping(map) = self {
329            let keys: Vec<Self> = map.keys().cloned().collect();
330            for key in keys {
331                let key_str = yaml_key_to_string(&key);
332                if let Some(v) = map.get_mut(&key) {
333                    f(&key_str, v)?;
334                }
335            }
336        }
337        Ok(())
338    }
339
340    fn for_each_seq_item<F>(&mut self, mut f: F) -> Result<()>
341    where
342        F: FnMut(&mut Self) -> Result<()>,
343    {
344        if let Self::Sequence(seq) = self {
345            for item in seq.iter_mut() {
346                f(item)?;
347            }
348        }
349        Ok(())
350    }
351
352    fn as_str_mut(&mut self) -> Option<&mut String> {
353        if let Self::String(s) = self {
354            Some(s)
355        } else {
356            None
357        }
358    }
359
360    fn is_scalar(&self) -> bool {
361        matches!(self, Self::Number(_) | Self::Bool(_))
362    }
363
364    fn scalar_to_string(&self) -> String {
365        yaml_scalar_to_string(self)
366    }
367
368    fn set_string(&mut self, s: String) {
369        *self = Self::String(s);
370    }
371}
372
373/// Recursively walk a YAML value tree, replacing matched field values.
374fn walk_yaml(
375    value: &mut Value,
376    prefix: &str,
377    profile: &FileTypeProfile,
378    store: &MappingStore,
379    depth: usize,
380) -> Result<()> {
381    walk_tree(value, prefix, profile, store, depth, "YAML")
382}
383
384fn yaml_key_to_string(key: &Value) -> String {
385    match key {
386        Value::String(s) => s.clone(),
387        Value::Number(n) => n.to_string(),
388        Value::Bool(b) => b.to_string(),
389        _ => format!("{:?}", key),
390    }
391}
392
393fn yaml_scalar_to_string(v: &Value) -> String {
394    match v {
395        Value::String(s) => s.clone(),
396        Value::Number(n) => n.to_string(),
397        Value::Bool(b) => b.to_string(),
398        _ => String::new(),
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::category::Category;
406    use crate::generator::HmacGenerator;
407    use crate::processor::profile::FieldRule;
408    use std::sync::Arc;
409
410    fn make_store() -> MappingStore {
411        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
412        MappingStore::new(gen, None)
413    }
414
415    #[test]
416    fn basic_yaml_replacement() {
417        let store = make_store();
418        let proc = YamlProcessor;
419
420        let content = b"database:\n  host: db.corp.com\n  password: s3cret\nport: 5432\n";
421        let profile = FileTypeProfile::new(
422            "yaml",
423            vec![
424                FieldRule::new("database.password").with_category(Category::Custom("pw".into())),
425                FieldRule::new("database.host").with_category(Category::Hostname),
426            ],
427        );
428
429        let result = proc.process(content, &profile, &store).unwrap();
430        let out = String::from_utf8(result).unwrap();
431
432        assert!(!out.contains("s3cret"));
433        assert!(!out.contains("db.corp.com"));
434        // port should be preserved
435        assert!(out.contains("5432"));
436    }
437
438    #[test]
439    fn can_handle_by_profile_name() {
440        let proc = YamlProcessor;
441        let profile = FileTypeProfile::new("yaml", vec![]).with_extension(".yaml");
442        assert!(proc.can_handle(b"anything", &profile));
443    }
444
445    #[test]
446    fn can_handle_detects_document_marker() {
447        let proc = YamlProcessor;
448        let profile = FileTypeProfile::new("json", vec![]).with_extension(".json");
449        assert!(proc.can_handle(b"---\nkey: value\n", &profile));
450    }
451
452    #[test]
453    fn can_handle_detects_key_value_heuristic() {
454        let proc = YamlProcessor;
455        let profile = FileTypeProfile::new("other", vec![]).with_extension(".conf");
456        assert!(proc.can_handle(b"host: localhost\nport: 5432\n", &profile));
457    }
458
459    #[test]
460    fn can_handle_detects_sequence_heuristic() {
461        let proc = YamlProcessor;
462        let profile = FileTypeProfile::new("other", vec![]).with_extension(".txt");
463        assert!(proc.can_handle(b"- item1\n- item2\n", &profile));
464    }
465
466    #[test]
467    fn can_handle_rejects_plaintext() {
468        let proc = YamlProcessor;
469        let profile = FileTypeProfile::new("json", vec![]).with_extension(".json");
470        assert!(!proc.can_handle(b"just plain text with no yaml markers", &profile));
471    }
472
473    #[test]
474    fn non_string_scalars_not_targeted_pass_through() {
475        let store = make_store();
476        let proc = YamlProcessor;
477        // Only target the 'secret' field; booleans and numbers are untouched.
478        let content = b"enabled: true\ncount: 42\nsecret: hunter2\n";
479        let profile = FileTypeProfile::new(
480            "yaml",
481            vec![FieldRule::new("secret").with_category(Category::Custom("pw".into()))],
482        );
483        let result = proc.process(content, &profile, &store).unwrap();
484        let out = String::from_utf8(result).unwrap();
485        assert!(!out.contains("hunter2"), "secret must be replaced");
486        assert!(out.contains("42"), "integer must be preserved");
487    }
488
489    #[test]
490    fn deeply_nested_yaml_replaced() {
491        let store = make_store();
492        let proc = YamlProcessor;
493        let content = b"a:\n  b:\n    c:\n      secret: hunter2\n";
494        let profile = FileTypeProfile::new(
495            "yaml",
496            vec![FieldRule::new("a.b.c.secret").with_category(Category::Custom("pw".into()))],
497        );
498        let result = proc.process(content, &profile, &store).unwrap();
499        let out = String::from_utf8(result).unwrap();
500        assert!(!out.contains("hunter2"));
501        // Non-secret structure preserved: only the value changed, the nested
502        // keys remain.
503        assert!(out.contains("secret:"));
504        let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
505        assert!(parsed["a"]["b"]["c"]["secret"].as_str().is_some());
506    }
507
508    #[test]
509    fn invalid_utf8_returns_parse_error() {
510        let store = make_store();
511        let proc = YamlProcessor;
512        let bad = b"\xff\xfe invalid";
513        let profile = FileTypeProfile::new("yaml", vec![]);
514        let err = proc.process(bad, &profile, &store).unwrap_err();
515        assert!(matches!(
516            err,
517            crate::error::SanitizeError::ParseError { .. }
518        ));
519    }
520
521    #[test]
522    fn invalid_yaml_returns_parse_error() {
523        let store = make_store();
524        let proc = YamlProcessor;
525        let bad = b"key: [unclosed";
526        let profile = FileTypeProfile::new("yaml", vec![]);
527        let err = proc.process(bad, &profile, &store).unwrap_err();
528        assert!(matches!(
529            err,
530            crate::error::SanitizeError::ParseError { .. }
531        ));
532    }
533
534    #[test]
535    fn yaml_sequence_traversal() {
536        let store = make_store();
537        let proc = YamlProcessor;
538
539        let content = b"users:\n  - email: a@b.com\n  - email: c@d.com\n";
540        let profile = FileTypeProfile::new(
541            "yaml",
542            vec![FieldRule::new("users.email").with_category(Category::Email)],
543        );
544
545        let result = proc.process(content, &profile, &store).unwrap();
546        let out = String::from_utf8(result).unwrap();
547
548        assert!(!out.contains("a@b.com"));
549        assert!(!out.contains("c@d.com"));
550        // Non-secret structure preserved: the key and both sequence items.
551        assert!(out.contains("users:"));
552        let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
553        assert_eq!(parsed["users"].as_sequence().unwrap().len(), 2);
554    }
555
556    // ── process_to_edits (span-based, format-preserving) ─────────────────────
557
558    /// Edit-mode alone must redact plain, double-quoted, single-quoted, and
559    /// escaped scalars while preserving comments and unrelated values.
560    #[test]
561    fn edits_redact_all_scalar_styles_and_preserve_comments() {
562        let store = make_store();
563        let proc = YamlProcessor;
564        let content = b"# top\ndb:\n  a: plain-SEC1   # inline\n  b: \"dq-SEC2\"\n  c: 'sq-SEC3'\n  d: \"x\\\"y-SEC4\"\n  host: keep.local\n";
565        let profile = FileTypeProfile::new(
566            "yaml",
567            vec![
568                FieldRule::new("db.a").with_category(Category::Custom("k".into())),
569                FieldRule::new("db.b").with_category(Category::Custom("k".into())),
570                FieldRule::new("db.c").with_category(Category::Custom("k".into())),
571                FieldRule::new("db.d").with_category(Category::Custom("k".into())),
572            ],
573        );
574        let edits = proc
575            .process_to_edits(content, &profile, &store)
576            .unwrap()
577            .unwrap();
578        let out = crate::processor::apply_edits(content, edits);
579        let text = String::from_utf8(out).unwrap();
580        for leak in ["SEC1", "SEC2", "SEC3", "SEC4"] {
581            assert!(!text.contains(leak), "leaked {leak}: {text}");
582        }
583        assert!(text.contains("# top"), "top comment dropped: {text}");
584        assert!(text.contains("# inline"), "inline comment dropped: {text}");
585        assert!(
586            text.contains("host: keep.local"),
587            "non-secret changed: {text}"
588        );
589        // Output remains valid YAML.
590        assert!(
591            serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&text).is_ok(),
592            "invalid YAML: {text}"
593        );
594    }
595
596    /// Regression: block scalars (`|`, `>`) must be redacted while staying
597    /// block-valid — the inline-`"token"` replacement collapsed the block and
598    /// absorbed following keys.
599    #[test]
600    fn edits_keep_block_scalars_valid() {
601        let store = make_store();
602        let proc = YamlProcessor;
603        let content = b"lit: |\n  line1-SEC1\n  line2-SEC2\nfold: >\n  folded-SEC3\nnext: keep\n";
604        let profile = FileTypeProfile::new(
605            "yaml",
606            vec![
607                FieldRule::new("lit").with_category(Category::Custom("k".into())),
608                FieldRule::new("fold").with_category(Category::Custom("k".into())),
609            ],
610        );
611        let edits = proc
612            .process_to_edits(content, &profile, &store)
613            .unwrap()
614            .unwrap();
615        let out = crate::processor::apply_edits(content, edits);
616        let text = String::from_utf8(out).unwrap();
617        for leak in ["SEC1", "SEC2", "SEC3"] {
618            assert!(!text.contains(leak), "leaked {leak}: {text}");
619        }
620        // Following keys are NOT absorbed into the block; output is valid YAML.
621        assert!(text.contains("fold:"), "fold key absorbed: {text}");
622        assert!(text.contains("next: keep"), "next key absorbed: {text}");
623        let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&text).unwrap();
624        assert_eq!(parsed["next"].as_str(), Some("keep"));
625    }
626}