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        _ => {}
117    }
118}
119
120/// Apply a scalar-only action to every scalar leaf of a name-matched subtree.
121fn mask_leaves(
122    node: &mut Value,
123    rule: &CompiledRule,
124    action: &MaskAction,
125    hasher: &hash::Hasher,
126    hits: &mut Vec<MaskHit>,
127) {
128    match node {
129        Value::Object(map) => {
130            for v in map.values_mut() {
131                mask_leaves(v, rule, action, hasher, hits);
132            }
133        }
134        Value::Array(items) => {
135            for v in items.iter_mut() {
136                mask_leaves(v, rule, action, hasher, hits);
137            }
138        }
139        leaf => {
140            if let Some(s) = scalar_to_string(leaf) {
141                *leaf = Value::String(apply_scalar_action(action, &s, hasher));
142                hits.push(hit(rule, None));
143            }
144        }
145    }
146}
147
148/// Find the first rule that matches `node` at `path`. Returns the rule and, if
149/// the match was value-based, the detector that fired.
150fn first_match<'a>(
151    node: &Value,
152    path: &str,
153    m: &'a CompiledMasking,
154) -> Option<(&'a CompiledRule, Option<&'static str>)> {
155    for rule in &m.rules {
156        // Name-based: field pattern over the dot-path, or explicit field list.
157        // The empty root path never matches a name rule.
158        if !path.is_empty() {
159            if let Some(re) = &rule.field_pattern
160                && re.is_match(path)
161            {
162                return Some((rule, None));
163            }
164            if rule.fields.contains(path) {
165                return Some((rule, None));
166            }
167        }
168        // Value-based: strings through every detector, numbers through the
169        // card detector only.
170        if let Some(det) = rule.value_detector
171            && detect::detects_value(det, node)
172        {
173            return Some((rule, Some(detector_label(det))));
174        }
175    }
176    None
177}
178
179fn apply_scalar_action(action: &MaskAction, value: &str, hasher: &hash::Hasher) -> String {
180    match action {
181        MaskAction::Hash => hasher.digest_hex(value),
182        MaskAction::Tokenize { prefix } => hasher.token(value, prefix.as_deref().unwrap_or("")),
183        MaskAction::Partial {
184            keep_last,
185            mask_char,
186        } => partial(value, *keep_last, mask_char.unwrap_or('*')),
187        // Redact is handled at the node level (works on any type).
188        MaskAction::Redact { .. } => "***".to_string(),
189    }
190}
191
192/// Reveal only the last `keep_last` characters; replace the rest with
193/// `mask_char`. Counts by Unicode scalar so multibyte strings mask correctly.
194fn partial(value: &str, keep_last: usize, mask_char: char) -> String {
195    let chars: Vec<char> = value.chars().collect();
196    let n = chars.len();
197    // Reveal at most `keep_last` trailing chars, but never the whole value
198    // (a short secret with keep_last ≥ len would otherwise leak in the clear).
199    let revealed = if keep_last >= n { 0 } else { keep_last };
200    let masked_len = n - revealed;
201    let mut s = String::with_capacity(n);
202    for _ in 0..masked_len {
203        s.push(mask_char);
204    }
205    s.extend(&chars[masked_len..]);
206    s
207}
208
209/// String form of a scalar JSON value (`string` / `number` / `bool`); `None`
210/// for null, objects, and arrays (nothing to mask as a scalar).
211fn scalar_to_string(v: &Value) -> Option<String> {
212    match v {
213        Value::String(s) => Some(s.clone()),
214        Value::Number(n) => Some(n.to_string()),
215        Value::Bool(b) => Some(b.to_string()),
216        _ => None,
217    }
218}
219
220/// The path of a non-root node when no rule matches by name.
221const UNTRACKED_PATH: &str = "*";
222
223fn child_path(prefix: &str, key: &str) -> String {
224    if prefix.is_empty() {
225        key.to_string()
226    } else {
227        format!("{prefix}.{key}")
228    }
229}
230
231fn hit(rule: &CompiledRule, detector: Option<&'static str>) -> MaskHit {
232    MaskHit {
233        rule: rule.label.clone(),
234        action: rule.action_label,
235        detector,
236    }
237}
238
239fn detector_label(d: Detector) -> &'static str {
240    match d {
241        Detector::Email => "email",
242        Detector::CreditCard => "credit_card",
243        Detector::Ssn => "ssn",
244        Detector::Phone => "phone",
245        Detector::Ipv4 => "ipv4",
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use serde_json::json;
253
254    fn compile(v: Value) -> CompiledMasking {
255        let spec: MaskingSpec = serde_json::from_value(v).unwrap();
256        CompiledMasking::compile(&spec).unwrap()
257    }
258
259    #[test]
260    fn redact_replaces_named_field() {
261        let m = compile(json!({
262            "rules": [{ "match": { "field_pattern": "^email$" }, "action": { "type": "redact" } }]
263        }));
264        let out = apply_masking(vec![json!({"email": "a@b.com", "name": "Al"})], &m);
265        assert_eq!(out.records[0], json!({"email": "***", "name": "Al"}));
266        assert_eq!(out.hits.len(), 1);
267        assert_eq!(out.hits[0].action, "redact");
268        assert_eq!(out.hits[0].detector, None);
269    }
270
271    #[test]
272    fn redact_custom_mask_value() {
273        // Explicit null mask nulls the field (e.g. for a nullable DB column).
274        let m = compile(json!({
275            "rules": [{ "match": { "fields": ["ssn"] },
276                        "action": { "type": "redact", "mask": null } }]
277        }));
278        let out = apply_masking(vec![json!({"ssn": "123-45-6789"})], &m);
279        assert_eq!(out.records[0], json!({"ssn": null}));
280
281        // A custom scalar mask replaces the value with that scalar.
282        let m2 = compile(json!({
283            "rules": [{ "match": { "fields": ["x"] },
284                        "action": { "type": "redact", "mask": "REDACTED" } }]
285        }));
286        let out2 = apply_masking(vec![json!({"x": "secret"})], &m2);
287        assert_eq!(out2.records[0], json!({"x": "REDACTED"}));
288    }
289
290    #[test]
291    fn value_detector_matches_across_field_names() {
292        let m = compile(json!({
293            "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
294        }));
295        let out = apply_masking(
296            vec![json!({"contact": "a@b.com", "note": "hi", "n": 5})],
297            &m,
298        );
299        assert_eq!(
300            out.records[0],
301            json!({"contact": "***", "note": "hi", "n": 5})
302        );
303        assert_eq!(out.hits[0].detector, Some("email"));
304    }
305
306    #[test]
307    fn numbers_are_not_phones_but_can_be_cards() {
308        let m = compile(json!({
309            "rules": [
310                { "match": { "value_detector": "phone" }, "action": { "type": "redact" } },
311                { "match": { "value_detector": "credit_card" }, "action": { "type": "redact" } }
312            ]
313        }));
314        let out = apply_masking(
315            vec![json!({
316                "ts": 1717200000000u64, "amount": 4155552671u64, "flag": true,
317                "tel": "415-555-2671", "cc": 4111111111111111u64
318            })],
319            &m,
320        );
321        assert_eq!(
322            out.records[0],
323            json!({"ts": 1717200000000u64, "amount": 4155552671u64, "flag": true,
324                   "tel": "***", "cc": "***"})
325        );
326    }
327
328    #[test]
329    fn hash_is_deterministic_and_joinable() {
330        let m = compile(json!({
331            "key": "k",
332            "rules": [{ "match": { "fields": ["uid"] }, "action": { "type": "hash" } }]
333        }));
334        let out = apply_masking(
335            vec![
336                json!({"uid": "u1"}),
337                json!({"uid": "u1"}),
338                json!({"uid": "u2"}),
339            ],
340            &m,
341        );
342        let h1 = out.records[0]["uid"].as_str().unwrap();
343        assert_eq!(
344            h1,
345            out.records[1]["uid"].as_str().unwrap(),
346            "same input → same hash"
347        );
348        assert_ne!(h1, out.records[2]["uid"].as_str().unwrap());
349        assert_eq!(h1.len(), 64);
350    }
351
352    #[test]
353    fn tokenize_with_prefix() {
354        let m = compile(json!({
355            "key": "k",
356            "rules": [{ "match": { "fields": ["id"] },
357                        "action": { "type": "tokenize", "prefix": "tok_" } }]
358        }));
359        let out = apply_masking(vec![json!({"id": "abc"})], &m);
360        assert!(out.records[0]["id"].as_str().unwrap().starts_with("tok_"));
361    }
362
363    #[test]
364    fn partial_keeps_last_n() {
365        let m = compile(json!({
366            "rules": [{ "match": { "fields": ["card"] },
367                        "action": { "type": "partial", "keep_last": 4 } }]
368        }));
369        let out = apply_masking(vec![json!({"card": "4111111111111111"})], &m);
370        assert_eq!(out.records[0]["card"], json!("************1111"));
371    }
372
373    #[test]
374    fn partial_masks_all_when_keep_ge_len() {
375        let m = compile(json!({
376            "rules": [{ "match": { "fields": ["pin"] },
377                        "action": { "type": "partial", "keep_last": 10 } }]
378        }));
379        let out = apply_masking(vec![json!({"pin": "1234"})], &m);
380        assert_eq!(
381            out.records[0]["pin"],
382            json!("****"),
383            "never reveal a short value whole"
384        );
385    }
386
387    #[test]
388    fn hash_coerces_number_to_string() {
389        let m = compile(json!({
390            "rules": [{ "match": { "fields": ["n"] }, "action": { "type": "hash" } }]
391        }));
392        let out = apply_masking(vec![json!({"n": 42})], &m);
393        assert!(out.records[0]["n"].is_string());
394    }
395
396    #[test]
397    fn nested_object_and_array_paths_masked() {
398        let m = compile(json!({
399            "rules": [{ "match": { "field_pattern": r"contacts\.\d+\.email" },
400                        "action": { "type": "redact" } }]
401        }));
402        let out = apply_masking(
403            vec![json!({
404                "user": {"name": "Al"},
405                "contacts": [ {"email": "a@b.com"}, {"email": "c@d.com"} ]
406            })],
407            &m,
408        );
409        assert_eq!(out.records[0]["contacts"][0]["email"], json!("***"));
410        assert_eq!(out.records[0]["contacts"][1]["email"], json!("***"));
411        assert_eq!(out.records[0]["user"]["name"], json!("Al"));
412        assert_eq!(out.hits.len(), 2);
413    }
414
415    #[test]
416    fn redact_whole_subtree_by_name() {
417        let m = compile(json!({
418            "rules": [{ "match": { "fields": ["address"] }, "action": { "type": "redact" } }]
419        }));
420        let out = apply_masking(
421            vec![json!({"address": {"street": "1 Main", "zip": "94103"}, "id": 1})],
422            &m,
423        );
424        assert_eq!(out.records[0], json!({"address": "***", "id": 1}));
425    }
426
427    #[test]
428    fn first_matching_rule_wins() {
429        let m = compile(json!({
430            "rules": [
431                { "name": "first", "match": { "fields": ["x"] }, "action": { "type": "redact" } },
432                { "name": "second", "match": { "fields": ["x"] }, "action": { "type": "hash" } }
433            ]
434        }));
435        let out = apply_masking(vec![json!({"x": "v"})], &m);
436        assert_eq!(out.records[0]["x"], json!("***"));
437        assert_eq!(out.hits[0].rule, "first");
438    }
439
440    #[test]
441    fn scalar_action_on_container_masks_every_leaf_before_later_rules() {
442        let m = compile(json!({
443            "key": "k",
444            "rules": [
445                { "match": { "field_pattern": "^blob$" }, "action": { "type": "hash" } },
446                { "match": { "value_detector": "email" }, "action": { "type": "redact" } }
447            ]
448        }));
449        let out = apply_masking(vec![json!({"blob": {"e": "a@b.com"}})], &m);
450        let e = out.records[0]["blob"]["e"].as_str().unwrap();
451        assert_eq!(
452            e.len(),
453            64,
454            "the name-matched hash rule wins over the detector"
455        );
456        assert_eq!(out.hits.len(), 1);
457        assert_eq!(out.hits[0].action, "hash");
458    }
459
460    #[test]
461    fn hash_on_name_matched_array_masks_each_element() {
462        let m = compile(json!({
463            "key": "k",
464            "rules": [{ "match": { "fields": ["phones"] }, "action": { "type": "hash" } }]
465        }));
466        let out = apply_masking(
467            vec![json!({"phones": ["+14155552671", "+14155550000", null], "id": 1})],
468            &m,
469        );
470        let phones = out.records[0]["phones"].as_array().unwrap();
471        for p in &phones[..2] {
472            let s = p.as_str().unwrap();
473            assert_eq!(s.len(), 64);
474            assert!(!s.contains("415"));
475        }
476        assert_eq!(phones[2], json!(null));
477        assert_eq!(out.records[0]["id"], json!(1));
478        assert_eq!(out.hits.len(), 2);
479    }
480
481    #[test]
482    fn partial_on_anchored_pattern_masks_nested_object_leaves() {
483        let m = compile(json!({
484            "rules": [{ "match": { "field_pattern": "^card$" },
485                        "action": { "type": "partial", "keep_last": 4 } }]
486        }));
487        let out = apply_masking(
488            vec![json!({"card": {"number": "4111111111111111", "exp": {"m": 12, "ok": true}}})],
489            &m,
490        );
491        let card = &out.records[0]["card"];
492        assert_eq!(card["number"], json!("************1111"));
493        assert_eq!(card["exp"]["m"], json!("**"));
494        assert_eq!(card["exp"]["ok"], json!("****"));
495        assert_eq!(out.hits.len(), 3);
496    }
497
498    #[test]
499    fn tokenize_on_anchored_pattern_masks_nested_object_leaves() {
500        let m = compile(json!({
501            "key": "k",
502            "rules": [{ "match": { "field_pattern": "^card$" },
503                        "action": { "type": "tokenize", "prefix": "tok_" } }]
504        }));
505        let out = apply_masking(vec![json!({"card": {"number": "4111111111111111"}})], &m);
506        let n = out.records[0]["card"]["number"].as_str().unwrap();
507        assert!(n.starts_with("tok_"));
508        assert!(!n.contains("4111"));
509        assert_eq!(out.hits.len(), 1);
510    }
511
512    #[test]
513    fn null_is_not_masked() {
514        let m = compile(json!({
515            "rules": [{ "match": { "fields": ["x"] }, "action": { "type": "hash" } }]
516        }));
517        let out = apply_masking(vec![json!({"x": null})], &m);
518        assert_eq!(out.records[0]["x"], json!(null));
519        assert!(out.hits.is_empty());
520    }
521
522    #[test]
523    fn non_object_record_untouched() {
524        let m = compile(json!({
525            "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
526        }));
527        // top-level string that is an email — root path is empty so name rules
528        // don't fire, but value detector does.
529        let out = apply_masking(vec![json!("a@b.com")], &m);
530        assert_eq!(out.records[0], json!("***"));
531    }
532
533    #[test]
534    fn no_match_leaves_page_untouched() {
535        let m = compile(json!({
536            "rules": [{ "match": { "fields": ["absent"] }, "action": { "type": "redact" } }]
537        }));
538        let page = vec![json!({"a": 1, "b": "x"})];
539        let out = apply_masking(page.clone(), &m);
540        assert_eq!(out.records, page);
541        assert!(out.hits.is_empty());
542    }
543}