Skip to main content

scour_secrets/processor/
xml_proc.rs

1//! XML structured processor.
2//!
3//! The CLI uses [`process_to_edits`](XmlProcessor::process_to_edits): it walks
4//! the document with `quick-xml` and replaces each matched element-text and
5//! attribute value at its exact source span, preserving structure, comments, and
6//! the entity encoding of unrelated content (entity-encoded values are hit as
7//! written, so they never leak). `process` re-serializes via the quick-xml
8//! writer as a fallback.
9//!
10//! # Key Paths
11//!
12//! Element paths are slash-separated: `database/password`. Attributes
13//! are expressed as `element/@attr` (e.g. `connection/@host`).
14//!
15//! For simplicity this processor tracks the element stack and matches
16//! text content of elements and attribute values against field rules.
17
18use crate::error::{Result, SanitizeError};
19use crate::processor::limits::{DEFAULT_INPUT_SIZE, XML_DEPTH};
20use crate::processor::{
21    edit_token, find_matching_rule, replace_value, FileTypeProfile, Processor, Replacement,
22};
23use crate::store::MappingStore;
24use quick_xml::events::{BytesStart, BytesText, Event};
25use quick_xml::{Reader, Writer, XmlVersion};
26use std::io::Cursor;
27
28/// Content-free XML parse error. quick-xml's error `Display` embeds document
29/// content — element names in `IllFormed` mismatched-tag errors, and the
30/// offending substring in some entity/attribute decode errors — which would
31/// echo input (potentially secret) into stderr and logs. Report only a
32/// position when one is available, never the parser's rendered message.
33fn xml_parse_error(kind: &str, byte_pos: Option<usize>) -> SanitizeError {
34    let loc = byte_pos.map_or_else(String::new, |p| format!(" at byte {p}"));
35    SanitizeError::ParseError {
36        format: "XML".into(),
37        message: format!(
38            "XML {kind} error{loc} \
39             (parser details withheld — input content is never echoed)"
40        ),
41    }
42}
43
44/// Scan a start/empty-tag's raw bytes (`<el a="v" b='w'/>`) for each attribute's
45/// **value** byte range (the content between the quotes), in source order.
46///
47/// XML attribute values cannot contain their own unescaped delimiter quote, so
48/// locating the closing quote is unambiguous. Returns ranges relative to `tag`.
49fn scan_attr_value_spans(tag: &[u8]) -> Vec<std::ops::Range<usize>> {
50    let mut spans = Vec::new();
51    let mut i = 0;
52    // Skip `<`, optional `/`, and the element name (up to whitespace or `>`/`/`).
53    while i < tag.len() && (tag[i] == b'<' || tag[i] == b'/' || tag[i] == b'?') {
54        i += 1;
55    }
56    while i < tag.len() && !tag[i].is_ascii_whitespace() && tag[i] != b'>' && tag[i] != b'/' {
57        i += 1;
58    }
59    // Walk attributes: name = (?:")value(?:") | name = '...'.
60    while i < tag.len() {
61        while i < tag.len() && tag[i].is_ascii_whitespace() {
62            i += 1;
63        }
64        if i >= tag.len() || tag[i] == b'>' || tag[i] == b'/' || tag[i] == b'?' {
65            break;
66        }
67        // attribute name
68        while i < tag.len() && tag[i] != b'=' && !tag[i].is_ascii_whitespace() && tag[i] != b'>' {
69            i += 1;
70        }
71        while i < tag.len() && tag[i].is_ascii_whitespace() {
72            i += 1;
73        }
74        if i >= tag.len() || tag[i] != b'=' {
75            continue;
76        }
77        i += 1; // '='
78        while i < tag.len() && tag[i].is_ascii_whitespace() {
79            i += 1;
80        }
81        if i >= tag.len() || (tag[i] != b'"' && tag[i] != b'\'') {
82            continue;
83        }
84        let quote = tag[i];
85        i += 1;
86        let val_start = i;
87        while i < tag.len() && tag[i] != quote {
88            i += 1;
89        }
90        spans.push(val_start..i);
91        if i < tag.len() {
92            i += 1; // closing quote
93        }
94    }
95    spans
96}
97
98/// Structured processor for XML files.
99pub struct XmlProcessor;
100
101impl Processor for XmlProcessor {
102    fn name(&self) -> &'static str {
103        "xml"
104    }
105
106    fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
107        if profile.processor == "xml" {
108            return true;
109        }
110        let trimmed = content
111            .iter()
112            .copied()
113            .skip_while(|b| b.is_ascii_whitespace())
114            .take(5)
115            .collect::<Vec<u8>>();
116        trimmed.starts_with(b"<?xml") || trimmed.starts_with(b"<")
117    }
118
119    fn process(
120        &self,
121        content: &[u8],
122        profile: &FileTypeProfile,
123        store: &MappingStore,
124    ) -> Result<Vec<u8>> {
125        // F-04 fix: enforce input size limit.
126        if content.len() > DEFAULT_INPUT_SIZE {
127            return Err(SanitizeError::InputTooLarge {
128                size: content.len(),
129                limit: DEFAULT_INPUT_SIZE,
130            });
131        }
132
133        // Security: quick-xml disables external entity expansion by default,
134        // so XXE attacks are not possible with this configuration.
135        let mut reader = Reader::from_reader(content);
136        reader.config_mut().trim_text(false);
137
138        let mut writer = Writer::new(Cursor::new(Vec::new()));
139        let mut element_stack: Vec<String> = Vec::new();
140        let mut buf = Vec::new();
141
142        loop {
143            match reader.read_event_into(&mut buf) {
144                Ok(Event::Start(ref e)) => {
145                    let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
146                    element_stack.push(name.clone());
147
148                    if element_stack.len() > XML_DEPTH {
149                        return Err(SanitizeError::RecursionDepthExceeded(format!(
150                            "XML element depth exceeds limit of {XML_DEPTH}"
151                        )));
152                    }
153
154                    // Process attributes.
155                    let current_path = element_stack.join("/");
156                    let new_elem = process_attributes(e, &current_path, profile, store)?;
157                    writer.write_event(Event::Start(new_elem)).map_err(|e| {
158                        SanitizeError::IoError(std::io::Error::other(format!(
159                            "XML write error: {e}"
160                        )))
161                    })?;
162                }
163                Ok(Event::End(ref e)) => {
164                    writer.write_event(Event::End(e.clone())).map_err(|e| {
165                        SanitizeError::IoError(std::io::Error::other(format!(
166                            "XML write error: {e}"
167                        )))
168                    })?;
169                    element_stack.pop();
170                }
171                Ok(Event::Empty(ref e)) => {
172                    let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
173                    let path = if element_stack.is_empty() {
174                        name.clone()
175                    } else {
176                        format!("{}/{}", element_stack.join("/"), name)
177                    };
178                    let new_elem = process_attributes(e, &path, profile, store)?;
179                    writer.write_event(Event::Empty(new_elem)).map_err(|e| {
180                        SanitizeError::IoError(std::io::Error::other(format!(
181                            "XML write error: {e}"
182                        )))
183                    })?;
184                }
185                Ok(Event::Text(ref e)) => {
186                    let current_path = element_stack.join("/");
187                    if let Some(rule) = find_matching_rule(&current_path, profile) {
188                        let text = unescape_text(e)?;
189                        let replaced = replace_value(&text, rule, store)?;
190                        writer
191                            .write_event(Event::Text(BytesText::new(&replaced)))
192                            .map_err(|e| {
193                                SanitizeError::IoError(std::io::Error::other(format!(
194                                    "XML write error: {e}"
195                                )))
196                            })?;
197                    } else {
198                        writer.write_event(Event::Text(e.clone())).map_err(|e| {
199                            SanitizeError::IoError(std::io::Error::other(format!(
200                                "XML write error: {e}"
201                            )))
202                        })?;
203                    }
204                }
205                Ok(Event::Eof) => break,
206                Ok(e) => {
207                    writer.write_event(e).map_err(|er| {
208                        SanitizeError::IoError(std::io::Error::other(format!(
209                            "XML write error: {er}"
210                        )))
211                    })?;
212                }
213                Err(_) => {
214                    return Err(xml_parse_error(
215                        "parse",
216                        Some(to_pos(reader.buffer_position())),
217                    ));
218                }
219            }
220            buf.clear();
221        }
222
223        let result = writer.into_inner().into_inner();
224        Ok(result)
225    }
226
227    /// Span-based redaction: walk the document with `quick-xml`, recording an
228    /// edit for each matched element-text and attribute value at its exact
229    /// source byte span. Element text spans come from `buffer_position()`;
230    /// attribute value spans are located within each tag's bytes (using
231    /// quick-xml for the unescaped values and key/path matching). Structure,
232    /// comments, and unrelated bytes are preserved, and values are hit as
233    /// written so escaped/entity-encoded content never leaks.
234    fn process_to_edits(
235        &self,
236        content: &[u8],
237        profile: &FileTypeProfile,
238        store: &MappingStore,
239    ) -> Result<Option<Vec<Replacement>>> {
240        if content.len() > DEFAULT_INPUT_SIZE {
241            return Err(SanitizeError::InputTooLarge {
242                size: content.len(),
243                limit: DEFAULT_INPUT_SIZE,
244            });
245        }
246        let mut reader = Reader::from_reader(content);
247        reader.config_mut().trim_text(false);
248        let mut edits = Vec::new();
249        let mut stack: Vec<String> = Vec::new();
250        let mut buf = Vec::new();
251
252        loop {
253            let before = to_pos(reader.buffer_position());
254            match reader.read_event_into(&mut buf) {
255                Ok(Event::Start(e)) => {
256                    let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
257                    stack.push(name);
258                    if stack.len() > XML_DEPTH {
259                        return Err(SanitizeError::RecursionDepthExceeded(format!(
260                            "XML element depth exceeds limit of {XML_DEPTH}"
261                        )));
262                    }
263                    let path = stack.join("/");
264                    collect_attr_edits(
265                        &e,
266                        content,
267                        before,
268                        to_pos(reader.buffer_position()),
269                        &path,
270                        profile,
271                        store,
272                        &mut edits,
273                    )?;
274                }
275                Ok(Event::Empty(e)) => {
276                    let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
277                    let path = if stack.is_empty() {
278                        name
279                    } else {
280                        format!("{}/{name}", stack.join("/"))
281                    };
282                    collect_attr_edits(
283                        &e,
284                        content,
285                        before,
286                        to_pos(reader.buffer_position()),
287                        &path,
288                        profile,
289                        store,
290                        &mut edits,
291                    )?;
292                }
293                Ok(Event::Text(e)) => {
294                    let end = to_pos(reader.buffer_position());
295                    let path = stack.join("/");
296                    let key = stack.last().map_or("", String::as_str);
297                    let text = unescape_text(&e)?;
298                    if let Some(token) = edit_token(key, &path, &text, profile, store)? {
299                        edits.push(Replacement {
300                            start: before,
301                            end,
302                            value: xml_escape_token(&token),
303                        });
304                    }
305                }
306                Ok(Event::End(_)) => {
307                    stack.pop();
308                }
309                Ok(Event::Eof) => break,
310                Ok(_) => {}
311                Err(_) => {
312                    return Err(xml_parse_error(
313                        "parse",
314                        Some(to_pos(reader.buffer_position())),
315                    ));
316                }
317            }
318            buf.clear();
319        }
320        Ok(Some(edits))
321    }
322}
323
324/// XML-escape a (safe-ASCII) token for insertion as text/attribute content.
325/// Tokens contain no markup characters in practice, but escape defensively.
326fn xml_escape_token(token: &str) -> String {
327    token
328        .replace('&', "&amp;")
329        .replace('<', "&lt;")
330        .replace('>', "&gt;")
331        .replace('"', "&quot;")
332}
333
334/// Byte position from the reader as `usize`. Inputs are capped at
335/// `DEFAULT_INPUT_SIZE`, so positions always fit.
336fn to_pos(p: u64) -> usize {
337    usize::try_from(p).expect("XML input is bounded by DEFAULT_INPUT_SIZE")
338}
339
340/// Decode and entity-unescape element text (quick-xml 0.38 split the old
341/// `BytesText::unescape` into `decode()` + `escape::unescape()`).
342fn unescape_text(e: &quick_xml::events::BytesText<'_>) -> Result<String> {
343    let raw = e.decode().map_err(|_| xml_parse_error("decode", None))?;
344    let text = quick_xml::escape::unescape(&raw).map_err(|_| xml_parse_error("decode", None))?;
345    Ok(text.into_owned())
346}
347
348/// Emit edits for matched attribute values of a start/empty element. Uses
349/// quick-xml for the unescaped values (the mapping-store key) and a byte scan of
350/// the tag for the exact source spans, correlated in source order.
351#[allow(clippy::too_many_arguments)]
352fn collect_attr_edits(
353    elem: &BytesStart<'_>,
354    content: &[u8],
355    tag_start: usize,
356    tag_end: usize,
357    element_path: &str,
358    profile: &FileTypeProfile,
359    store: &MappingStore,
360    edits: &mut Vec<Replacement>,
361) -> Result<()> {
362    let tag_end = tag_end.min(content.len());
363    let tag = &content[tag_start..tag_end];
364    let value_spans = scan_attr_value_spans(tag);
365
366    for (idx, attr_result) in elem.attributes().enumerate() {
367        let attr = attr_result.map_err(|_| xml_parse_error("attribute", None))?;
368        let Some(span) = value_spans.get(idx) else {
369            // Span scan and quick-xml disagreed on attribute count — skip
370            // editing this attribute rather than risk a wrong splice.
371            continue;
372        };
373        let key = String::from_utf8_lossy(attr.key.as_ref()).into_owned();
374        let attr_path = format!("{element_path}/@{key}");
375        let value = attr
376            .normalized_value(XmlVersion::Implicit1_0)
377            .map_err(|_| xml_parse_error("attribute decode", None))?;
378        if let Some(token) = edit_token(&key, &attr_path, &value, profile, store)? {
379            edits.push(Replacement {
380                start: tag_start + span.start,
381                end: tag_start + span.end,
382                value: xml_escape_token(&token),
383            });
384        }
385    }
386    Ok(())
387}
388
389/// Process attributes of an element, replacing matched ones.
390fn process_attributes(
391    elem: &BytesStart<'_>,
392    element_path: &str,
393    profile: &FileTypeProfile,
394    store: &MappingStore,
395) -> Result<BytesStart<'static>> {
396    let name = elem.name();
397    let mut new_elem = BytesStart::new(String::from_utf8_lossy(name.as_ref()).to_string());
398
399    for attr_result in elem.attributes() {
400        let attr = attr_result.map_err(|_| xml_parse_error("attribute", None))?;
401        let attr_key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
402        let attr_path = format!("{}/@{}", element_path, attr_key);
403
404        if let Some(rule) = find_matching_rule(&attr_path, profile) {
405            let attr_value = attr
406                .normalized_value(XmlVersion::Implicit1_0)
407                .map_err(|_| xml_parse_error("attribute decode", None))?;
408            let replaced = replace_value(&attr_value, rule, store)?;
409            new_elem.push_attribute((attr_key.as_str(), replaced.as_str()));
410        } else {
411            let attr_value = attr
412                .normalized_value(XmlVersion::Implicit1_0)
413                .map_err(|_| xml_parse_error("attribute decode", None))?;
414            new_elem.push_attribute((attr_key.as_str(), attr_value.as_ref()));
415        }
416    }
417
418    Ok(new_elem)
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::category::Category;
425    use crate::generator::HmacGenerator;
426    use crate::processor::profile::FieldRule;
427    use std::fmt::Write as _;
428    use std::sync::Arc;
429
430    fn make_store() -> MappingStore {
431        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
432        MappingStore::new(gen, None)
433    }
434
435    #[test]
436    fn parse_error_omits_input_content() {
437        // quick-xml's mismatched-tag IllFormed error renders the expected
438        // element name verbatim; a malformed document must never echo its own
439        // names (or values) into the error. Cover both processing paths.
440        const LEAK_MARKER: &str = "SEKRET-TAG-0xD34DB33F";
441        let store = make_store();
442        let proc = XmlProcessor;
443        let content = format!("<config><{LEAK_MARKER}>v</WRONG></config>");
444        let profile = FileTypeProfile::new("xml", vec![FieldRule::new("*")]);
445
446        let msg = proc
447            .process(content.as_bytes(), &profile, &store)
448            .expect_err("malformed input must fail to parse")
449            .to_string();
450        assert!(
451            !msg.contains(LEAK_MARKER),
452            "parse error echoed input content: {msg}"
453        );
454
455        let msg = proc
456            .process_to_edits(content.as_bytes(), &profile, &store)
457            .expect_err("malformed input must fail to parse")
458            .to_string();
459        assert!(
460            !msg.contains(LEAK_MARKER),
461            "parse error echoed input content: {msg}"
462        );
463    }
464
465    #[test]
466    fn basic_xml_text_replacement() {
467        let store = make_store();
468        let proc = XmlProcessor;
469
470        let content =
471            b"<config><database><password>s3cret</password><port>5432</port></database></config>";
472        let profile = FileTypeProfile::new(
473            "xml",
474            vec![FieldRule::new("config/database/password")
475                .with_category(Category::Custom("pw".into()))],
476        );
477
478        let result = proc.process(content, &profile, &store).unwrap();
479        let out = String::from_utf8(result).unwrap();
480
481        assert!(!out.contains("s3cret"));
482        assert!(out.contains("<port>5432</port>"));
483    }
484
485    #[test]
486    fn xml_attribute_replacement() {
487        let store = make_store();
488        let proc = XmlProcessor;
489
490        let content = b"<config><connection host=\"db.corp.com\" port=\"5432\"/></config>";
491        let profile = FileTypeProfile::new(
492            "xml",
493            vec![FieldRule::new("config/connection/@host").with_category(Category::Hostname)],
494        );
495
496        let result = proc.process(content, &profile, &store).unwrap();
497        let out = String::from_utf8(result).unwrap();
498
499        assert!(!out.contains("db.corp.com"));
500        assert!(out.contains("5432"));
501    }
502
503    #[test]
504    fn can_handle_xml_declaration() {
505        let proc = XmlProcessor;
506        let profile = FileTypeProfile::new("other", vec![]).with_extension(".txt");
507        assert!(proc.can_handle(b"<?xml version=\"1.0\"?><root/>", &profile));
508    }
509
510    #[test]
511    fn can_handle_bare_tag() {
512        let proc = XmlProcessor;
513        let profile = FileTypeProfile::new("other", vec![]).with_extension(".txt");
514        assert!(proc.can_handle(b"<root><child/></root>", &profile));
515    }
516
517    #[test]
518    fn can_handle_by_profile_name() {
519        let proc = XmlProcessor;
520        let profile = FileTypeProfile::new("xml", vec![]).with_extension(".xml");
521        assert!(proc.can_handle(b"not xml at all", &profile));
522    }
523
524    #[test]
525    fn can_handle_rejects_plaintext() {
526        let proc = XmlProcessor;
527        let profile = FileTypeProfile::new("json", vec![]).with_extension(".json");
528        assert!(!proc.can_handle(b"just some plain text", &profile));
529    }
530
531    #[test]
532    fn empty_element_attributes_replaced() {
533        let store = make_store();
534        let proc = XmlProcessor;
535        let content = b"<config><server host=\"prod.corp.com\" port=\"443\"/></config>";
536        let profile = FileTypeProfile::new(
537            "xml",
538            vec![FieldRule::new("config/server/@host").with_category(Category::Hostname)],
539        );
540        let result = proc.process(content, &profile, &store).unwrap();
541        let out = String::from_utf8(result).unwrap();
542        assert!(!out.contains("prod.corp.com"));
543        assert!(out.contains("443"));
544    }
545
546    #[test]
547    fn empty_element_at_root_level() {
548        let store = make_store();
549        let proc = XmlProcessor;
550        let content = b"<server host=\"root.corp.com\"/>";
551        let profile = FileTypeProfile::new(
552            "xml",
553            vec![FieldRule::new("server/@host").with_category(Category::Hostname)],
554        );
555        let result = proc.process(content, &profile, &store).unwrap();
556        let out = String::from_utf8(result).unwrap();
557        assert!(!out.contains("root.corp.com"));
558        // Non-secret structure preserved: element name and attribute key remain.
559        assert!(out.contains("<server"));
560        assert!(out.contains("host="));
561    }
562
563    #[test]
564    fn unmatched_attributes_pass_through() {
565        let store = make_store();
566        let proc = XmlProcessor;
567        let content = b"<config><db host=\"db.corp.com\" port=\"5432\"/></config>";
568        let profile = FileTypeProfile::new("xml", vec![]); // no field rules
569        let result = proc.process(content, &profile, &store).unwrap();
570        let out = String::from_utf8(result).unwrap();
571        assert!(out.contains("db.corp.com"));
572        assert!(out.contains("5432"));
573    }
574
575    #[test]
576    fn other_xml_events_pass_through() {
577        let store = make_store();
578        let proc = XmlProcessor;
579        let content = b"<?xml version=\"1.0\"?><!-- comment --><root><child>value</child></root>";
580        let profile = FileTypeProfile::new("xml", vec![]);
581        let result = proc.process(content, &profile, &store).unwrap();
582        let out = String::from_utf8(result).unwrap();
583        assert!(out.contains("value"));
584    }
585
586    #[test]
587    fn depth_limit_exceeded_returns_error() {
588        let store = make_store();
589        let proc = XmlProcessor;
590        // Build XML that exceeds XML_DEPTH (256) levels of nesting.
591        let open: String = (0..260).fold(String::new(), |mut s, i| {
592            write!(s, "<l{i}>").unwrap();
593            s
594        });
595        let close: String = (0..260).rev().fold(String::new(), |mut s, i| {
596            write!(s, "</l{i}>").unwrap();
597            s
598        });
599        let content = format!("{open}secret{close}");
600        let profile = FileTypeProfile::new("xml", vec![]);
601        let err = proc
602            .process(content.as_bytes(), &profile, &store)
603            .unwrap_err();
604        assert!(matches!(
605            err,
606            crate::error::SanitizeError::RecursionDepthExceeded(_)
607        ));
608    }
609
610    /// Edit-mode redacts element text and attribute values — including
611    /// entity-encoded content — preserving structure and non-matched values.
612    #[test]
613    fn edits_redact_text_attr_and_entities() {
614        let store = make_store();
615        let proc = XmlProcessor;
616        let content = b"<c><db pw=\"a&lt;b-SEC1\" host=\"keep\"/><t>tok-SEC2</t><k>ok</k></c>";
617        let profile = FileTypeProfile::new(
618            "xml",
619            vec![
620                FieldRule::new("c/db/@pw").with_category(Category::Custom("k".into())),
621                FieldRule::new("c/t").with_category(Category::Custom("k".into())),
622            ],
623        );
624        let edits = proc
625            .process_to_edits(content, &profile, &store)
626            .unwrap()
627            .unwrap();
628        let out = crate::processor::apply_edits(content, edits);
629        let text = String::from_utf8(out).unwrap();
630        assert!(!text.contains("SEC1"), "entity-encoded attr leaked: {text}");
631        assert!(!text.contains("SEC2"), "element text leaked: {text}");
632        assert!(
633            text.contains("host=\"keep\""),
634            "non-matched attr changed: {text}"
635        );
636        assert!(
637            text.contains("<k>ok</k>"),
638            "non-matched text changed: {text}"
639        );
640    }
641}