Skip to main content

adk_payments/guardrail/
redaction.rs

1use std::collections::HashMap;
2
3use adk_core::{Content, Part};
4use adk_guardrail::{Guardrail, GuardrailResult, PiiRedactor, PiiType};
5use async_trait::async_trait;
6use regex::{Captures, Regex};
7use serde_json::{Map, Value};
8use sha2::{Digest, Sha256};
9
10/// Content redactor for card data, billing PII, and signed payment artifacts.
11pub struct SensitivePaymentDataGuardrail {
12    pii_redactor: PiiRedactor,
13    card_number_regex: Regex,
14    cvc_regex: Regex,
15    expiry_regex: Regex,
16    billing_address_regex: Regex,
17    keyed_secret_regex: Regex,
18}
19
20impl SensitivePaymentDataGuardrail {
21    /// Creates a new payment-data redactor.
22    #[must_use]
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Redacts sensitive payment material from plain text.
28    #[must_use]
29    pub fn redact_text(&self, text: &str) -> String {
30        let keyed_secrets = self
31            .keyed_secret_regex
32            .replace_all(text, |captures: &Captures<'_>| {
33                let key = captures.name("key").map_or("secret", |value| value.as_str());
34                let value = captures.name("value").map_or("", |value| value.as_str());
35                format!("{key}: {}", digest_marker(value))
36            })
37            .to_string();
38        let cvc_redacted = self
39            .cvc_regex
40            .replace_all(&keyed_secrets, |captures: &Captures<'_>| {
41                let key = captures.name("key").map_or("cvc", |value| value.as_str());
42                format!("{key}: [CVC REDACTED]")
43            })
44            .to_string();
45        let expiry_redacted = self
46            .expiry_regex
47            .replace_all(&cvc_redacted, |captures: &Captures<'_>| {
48                let key = captures.name("key").map_or("expiry", |value| value.as_str());
49                format!("{key}: [EXPIRY REDACTED]")
50            })
51            .to_string();
52        let billing_redacted = self
53            .billing_address_regex
54            .replace_all(&expiry_redacted, "billing address: [BILLING DETAILS REDACTED]")
55            .to_string();
56        let card_masked = self
57            .card_number_regex
58            .replace_all(&billing_redacted, |captures: &Captures<'_>| {
59                captures.get(0).map_or_else(
60                    || "[CARD REDACTED]".to_string(),
61                    |value| mask_card_number(value.as_str()),
62                )
63            })
64            .to_string();
65        let (pii_redacted, _) = self.pii_redactor.redact(&card_masked);
66        pii_redacted
67    }
68
69    /// Redacts sensitive payment material from ADK content parts.
70    #[must_use]
71    pub fn redact_content(&self, content: &Content) -> Content {
72        self.redact_content_internal(content).0
73    }
74
75    /// Redacts sensitive payment material from JSON payloads.
76    #[must_use]
77    pub fn redact_json(&self, value: &Value) -> Value {
78        self.redact_json_internal(None, value)
79    }
80
81    /// Redacts sensitive payment material from telemetry span fields.
82    #[must_use]
83    pub fn redact_telemetry_fields(
84        &self,
85        fields: &HashMap<String, String>,
86    ) -> HashMap<String, String> {
87        fields
88            .iter()
89            .map(|(key, value)| {
90                let redacted =
91                    self.redact_json_internal(Some(key.as_str()), &Value::String(value.clone()));
92                let value = match redacted {
93                    Value::String(value) => value,
94                    other => other.to_string(),
95                };
96                (key.clone(), value)
97            })
98            .collect()
99    }
100
101    fn redact_content_internal(&self, content: &Content) -> (Content, bool) {
102        let mut changed = false;
103        let mut new_parts = Vec::with_capacity(content.parts.len());
104
105        for part in &content.parts {
106            match part {
107                Part::Text { text } => {
108                    let redacted = self.redact_text(text);
109                    if redacted != *text {
110                        changed = true;
111                    }
112                    new_parts.push(Part::Text { text: redacted });
113                }
114                Part::Thinking { thinking, signature } => {
115                    let redacted = self.redact_text(thinking);
116                    if redacted != *thinking {
117                        changed = true;
118                    }
119                    new_parts
120                        .push(Part::Thinking { thinking: redacted, signature: signature.clone() });
121                }
122                Part::FunctionCall { name, args, id, thought_signature } => {
123                    let redacted_args = self.redact_json(args);
124                    if redacted_args != *args {
125                        changed = true;
126                    }
127                    new_parts.push(Part::FunctionCall {
128                        name: name.clone(),
129                        args: redacted_args,
130                        id: id.clone(),
131                        thought_signature: thought_signature.clone(),
132                    });
133                }
134                Part::FunctionResponse { function_response, id, annotations } => {
135                    let redacted_response = self.redact_json(&function_response.response);
136                    if redacted_response != function_response.response {
137                        changed = true;
138                    }
139                    new_parts.push(Part::FunctionResponse {
140                        function_response: adk_core::FunctionResponseData::new(
141                            function_response.name.clone(),
142                            redacted_response,
143                        ),
144                        id: id.clone(),
145                        annotations: annotations.clone(),
146                    });
147                }
148                _ => new_parts.push(part.clone()),
149            }
150        }
151
152        (Content { role: content.role.clone(), parts: new_parts }, changed)
153    }
154
155    fn redact_json_internal(&self, key: Option<&str>, value: &Value) -> Value {
156        if let Some(key) = key {
157            let normalized = normalize_key(key);
158            if is_card_key(&normalized) {
159                return redact_card_value(value);
160            }
161            if is_cvc_key(&normalized) {
162                return Value::String("[CVC REDACTED]".to_string());
163            }
164            if is_expiry_key(&normalized) {
165                return Value::String("[EXPIRY REDACTED]".to_string());
166            }
167            if is_secret_key(&normalized) {
168                return Value::String(digest_marker(&canonical_value(value)));
169            }
170            if is_email_key(&normalized) || is_phone_key(&normalized) {
171                return match value {
172                    Value::String(text) => Value::String(self.redact_text(text)),
173                    _ => Value::String("[PII REDACTED]".to_string()),
174                };
175            }
176            if is_billing_key(&normalized) {
177                return minimize_billing_value(value);
178            }
179        }
180
181        match value {
182            Value::Object(object) => Value::Object(
183                object
184                    .iter()
185                    .map(|(child_key, child_value)| {
186                        (child_key.clone(), self.redact_json_internal(Some(child_key), child_value))
187                    })
188                    .collect::<Map<String, Value>>(),
189            ),
190            Value::Array(values) => Value::Array(
191                values.iter().map(|child| self.redact_json_internal(None, child)).collect(),
192            ),
193            Value::String(text) => Value::String(self.redact_text(text)),
194            _ => value.clone(),
195        }
196    }
197}
198
199impl Default for SensitivePaymentDataGuardrail {
200    fn default() -> Self {
201        Self {
202            pii_redactor: PiiRedactor::with_types(&[PiiType::Email, PiiType::Phone, PiiType::Ssn]),
203            card_number_regex: Regex::new(r"\b(?:\d[ -]?){13,19}\b").unwrap(),
204            cvc_regex: Regex::new(
205                r"(?i)\b(?P<key>cvv|cvc|cid|security[_ -]?code)\b\s*[:=]\s*(?P<value>\d{3,4})",
206            )
207            .unwrap(),
208            expiry_regex: Regex::new(
209                r"(?i)\b(?P<key>exp(?:iry|iration)?(?:[_ -]?date)?)\b\s*[:=]\s*(?P<value>(?:0[1-9]|1[0-2])[/-]\d{2,4})",
210            )
211            .unwrap(),
212            billing_address_regex: Regex::new(
213                r"(?i)\bbilling(?:[_ ]address)?\b\s*[:=]\s*(?P<value>[^\n;]+)",
214            )
215            .unwrap(),
216            keyed_secret_regex: Regex::new(
217                r"(?i)\b(?P<key>signed_?authorization|authorization(?:_blob)?|merchant_?signature|buyer_?signature|signature|signed_?mandate|cryptogram|payment_?token|delegated_?credential|continuation_?token|nonce|jwt|jws)\b\s*[:=]\s*(?P<value>[A-Za-z0-9._:+/=-]{8,})",
218            )
219            .unwrap(),
220        }
221    }
222}
223
224#[async_trait]
225impl Guardrail for SensitivePaymentDataGuardrail {
226    fn name(&self) -> &str {
227        "sensitive_payment_data"
228    }
229
230    async fn validate(&self, content: &Content) -> GuardrailResult {
231        let (new_content, changed) = self.redact_content_internal(content);
232        if changed {
233            GuardrailResult::transform(
234                new_content,
235                "redacted payment card, billing, or signed authorization material",
236            )
237        } else {
238            GuardrailResult::pass()
239        }
240    }
241
242    fn run_parallel(&self) -> bool {
243        false
244    }
245}
246
247/// Redacts sensitive payment material from plain text.
248#[must_use]
249pub fn redact_payment_text(text: &str) -> String {
250    SensitivePaymentDataGuardrail::new().redact_text(text)
251}
252
253/// Redacts sensitive payment material from ADK content.
254#[must_use]
255pub fn redact_payment_content(content: &Content) -> Content {
256    SensitivePaymentDataGuardrail::new().redact_content(content)
257}
258
259/// Redacts sensitive payment material from JSON payloads.
260#[must_use]
261pub fn redact_payment_value(value: &Value) -> Value {
262    SensitivePaymentDataGuardrail::new().redact_json(value)
263}
264
265/// Redacts sensitive payment material from tool JSON outputs.
266#[must_use]
267pub fn redact_tool_output(value: &Value) -> Value {
268    redact_payment_value(value)
269}
270
271/// Redacts sensitive payment material from telemetry span fields.
272#[must_use]
273pub fn redact_telemetry_fields(fields: &HashMap<String, String>) -> HashMap<String, String> {
274    SensitivePaymentDataGuardrail::new().redact_telemetry_fields(fields)
275}
276
277fn redact_card_value(value: &Value) -> Value {
278    match value {
279        Value::String(text) => Value::String(mask_card_number(text)),
280        _ => Value::String("[CARD REDACTED]".to_string()),
281    }
282}
283
284fn minimize_billing_value(value: &Value) -> Value {
285    match value {
286        Value::Object(object) => {
287            let mut minimized = Map::new();
288
289            if let Some(country) = object
290                .get("country")
291                .or_else(|| object.get("countryCode"))
292                .or_else(|| object.get("country_code"))
293                .and_then(Value::as_str)
294            {
295                minimized.insert("country".to_string(), Value::String(country.to_string()));
296            }
297
298            if let Some(postal_code) = object
299                .get("postalCode")
300                .or_else(|| object.get("postal_code"))
301                .or_else(|| object.get("zip"))
302                .or_else(|| object.get("zipCode"))
303                .and_then(Value::as_str)
304            {
305                minimized.insert(
306                    "postalCodeMasked".to_string(),
307                    Value::String(mask_postal_code(postal_code)),
308                );
309            }
310
311            if minimized.is_empty() {
312                Value::String("[BILLING DETAILS REDACTED]".to_string())
313            } else {
314                Value::Object(minimized)
315            }
316        }
317        _ => Value::String("[BILLING DETAILS REDACTED]".to_string()),
318    }
319}
320
321fn is_card_key(key: &str) -> bool {
322    key == "pan"
323        || key.contains("cardnumber")
324        || key.contains("primaryaccountnumber")
325        || key.contains("paymentcard")
326}
327
328fn is_cvc_key(key: &str) -> bool {
329    key == "cvv" || key == "cvc" || key == "cid" || key.contains("securitycode")
330}
331
332fn is_expiry_key(key: &str) -> bool {
333    key == "exp" || key.contains("expiry") || key.contains("expiration") || key.contains("expdate")
334}
335
336fn is_secret_key(key: &str) -> bool {
337    key.contains("signature")
338        || key.contains("signedauthorization")
339        || key.contains("authorizationblob")
340        || key.contains("signedmandate")
341        || key.contains("cryptogram")
342        || key.contains("token")
343        || key.contains("nonce")
344        || key.contains("delegatedcredential")
345        || key == "jwt"
346        || key == "jws"
347}
348
349fn is_email_key(key: &str) -> bool {
350    key == "email" || key.ends_with("email")
351}
352
353fn is_phone_key(key: &str) -> bool {
354    key == "phone" || key.ends_with("phone") || key.ends_with("phonenumber")
355}
356
357fn is_billing_key(key: &str) -> bool {
358    key == "billing"
359        || key == "billingdetails"
360        || key.ends_with("billingaddress")
361        || key.starts_with("billingaddress")
362}
363
364fn normalize_key(key: &str) -> String {
365    key.chars()
366        .filter(|char| char.is_ascii_alphanumeric())
367        .map(|char| char.to_ascii_lowercase())
368        .collect()
369}
370
371fn canonical_value(value: &Value) -> String {
372    match value {
373        Value::String(text) => text.clone(),
374        _ => serde_json::to_string(value).unwrap_or_else(|_| "<unserializable>".to_string()),
375    }
376}
377
378fn digest_marker(value: &str) -> String {
379    format!("[REDACTED sha256:{}]", &hex::encode(Sha256::digest(value.as_bytes()))[..16])
380}
381
382fn mask_card_number(text: &str) -> String {
383    let digits: String = text.chars().filter(|char| char.is_ascii_digit()).collect();
384    if digits.len() < 4 {
385        "[CARD REDACTED]".to_string()
386    } else {
387        let last4 = &digits[digits.len() - 4..];
388        format!("[CARD ****{last4}]")
389    }
390}
391
392fn mask_postal_code(postal_code: &str) -> String {
393    let mut chars = postal_code.chars();
394    let prefix: String = chars.by_ref().take(2).collect();
395    if prefix.is_empty() { "***".to_string() } else { format!("{prefix}***") }
396}
397
398#[cfg(test)]
399mod tests {
400    use adk_core::FunctionResponseData;
401    use serde_json::json;
402
403    use super::*;
404
405    #[test]
406    fn redacts_text_card_signature_and_pii() {
407        let redactor = SensitivePaymentDataGuardrail::new();
408        let redacted = redactor.redact_text(
409            "card 4111-1111-1111-1111 billing address: 123 Main St; email payer@example.com signature=signed_blob",
410        );
411
412        assert!(!redacted.contains("4111-1111-1111-1111"));
413        assert!(!redacted.contains("payer@example.com"));
414        assert!(!redacted.contains("signed_blob"));
415        assert!(redacted.contains("[CARD ****1111]"));
416        assert!(redacted.contains("[EMAIL REDACTED]"));
417        assert!(redacted.contains("[REDACTED sha256:"));
418    }
419
420    #[test]
421    fn redacts_tool_output_and_minimizes_billing_details() {
422        let redacted = redact_tool_output(&json!({
423            "cardNumber": "4111111111111111",
424            "cvv": "123",
425            "billingAddress": {
426                "line1": "123 Main St",
427                "city": "San Francisco",
428                "country": "US",
429                "postalCode": "94105"
430            },
431            "signedAuthorization": "signed_blob",
432            "receiptEmail": "payer@example.com"
433        }));
434
435        assert_eq!(redacted["cardNumber"], "[CARD ****1111]");
436        assert_eq!(redacted["cvv"], "[CVC REDACTED]");
437        assert_eq!(redacted["billingAddress"]["country"], "US");
438        assert_eq!(redacted["billingAddress"]["postalCodeMasked"], "94***");
439        assert!(redacted["signedAuthorization"].as_str().unwrap().starts_with("[REDACTED sha256:"));
440        assert_eq!(redacted["receiptEmail"], "[EMAIL REDACTED]");
441    }
442
443    #[test]
444    fn redacts_telemetry_fields() {
445        let mut fields = HashMap::new();
446        fields.insert("payment.pan".to_string(), "4111111111111111".to_string());
447        fields.insert("payment.signature".to_string(), "signed_blob".to_string());
448        fields.insert("billing.email".to_string(), "payer@example.com".to_string());
449
450        let redacted = redact_telemetry_fields(&fields);
451
452        assert_eq!(redacted["payment.pan"], "[CARD ****1111]");
453        assert!(redacted["payment.signature"].starts_with("[REDACTED sha256:"));
454        assert_eq!(redacted["billing.email"], "[EMAIL REDACTED]");
455    }
456
457    #[tokio::test]
458    async fn guardrail_transforms_text_and_function_responses() {
459        let guardrail = SensitivePaymentDataGuardrail::new();
460        let content = Content {
461            role: "tool".to_string(),
462            parts: vec![
463                Part::Text { text: "card 4111 1111 1111 1111".to_string() },
464                Part::FunctionResponse {
465                    function_response: FunctionResponseData::new(
466                        "checkout",
467                        json!({
468                            "signedAuthorization": "signed_blob",
469                            "billingAddress": {
470                                "country": "US",
471                                "postalCode": "10001",
472                                "line1": "123 Main St"
473                            }
474                        }),
475                    ),
476                    id: None,
477                    annotations: None,
478                },
479            ],
480        };
481
482        match guardrail.validate(&content).await {
483            GuardrailResult::Transform { new_content, .. } => {
484                let text = new_content.parts[0].text().unwrap();
485                assert!(text.contains("[CARD ****1111]"));
486
487                let Part::FunctionResponse { function_response, .. } = &new_content.parts[1] else {
488                    panic!("expected function response");
489                };
490                assert!(
491                    function_response.response["signedAuthorization"]
492                        .as_str()
493                        .unwrap()
494                        .starts_with("[REDACTED sha256:")
495                );
496                assert_eq!(
497                    function_response.response["billingAddress"]["postalCodeMasked"],
498                    "10***"
499                );
500            }
501            other => panic!("expected transform result, got {other:?}"),
502        }
503    }
504}