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;
21mod 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/// (redacted wholesale); value detectors and the hash/tokenize/partial actions
57/// 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            // Scalar-only actions: apply to a scalar leaf, otherwise fall
82            // through and recurse into the container's children.
83            action => {
84                if let Some(s) = scalar_to_string(node) {
85                    *node = Value::String(apply_scalar_action(action, &s, &m.hasher));
86                    hits.push(hit(rule, detector));
87                    return;
88                }
89            }
90        }
91    }
92
93    // 2. No terminal match — recurse into children.
94    match node {
95        Value::Object(map) => {
96            for (k, v) in map.iter_mut() {
97                let child = child_path(path, k);
98                mask_node(v, &child, m, hits);
99            }
100        }
101        Value::Array(items) => {
102            for (i, v) in items.iter_mut().enumerate() {
103                let child = child_path(path, &i.to_string());
104                mask_node(v, &child, m, hits);
105            }
106        }
107        _ => {}
108    }
109}
110
111/// Find the first rule that matches `node` at `path`. Returns the rule and, if
112/// the match was value-based, the detector that fired.
113fn first_match<'a>(
114    node: &Value,
115    path: &str,
116    m: &'a CompiledMasking,
117) -> Option<(&'a CompiledRule, Option<&'static str>)> {
118    // The scalar string form, computed once, for value-detector matching.
119    let scalar = scalar_to_string(node);
120    for rule in &m.rules {
121        // Name-based: field pattern over the dot-path, or explicit field list.
122        // The empty root path never matches a name rule.
123        if !path.is_empty() {
124            if let Some(re) = &rule.field_pattern
125                && re.is_match(path)
126            {
127                return Some((rule, None));
128            }
129            if rule.fields.contains(path) {
130                return Some((rule, None));
131            }
132        }
133        // Value-based: run the detector over the scalar string form.
134        if let (Some(det), Some(s)) = (rule.value_detector, scalar.as_deref())
135            && detect::detects(det, s)
136        {
137            return Some((rule, Some(detector_label(det))));
138        }
139    }
140    None
141}
142
143fn apply_scalar_action(action: &MaskAction, value: &str, hasher: &hash::Hasher) -> String {
144    match action {
145        MaskAction::Hash => hasher.digest_hex(value),
146        MaskAction::Tokenize { prefix } => hasher.token(value, prefix.as_deref().unwrap_or("")),
147        MaskAction::Partial {
148            keep_last,
149            mask_char,
150        } => partial(value, *keep_last, mask_char.unwrap_or('*')),
151        // Redact is handled at the node level (works on any type).
152        MaskAction::Redact { .. } => "***".to_string(),
153    }
154}
155
156/// Reveal only the last `keep_last` characters; replace the rest with
157/// `mask_char`. Counts by Unicode scalar so multibyte strings mask correctly.
158fn partial(value: &str, keep_last: usize, mask_char: char) -> String {
159    let chars: Vec<char> = value.chars().collect();
160    let n = chars.len();
161    // Reveal at most `keep_last` trailing chars, but never the whole value
162    // (a short secret with keep_last ≥ len would otherwise leak in the clear).
163    let revealed = if keep_last >= n { 0 } else { keep_last };
164    let masked_len = n - revealed;
165    let mut s = String::with_capacity(n);
166    for _ in 0..masked_len {
167        s.push(mask_char);
168    }
169    s.extend(&chars[masked_len..]);
170    s
171}
172
173/// String form of a scalar JSON value (`string` / `number` / `bool`); `None`
174/// for null, objects, and arrays (nothing to mask as a scalar).
175fn scalar_to_string(v: &Value) -> Option<String> {
176    match v {
177        Value::String(s) => Some(s.clone()),
178        Value::Number(n) => Some(n.to_string()),
179        Value::Bool(b) => Some(b.to_string()),
180        _ => None,
181    }
182}
183
184fn child_path(prefix: &str, key: &str) -> String {
185    if prefix.is_empty() {
186        key.to_string()
187    } else {
188        format!("{prefix}.{key}")
189    }
190}
191
192fn hit(rule: &CompiledRule, detector: Option<&'static str>) -> MaskHit {
193    MaskHit {
194        rule: rule.label.clone(),
195        action: rule.action_label,
196        detector,
197    }
198}
199
200fn detector_label(d: Detector) -> &'static str {
201    match d {
202        Detector::Email => "email",
203        Detector::CreditCard => "credit_card",
204        Detector::Ssn => "ssn",
205        Detector::Phone => "phone",
206        Detector::Ipv4 => "ipv4",
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use serde_json::json;
214
215    fn compile(v: Value) -> CompiledMasking {
216        let spec: MaskingSpec = serde_json::from_value(v).unwrap();
217        CompiledMasking::compile(&spec).unwrap()
218    }
219
220    #[test]
221    fn redact_replaces_named_field() {
222        let m = compile(json!({
223            "rules": [{ "match": { "field_pattern": "^email$" }, "action": { "type": "redact" } }]
224        }));
225        let out = apply_masking(vec![json!({"email": "a@b.com", "name": "Al"})], &m);
226        assert_eq!(out.records[0], json!({"email": "***", "name": "Al"}));
227        assert_eq!(out.hits.len(), 1);
228        assert_eq!(out.hits[0].action, "redact");
229        assert_eq!(out.hits[0].detector, None);
230    }
231
232    #[test]
233    fn redact_custom_mask_value() {
234        // Explicit null mask nulls the field (e.g. for a nullable DB column).
235        let m = compile(json!({
236            "rules": [{ "match": { "fields": ["ssn"] },
237                        "action": { "type": "redact", "mask": null } }]
238        }));
239        let out = apply_masking(vec![json!({"ssn": "123-45-6789"})], &m);
240        assert_eq!(out.records[0], json!({"ssn": null}));
241
242        // A custom scalar mask replaces the value with that scalar.
243        let m2 = compile(json!({
244            "rules": [{ "match": { "fields": ["x"] },
245                        "action": { "type": "redact", "mask": "REDACTED" } }]
246        }));
247        let out2 = apply_masking(vec![json!({"x": "secret"})], &m2);
248        assert_eq!(out2.records[0], json!({"x": "REDACTED"}));
249    }
250
251    #[test]
252    fn value_detector_matches_across_field_names() {
253        let m = compile(json!({
254            "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
255        }));
256        let out = apply_masking(
257            vec![json!({"contact": "a@b.com", "note": "hi", "n": 5})],
258            &m,
259        );
260        assert_eq!(
261            out.records[0],
262            json!({"contact": "***", "note": "hi", "n": 5})
263        );
264        assert_eq!(out.hits[0].detector, Some("email"));
265    }
266
267    #[test]
268    fn hash_is_deterministic_and_joinable() {
269        let m = compile(json!({
270            "key": "k",
271            "rules": [{ "match": { "fields": ["uid"] }, "action": { "type": "hash" } }]
272        }));
273        let out = apply_masking(
274            vec![
275                json!({"uid": "u1"}),
276                json!({"uid": "u1"}),
277                json!({"uid": "u2"}),
278            ],
279            &m,
280        );
281        let h1 = out.records[0]["uid"].as_str().unwrap();
282        assert_eq!(
283            h1,
284            out.records[1]["uid"].as_str().unwrap(),
285            "same input → same hash"
286        );
287        assert_ne!(h1, out.records[2]["uid"].as_str().unwrap());
288        assert_eq!(h1.len(), 64);
289    }
290
291    #[test]
292    fn tokenize_with_prefix() {
293        let m = compile(json!({
294            "key": "k",
295            "rules": [{ "match": { "fields": ["id"] },
296                        "action": { "type": "tokenize", "prefix": "tok_" } }]
297        }));
298        let out = apply_masking(vec![json!({"id": "abc"})], &m);
299        assert!(out.records[0]["id"].as_str().unwrap().starts_with("tok_"));
300    }
301
302    #[test]
303    fn partial_keeps_last_n() {
304        let m = compile(json!({
305            "rules": [{ "match": { "fields": ["card"] },
306                        "action": { "type": "partial", "keep_last": 4 } }]
307        }));
308        let out = apply_masking(vec![json!({"card": "4111111111111111"})], &m);
309        assert_eq!(out.records[0]["card"], json!("************1111"));
310    }
311
312    #[test]
313    fn partial_masks_all_when_keep_ge_len() {
314        let m = compile(json!({
315            "rules": [{ "match": { "fields": ["pin"] },
316                        "action": { "type": "partial", "keep_last": 10 } }]
317        }));
318        let out = apply_masking(vec![json!({"pin": "1234"})], &m);
319        assert_eq!(
320            out.records[0]["pin"],
321            json!("****"),
322            "never reveal a short value whole"
323        );
324    }
325
326    #[test]
327    fn hash_coerces_number_to_string() {
328        let m = compile(json!({
329            "rules": [{ "match": { "fields": ["n"] }, "action": { "type": "hash" } }]
330        }));
331        let out = apply_masking(vec![json!({"n": 42})], &m);
332        assert!(out.records[0]["n"].is_string());
333    }
334
335    #[test]
336    fn nested_object_and_array_paths_masked() {
337        let m = compile(json!({
338            "rules": [{ "match": { "field_pattern": r"contacts\.\d+\.email" },
339                        "action": { "type": "redact" } }]
340        }));
341        let out = apply_masking(
342            vec![json!({
343                "user": {"name": "Al"},
344                "contacts": [ {"email": "a@b.com"}, {"email": "c@d.com"} ]
345            })],
346            &m,
347        );
348        assert_eq!(out.records[0]["contacts"][0]["email"], json!("***"));
349        assert_eq!(out.records[0]["contacts"][1]["email"], json!("***"));
350        assert_eq!(out.records[0]["user"]["name"], json!("Al"));
351        assert_eq!(out.hits.len(), 2);
352    }
353
354    #[test]
355    fn redact_whole_subtree_by_name() {
356        let m = compile(json!({
357            "rules": [{ "match": { "fields": ["address"] }, "action": { "type": "redact" } }]
358        }));
359        let out = apply_masking(
360            vec![json!({"address": {"street": "1 Main", "zip": "94103"}, "id": 1})],
361            &m,
362        );
363        assert_eq!(out.records[0], json!({"address": "***", "id": 1}));
364    }
365
366    #[test]
367    fn first_matching_rule_wins() {
368        let m = compile(json!({
369            "rules": [
370                { "name": "first", "match": { "fields": ["x"] }, "action": { "type": "redact" } },
371                { "name": "second", "match": { "fields": ["x"] }, "action": { "type": "hash" } }
372            ]
373        }));
374        let out = apply_masking(vec![json!({"x": "v"})], &m);
375        assert_eq!(out.records[0]["x"], json!("***"));
376        assert_eq!(out.hits[0].rule, "first");
377    }
378
379    #[test]
380    fn scalar_action_on_container_falls_through_to_children() {
381        // A hash rule matching an object by name can't hash the object, so it
382        // recurses; the inner email detector then fires.
383        let m = compile(json!({
384            "rules": [
385                { "match": { "field_pattern": "^blob$" }, "action": { "type": "hash" } },
386                { "match": { "value_detector": "email" }, "action": { "type": "redact" } }
387            ]
388        }));
389        let out = apply_masking(vec![json!({"blob": {"e": "a@b.com"}})], &m);
390        assert_eq!(out.records[0]["blob"]["e"], json!("***"));
391    }
392
393    #[test]
394    fn null_is_not_masked() {
395        let m = compile(json!({
396            "rules": [{ "match": { "fields": ["x"] }, "action": { "type": "hash" } }]
397        }));
398        let out = apply_masking(vec![json!({"x": null})], &m);
399        assert_eq!(out.records[0]["x"], json!(null));
400        assert!(out.hits.is_empty());
401    }
402
403    #[test]
404    fn non_object_record_untouched() {
405        let m = compile(json!({
406            "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
407        }));
408        // top-level string that is an email — root path is empty so name rules
409        // don't fire, but value detector does.
410        let out = apply_masking(vec![json!("a@b.com")], &m);
411        assert_eq!(out.records[0], json!("***"));
412    }
413
414    #[test]
415    fn no_match_leaves_page_untouched() {
416        let m = compile(json!({
417            "rules": [{ "match": { "fields": ["absent"] }, "action": { "type": "redact" } }]
418        }));
419        let page = vec![json!({"a": 1, "b": "x"})];
420        let out = apply_masking(page.clone(), &m);
421        assert_eq!(out.records, page);
422        assert!(out.hits.is_empty());
423    }
424}