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 } => {
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#[must_use]
248pub fn redact_payment_text(text: &str) -> String {
249 SensitivePaymentDataGuardrail::new().redact_text(text)
250}
251
252#[must_use]
254pub fn redact_payment_content(content: &Content) -> Content {
255 SensitivePaymentDataGuardrail::new().redact_content(content)
256}
257
258#[must_use]
260pub fn redact_payment_value(value: &Value) -> Value {
261 SensitivePaymentDataGuardrail::new().redact_json(value)
262}
263
264#[must_use]
266pub fn redact_tool_output(value: &Value) -> Value {
267 redact_payment_value(value)
268}
269
270#[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}