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
10pub 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 #[must_use]
23 pub fn new() -> Self {
24 Self::default()
25 }
26
27 #[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 #[must_use]
71 pub fn redact_content(&self, content: &Content) -> Content {
72 self.redact_content_internal(content).0
73 }
74
75 #[must_use]
77 pub fn redact_json(&self, value: &Value) -> Value {
78 self.redact_json_internal(None, value)
79 }
80
81 #[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#[must_use]
249pub fn redact_payment_text(text: &str) -> String {
250 SensitivePaymentDataGuardrail::new().redact_text(text)
251}
252
253#[must_use]
255pub fn redact_payment_content(content: &Content) -> Content {
256 SensitivePaymentDataGuardrail::new().redact_content(content)
257}
258
259#[must_use]
261pub fn redact_payment_value(value: &Value) -> Value {
262 SensitivePaymentDataGuardrail::new().redact_json(value)
263}
264
265#[must_use]
267pub fn redact_tool_output(value: &Value) -> Value {
268 redact_payment_value(value)
269}
270
271#[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}