1use crate::error::{Result, SanitizeError};
19use crate::processor::limits::DEFAULT_INPUT_SIZE;
20use crate::processor::{
21 build_path, edit_token, walk_tree, FileTypeProfile, Processor, Replacement, TreeNode,
22};
23use crate::store::MappingStore;
24use jiter::{Jiter, Peek};
25use serde_json::Value;
26
27fn json_err(e: impl std::fmt::Display) -> SanitizeError {
29 SanitizeError::ParseError {
30 format: "JSON".into(),
31 message: format!("JSON parse error: {e}"),
32 }
33}
34
35pub struct JsonProcessor;
37
38impl Processor for JsonProcessor {
39 fn name(&self) -> &'static str {
40 "json"
41 }
42
43 fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
44 if profile.processor == "json" {
45 return true;
46 }
47 let trimmed = content.iter().copied().find(|b| !b.is_ascii_whitespace());
49 matches!(trimmed, Some(b'{' | b'['))
50 }
51
52 fn process(
53 &self,
54 content: &[u8],
55 profile: &FileTypeProfile,
56 store: &MappingStore,
57 ) -> Result<Vec<u8>> {
58 let text = crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
60
61 let mut value: Value =
62 serde_json::from_str(text).map_err(|e| SanitizeError::ParseError {
63 format: "JSON".into(),
64 message: format!("JSON parse error: {}", e),
65 })?;
66
67 walk_json(&mut value, "", profile, store, 0)?;
68
69 let compact = profile.options.get("compact").is_some_and(|v| v == "true");
70
71 let output = if compact {
72 serde_json::to_vec(&value)
73 } else {
74 serde_json::to_vec_pretty(&value)
75 }
76 .map_err(|e| {
77 SanitizeError::IoError(std::io::Error::other(format!("JSON serialize error: {e}")))
78 })?;
79
80 Ok(output)
81 }
82
83 fn process_to_edits(
90 &self,
91 content: &[u8],
92 profile: &FileTypeProfile,
93 store: &MappingStore,
94 ) -> Result<Option<Vec<Replacement>>> {
95 crate::processor::check_size_and_decode(content, "JSON", DEFAULT_INPUT_SIZE)?;
97 Ok(Some(json_value_edits(content, profile, store)?))
98 }
99}
100
101pub(crate) fn json_value_edits(
109 content: &[u8],
110 profile: &FileTypeProfile,
111 store: &MappingStore,
112) -> Result<Vec<Replacement>> {
113 let bom = if content.starts_with(&[0xEF, 0xBB, 0xBF]) {
119 3
120 } else {
121 0
122 };
123 let body = &content[bom..];
124 let mut jiter = Jiter::new(body);
125 let mut edits = Vec::new();
126 let peek = jiter.peek().map_err(json_err)?;
127 collect_json_edits(&mut jiter, peek, "", "", body, profile, store, &mut edits)?;
128 if bom != 0 {
129 for e in &mut edits {
130 e.start += bom;
131 e.end += bom;
132 }
133 }
134 Ok(edits)
135}
136
137#[allow(clippy::too_many_arguments)]
141fn collect_json_edits(
142 jiter: &mut Jiter,
143 peek: Peek,
144 key: &str,
145 path: &str,
146 content: &[u8],
147 profile: &FileTypeProfile,
148 store: &MappingStore,
149 edits: &mut Vec<Replacement>,
150) -> Result<()> {
151 if peek == Peek::Object {
152 let mut next = jiter.next_object().map_err(json_err)?.map(str::to_owned);
155 while let Some(k) = next {
156 let child_path = build_path(path, &k);
157 let child_peek = jiter.peek().map_err(json_err)?;
158 collect_json_edits(
159 jiter,
160 child_peek,
161 &k,
162 &child_path,
163 content,
164 profile,
165 store,
166 edits,
167 )?;
168 next = jiter.next_key().map_err(json_err)?.map(str::to_owned);
169 }
170 } else if peek == Peek::Array {
171 let mut elem = jiter.next_array().map_err(json_err)?;
173 while let Some(elem_peek) = elem {
174 collect_json_edits(jiter, elem_peek, key, path, content, profile, store, edits)?;
175 elem = jiter.array_step().map_err(json_err)?;
176 }
177 } else if peek == Peek::String {
178 let start = jiter.current_index();
179 let s = jiter.next_str().map_err(json_err)?.to_owned();
180 let end = jiter.current_index();
181 if let Some(token) = edit_token(key, path, &s, profile, store)? {
182 edits.push(Replacement {
183 start,
184 end,
185 value: format!("\"{token}\""),
186 });
187 }
188 } else {
189 let start = jiter.current_index();
191 jiter.next_skip().map_err(json_err)?;
192 let end = jiter.current_index();
193 let s = String::from_utf8_lossy(&content[start..end]).into_owned();
194 if let Some(token) = edit_token(key, path, &s, profile, store)? {
195 edits.push(Replacement {
196 start,
197 end,
198 value: format!("\"{token}\""),
199 });
200 }
201 }
202 Ok(())
203}
204
205impl TreeNode for Value {
206 fn for_each_map_entry<F>(&mut self, mut f: F) -> Result<()>
207 where
208 F: FnMut(&str, &mut Self) -> Result<()>,
209 {
210 if let Self::Object(map) = self {
211 let keys: Vec<String> = map.keys().cloned().collect();
212 for key in keys {
213 if let Some(v) = map.get_mut(&key) {
214 f(&key, v)?;
215 }
216 }
217 }
218 Ok(())
219 }
220
221 fn for_each_seq_item<F>(&mut self, mut f: F) -> Result<()>
222 where
223 F: FnMut(&mut Self) -> Result<()>,
224 {
225 if let Self::Array(arr) = self {
226 for item in arr.iter_mut() {
227 f(item)?;
228 }
229 }
230 Ok(())
231 }
232
233 fn as_str_mut(&mut self) -> Option<&mut String> {
234 if let Self::String(s) = self {
235 Some(s)
236 } else {
237 None
238 }
239 }
240
241 fn is_scalar(&self) -> bool {
242 matches!(self, Self::Number(_) | Self::Bool(_))
243 }
244
245 fn scalar_to_string(&self) -> String {
246 self.to_string()
247 }
248
249 fn set_string(&mut self, s: String) {
250 *self = Self::String(s);
251 }
252}
253
254pub(crate) fn walk_json(
256 value: &mut Value,
257 prefix: &str,
258 profile: &FileTypeProfile,
259 store: &MappingStore,
260 depth: usize,
261) -> Result<()> {
262 walk_tree(value, prefix, profile, store, depth, "JSON")
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::category::Category;
269 use crate::generator::HmacGenerator;
270 use crate::processor::profile::FieldRule;
271 use std::sync::Arc;
272
273 fn make_store() -> MappingStore {
274 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
275 MappingStore::new(gen, None)
276 }
277
278 #[test]
279 fn basic_json_replacement() {
280 let store = make_store();
281 let proc = JsonProcessor;
282
283 let content =
284 br#"{"database": {"host": "db.corp.com", "password": "s3cret"}, "port": 5432}"#;
285 let profile = FileTypeProfile::new(
286 "json",
287 vec![
288 FieldRule::new("database.password").with_category(Category::Custom("pw".into())),
289 FieldRule::new("database.host").with_category(Category::Hostname),
290 ],
291 )
292 .with_option("compact", "true");
293
294 let result = proc.process(content, &profile, &store).unwrap();
295 let out: Value = serde_json::from_slice(&result).unwrap();
296
297 assert_ne!(out["database"]["password"].as_str().unwrap(), "s3cret");
298 assert_ne!(out["database"]["host"].as_str().unwrap(), "db.corp.com");
299 assert_eq!(out["port"], 5432);
300 }
301
302 #[test]
303 fn json_array_traversal() {
304 let store = make_store();
305 let proc = JsonProcessor;
306
307 let content = br#"{"users": [{"email": "a@b.com"}, {"email": "c@d.com"}]}"#;
308 let profile = FileTypeProfile::new(
309 "json",
310 vec![FieldRule::new("users.email").with_category(Category::Email)],
311 )
312 .with_option("compact", "true");
313
314 let result = proc.process(content, &profile, &store).unwrap();
315 let out: Value = serde_json::from_slice(&result).unwrap();
316
317 let users = out["users"].as_array().unwrap();
318 assert_ne!(users[0]["email"].as_str().unwrap(), "a@b.com");
319 assert_ne!(users[1]["email"].as_str().unwrap(), "c@d.com");
320 assert_eq!(users.len(), 2);
322 let text = String::from_utf8_lossy(&result);
323 assert!(text.contains("\"users\"") && text.contains("\"email\""));
324 }
325
326 #[test]
327 fn json_glob_suffix_pattern() {
328 let store = make_store();
329 let proc = JsonProcessor;
330
331 let content =
332 br#"{"db": {"password": "pw1"}, "cache": {"password": "pw2"}, "name": "app"}"#;
333 let profile = FileTypeProfile::new(
334 "json",
335 vec![FieldRule::new("*.password").with_category(Category::Custom("pw".into()))],
336 )
337 .with_option("compact", "true");
338
339 let result = proc.process(content, &profile, &store).unwrap();
340 let out: Value = serde_json::from_slice(&result).unwrap();
341
342 assert_ne!(out["db"]["password"].as_str().unwrap(), "pw1");
343 assert_ne!(out["cache"]["password"].as_str().unwrap(), "pw2");
344 assert_eq!(out["name"], "app");
345 }
346
347 #[test]
353 fn edits_redact_escaped_and_noncanonical_values() {
354 let store = make_store();
355 let proc = JsonProcessor;
356 let content =
358 br#"{"a":"x\"y-SEC1","u":"http:\/\/SEC2.test","n":"caf\u00e9-SEC3","keep":"ok"}"#;
359 let profile = FileTypeProfile::new(
360 "json",
361 vec![
362 FieldRule::new("a").with_category(Category::Custom("k".into())),
363 FieldRule::new("u").with_category(Category::Custom("k".into())),
364 FieldRule::new("n").with_category(Category::Custom("k".into())),
365 ],
366 );
367 let edits = proc
368 .process_to_edits(content, &profile, &store)
369 .unwrap()
370 .unwrap();
371 let out = crate::processor::apply_edits(content, edits);
372 let text = String::from_utf8(out).unwrap();
373 for leak in ["SEC1", "SEC2", "SEC3"] {
374 assert!(!text.contains(leak), "leaked {leak}: {text}");
375 }
376 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
378 assert_eq!(v["keep"], "ok");
379 }
380
381 #[test]
383 fn edits_preserve_compact_layout() {
384 let store = make_store();
385 let proc = JsonProcessor;
386 let content = br#"{"db":{"password":"SECRETpw","host":"keep.local"},"port":5432}"#;
387 let profile = FileTypeProfile::new(
388 "json",
389 vec![FieldRule::new("db.password").with_category(Category::Custom("pw".into()))],
390 );
391 let edits = proc
392 .process_to_edits(content, &profile, &store)
393 .unwrap()
394 .unwrap();
395 let out = crate::processor::apply_edits(content, edits);
396 let text = String::from_utf8(out).unwrap();
397 assert!(!text.contains("SECRETpw"), "secret leaked: {text}");
398 assert!(!text.contains('\n'), "formatting changed: {text}");
400 assert!(
401 text.contains(r#""host":"keep.local""#),
402 "non-secret changed: {text}"
403 );
404 assert!(
405 text.contains(r#""port":5432"#),
406 "non-secret changed: {text}"
407 );
408 }
409}