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 } => {
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                    });
146                }
147                _ => new_parts.push(part.clone()),
148            }
149        }
150
151        (Content { role: content.role.clone(), parts: new_parts }, changed)
152    }
153
154    fn redact_json_internal(&self, key: Option<&str>, value: &Value) -> Value {
155        if let Some(key) = key {
156            let normalized = normalize_key(key);
157            if is_card_key(&normalized) {
158                return redact_card_value(value);
159            }
160            if is_cvc_key(&normalized) {
161                return Value::String("[CVC REDACTED]".to_string());
162            }
163            if is_expiry_key(&normalized) {
164                return Value::String("[EXPIRY REDACTED]".to_string());
165            }
166            if is_secret_key(&normalized) {
167                return Value::String(digest_marker(&canonical_value(value)));
168            }
169            if is_email_key(&normalized) || is_phone_key(&normalized) {
170                return match value {
171                    Value::String(text) => Value::String(self.redact_text(text)),
172                    _ => Value::String("[PII REDACTED]".to_string()),
173                };
174            }
175            if is_billing_key(&normalized) {
176                return minimize_billing_value(value);
177            }
178        }
179
180        match value {
181            Value::Object(object) => Value::Object(
182                object
183                    .iter()
184                    .map(|(child_key, child_value)| {
185                        (child_key.clone(), self.redact_json_internal(Some(child_key), child_value))
186                    })
187                    .collect::<Map<String, Value>>(),
188            ),
189            Value::Array(values) => Value::Array(
190                values.iter().map(|child| self.redact_json_internal(None, child)).collect(),
191            ),
192            Value::String(text) => Value::String(self.redact_text(text)),
193            _ => value.clone(),
194        }
195    }
196}
197
198impl Default for SensitivePaymentDataGuardrail {
199    fn default() -> Self {
200        Self {
201            pii_redactor: PiiRedactor::with_types(&[PiiType::Email, PiiType::Phone, PiiType::Ssn]),
202            card_number_regex: Regex::new(r"\b(?:\d[ -]?){13,19}\b").unwrap(),
203            cvc_regex: Regex::new(
204                r"(?i)\b(?P<key>cvv|cvc|cid|security[_ -]?code)\b\s*[:=]\s*(?P<value>\d{3,4})",
205            )
206            .unwrap(),
207            expiry_regex: Regex::new(
208                r"(?i)\b(?P<key>exp(?:iry|iration)?(?:[_ -]?date)?)\b\s*[:=]\s*(?P<value>(?:0[1-9]|1[0-2])[/-]\d{2,4})",
209            )
210            .unwrap(),
211            billing_address_regex: Regex::new(
212                r"(?i)\bbilling(?:[_ ]address)?\b\s*[:=]\s*(?P<value>[^\n;]+)",
213            )
214            .unwrap(),
215            keyed_secret_regex: Regex::new(
216                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,})",
217            )
218            .unwrap(),
219        }
220    }
221}
222
223#[async_trait]
224impl Guardrail for SensitivePaymentDataGuardrail {
225    fn name(&self) -> &str {
226        "sensitive_payment_data"
227    }
228
229    async fn validate(&self, content: &Content) -> GuardrailResult {
230        let (new_content, changed) = self.redact_content_internal(content);
231        if changed {
232            GuardrailResult::transform(
233                new_content,
234                "redacted payment card, billing, or signed authorization material",
235            )
236        } else {
237            GuardrailResult::pass()
238        }
239    }
240
241    fn run_parallel(&self) -> bool {
242        false
243    }
244}
245
246/// Redacts sensitive payment material from plain text.
247#[must_use]
248pub fn redact_payment_text(text: &str) -> String {
249    SensitivePaymentDataGuardrail::new().redact_text(text)
250}
251
252/// Redacts sensitive payment material from ADK content.
253#[must_use]
254pub fn redact_payment_content(content: &Content) -> Content {
255    SensitivePaymentDataGuardrail::new().redact_content(content)
256}
257
258/// Redacts sensitive payment material from JSON payloads.
259#[must_use]
260pub fn redact_payment_value(value: &Value) -> Value {
261    SensitivePaymentDataGuardrail::new().redact_json(value)
262}
263
264/// Redacts sensitive payment material from tool JSON outputs.
265#[must_use]
266pub fn redact_tool_output(value: &Value) -> Value {
267    redact_payment_value(value)
268}
269
270/// Redacts sensitive payment material from telemetry span fields.
271#[must_use]
272pub fn redact_telemetry_fields(fields: &HashMap<String, String>) -> HashMap<String, String> {
273    SensitivePaymentDataGuardrail::new().redact_telemetry_fields(fields)
274}
275
276fn redact_card_value(value: &Value) -> Value {
277    match value {
278        Value::String(text) => Value::String(mask_card_number(text)),
279        _ => Value::String("[CARD REDACTED]".to_string()),
280    }
281}
282
283fn minimize_billing_value(value: &Value) -> Value {
284    match value {
285        Value::Object(object) => {
286            let mut minimized = Map::new();
287
288            if let Some(country) = object
289                .get("country")
290                .or_else(|| object.get("countryCode"))
291                .or_else(|| object.get("country_code"))
292                .and_then(Value::as_str)
293            {
294                minimized.insert("country".to_string(), Value::String(country.to_string()));
295            }
296
297            if let Some(postal_code) = object
298                .get("postalCode")
299                .or_else(|| object.get("postal_code"))
300                .or_else(|| object.get("zip"))
301                .or_else(|| object.get("zipCode"))
302                .and_then(Value::as_str)
303            {
304                minimized.insert(
305                    "postalCodeMasked".to_string(),
306                    Value::String(mask_postal_code(postal_code)),
307                );
308            }
309
310            if minimized.is_empty() {
311                Value::String("[BILLING DETAILS REDACTED]".to_string())
312            } else {
313                Value::Object(minimized)
314            }
315        }
316        _ => Value::String("[BILLING DETAILS REDACTED]".to_string()),
317    }
318}
319
320fn is_card_key(key: &str) -> bool {
321    key == "pan"
322        || key.contains("cardnumber")
323        || key.contains("primaryaccountnumber")
324        || key.contains("paymentcard")
325}
326
327fn is_cvc_key(key: &str) -> bool {
328    key == "cvv" || key == "cvc" || key == "cid" || key.contains("securitycode")
329}
330
331fn is_expiry_key(key: &str) -> bool {
332    key == "exp" || key.contains("expiry") || key.contains("expiration") || key.contains("expdate")
333}
334
335fn is_secret_key(key: &str) -> bool {
336    key.contains("signature")
337        || key.contains("signedauthorization")
338        || key.contains("authorizationblob")
339        || key.contains("signedmandate")
340        || key.contains("cryptogram")
341        || key.contains("token")
342        || key.contains("nonce")
343        || key.contains("delegatedcredential")
344        || key == "jwt"
345        || key == "jws"
346}
347
348fn is_email_key(key: &str) -> bool {
349    key == "email" || key.ends_with("email")
350}
351
352fn is_phone_key(key: &str) -> bool {
353    key == "phone" || key.ends_with("phone") || key.ends_with("phonenumber")
354}
355
356fn is_billing_key(key: &str) -> bool {
357    key == "billing"
358        || key == "billingdetails"
359        || key.ends_with("billingaddress")
360        || key.starts_with("billingaddress")
361}
362
363fn normalize_key(key: &str) -> String {
364    key.chars()
365        .filter(|char| char.is_ascii_alphanumeric())
366        .map(|char| char.to_ascii_lowercase())
367        .collect()
368}
369
370fn canonical_value(value: &Value) -> String {
371    match value {
372        Value::String(text) => text.clone(),
373        _ => serde_json::to_string(value).unwrap_or_else(|_| "<unserializable>".to_string()),
374    }
375}
376
377fn digest_marker(value: &str) -> String {
378    format!("[REDACTED sha256:{}]", &hex::encode(Sha256::digest(value.as_bytes()))[..16])
379}
380
381fn mask_card_number(text: &str) -> String {
382    let digits: String = text.chars().filter(|char| char.is_ascii_digit()).collect();
383    if digits.len() < 4 {
384        "[CARD REDACTED]".to_string()
385    } else {
386        let last4 = &digits[digits.len() - 4..];
387        format!("[CARD ****{last4}]")
388    }
389}
390
391fn mask_postal_code(postal_code: &str) -> String {
392    let mut chars = postal_code.chars();
393    let prefix: String = chars.by_ref().take(2).collect();
394    if prefix.is_empty() { "***".to_string() } else { format!("{prefix}***") }
395}
396
397#[cfg(test)]
398mod tests {
399    use adk_core::FunctionResponseData;
400    use serde_json::json;
401
402    use super::*;
403
404    #[test]
405    fn redacts_text_card_signature_and_pii() {
406        let redactor = SensitivePaymentDataGuardrail::new();
407        let redacted = redactor.redact_text(
408            "card 4111-1111-1111-1111 billing address: 123 Main St; email payer@example.com signature=signed_blob",
409        );
410
411        assert!(!redacted.contains("4111-1111-1111-1111"));
412        assert!(!redacted.contains("payer@example.com"));
413        assert!(!redacted.contains("signed_blob"));
414        assert!(redacted.contains("[CARD ****1111]"));
415        assert!(redacted.contains("[EMAIL REDACTED]"));
416        assert!(redacted.contains("[REDACTED sha256:"));
417    }
418
419    #[test]
420    fn redacts_tool_output_and_minimizes_billing_details() {
421        let redacted = redact_tool_output(&json!({
422            "cardNumber": "4111111111111111",
423            "cvv": "123",
424            "billingAddress": {
425                "line1": "123 Main St",
426                "city": "San Francisco",
427                "country": "US",
428                "postalCode": "94105"
429            },
430            "signedAuthorization": "signed_blob",
431            "receiptEmail": "payer@example.com"
432        }));
433
434        assert_eq!(redacted["cardNumber"], "[CARD ****1111]");
435        assert_eq!(redacted["cvv"], "[CVC REDACTED]");
436        assert_eq!(redacted["billingAddress"]["country"], "US");
437        assert_eq!(redacted["billingAddress"]["postalCodeMasked"], "94***");
438        assert!(redacted["signedAuthorization"].as_str().unwrap().starts_with("[REDACTED sha256:"));
439        assert_eq!(redacted["receiptEmail"], "[EMAIL REDACTED]");
440    }
441
442    #[test]
443    fn redacts_telemetry_fields() {
444        let mut fields = HashMap::new();
445        fields.insert("payment.pan".to_string(), "4111111111111111".to_string());
446        fields.insert("payment.signature".to_string(), "signed_blob".to_string());
447        fields.insert("billing.email".to_string(), "payer@example.com".to_string());
448
449        let redacted = redact_telemetry_fields(&fields);
450
451        assert_eq!(redacted["payment.pan"], "[CARD ****1111]");
452        assert!(redacted["payment.signature"].starts_with("[REDACTED sha256:"));
453        assert_eq!(redacted["billing.email"], "[EMAIL REDACTED]");
454    }
455
456    #[tokio::test]
457    async fn guardrail_transforms_text_and_function_responses() {
458        let guardrail = SensitivePaymentDataGuardrail::new();
459        let content = Content {
460            role: "tool".to_string(),
461            parts: vec![
462                Part::Text { text: "card 4111 1111 1111 1111".to_string() },
463                Part::FunctionResponse {
464                    function_response: FunctionResponseData::new(
465                        "checkout",
466                        json!({
467                            "signedAuthorization": "signed_blob",
468                            "billingAddress": {
469                                "country": "US",
470                                "postalCode": "10001",
471                                "line1": "123 Main St"
472                            }
473                        }),
474                    ),
475                    id: None,
476                },
477            ],
478        };
479
480        match guardrail.validate(&content).await {
481            GuardrailResult::Transform { new_content, .. } => {
482                let text = new_content.parts[0].text().unwrap();
483                assert!(text.contains("[CARD ****1111]"));
484
485                let Part::FunctionResponse { function_response, .. } = &new_content.parts[1] else {
486                    panic!("expected function response");
487                };
488                assert!(
489                    function_response.response["signedAuthorization"]
490                        .as_str()
491                        .unwrap()
492                        .starts_with("[REDACTED sha256:")
493                );
494                assert_eq!(
495                    function_response.response["billingAddress"]["postalCodeMasked"],
496                    "10***"
497                );
498            }
499            other => panic!("expected transform result, got {other:?}"),
500        }
501    }
502}