Skip to main content

faucet_core/masking/
compile.rs

1//! Compilation + fail-fast validation for the masking layer (issue #206).
2//! Turns a [`MaskingSpec`] into a [`CompiledMasking`] with pre-compiled
3//! regexes and a resolved `Hasher`, optionally scoped to a single
4//! destination sink. Bad regexes / empty rule sets surface as
5//! [`FaucetError::Config`] at config-load time, never mid-run.
6
7use super::config::{Detector, MaskAction, MaskRule, MaskingSpec, MatchSpec};
8use super::hash::Hasher;
9use crate::error::FaucetError;
10use regex::Regex;
11use std::collections::HashSet;
12
13/// Once per process: `phone` stopped matching bare digit runs in faucet-core
14/// 1.13, so a policy relying on it may now pass phone numbers through (#862 M-019).
15pub(crate) fn warn_phone_narrowed() {
16    static WARNED: std::sync::Once = std::sync::Once::new();
17    WARNED.call_once(|| {
18        tracing::warn!(
19            "masking/policy: `value_detector: phone` matches only E.164 (`+…`) and separated \
20             North-American numbers; bare digit runs and national formats such as \
21             `020 7946 0958` pass through. Use `phone_loose` for the broader matcher"
22        );
23    });
24}
25
26fn config_err(msg: impl Into<String>) -> FaucetError {
27    FaucetError::Config(format!("masking: {}", msg.into()))
28}
29
30/// A masking policy compiled and ready to apply to pages.
31#[derive(Debug, Clone)]
32pub struct CompiledMasking {
33    pub(crate) rules: Vec<CompiledRule>,
34    pub(crate) hasher: Hasher,
35    /// Some rule matches by field name, so the walk must build dot-paths.
36    pub(crate) needs_paths: bool,
37    /// A value-detector rule can match a number, so every number the pass
38    /// leaves unmasked is written as a string too: a column keeps one type on
39    /// every page instead of flipping when a matching value appears.
40    pub(crate) stringify_numbers: bool,
41}
42
43impl CompiledMasking {
44    /// Whether a name-based rule matches the top-level field `name`, so the
45    /// pass may rewrite (and re-type) every value of it.
46    #[cfg_attr(not(feature = "arrow"), allow(dead_code))]
47    pub(crate) fn rewrites_name(&self, name: &str) -> bool {
48        self.rules.iter().any(|r| {
49            r.fields.contains(name) || r.field_pattern.as_ref().is_some_and(|p| p.is_match(name))
50        })
51    }
52
53    /// Whether a value-detector rule can match a number (a card number held
54    /// as an integer), so the pass turns every number into a string and a
55    /// columnar run's Arrow schema no longer describes the masked records.
56    #[cfg_attr(not(feature = "arrow"), allow(dead_code))]
57    pub(crate) fn retypes_by_value(&self) -> bool {
58        self.stringify_numbers
59    }
60}
61
62/// One compiled rule: its matchers plus the action to apply.
63#[derive(Debug, Clone)]
64pub(crate) struct CompiledRule {
65    /// Stable label for logs + the `rule` metric dimension.
66    pub(crate) label: String,
67    /// Compiled field-name (dot-path) regex.
68    pub(crate) field_pattern: Option<Regex>,
69    /// Value-based detector.
70    pub(crate) value_detector: Option<Detector>,
71    /// Explicit dot-paths.
72    pub(crate) fields: HashSet<String>,
73    /// The action to apply to a matching field.
74    pub(crate) action: MaskAction,
75    /// Stable action label for the `action` metric dimension.
76    pub(crate) action_label: &'static str,
77}
78
79impl CompiledMasking {
80    /// Compile the full policy (every rule, ignoring `applies_to`). Use this
81    /// for library callers that don't scope by destination.
82    pub fn compile(spec: &MaskingSpec) -> Result<Self, FaucetError> {
83        Self::compile_scoped(spec, None)
84    }
85
86    /// Compile the policy scoped to one destination sink, identified by any of
87    /// `sink_ids` (typically its template name and its connector kind): rules
88    /// with a non-empty `applies_to` that names none of `sink_ids` are dropped.
89    /// Every rule is still validated (so a bad regex fails the load even if the
90    /// rule doesn't apply to this sink).
91    pub fn compile_for_sink(spec: &MaskingSpec, sink_ids: &[&str]) -> Result<Self, FaucetError> {
92        Self::compile_scoped(spec, Some(sink_ids))
93    }
94
95    fn compile_scoped(spec: &MaskingSpec, sink_ids: Option<&[&str]>) -> Result<Self, FaucetError> {
96        if spec.rules.is_empty() {
97            return Err(config_err("at least one rule is required"));
98        }
99        let mut rules = Vec::with_capacity(spec.rules.len());
100        for (i, rule) in spec.rules.iter().enumerate() {
101            let compiled = compile_rule(rule, i)?; // validate every rule
102            if applies(rule, sink_ids) {
103                rules.push(compiled);
104            }
105        }
106        if rules
107            .iter()
108            .any(|r| r.value_detector == Some(Detector::Phone))
109        {
110            warn_phone_narrowed();
111        }
112        let needs_paths = rules
113            .iter()
114            .any(|r| r.field_pattern.is_some() || !r.fields.is_empty());
115        let stringify_numbers = rules
116            .iter()
117            .any(|r| r.value_detector.is_some_and(super::detect::matches_numbers));
118        Ok(Self {
119            rules,
120            hasher: Hasher::from_key(spec.key.as_deref()),
121            needs_paths,
122            stringify_numbers,
123        })
124    }
125
126    /// `true` when no rule applies (e.g. every rule is scoped to other sinks).
127    /// The caller can skip attaching the pass entirely.
128    pub fn is_empty(&self) -> bool {
129        self.rules.is_empty()
130    }
131
132    /// Number of active (post-scoping) rules.
133    pub fn rule_count(&self) -> usize {
134        self.rules.len()
135    }
136
137    /// The action a name-based rule applies to the field at the dot-path
138    /// `column`, matched on the field itself or on any ancestor — the same
139    /// depth-first walk [`apply_masking`](super::apply_masking) performs, so a
140    /// container matched by name masks every leaf beneath it. A value-detector
141    /// rule declared before the name rule could fire on the leaf first with a
142    /// different action, so the action is then unknown (`None`).
143    pub fn name_action_for(&self, column: &str) -> Option<&'static str> {
144        let mut end = 0;
145        while end < column.len() {
146            end = column[end..].find('.').map_or(column.len(), |i| end + i);
147            let prefix = &column[..end];
148            let leaf = end == column.len();
149            for r in &self.rules {
150                if r.fields.contains(prefix)
151                    || r.field_pattern
152                        .as_ref()
153                        .is_some_and(|re| re.is_match(prefix))
154                {
155                    return Some(r.action_label);
156                }
157                if leaf && r.value_detector.is_some() {
158                    return None;
159                }
160            }
161            end += 1;
162        }
163        None
164    }
165}
166
167/// Whether `rule` applies to a sink identified by any of `sink_ids` (unscoped
168/// rules — empty `applies_to` — apply everywhere).
169fn applies(rule: &MaskRule, sink_ids: Option<&[&str]>) -> bool {
170    match sink_ids {
171        None => true,
172        Some(ids) => {
173            rule.applies_to.is_empty() || rule.applies_to.iter().any(|t| ids.contains(&t.as_str()))
174        }
175    }
176}
177
178fn compile_rule(rule: &MaskRule, index: usize) -> Result<CompiledRule, FaucetError> {
179    let MatchSpec {
180        field_pattern,
181        value_detector,
182        fields,
183    } = &rule.matcher;
184
185    if field_pattern.is_none() && value_detector.is_none() && fields.is_empty() {
186        return Err(config_err(format!(
187            "rule {index}: `match` must set at least one of field_pattern / value_detector / fields"
188        )));
189    }
190
191    let field_pattern = match field_pattern {
192        Some(p) => Some(Regex::new(p).map_err(|e| {
193            config_err(format!(
194                "rule {index}: invalid field_pattern regex '{p}': {e}"
195            ))
196        })?),
197        None => None,
198    };
199
200    // Validate the partial action's config.
201    if let MaskAction::Tokenize { prefix: Some(p) } = &rule.action
202        && p.is_empty()
203    {
204        return Err(config_err(format!(
205            "rule {index}: tokenize prefix, when set, must be non-empty (omit it for no prefix)"
206        )));
207    }
208
209    let label = rule.name.clone().unwrap_or_else(|| format!("rule_{index}"));
210
211    Ok(CompiledRule {
212        label,
213        field_pattern,
214        value_detector: *value_detector,
215        fields: fields.iter().cloned().collect(),
216        action: rule.action.clone(),
217        action_label: rule.action.label(),
218    })
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use serde_json::json;
225
226    #[test]
227    fn retypes_by_value_only_for_number_matching_detectors() {
228        let with = |det: &str| {
229            let s = spec(json!({"rules": [
230                {"match": {"fields": ["id"]}, "action": {"type": "hash"}},
231                {"match": {"value_detector": det}, "action": {"type": "hash"}}
232            ]}));
233            CompiledMasking::compile(&s).unwrap().retypes_by_value()
234        };
235        assert!(with("credit_card"));
236        assert!(with("phone_loose"));
237        for det in ["email", "ssn", "phone", "ipv4"] {
238            assert!(!with(det), "{det}");
239        }
240        let name_only = spec(json!({"rules": [
241            {"match": {"field_pattern": "card"}, "action": {"type": "hash"}}
242        ]}));
243        assert!(
244            !CompiledMasking::compile(&name_only)
245                .unwrap()
246                .retypes_by_value()
247        );
248    }
249
250    #[test]
251    fn a_phone_rule_compiles_and_warns_once() {
252        let s = spec(
253            json!({"rules": [{"match": {"value_detector": "phone"}, "action": {"type": "hash"}}]}),
254        );
255        assert_eq!(CompiledMasking::compile(&s).unwrap().rule_count(), 1);
256        assert_eq!(CompiledMasking::compile(&s).unwrap().rule_count(), 1);
257    }
258
259    fn spec(v: serde_json::Value) -> MaskingSpec {
260        serde_json::from_value(v).unwrap()
261    }
262
263    #[test]
264    fn compiles_valid_policy() {
265        let c = CompiledMasking::compile(&spec(json!({
266            "rules": [
267                { "match": { "field_pattern": "email" }, "action": { "type": "redact" } },
268                { "match": { "value_detector": "ssn" }, "action": { "type": "hash" } }
269            ]
270        })))
271        .unwrap();
272        assert_eq!(c.rule_count(), 2);
273        assert!(!c.is_empty());
274        assert_eq!(c.rules[0].label, "rule_0");
275        assert_eq!(c.rules[0].action_label, "redact");
276    }
277
278    #[test]
279    fn empty_rules_rejected() {
280        let err = CompiledMasking::compile(&spec(json!({ "rules": [] }))).unwrap_err();
281        assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one rule")));
282    }
283
284    #[test]
285    fn empty_match_rejected() {
286        let err = CompiledMasking::compile(&spec(json!({
287            "rules": [{ "match": {}, "action": { "type": "redact" } }]
288        })))
289        .unwrap_err();
290        assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one of")));
291    }
292
293    #[test]
294    fn bad_regex_rejected() {
295        let err = CompiledMasking::compile(&spec(json!({
296            "rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" } }]
297        })))
298        .unwrap_err();
299        assert!(matches!(err, FaucetError::Config(m) if m.contains("invalid field_pattern")));
300    }
301
302    #[test]
303    fn empty_tokenize_prefix_rejected() {
304        let err = CompiledMasking::compile(&spec(json!({
305            "rules": [{ "match": { "fields": ["t"] }, "action": { "type": "tokenize", "prefix": "" } }]
306        })))
307        .unwrap_err();
308        assert!(matches!(err, FaucetError::Config(m) if m.contains("tokenize prefix")));
309    }
310
311    /// #862 L-043: a detector rule declared before a name rule can mask the
312    /// leaf first with its own action, so the leaf's action is unknown; an
313    /// ancestor matched by name still decides, as detectors never fire on a
314    /// container.
315    #[test]
316    fn name_action_for_is_unknown_behind_an_earlier_detector_rule() {
317        let c = CompiledMasking::compile(&spec(json!({
318            "rules": [
319                { "match": { "value_detector": "email" }, "action": { "type": "redact" } },
320                { "match": { "fields": ["contact"] }, "action": { "type": "hash" } },
321                { "match": { "fields": ["mail"] }, "action": { "type": "hash" } }
322            ]
323        })))
324        .unwrap();
325        assert_eq!(c.name_action_for("mail"), None);
326        assert_eq!(c.name_action_for("contact.mail"), Some("hash"));
327    }
328
329    #[test]
330    fn name_action_for_matches_the_field_or_an_ancestor() {
331        let c = CompiledMasking::compile_for_sink(
332            &spec(json!({
333                "rules": [
334                    { "match": { "fields": ["phones"] }, "action": { "type": "hash" } },
335                    { "match": { "field_pattern": "^card$" }, "action": { "type": "partial" } },
336                    { "match": { "fields": ["user.ssn"] }, "action": { "type": "tokenize" } },
337                    { "match": { "fields": ["other"] }, "action": { "type": "redact" },
338                      "applies_to": ["elsewhere"] },
339                    { "match": { "value_detector": "email" }, "action": { "type": "redact" } }
340                ]
341            })),
342            &["here"],
343        )
344        .unwrap();
345        assert_eq!(c.name_action_for("phones"), Some("hash"));
346        assert_eq!(c.name_action_for("phones.0"), Some("hash"));
347        assert_eq!(c.name_action_for("card.number"), Some("partial"));
348        assert_eq!(c.name_action_for("card"), Some("partial"));
349        assert_eq!(c.name_action_for("user.ssn"), Some("tokenize"));
350        assert_eq!(
351            c.name_action_for("ssn"),
352            None,
353            "fields match the full path only"
354        );
355        assert_eq!(c.name_action_for("user"), None);
356        assert_eq!(c.name_action_for("cardholder"), None);
357        assert_eq!(c.name_action_for("other"), None, "scoped to another sink");
358        assert_eq!(
359            c.name_action_for("email"),
360            None,
361            "detectors are value-based"
362        );
363        assert_eq!(c.name_action_for(""), None);
364    }
365
366    #[test]
367    fn custom_rule_name_used_as_label() {
368        let c = CompiledMasking::compile(&spec(json!({
369            "rules": [{ "name": "cards", "match": { "value_detector": "credit_card" },
370                        "action": { "type": "partial" } }]
371        })))
372        .unwrap();
373        assert_eq!(c.rules[0].label, "cards");
374    }
375
376    #[test]
377    fn sink_scoping_filters_rules_but_validates_all() {
378        let s = spec(json!({
379            "rules": [
380                { "match": { "fields": ["a"] }, "action": { "type": "redact" }, "applies_to": ["secure"] },
381                { "match": { "fields": ["b"] }, "action": { "type": "redact" }, "applies_to": ["default"] },
382                { "match": { "fields": ["c"] }, "action": { "type": "redact" } }
383            ]
384        }));
385        let def = CompiledMasking::compile_for_sink(&s, &["default"]).unwrap();
386        // rule b (default) + rule c (unscoped) apply; rule a (secure) does not.
387        assert_eq!(def.rule_count(), 2);
388        let sec = CompiledMasking::compile_for_sink(&s, &["secure"]).unwrap();
389        assert_eq!(sec.rule_count(), 2); // rule a + rule c
390        let other = CompiledMasking::compile_for_sink(&s, &["elsewhere"]).unwrap();
391        assert_eq!(other.rule_count(), 1); // only unscoped rule c
392        assert!(!other.is_empty());
393        // Multiple identifiers (template name + kind): matches on either.
394        let by_kind = CompiledMasking::compile_for_sink(&s, &["postgres", "secure"]).unwrap();
395        assert_eq!(by_kind.rule_count(), 2); // rule a (secure) + rule c
396    }
397
398    #[test]
399    fn sink_scoping_still_validates_inapplicable_rule_regex() {
400        // The bad-regex rule is scoped to "secure" but must still fail when
401        // compiling for "default".
402        let s = spec(json!({
403            "rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" },
404                        "applies_to": ["secure"] }]
405        }));
406        assert!(CompiledMasking::compile_for_sink(&s, &["default"]).is_err());
407    }
408}