Skip to main content

faucet_core/masking/
config.rs

1//! Config-shaped types for the PII detection + column-masking layer (issue
2//! #206). Pure declarations — validation and compilation live in `compile.rs`,
3//! evaluation in the module root.
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// The `masking:` config block: a declarative, destination-scoped policy that
10/// classifies sensitive fields (by field-name pattern, by value detector, or
11/// by explicit field list) and applies a masking action per page. The masking
12/// pass runs *first* — before the quality, contract, and schema-drift passes
13/// and before every sink write — so PII never reaches a sink, the DLQ, or a
14/// lineage sample unmasked.
15///
16/// Masking never fails a run or quarantines records: every matching field is
17/// rewritten in place. Rules are evaluated in declared order; for a given
18/// field, the first matching rule wins.
19#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
20#[serde(deny_unknown_fields)]
21pub struct MaskingSpec {
22    /// Human-readable description of the policy (documentation metadata).
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub description: Option<String>,
25
26    /// Secret key for keyed hashing/tokenization (`hash` / `tokenize`
27    /// actions). When present, masked values are HMAC-SHA256 keyed digests, so
28    /// the mapping cannot be reversed by an attacker who lacks the key yet stays
29    /// deterministic (equal inputs → equal tokens) so masked values remain
30    /// joinable downstream. Pull it from a secrets manager
31    /// (`${vault:...}` / `${aws-sm:...}` / …) rather than hard-coding it.
32    ///
33    /// When absent, `hash` / `tokenize` fall back to an *unkeyed* SHA-256
34    /// digest — still deterministic, but not secret (anyone can recompute it).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub key: Option<String>,
37
38    /// The masking rules. Must be non-empty. Evaluated in declared order; the
39    /// first rule that matches a given field wins for that field.
40    pub rules: Vec<MaskRule>,
41}
42
43/// One masking rule: a matcher + the action to apply to matching fields,
44/// optionally scoped to a subset of destination sinks.
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46#[serde(deny_unknown_fields)]
47pub struct MaskRule {
48    /// Optional stable label for this rule — surfaced in logs and as the
49    /// `rule` metric label. Defaults to a generated `rule_<n>` when absent.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub name: Option<String>,
52
53    /// How this rule decides a field is sensitive. At least one of
54    /// `field_pattern` / `value_detector` / `fields` must be set.
55    #[serde(rename = "match")]
56    pub matcher: MatchSpec,
57
58    /// What to do with a matching field's value.
59    pub action: MaskAction,
60
61    /// Destination sinks this rule applies to, by template name (e.g.
62    /// `default`, `secure`) or connector kind (e.g. `bigquery`). Empty (the
63    /// default) means the rule applies to every sink — so the same source can
64    /// be fully masked to one sink and partially masked to another by scoping
65    /// rules per destination.
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub applies_to: Vec<String>,
68}
69
70/// How a rule classifies a field as sensitive. Any combination is allowed;
71/// a field matches the rule if *any* configured criterion matches (name
72/// pattern OR value detector OR explicit field name).
73#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct MatchSpec {
76    /// Regex matched against each field's dot-path (e.g. `user.email`,
77    /// `contacts.0.phone`). Case-sensitive unless the pattern opts in with
78    /// `(?i)`. Name-based classification — cheap and precise when you know
79    /// your field names.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub field_pattern: Option<String>,
82
83    /// Value-based PII detector run over each *string* field value (and
84    /// integer values, for `credit_card`). Conservative (fully anchored) by
85    /// default to avoid over-masking.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub value_detector: Option<Detector>,
88
89    /// Explicit dot-paths to mask unconditionally — the pre-classification /
90    /// tagging escape hatch.
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub fields: Vec<String>,
93}
94
95/// Built-in value detectors. All are conservative (anchored full-string
96/// matches; the card detector additionally requires a valid Luhn checksum) so
97/// false positives stay rare — masking silently rewrites data, so a
98/// false positive is a data-quality bug, not just noise.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100#[serde(rename_all = "snake_case")]
101pub enum Detector {
102    /// RFC-5322-ish email address.
103    Email,
104    /// 13–19 digit card number (spaces/dashes allowed, or a JSON integer)
105    /// passing the Luhn check.
106    CreditCard,
107    /// US Social Security Number `NNN-NN-NNNN`.
108    Ssn,
109    /// E.164 (`+` then 8–15 digits) or a North-American number written with
110    /// separators; a bare run of digits never matches.
111    Phone,
112    /// IPv4 dotted-quad address.
113    Ipv4,
114}
115
116impl std::fmt::Display for Detector {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.write_str(match self {
119            Detector::Email => "email",
120            Detector::CreditCard => "credit_card",
121            Detector::Ssn => "ssn",
122            Detector::Phone => "phone",
123            Detector::Ipv4 => "ipv4",
124        })
125    }
126}
127
128/// What to do with a value once a rule matches it.
129#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
130#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
131pub enum MaskAction {
132    /// Replace the value wholesale with a fixed mask (default `"***"`).
133    /// Irreversible and not joinable — use for values you never need again.
134    Redact {
135        /// Replacement value. Defaults to the JSON string `"***"`. Set it to an
136        /// explicit `null` to null the field (e.g. for a nullable DB column),
137        /// or to any other JSON scalar you prefer.
138        #[serde(default = "default_mask")]
139        mask: Value,
140    },
141    /// Replace the value with its hex HMAC-SHA256 (keyed) / SHA-256 (unkeyed)
142    /// digest. Deterministic → joinable; irreversible.
143    Hash,
144    /// Replace the value with a short opaque token derived from a keyed digest,
145    /// optionally with a stable prefix (e.g. `tok_`). Deterministic → joinable.
146    Tokenize {
147        /// Literal prefix prepended to every token (e.g. `"tok_"`).
148        #[serde(default, skip_serializing_if = "Option::is_none")]
149        prefix: Option<String>,
150    },
151    /// Reveal only the last `keep_last` characters, replacing the rest with
152    /// `mask_char` (default `*`). Preserves format/length for readability
153    /// (e.g. `****1234`). `keep_last: 0` masks everything.
154    Partial {
155        /// Number of trailing characters to keep in the clear. Default `4`.
156        #[serde(default = "default_keep_last")]
157        keep_last: usize,
158        /// Character used for the masked prefix. Default `*`.
159        #[serde(default, skip_serializing_if = "Option::is_none")]
160        mask_char: Option<char>,
161    },
162}
163
164fn default_keep_last() -> usize {
165    4
166}
167
168fn default_mask() -> Value {
169    Value::String("***".to_string())
170}
171
172impl MaskAction {
173    /// Stable action label (closed set — safe as a metric label).
174    pub fn label(&self) -> &'static str {
175        match self {
176            MaskAction::Redact { .. } => "redact",
177            MaskAction::Hash => "hash",
178            MaskAction::Tokenize { .. } => "tokenize",
179            MaskAction::Partial { .. } => "partial",
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use serde_json::json;
188
189    #[test]
190    fn parses_full_masking_block() {
191        let spec: MaskingSpec = serde_json::from_value(json!({
192            "description": "customer PII",
193            "key": "s3cr3t",
194            "rules": [
195                { "name": "emails",
196                  "match": { "value_detector": "email" },
197                  "action": { "type": "redact" } },
198                { "match": { "field_pattern": "(?i)ssn|social" },
199                  "action": { "type": "hash" },
200                  "applies_to": ["default"] },
201                { "match": { "fields": ["user.card"] },
202                  "action": { "type": "partial", "keep_last": 4 } },
203                { "match": { "field_pattern": "^token$" },
204                  "action": { "type": "tokenize", "prefix": "tok_" } }
205            ]
206        }))
207        .unwrap();
208        assert_eq!(spec.key.as_deref(), Some("s3cr3t"));
209        assert_eq!(spec.rules.len(), 4);
210        assert_eq!(spec.rules[0].name.as_deref(), Some("emails"));
211        assert_eq!(spec.rules[0].matcher.value_detector, Some(Detector::Email));
212        assert_eq!(spec.rules[1].applies_to, vec!["default".to_string()]);
213        assert!(matches!(
214            spec.rules[2].action,
215            MaskAction::Partial { keep_last: 4, .. }
216        ));
217    }
218
219    #[test]
220    fn partial_keep_last_defaults_to_four() {
221        let a: MaskAction = serde_json::from_value(json!({ "type": "partial" })).unwrap();
222        assert!(matches!(
223            a,
224            MaskAction::Partial {
225                keep_last: 4,
226                mask_char: None
227            }
228        ));
229    }
230
231    #[test]
232    fn action_labels_are_stable() {
233        assert_eq!(MaskAction::Hash.label(), "hash");
234        assert_eq!(
235            MaskAction::Redact {
236                mask: default_mask()
237            }
238            .label(),
239            "redact"
240        );
241        assert_eq!(MaskAction::Tokenize { prefix: None }.label(), "tokenize");
242        assert_eq!(
243            MaskAction::Partial {
244                keep_last: 2,
245                mask_char: None
246            }
247            .label(),
248            "partial"
249        );
250    }
251
252    #[test]
253    fn detector_display_is_snake_case() {
254        assert_eq!(Detector::CreditCard.to_string(), "credit_card");
255        let d: Detector = serde_json::from_str("\"ipv4\"").unwrap();
256        assert_eq!(d, Detector::Ipv4);
257    }
258
259    #[test]
260    fn rejects_unknown_keys() {
261        assert!(
262            serde_json::from_value::<MaskingSpec>(json!({
263                "rules": [], "nope": 1
264            }))
265            .is_err()
266        );
267        assert!(
268            serde_json::from_value::<MaskRule>(json!({
269                "match": {}, "action": {"type":"hash"}, "bogus": true
270            }))
271            .is_err()
272        );
273    }
274}