Skip to main content

scour_secrets/processor/
json_proc.rs

1//! JSON structured processor.
2//!
3//! The CLI uses [`process_to_edits`](JsonProcessor::process_to_edits): it walks
4//! the document with a byte-position parser (`jiter`) and replaces each matched
5//! value at its exact source span, so whitespace, key order, and the escaping of
6//! unrelated content are preserved byte-for-byte and values escaped in the
7//! source (e.g. `\/`, `\uXXXX`) are redacted without leaking. `process` is the
8//! re-serializing fallback used when span editing is unavailable; it also
9//! accepts lenient JSON (JSON5: comments, unquoted keys, trailing commas,
10//! single quotes) when strict parsing fails, so field rules still reach config
11//! files written by relaxed parsers.
12//!
13//! # Key Paths
14//!
15//! Nested keys are expressed as dot-separated paths:
16//! `database.password`, `smtp.credentials.user`.
17//!
18//! Array elements are traversed transparently — a rule for `users.email`
19//! matches the `email` field inside every object in the `users` array.
20
21use crate::error::{Result, SanitizeError};
22use crate::processor::limits::DEFAULT_INPUT_SIZE;
23use crate::processor::{
24    build_path, edit_token, walk_tree, FileTypeProfile, Processor, Replacement, TreeNode,
25};
26use crate::store::MappingStore;
27use jiter::{Jiter, Peek};
28use serde_json::Value;
29
30/// Map a `jiter` parse error to a `SanitizeError::ParseError`.
31fn json_err(e: impl std::fmt::Display) -> SanitizeError {
32    SanitizeError::ParseError {
33        format: "JSON".into(),
34        message: format!("JSON parse error: {e}"),
35    }
36}
37
38/// Parse a JSON document, falling back to lenient JSON5 parsing when strict
39/// parsing fails.
40///
41/// Config files written by relaxed parsers (Dataiku project `variables.json`,
42/// hand-edited app configs) routinely carry comments, unquoted keys, trailing
43/// commas, or single-quoted strings that `serde_json` rejects. Without the
44/// fallback such files skip the structured pass entirely and their field rules
45/// (`*password*`, `*secret*`, …) are never applied — a redaction coverage gap.
46///
47/// On strict-parse failure the strict error is kept and returned if the
48/// lenient parse also fails: its line/column information is the more precise
49/// diagnostic.
50fn parse_strict_or_lenient(text: &str) -> Result<Value> {
51    match serde_json::from_str(text) {
52        Ok(v) => Ok(v),
53        Err(strict_err) => match json5::from_str(text) {
54            Ok(v) => {
55                tracing::debug!(error = %strict_err, "strict JSON parse failed; parsed leniently as JSON5");
56                Ok(v)
57            }
58            Err(_) => Err(SanitizeError::ParseError {
59                format: "JSON".into(),
60                message: format!("JSON parse error: {strict_err}"),
61            }),
62        },
63    }
64}
65
66/// Structured processor for JSON files.
67pub struct JsonProcessor;
68
69impl Processor for JsonProcessor {
70    fn name(&self) -> &'static str {
71        "json"
72    }
73
74    fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
75        if profile.processor == "json" {
76            return true;
77        }
78        // Heuristic: starts with `{` or `[` after optional whitespace.
79        let trimmed = content.iter().copied().find(|b| !b.is_ascii_whitespace());
80        matches!(trimmed, Some(b'{' | b'['))
81    }
82
83    fn process(
84        &self,
85        content: &[u8],
86        profile: &FileTypeProfile,
87        store: &MappingStore,
88    ) -> Result<Vec<u8>> {
89        // F-04 fix: enforce input size limit.
90        let text = crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
91
92        let mut value: Value = parse_strict_or_lenient(text)?;
93
94        walk_json(&mut value, "", profile, store, 0)?;
95
96        let compact = profile.options.get("compact").is_some_and(|v| v == "true");
97
98        let output = if compact {
99            serde_json::to_vec(&value)
100        } else {
101            serde_json::to_vec_pretty(&value)
102        }
103        .map_err(|e| {
104            SanitizeError::IoError(std::io::Error::other(format!("JSON serialize error: {e}")))
105        })?;
106
107        Ok(output)
108    }
109
110    /// Span-based redaction: walk the document with `jiter` (a byte-position
111    /// JSON parser), recording an edit that replaces each matched value's exact
112    /// source span with a quoted token. Whitespace, key order, and the precise
113    /// escaping of unrelated content are preserved, and the value is hit in the
114    /// source *as written* — so values escaped as `\/`, `\uXXXX`, etc. are
115    /// redacted with no leak.
116    fn process_to_edits(
117        &self,
118        content: &[u8],
119        profile: &FileTypeProfile,
120        store: &MappingStore,
121    ) -> Result<Option<Vec<Replacement>>> {
122        // Enforce the size limit and reject non-UTF-8 (JSON must be UTF-8).
123        crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
124        Ok(Some(json_value_edits(content, profile, store)?))
125    }
126}
127
128/// Compute span edits for a single JSON document in `content` (spans are
129/// relative to `content`). Shared by the JSON processor and, per line, by the
130/// JSONL processor.
131///
132/// # Errors
133///
134/// Returns [`SanitizeError::ParseError`] if `content` is not valid JSON.
135pub(crate) fn json_value_edits(
136    content: &[u8],
137    profile: &FileTypeProfile,
138    store: &MappingStore,
139) -> Result<Vec<Replacement>> {
140    // A leading UTF-8 BOM is not valid JSON to jiter (which would error and
141    // leave the value un-redacted). Skip it for parsing, then shift the spans
142    // back so they stay aligned to the original `content` (the BOM is preserved
143    // in the output, sitting before the first edit). For JSONL this only
144    // affects the file's first line.
145    let bom = if content.starts_with(&[0xEF, 0xBB, 0xBF]) {
146        3
147    } else {
148        0
149    };
150    let body = &content[bom..];
151    let mut jiter = Jiter::new(body);
152    let mut edits = Vec::new();
153    let peek = jiter.peek().map_err(json_err)?;
154    collect_json_edits(&mut jiter, peek, "", "", body, profile, store, &mut edits)?;
155    if bom != 0 {
156        for e in &mut edits {
157            e.start += bom;
158            e.end += bom;
159        }
160    }
161    Ok(edits)
162}
163
164/// Recursively walk a JSON value via `jiter`, emitting span edits for matched
165/// leaf values. `peek` is the already-peeked type of the value about to be read,
166/// and the parser is positioned at its first byte.
167#[allow(clippy::too_many_arguments)]
168fn collect_json_edits(
169    jiter: &mut Jiter,
170    peek: Peek,
171    key: &str,
172    path: &str,
173    content: &[u8],
174    profile: &FileTypeProfile,
175    store: &MappingStore,
176    edits: &mut Vec<Replacement>,
177) -> Result<()> {
178    if peek == Peek::Object {
179        // `next_object`/`next_key` return each key and leave the parser at the
180        // value (the `:` is consumed). Copy the key to release the borrow.
181        let mut next = jiter.next_object().map_err(json_err)?.map(str::to_owned);
182        while let Some(k) = next {
183            let child_path = build_path(path, &k);
184            let child_peek = jiter.peek().map_err(json_err)?;
185            collect_json_edits(
186                jiter,
187                child_peek,
188                &k,
189                &child_path,
190                content,
191                profile,
192                store,
193                edits,
194            )?;
195            next = jiter.next_key().map_err(json_err)?.map(str::to_owned);
196        }
197    } else if peek == Peek::Array {
198        // Array elements are path-transparent (keep the parent key/path).
199        let mut elem = jiter.next_array().map_err(json_err)?;
200        while let Some(elem_peek) = elem {
201            collect_json_edits(jiter, elem_peek, key, path, content, profile, store, edits)?;
202            elem = jiter.array_step().map_err(json_err)?;
203        }
204    } else if peek == Peek::String {
205        let start = jiter.current_index();
206        let s = jiter.next_str().map_err(json_err)?.to_owned();
207        let end = jiter.current_index();
208        if let Some(token) = edit_token(key, path, &s, profile, store)? {
209            edits.push(Replacement {
210                start,
211                end,
212                value: format!("\"{token}\""),
213            });
214        }
215    } else {
216        // null / true / false / number — capture the exact source text.
217        let start = jiter.current_index();
218        jiter.next_skip().map_err(json_err)?;
219        let end = jiter.current_index();
220        let s = String::from_utf8_lossy(&content[start..end]).into_owned();
221        if let Some(token) = edit_token(key, path, &s, profile, store)? {
222            edits.push(Replacement {
223                start,
224                end,
225                value: format!("\"{token}\""),
226            });
227        }
228    }
229    Ok(())
230}
231
232impl TreeNode for Value {
233    fn for_each_map_entry<F>(&mut self, mut f: F) -> Result<()>
234    where
235        F: FnMut(&str, &mut Self) -> Result<()>,
236    {
237        if let Self::Object(map) = self {
238            let keys: Vec<String> = map.keys().cloned().collect();
239            for key in keys {
240                if let Some(v) = map.get_mut(&key) {
241                    f(&key, v)?;
242                }
243            }
244        }
245        Ok(())
246    }
247
248    fn for_each_seq_item<F>(&mut self, mut f: F) -> Result<()>
249    where
250        F: FnMut(&mut Self) -> Result<()>,
251    {
252        if let Self::Array(arr) = self {
253            for item in arr.iter_mut() {
254                f(item)?;
255            }
256        }
257        Ok(())
258    }
259
260    fn as_str_mut(&mut self) -> Option<&mut String> {
261        if let Self::String(s) = self {
262            Some(s)
263        } else {
264            None
265        }
266    }
267
268    fn is_scalar(&self) -> bool {
269        matches!(self, Self::Number(_) | Self::Bool(_))
270    }
271
272    fn scalar_to_string(&self) -> String {
273        self.to_string()
274    }
275
276    fn set_string(&mut self, s: String) {
277        *self = Self::String(s);
278    }
279}
280
281/// Recursively walk a JSON value tree, replacing matched field values.
282pub(crate) fn walk_json(
283    value: &mut Value,
284    prefix: &str,
285    profile: &FileTypeProfile,
286    store: &MappingStore,
287    depth: usize,
288) -> Result<()> {
289    walk_tree(value, prefix, profile, store, depth, "JSON")
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::category::Category;
296    use crate::generator::HmacGenerator;
297    use crate::processor::profile::FieldRule;
298    use std::sync::Arc;
299
300    fn make_store() -> MappingStore {
301        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
302        MappingStore::new(gen, None)
303    }
304
305    #[test]
306    fn basic_json_replacement() {
307        let store = make_store();
308        let proc = JsonProcessor;
309
310        let content =
311            br#"{"database": {"host": "db.corp.com", "password": "s3cret"}, "port": 5432}"#;
312        let profile = FileTypeProfile::new(
313            "json",
314            vec![
315                FieldRule::new("database.password").with_category(Category::Custom("pw".into())),
316                FieldRule::new("database.host").with_category(Category::Hostname),
317            ],
318        )
319        .with_option("compact", "true");
320
321        let result = proc.process(content, &profile, &store).unwrap();
322        let out: Value = serde_json::from_slice(&result).unwrap();
323
324        assert_ne!(out["database"]["password"].as_str().unwrap(), "s3cret");
325        assert_ne!(out["database"]["host"].as_str().unwrap(), "db.corp.com");
326        assert_eq!(out["port"], 5432);
327    }
328
329    #[test]
330    fn json_array_traversal() {
331        let store = make_store();
332        let proc = JsonProcessor;
333
334        let content = br#"{"users": [{"email": "a@b.com"}, {"email": "c@d.com"}]}"#;
335        let profile = FileTypeProfile::new(
336            "json",
337            vec![FieldRule::new("users.email").with_category(Category::Email)],
338        )
339        .with_option("compact", "true");
340
341        let result = proc.process(content, &profile, &store).unwrap();
342        let out: Value = serde_json::from_slice(&result).unwrap();
343
344        let users = out["users"].as_array().unwrap();
345        assert_ne!(users[0]["email"].as_str().unwrap(), "a@b.com");
346        assert_ne!(users[1]["email"].as_str().unwrap(), "c@d.com");
347        // Non-secret structure preserved: both elements and their keys remain.
348        assert_eq!(users.len(), 2);
349        let text = String::from_utf8_lossy(&result);
350        assert!(text.contains("\"users\"") && text.contains("\"email\""));
351    }
352
353    #[test]
354    fn json_glob_suffix_pattern() {
355        let store = make_store();
356        let proc = JsonProcessor;
357
358        let content =
359            br#"{"db": {"password": "pw1"}, "cache": {"password": "pw2"}, "name": "app"}"#;
360        let profile = FileTypeProfile::new(
361            "json",
362            vec![FieldRule::new("*.password").with_category(Category::Custom("pw".into()))],
363        )
364        .with_option("compact", "true");
365
366        let result = proc.process(content, &profile, &store).unwrap();
367        let out: Value = serde_json::from_slice(&result).unwrap();
368
369        assert_ne!(out["db"]["password"].as_str().unwrap(), "pw1");
370        assert_ne!(out["cache"]["password"].as_str().unwrap(), "pw2");
371        assert_eq!(out["name"], "app");
372    }
373
374    // ── lenient (JSON5) fallback ─────────────────────────────────────────────
375
376    /// Files written by relaxed parsers (Dataiku project variables) carry
377    /// unquoted keys, trailing commas, comments, and single quotes. The
378    /// literal `process` pass must still parse them and apply field rules.
379    #[test]
380    fn lenient_json_fallback_applies_field_rules() {
381        let store = make_store();
382        let proc = JsonProcessor;
383
384        let content = br#"{
385  // user-edited project variables
386  db_password: 'hunter2secret',
387  api_token: "tok-abcdef123456",
388  keep: "plain",
389}"#;
390        let profile = FileTypeProfile::new(
391            "json",
392            vec![
393                FieldRule::new("*password*").with_category(Category::Custom("pw".into())),
394                FieldRule::new("*token*").with_category(Category::AuthToken),
395            ],
396        )
397        .with_option("compact", "true");
398
399        let result = proc.process(content, &profile, &store).unwrap();
400        let text = String::from_utf8(result).unwrap();
401        assert!(!text.contains("hunter2secret"), "password leaked: {text}");
402        assert!(!text.contains("tok-abcdef123456"), "token leaked: {text}");
403        // Output is re-serialized strict JSON with unmatched fields intact.
404        let out: Value = serde_json::from_str(&text).unwrap();
405        assert_eq!(out["keep"], "plain");
406    }
407
408    /// Content that is not valid JSON5 either must still fail with the
409    /// original strict parse error (more precise line/column diagnostics).
410    #[test]
411    fn invalid_json_still_errors_after_lenient_fallback() {
412        let store = make_store();
413        let proc = JsonProcessor;
414        let profile = FileTypeProfile::new("json", vec![]);
415        let err = proc
416            .process(b"not json at all {{{", &profile, &store)
417            .expect_err("garbage input must not parse");
418        assert!(err.to_string().contains("JSON parse error"));
419    }
420
421    /// Strict JSON must never take the lenient path (identical behavior to
422    /// before the fallback existed).
423    #[test]
424    fn strict_json_unaffected_by_lenient_fallback() {
425        let store = make_store();
426        let proc = JsonProcessor;
427        let content = br#"{"a": 1, "b": "two"}"#;
428        let profile = FileTypeProfile::new("json", vec![]).with_option("compact", "true");
429        let result = proc.process(content, &profile, &store).unwrap();
430        let out: Value = serde_json::from_slice(&result).unwrap();
431        assert_eq!(out["a"], 1);
432        assert_eq!(out["b"], "two");
433    }
434
435    // ── process_to_edits (span-based, format-preserving) ─────────────────────
436
437    /// Edit-mode alone (no scanner) must redact values that are **escaped** in
438    /// the source — including non-canonical escapes (`\/`, `\uXXXX`) that the
439    /// literal/alias approach leaks.
440    #[test]
441    fn edits_redact_escaped_and_noncanonical_values() {
442        let store = make_store();
443        let proc = JsonProcessor;
444        // \" escaped quote, \/ PHP-style slash, \uXXXX unicode escape.
445        let content =
446            br#"{"a":"x\"y-SEC1","u":"http:\/\/SEC2.test","n":"caf\u00e9-SEC3","keep":"ok"}"#;
447        let profile = FileTypeProfile::new(
448            "json",
449            vec![
450                FieldRule::new("a").with_category(Category::Custom("k".into())),
451                FieldRule::new("u").with_category(Category::Custom("k".into())),
452                FieldRule::new("n").with_category(Category::Custom("k".into())),
453            ],
454        );
455        let edits = proc
456            .process_to_edits(content, &profile, &store)
457            .unwrap()
458            .unwrap();
459        let out = crate::processor::apply_edits(content, edits);
460        let text = String::from_utf8(out).unwrap();
461        for leak in ["SEC1", "SEC2", "SEC3"] {
462            assert!(!text.contains(leak), "leaked {leak}: {text}");
463        }
464        // Untouched field preserved and output is still valid JSON.
465        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
466        assert_eq!(v["keep"], "ok");
467    }
468
469    /// Edits preserve compact formatting and leave non-matched values byte-exact.
470    #[test]
471    fn edits_preserve_compact_layout() {
472        let store = make_store();
473        let proc = JsonProcessor;
474        let content = br#"{"db":{"password":"SECRETpw","host":"keep.local"},"port":5432}"#;
475        let profile = FileTypeProfile::new(
476            "json",
477            vec![FieldRule::new("db.password").with_category(Category::Custom("pw".into()))],
478        );
479        let edits = proc
480            .process_to_edits(content, &profile, &store)
481            .unwrap()
482            .unwrap();
483        let out = crate::processor::apply_edits(content, edits);
484        let text = String::from_utf8(out).unwrap();
485        assert!(!text.contains("SECRETpw"), "secret leaked: {text}");
486        // Still single-line/compact, untouched bytes intact.
487        assert!(!text.contains('\n'), "formatting changed: {text}");
488        assert!(
489            text.contains(r#""host":"keep.local""#),
490            "non-secret changed: {text}"
491        );
492        assert!(
493            text.contains(r#""port":5432"#),
494            "non-secret changed: {text}"
495        );
496    }
497}