#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 스캔