#PII 마스킹 Cookbook
버전: 0.40.0 | 업데이트: 2026-03-23 | 적용 대상: ranvier-core 0.40+ | 카테고리: 쿡북
#Overview
개인정보(PII) 탐지와 마스킹은 사용자 데이터를 다루는 SaaS의 규제 요구사항(GDPR, CCPA, 개인정보보호법)이다. Ranvier의 Guard 패턴은 이 사용 사례에 자연스럽게 맞는다: Guard가 입력에서 PII 패턴을 스캔하고, 비즈니스 로직에 도달하기 전에 차단, 마스킹, 또는 경고한다.
이 쿡북은 다국어 PII 정규식 패턴, 마스킹 전략, Guard 체인, Bus 기반 정책 설정, 감사 연동을 다룬다.
#1. PII 패턴 정의
일반적인 PII 유형에 대한 정규식 패턴을 정의한다. 각 패턴은 특정 로케일을 커버한다.
use regex::Regex;
#[derive(Debug, Clone)]
pub struct PiiPattern {
pub name: &'static str,
pub regex: Regex,
pub locale: &'static str,
}
pub fn pii_patterns() -> Vec<PiiPattern> {
vec![
// 한국: 주민등록번호
PiiPattern {
name: "KR_RRN",
regex: Regex::new(r"\d{6}-[1-4]\d{6}").unwrap(),
locale: "KR",
},
// 한국: 휴대전화번호
PiiPattern {
name: "KR_PHONE",
regex: Regex::new(r"01[016789]-?\d{3,4}-?\d{4}").unwrap(),
locale: "KR",
},
// 미국: Social Security Number
PiiPattern {
name: "US_SSN",
regex: Regex::new(r"\d{3}-\d{2}-\d{4}").unwrap(),
locale: "US",
},
// 공통: 이메일
PiiPattern {
name: "EMAIL",
regex: Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap(),
locale: "GLOBAL",
},
// 공통: 신용카드 (Luhn 체크 가능 패턴)
PiiPattern {
name: "CREDIT_CARD",
regex: Regex::new(r"\b(?:\d{4}[- ]?){3}\d{4}\b").unwrap(),
locale: "GLOBAL",
},
// 공통: IP 주소 (v4)
PiiPattern {
name: "IPV4",
regex: Regex::new(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b").unwrap(),
locale: "GLOBAL",
},
]
}#2. 마스킹 전략
컨텍스트에 따라 다른 마스킹 방법이 필요하다.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MaskingStrategy {
/// [REDACTED]로 전체 대체
Redact,
/// 첫/끝 문자만 표시: 123-**-4567
Partial,
/// SHA-256 해시로 대체
Hash,
/// 일관된 가명으로 대체
Pseudonymize,
}
pub fn apply_mask(value: &str, strategy: MaskingStrategy) -> String {
match strategy {
MaskingStrategy::Redact => "[REDACTED]".to_string(),
MaskingStrategy::Partial => {
if value.len() <= 4 {
"****".to_string()
} else {
let first = &value[..2];
let last = &value[value.len()-2..];
format!("{}{}{}",
first,
"*".repeat(value.len() - 4),
last
)
}
}
MaskingStrategy::Hash => {
use sha2::{Sha256, Digest};
let hash = Sha256::digest(value.as_bytes());
format!("sha256:{}", hex::encode(&hash[..8]))
}
MaskingStrategy::Pseudonymize => {
use sha2::{Sha256, Digest};
let hash = Sha256::digest(value.as_bytes());
// 해시에서 일관된 가명 생성
format!("user_{}", hex::encode(&hash[..4]))
}
}
}#3. PII 스캔 Guard
문자열 입력에서 PII 패턴을 스캔하고 설정된 정책을 적용하는 Guard.
use async_trait::async_trait;
use ranvier_core::prelude::*;
/// Bus 타입: PII 탐지 정책.
#[derive(Debug, Clone)]
pub struct PiiPolicy {
pub action: PiiAction,
pub strategy: MaskingStrategy,
pub locales: Vec<String>, // 빈 배열 = 모든 로케일
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PiiAction {
Block, // 요청 전체 거부
Mask, // PII 마스킹 후 계속
Warn, // 경고 로그만 남기고 통과
}
impl Default for PiiPolicy {
fn default() -> Self {
Self {
action: PiiAction::Mask,
strategy: MaskingStrategy::Redact,
locales: vec![],
}
}
}
#[derive(Debug, Clone)]
pub struct PiiScanResult {
pub detections: Vec<PiiDetection>,
pub masked_text: String,
}
#[derive(Debug, Clone)]
pub struct PiiDetection {
pub pattern_name: String,
pub original: String,
pub masked: String,
pub position: usize,
}
pub struct PiiScanGuard;
#[async_trait]
impl Transition<String, String> for PiiScanGuard {
type Error = String;
type Resources = ();
async fn run(
&self,
input: String,
_resources: &Self::Resources,
bus: &mut Bus,
) -> Outcome<String, Self::Error> {
let policy = bus.get_cloned::<PiiPolicy>()
.unwrap_or_default();
let patterns = pii_patterns();
let active_patterns: Vec<_> = patterns.iter()
.filter(|p| {
policy.locales.is_empty() || policy.locales.contains(&p.locale.to_string())
})
.collect();
let mut detections = Vec::new();
let mut masked = input.clone();
for pattern in &active_patterns {
for mat in pattern.regex.find_iter(&input) {
let original = mat.as_str().to_string();
let replacement = apply_mask(&original, policy.strategy);
detections.push(PiiDetection {
pattern_name: pattern.name.to_string(),
original: original.clone(),
masked: replacement.clone(),
position: mat.start(),
});
masked = masked.replace(&original, &replacement);
}
}
// 감사를 위해 스캔 결과를 Bus에 저장
bus.insert(PiiScanResult {
detections: detections.clone(),
masked_text: masked.clone(),
});
if detections.is_empty() {
return Outcome::next(input);
}
match policy.action {
PiiAction::Block => {
let names: Vec<_> = detections.iter()
.map(|d| d.pattern_name.as_str())
.collect();
Outcome::fault(format!(
"400 PII 탐지: {} 패턴 발견: {:?}",
detections.len(), names
))
}
PiiAction::Mask => {
tracing::info!(
count = detections.len(),
"PII 탐지 및 마스킹 완료"
);
Outcome::next(masked)
}
PiiAction::Warn => {
tracing::warn!(
count = detections.len(),
"PII 탐지 (경고 모드 — 통과)"
);
Outcome::next(input)
}
}
}
}#4. JSON 본문 PII 스캔
구조화된 페이로드의 모든 문자열 필드를 재귀적으로 스캔한다.
pub fn scan_json_value(
value: &mut serde_json::Value,
patterns: &[PiiPattern],
strategy: MaskingStrategy,
) -> Vec<PiiDetection> {
let mut detections = Vec::new();
match value {
serde_json::Value::String(s) => {
for pattern in patterns {
for mat in pattern.regex.find_iter(&s.clone()) {
let original = mat.as_str().to_string();
let replacement = apply_mask(&original, strategy);
detections.push(PiiDetection {
pattern_name: pattern.name.to_string(),
original,
masked: replacement.clone(),
position: mat.start(),
});
*s = s.replace(mat.as_str(), &replacement);
}
}
}
serde_json::Value::Object(map) => {
for (_key, val) in map.iter_mut() {
detections.extend(scan_json_value(val, patterns, strategy));
}
}
serde_json::Value::Array(arr) => {
for val in arr.iter_mut() {
detections.extend(scan_json_value(val, patterns, strategy));
}
}
_ => {}
}
detections
}
/// JSON 페이로드용 Guard
pub struct JsonPiiScanGuard;
#[async_trait]
impl Transition<serde_json::Value, serde_json::Value> for JsonPiiScanGuard {
type Error = String;
type Resources = ();
async fn run(
&self,
mut input: serde_json::Value,
_resources: &Self::Resources,
bus: &mut Bus,
) -> Outcome<serde_json::Value, Self::Error> {
let policy = bus.get_cloned::<PiiPolicy>().unwrap_or_default();
let patterns = pii_patterns();
let detections = scan_json_value(&mut input, &patterns, policy.strategy);
bus.insert(PiiScanResult {
detections: detections.clone(),
masked_text: input.to_string(),
});
if detections.is_empty() || policy.action != PiiAction::Block {
Outcome::next(input)
} else {
Outcome::fault(format!(
"400 JSON 내 PII 탐지: {} 필드에 PII 포함",
detections.len()
))
}
}
}#5. 이중 스캔 Guard 체인
입력과 출력 양쪽을 스캔하여 비즈니스 로직에서 생성될 수 있는 PII까지 포착한다.
use ranvier_runtime::Axon;
let pii_protected_pipeline = Axon::typed::<String, String>("pii-protected")
// 1단계: 입력 스캔
.then(PiiScanGuard)
// 2단계: 비즈니스 로직 (마스킹된 입력을 받음)
.then_fn("process", |input, _res, _bus| async move {
// 비즈니스 로직
let result = format!("처리 완료: {input}");
Outcome::next(result)
})
// 3단계: 출력 스캔 (DB 조회, LLM 응답 등에서 발생한 PII 포착)
.then(PiiScanGuard);#6. Bus를 통한 정책 설정
라우트마다 다른 PII 정책을 적용할 수 있다.
use ranvier_http::prelude::*;
use ranvier_guard::prelude::*;
Ranvier::http()
.bus_injector(|_parts, bus| {
// 기본값: PII 마스킹
bus.insert(PiiPolicy::default());
})
// 엄격 라우트: PII 발견 시 차단
.post_typed("/api/public/submit", {
Axon::typed::<String, String>("strict-pii")
.then_fn("set-strict", |input, _res, bus| async move {
bus.insert(PiiPolicy {
action: PiiAction::Block,
strategy: MaskingStrategy::Redact,
locales: vec![],
});
Outcome::next(input)
})
.then(PiiScanGuard)
.then_fn("process", |input, _res, _bus| async move {
Outcome::next(format!("OK: {input}"))
})
})
// 내부 라우트: 경고만
.post_typed("/api/internal/process", {
Axon::typed::<String, String>("warn-pii")
.then_fn("set-warn", |input, _res, bus| async move {
bus.insert(PiiPolicy {
action: PiiAction::Warn,
strategy: MaskingStrategy::Partial,
locales: vec![],
});
Outcome::next(input)
})
.then(PiiScanGuard)
.then_fn("process", |input, _res, _bus| async move {
Outcome::next(format!("OK: {input}"))
})
})
.run(())
.await?;#7. 감사 연동
규제 준수 보고를 위해 PII 탐지 이벤트를 감사 해시 체인에 기록한다.
use ranvier_audit::prelude::*;
let audited_pii_pipeline = Axon::typed::<String, String>("audited-pii")
.then(PiiScanGuard)
.then_fn("audit-pii", |input, _res, bus| async move {
let scan_result = bus.get_cloned::<PiiScanResult>();
if let Ok(result) = scan_result {
if !result.detections.is_empty() {
if let Ok(audit) = bus.get_cloned::<AuditTrail>() {
let patterns: Vec<_> = result.detections.iter()
.map(|d| d.pattern_name.as_str())
.collect();
audit.record(AuditEntry {
action: "pii.detected".into(),
actor: bus.get_cloned::<super::TenantContext>()
.map(|t| t.tenant_id)
.unwrap_or_else(|_| "system".into()),
resource: format!(
"patterns={:?}, count={}",
patterns, result.detections.len()
),
timestamp: chrono::Utc::now(),
}).await;
}
}
}
Outcome::next(input)
})
.then_fn("process", |input, _res, _bus| async move {
Outcome::next(format!("처리 완료: {input}"))
});#8. PII 탐지 테스트
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_korean_rrn_detection() {
let patterns = pii_patterns();
let rrn_pattern = patterns.iter().find(|p| p.name == "KR_RRN").unwrap();
assert!(rrn_pattern.regex.is_match("950101-1234567"));
assert!(!rrn_pattern.regex.is_match("950101"));
}
#[test]
fn test_us_ssn_detection() {
let patterns = pii_patterns();
let ssn_pattern = patterns.iter().find(|p| p.name == "US_SSN").unwrap();
assert!(ssn_pattern.regex.is_match("123-45-6789"));
assert!(!ssn_pattern.regex.is_match("12345"));
}
#[test]
fn test_email_detection() {
let patterns = pii_patterns();
let email_pattern = patterns.iter().find(|p| p.name == "EMAIL").unwrap();
assert!(email_pattern.regex.is_match("user@example.com"));
assert!(!email_pattern.regex.is_match("not-an-email"));
}
#[test]
fn test_masking_strategies() {
assert_eq!(
apply_mask("123-45-6789", MaskingStrategy::Redact),
"[REDACTED]"
);
assert_eq!(
apply_mask("123-45-6789", MaskingStrategy::Partial),
"12*******89"
);
// Hash와 Pseudonymize는 일관된 출력을 생성
let hash1 = apply_mask("test@email.com", MaskingStrategy::Hash);
let hash2 = apply_mask("test@email.com", MaskingStrategy::Hash);
assert_eq!(hash1, hash2);
}
#[tokio::test]
async fn test_pii_guard_block_mode() {
let mut bus = Bus::new();
bus.insert(PiiPolicy {
action: PiiAction::Block,
strategy: MaskingStrategy::Redact,
locales: vec![],
});
let guard = PiiScanGuard;
let result = guard.run(
"My SSN is 123-45-6789".to_string(),
&(),
&mut bus,
).await;
assert!(matches!(result, Outcome::Fault(_)));
}
#[tokio::test]
async fn test_pii_guard_mask_mode() {
let mut bus = Bus::new();
bus.insert(PiiPolicy {
action: PiiAction::Mask,
strategy: MaskingStrategy::Redact,
locales: vec![],
});
let guard = PiiScanGuard;
let result = guard.run(
"My SSN is 123-45-6789".to_string(),
&(),
&mut bus,
).await;
match result {
Outcome::Next(masked) => {
assert!(masked.contains("[REDACTED]"));
assert!(!masked.contains("123-45-6789"));
}
_ => panic!("마스킹된 값으로 성공해야 함"),
}
}
#[tokio::test]
async fn test_clean_input_passes_through() {
let mut bus = Bus::new();
bus.insert(PiiPolicy::default());
let guard = PiiScanGuard;
let result = guard.run(
"안녕하세요, 깨끗한 텍스트입니다".to_string(),
&(),
&mut bus,
).await;
match result {
Outcome::Next(text) => assert_eq!(text, "안녕하세요, 깨끗한 텍스트입니다"),
_ => panic!("깨끗한 텍스트는 통과해야 함"),
}
}
#[test]
fn test_json_pii_scan() {
let mut json = serde_json::json!({
"name": "홍길동",
"email": "hong@example.com",
"nested": {
"ssn": "123-45-6789"
}
});
let patterns = pii_patterns();
let detections = scan_json_value(
&mut json,
&patterns,
MaskingStrategy::Redact,
);
assert_eq!(detections.len(), 2); // email + SSN
assert_eq!(json["email"], "[REDACTED]");
assert_eq!(json["nested"]["ssn"], "[REDACTED]");
}
}#See Also
- Guard Patterns Cookbook — Guard 조합 패턴
- Multi-Tenant Isolation Cookbook — 테넌트 스코프 PII 정책
- Bus Access Patterns Cookbook — Bus 정책 설정
- LLM Gateway Cookbook — LLM 입력/출력 PII 스캔