Skip to main content

faucet_core/masking/
mod.rs

1//! PII detection + column-level masking policies (issue #206): a declarative,
2//! destination-scoped policy that classifies sensitive fields — by field-name
3//! pattern, by value detector (email / card-with-Luhn / SSN / phone / IPv4), or
4//! by explicit field list — and rewrites them per action (`redact` / `hash` /
5//! `tokenize` / `partial`).
6//!
7//! **Ordering.** The masking pass runs *first* in the page loop — before the
8//! quality, contract, and schema-drift passes and before every sink write. So
9//! every downstream consumer (those passes, the sink, the DLQ, and the
10//! sink-side lineage sample) observes only masked values; PII never leaks to a
11//! sink, a dead-letter queue, or a lineage facet. Masking is value-only and
12//! key-preserving, and it keeps determinism (equal inputs → equal masked
13//! outputs), non-null-ness, and per-key uniqueness, so the later quality /
14//! contract checks stay meaningful over masked data.
15//!
16//! Masking never fails a run or quarantines a record: matching fields are
17//! rewritten in place. The pipeline wires metrics in `instrumented_apply_masking`.
18
19pub mod compile;
20pub mod config;
21pub mod detect;
22mod hash;
23
24pub use compile::CompiledMasking;
25pub use config::{Detector, MaskAction, MaskRule, MaskingSpec, MatchSpec};
26
27use compile::CompiledRule;
28use serde_json::Value;
29
30/// One field-masking event, for metrics/observability.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct MaskHit {
33    /// The rule's stable label.
34    pub rule: String,
35    /// The action applied (`redact` / `hash` / `tokenize` / `partial`).
36    pub action: &'static str,
37    /// The value detector that matched, if the match was value-based.
38    pub detector: Option<&'static str>,
39}
40
41/// Result of masking one page.
42#[derive(Debug, Clone, Default)]
43pub struct MaskingOutcome {
44    /// The page with every matching field rewritten in place.
45    pub records: Vec<Value>,
46    /// One entry per masked field (order = discovery order).
47    pub hits: Vec<MaskHit>,
48}
49
50/// Apply the masking policy to one page. Pure: no metrics, no I/O, infallible.
51///
52/// Each record is walked depth-first; for every field (including nested object
53/// keys and array elements, addressed by dot-path such as `user.contacts.0.email`)
54/// the first rule that matches wins and its action rewrites the value. A
55/// name-based match (`field_pattern` / `fields`) can target a whole subtree:
56/// `redact` replaces it wholesale and the hash/tokenize/partial actions rewrite
57/// every scalar leaf inside it. Value detectors operate on scalar leaves.
58pub fn apply_masking(records: Vec<Value>, masking: &CompiledMasking) -> MaskingOutcome {
59    let mut out = MaskingOutcome {
60        records: Vec::with_capacity(records.len()),
61        hits: Vec::new(),
62    };
63    for mut rec in records {
64        mask_node(&mut rec, "", masking, &mut out.hits);
65        out.records.push(rec);
66    }
67    out
68}
69
70/// Recursively mask `node`, addressed by `path`. Returns nothing; mutates in
71/// place and appends any hits.
72fn mask_node(node: &mut Value, path: &str, m: &CompiledMasking, hits: &mut Vec<MaskHit>) {
73    // 1. Does a rule match this node as a whole?
74    if let Some((rule, detector)) = first_match(node, path, m) {
75        match &rule.action {
76            MaskAction::Redact { mask } => {
77                *node = mask.clone();
78                hits.push(hit(rule, detector));
79                return; // replaced wholesale — do not recurse
80            }
81            action => {
82                if let Some(s) = scalar_to_string(node) {
83                    *node = Value::String(apply_scalar_action(action, &s, &m.hasher));
84                    hits.push(hit(rule, detector));
85                    return;
86                }
87                if matches!(node, Value::Object(_) | Value::Array(_)) {
88                    mask_leaves(node, rule, action, &m.hasher, hits);
89                    return;
90                }
91            }
92        }
93    }
94
95    // 2. No terminal match — recurse into children.
96    match node {
97        Value::Object(map) => {
98            for (k, v) in map.iter_mut() {
99                // No name rule → the path is never read, so build none.
100                if m.needs_paths {
101                    mask_node(v, &child_path(path, k), m, hits);
102                } else {
103                    mask_node(v, UNTRACKED_PATH, m, hits);
104                }
105            }
106        }
107        Value::Array(items) => {
108            for (i, v) in items.iter_mut().enumerate() {
109                if m.needs_paths {
110                    mask_node(v, &child_path(path, &i.to_string()), m, hits);
111                } else {
112                    mask_node(v, UNTRACKED_PATH, m, hits);
113                }
114            }
115        }
116        Value::Number(n) if m.stringify_numbers => *node = Value::String(n.to_string()),
117        _ => {}
118    }
119}
120
121/// Apply a scalar-only action to every scalar leaf of a name-matched subtree.
122fn mask_leaves(
123    node: &mut Value,
124    rule: &CompiledRule,
125    action: &MaskAction,
126    hasher: &hash::Hasher,
127    hits: &mut Vec<MaskHit>,
128) {
129    match node {
130        Value::Object(map) => {
131            for v in map.values_mut() {
132                mask_leaves(v, rule, action, hasher, hits);
133            }
134        }
135        Value::Array(items) => {
136            for v in items.iter_mut() {
137                mask_leaves(v, rule, action, hasher, hits);
138            }
139        }
140        leaf => {
141            if let Some(s) = scalar_to_string(leaf) {
142                *leaf = Value::String(apply_scalar_action(action, &s, hasher));
143                hits.push(hit(rule, None));
144            }
145        }
146    }
147}
148
149/// Find the first rule that matches `node` at `path`. Returns the rule and, if
150/// the match was value-based, the detector that fired.
151fn first_match<'a>(
152    node: &Value,
153    path: &str,
154    m: &'a CompiledMasking,
155) -> Option<(&'a CompiledRule, Option<&'static str>)> {
156    for rule in &m.rules {
157        // Name-based: field pattern over the dot-path, or explicit field list.
158        // The empty root path never matches a name rule.
159        if !path.is_empty() {
160            if let Some(re) = &rule.field_pattern
161                && re.is_match(path)
162            {
163                return Some((rule, None));
164            }
165            if rule.fields.contains(path) {
166                return Some((rule, None));
167            }
168        }
169        // Value-based: strings through every detector, numbers through the
170        // card detector only.
171        if let Some(det) = rule.value_detector
172            && detect::detects_value(det, node)
173        {
174            return Some((rule, Some(detector_label(det))));
175        }
176    }
177    None
178}
179
180fn apply_scalar_action(action: &MaskAction, value: &str, hasher: &hash::Hasher) -> String {
181    match action {
182        MaskAction::Hash => hasher.digest_hex(value),
183        MaskAction::Tokenize { prefix } => hasher.token(value, prefix.as_deref().unwrap_or("")),
184        MaskAction::Partial {
185            keep_last,
186            mask_char,
187        } => partial(value, *keep_last, mask_char.unwrap_or('*')),
188        // Redact is handled at the node level (works on any type).
189        MaskAction::Redact { .. } => "***".to_string(),
190    }
191}
192
193/// Reveal only the last `keep_last` characters; replace the rest with
194/// `mask_char`. Counts by Unicode scalar so multibyte strings mask correctly.
195fn partial(value: &str, keep_last: usize, mask_char: char) -> String {
196    let chars: Vec<char> = value.chars().collect();
197    let n = chars.len();
198    // Reveal at most `keep_last` trailing chars, but never the whole value
199    // (a short secret with keep_last ≥ len would otherwise leak in the clear).
200    let revealed = if keep_last >= n { 0 } else { keep_last };
201    let masked_len = n - revealed;
202    let mut s = String::with_capacity(n);
203    for _ in 0..masked_len {
204        s.push(mask_char);
205    }
206    s.extend(&chars[masked_len..]);
207    s
208}
209
210/// String form of a scalar JSON value (`string` / `number` / `bool`); `None`
211/// for null, objects, and arrays (nothing to mask as a scalar).
212fn scalar_to_string(v: &Value) -> Option<String> {
213    match v {
214        Value::String(s) => Some(s.clone()),
215        Value::Number(n) => Some(n.to_string()),
216        Value::Bool(b) => Some(b.to_string()),
217        _ => None,
218    }
219}
220
221/// The path of a non-root node when no rule matches by name.
222const UNTRACKED_PATH: &str = "*";
223
224fn child_path(prefix: &str, key: &str) -> String {
225    if prefix.is_empty() {
226        key.to_string()
227    } else {
228        format!("{prefix}.{key}")
229    }
230}
231
232fn hit(rule: &CompiledRule, detector: Option<&'static str>) -> MaskHit {
233    MaskHit {
234        rule: rule.label.clone(),
235        action: rule.action_label,
236        detector,
237    }
238}
239
240fn detector_label(d: Detector) -> &'static str {
241    match d {
242        Detector::Email => "email",
243        Detector::CreditCard => "credit_card",
244        Detector::Ssn => "ssn",
245        Detector::Phone => "phone",
246        Detector::Ipv4 => "ipv4",
247        Detector::PhoneLoose => "phone_loose",
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use serde_json::json;
255
256    fn compile(v: Value) -> CompiledMasking {
257        let spec: MaskingSpec = serde_json::from_value(v).unwrap();
258        CompiledMasking::compile(&spec).unwrap()
259    }
260
261    #[test]
262    fn redact_replaces_named_field() {
263        let m = compile(json!({
264            "rules": [{ "match": { "field_pattern": "^email$" }, "action": { "type": "redact" } }]
265        }));
266        let out = apply_masking(vec![json!({"email": "a@b.com", "name": "Al"})], &m);
267        assert_eq!(out.records[0], json!({"email": "***", "name": "Al"}));
268        assert_eq!(out.hits.len(), 1);
269        assert_eq!(out.hits[0].action, "redact");
270        assert_eq!(out.hits[0].detector, None);
271    }
272
273    #[test]
274    fn redact_custom_mask_value() {
275        // Explicit null mask nulls the field (e.g. for a nullable DB column).
276        let m = compile(json!({
277            "rules": [{ "match": { "fields": ["ssn"] },
278                        "action": { "type": "redact", "mask": null } }]
279        }));
280        let out = apply_masking(vec![json!({"ssn": "123-45-6789"})], &m);
281        assert_eq!(out.records[0], json!({"ssn": null}));
282
283        // A custom scalar mask replaces the value with that scalar.
284        let m2 = compile(json!({
285            "rules": [{ "match": { "fields": ["x"] },
286                        "action": { "type": "redact", "mask": "REDACTED" } }]
287        }));
288        let out2 = apply_masking(vec![json!({"x": "secret"})], &m2);
289        assert_eq!(out2.records[0], json!({"x": "REDACTED"}));
290    }
291
292    #[test]
293    fn value_detector_matches_across_field_names() {
294        let m = compile(json!({
295            "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
296        }));
297        let out = apply_masking(
298            vec![json!({"contact": "a@b.com", "note": "hi", "n": 5})],
299            &m,
300        );
301        assert_eq!(
302            out.records[0],
303            json!({"contact": "***", "note": "hi", "n": 5})
304        );
305        assert_eq!(out.hits[0].detector, Some("email"));
306    }
307
308    #[test]
309    fn numbers_are_not_phones_but_can_be_cards() {
310        let m = compile(json!({
311            "rules": [
312                { "match": { "value_detector": "phone" }, "action": { "type": "redact" } },
313                { "match": { "value_detector": "credit_card" }, "action": { "type": "redact" } }
314            ]
315        }));
316        let out = apply_masking(
317            vec![json!({
318                "ts": 1717200000000u64, "amount": 4155552671u64, "flag": true,
319                "tel": "415-555-2671", "cc": 4111111111111111u64
320            })],
321            &m,
322        );
323        assert_eq!(
324            out.records[0],
325            json!({"ts": "1717200000000", "amount": "4155552671", "flag": true,
326                   "tel": "***", "cc": "***"}),
327            "numbers are not phones (kept, as strings because a card rule can match numbers)"
328        );
329    }
330
331    #[test]
332    fn hash_is_deterministic_and_joinable() {
333        let m = compile(json!({
334            "key": "k",
335            "rules": [{ "match": { "fields": ["uid"] }, "action": { "type": "hash" } }]
336        }));
337        let out = apply_masking(
338            vec![
339                json!({"uid": "u1"}),
340                json!({"uid": "u1"}),
341                json!({"uid": "u2"}),
342            ],
343            &m,
344        );
345        let h1 = out.records[0]["uid"].as_str().unwrap();
346        assert_eq!(
347            h1,
348            out.records[1]["uid"].as_str().unwrap(),
349            "same input → same hash"
350        );
351        assert_ne!(h1, out.records[2]["uid"].as_str().unwrap());
352        assert_eq!(h1.len(), 64);
353    }
354
355    #[test]
356    fn tokenize_with_prefix() {
357        let m = compile(json!({
358            "key": "k",
359            "rules": [{ "match": { "fields": ["id"] },
360                        "action": { "type": "tokenize", "prefix": "tok_" } }]
361        }));
362        let out = apply_masking(vec![json!({"id": "abc"})], &m);
363        assert!(out.records[0]["id"].as_str().unwrap().starts_with("tok_"));
364    }
365
366    #[test]
367    fn partial_keeps_last_n() {
368        let m = compile(json!({
369            "rules": [{ "match": { "fields": ["card"] },
370                        "action": { "type": "partial", "keep_last": 4 } }]
371        }));
372        let out = apply_masking(vec![json!({"card": "4111111111111111"})], &m);
373        assert_eq!(out.records[0]["card"], json!("************1111"));
374    }
375
376    #[test]
377    fn partial_masks_all_when_keep_ge_len() {
378        let m = compile(json!({
379            "rules": [{ "match": { "fields": ["pin"] },
380                        "action": { "type": "partial", "keep_last": 10 } }]
381        }));
382        let out = apply_masking(vec![json!({"pin": "1234"})], &m);
383        assert_eq!(
384            out.records[0]["pin"],
385            json!("****"),
386            "never reveal a short value whole"
387        );
388    }
389
390    #[test]
391    fn hash_coerces_number_to_string() {
392        let m = compile(json!({
393            "rules": [{ "match": { "fields": ["n"] }, "action": { "type": "hash" } }]
394        }));
395        let out = apply_masking(vec![json!({"n": 42})], &m);
396        assert!(out.records[0]["n"].is_string());
397    }
398
399    #[test]
400    fn nested_object_and_array_paths_masked() {
401        let m = compile(json!({
402            "rules": [{ "match": { "field_pattern": r"contacts\.\d+\.email" },
403                        "action": { "type": "redact" } }]
404        }));
405        let out = apply_masking(
406            vec![json!({
407                "user": {"name": "Al"},
408                "contacts": [ {"email": "a@b.com"}, {"email": "c@d.com"} ]
409            })],
410            &m,
411        );
412        assert_eq!(out.records[0]["contacts"][0]["email"], json!("***"));
413        assert_eq!(out.records[0]["contacts"][1]["email"], json!("***"));
414        assert_eq!(out.records[0]["user"]["name"], json!("Al"));
415        assert_eq!(out.hits.len(), 2);
416    }
417
418    #[test]
419    fn redact_whole_subtree_by_name() {
420        let m = compile(json!({
421            "rules": [{ "match": { "fields": ["address"] }, "action": { "type": "redact" } }]
422        }));
423        let out = apply_masking(
424            vec![json!({"address": {"street": "1 Main", "zip": "94103"}, "id": 1})],
425            &m,
426        );
427        assert_eq!(out.records[0], json!({"address": "***", "id": 1}));
428    }
429
430    #[test]
431    fn a_number_matching_detector_writes_every_number_as_a_string() {
432        let m = compile(json!({
433            "key": "k",
434            "rules": [{ "match": { "value_detector": "credit_card" }, "action": { "type": "hash" } }]
435        }));
436        let page1 = apply_masking(
437            vec![json!({"acct": 12345, "amount": -1.5, "n": {"x": 7}, "ok": true, "z": null})],
438            &m,
439        );
440        let page2 = apply_masking(vec![json!({"acct": 4111111111111111u64})], &m);
441        assert_eq!(
442            page1.records[0],
443            json!({"acct": "12345", "amount": "-1.5", "n": {"x": "7"}, "ok": true, "z": null})
444        );
445        assert!(
446            page1.hits.is_empty(),
447            "a coerced number is not a masking hit"
448        );
449        let masked = page2.records[0]["acct"].as_str().unwrap();
450        assert_eq!(masked.len(), 64, "the card is hashed, still a string");
451
452        let plain = compile(json!({
453            "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
454        }));
455        let out = apply_masking(vec![json!({"acct": 12345})], &plain);
456        assert_eq!(out.records[0]["acct"], json!(12345));
457    }
458
459    #[test]
460    fn first_matching_rule_wins() {
461        let m = compile(json!({
462            "rules": [
463                { "name": "first", "match": { "fields": ["x"] }, "action": { "type": "redact" } },
464                { "name": "second", "match": { "fields": ["x"] }, "action": { "type": "hash" } }
465            ]
466        }));
467        let out = apply_masking(vec![json!({"x": "v"})], &m);
468        assert_eq!(out.records[0]["x"], json!("***"));
469        assert_eq!(out.hits[0].rule, "first");
470    }
471
472    #[test]
473    fn scalar_action_on_container_masks_every_leaf_before_later_rules() {
474        let m = compile(json!({
475            "key": "k",
476            "rules": [
477                { "match": { "field_pattern": "^blob$" }, "action": { "type": "hash" } },
478                { "match": { "value_detector": "email" }, "action": { "type": "redact" } }
479            ]
480        }));
481        let out = apply_masking(vec![json!({"blob": {"e": "a@b.com"}})], &m);
482        let e = out.records[0]["blob"]["e"].as_str().unwrap();
483        assert_eq!(
484            e.len(),
485            64,
486            "the name-matched hash rule wins over the detector"
487        );
488        assert_eq!(out.hits.len(), 1);
489        assert_eq!(out.hits[0].action, "hash");
490    }
491
492    #[test]
493    fn hash_on_name_matched_array_masks_each_element() {
494        let m = compile(json!({
495            "key": "k",
496            "rules": [{ "match": { "fields": ["phones"] }, "action": { "type": "hash" } }]
497        }));
498        let out = apply_masking(
499            vec![json!({"phones": ["+14155552671", "+14155550000", null], "id": 1})],
500            &m,
501        );
502        let phones = out.records[0]["phones"].as_array().unwrap();
503        for p in &phones[..2] {
504            let s = p.as_str().unwrap();
505            assert_eq!(s.len(), 64);
506            assert!(!s.contains("415"));
507        }
508        assert_eq!(phones[2], json!(null));
509        assert_eq!(out.records[0]["id"], json!(1));
510        assert_eq!(out.hits.len(), 2);
511    }
512
513    #[test]
514    fn partial_on_anchored_pattern_masks_nested_object_leaves() {
515        let m = compile(json!({
516            "rules": [{ "match": { "field_pattern": "^card$" },
517                        "action": { "type": "partial", "keep_last": 4 } }]
518        }));
519        let out = apply_masking(
520            vec![json!({"card": {"number": "4111111111111111", "exp": {"m": 12, "ok": true}}})],
521            &m,
522        );
523        let card = &out.records[0]["card"];
524        assert_eq!(card["number"], json!("************1111"));
525        assert_eq!(card["exp"]["m"], json!("**"));
526        assert_eq!(card["exp"]["ok"], json!("****"));
527        assert_eq!(out.hits.len(), 3);
528    }
529
530    #[test]
531    fn tokenize_on_anchored_pattern_masks_nested_object_leaves() {
532        let m = compile(json!({
533            "key": "k",
534            "rules": [{ "match": { "field_pattern": "^card$" },
535                        "action": { "type": "tokenize", "prefix": "tok_" } }]
536        }));
537        let out = apply_masking(vec![json!({"card": {"number": "4111111111111111"}})], &m);
538        let n = out.records[0]["card"]["number"].as_str().unwrap();
539        assert!(n.starts_with("tok_"));
540        assert!(!n.contains("4111"));
541        assert_eq!(out.hits.len(), 1);
542    }
543
544    #[test]
545    fn null_is_not_masked() {
546        let m = compile(json!({
547            "rules": [{ "match": { "fields": ["x"] }, "action": { "type": "hash" } }]
548        }));
549        let out = apply_masking(vec![json!({"x": null})], &m);
550        assert_eq!(out.records[0]["x"], json!(null));
551        assert!(out.hits.is_empty());
552    }
553
554    #[test]
555    fn non_object_record_untouched() {
556        let m = compile(json!({
557            "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
558        }));
559        // top-level string that is an email — root path is empty so name rules
560        // don't fire, but value detector does.
561        let out = apply_masking(vec![json!("a@b.com")], &m);
562        assert_eq!(out.records[0], json!("***"));
563    }
564
565    #[test]
566    fn no_match_leaves_page_untouched() {
567        let m = compile(json!({
568            "rules": [{ "match": { "fields": ["absent"] }, "action": { "type": "redact" } }]
569        }));
570        let page = vec![json!({"a": 1, "b": "x"})];
571        let out = apply_masking(page.clone(), &m);
572        assert_eq!(out.records, page);
573        assert!(out.hits.is_empty());
574    }
575}