diff --git a/src/openhuman/security/mod.rs b/src/openhuman/security/mod.rs index b453b98bb..e98875cb8 100644 --- a/src/openhuman/security/mod.rs +++ b/src/openhuman/security/mod.rs @@ -11,6 +11,7 @@ pub mod firejail; pub mod landlock; pub mod live_policy; pub mod pairing; +pub mod pii; pub mod policy; pub mod secrets; pub mod traits; @@ -50,3 +51,6 @@ pub use schemas::{ all_controller_schemas as all_security_controller_schemas, all_registered_controllers as all_security_registered_controllers, }; + +#[allow(unused_imports)] +pub use pii::{scan as scan_pii, CategoryHit, PiiCategory, PiiScanResult, RiskLevel}; diff --git a/src/openhuman/security/pii/detector.rs b/src/openhuman/security/pii/detector.rs new file mode 100644 index 000000000..cf5f40ba1 --- /dev/null +++ b/src/openhuman/security/pii/detector.rs @@ -0,0 +1,120 @@ +//! Classifier entry point for the local PII detector. +//! +//! [`scan`] is a pure, synchronous function: it walks the compiled rule set +//! (see [`super::rules`]) over the input, tallies matches per category, and +//! folds the tally into a [`PiiScanResult`]. No async, no I/O, no network. + +use std::collections::BTreeMap; + +use super::rules::rules; +use super::types::{CategoryHit, PiiCategory, PiiScanResult, RiskLevel}; + +/// Extra points added per additional co-occurring category. Multiple distinct +/// identifier/context categories in one blob look more like a real record than +/// any single one alone, so co-occurrence nudges the score upward. +const CO_OCCURRENCE_BONUS: u32 = 5; + +/// Map a raw score to a [`RiskLevel`]. Thresholds lean toward escalation +/// (recall over precision). +fn level_from_score(score: u32) -> RiskLevel { + match score { + 0 => RiskLevel::None, + 1..=19 => RiskLevel::Low, + 20..=44 => RiskLevel::Medium, + _ => RiskLevel::High, + } +} + +/// Scan `content` for identification risk. +/// +/// Returns a [`PiiScanResult`] carrying the overall [`RiskLevel`], a numeric +/// score, and the distinct categories detected (with per-category counts). +/// +/// Detection runs **fully locally** — pattern + keyword matching only. The +/// function is deterministic and side-effect-free, so it is safe to call from +/// any context (sync or async, on or off a Tokio runtime). +/// +/// ## Scoring +/// * Each distinct category contributes its [`PiiCategory::weight`] once. +/// * Each additional co-occurring category adds [`CO_OCCURRENCE_BONUS`]. +/// * A "strong identifier" (SSN, card, passport, bank account) forces +/// [`RiskLevel::High`] regardless of the numeric score. +pub fn scan(content: &str) -> PiiScanResult { + // BTreeMap keeps the output deterministic (categories emitted in enum + // order) without a separate sort pass. + let mut counts: BTreeMap = BTreeMap::new(); + + for rule in rules() { + for m in rule.regex.find_iter(content) { + if let Some(validator) = rule.validator { + if !validator(m.as_str()) { + continue; + } + } + *counts.entry(rule.category).or_default() += 1; + } + } + + if counts.is_empty() { + log::trace!("[security][pii] scan clean (no categories matched)"); + return PiiScanResult::default(); + } + + let distinct = counts.len() as u32; + let mut score: u32 = counts.keys().map(|c| c.weight()).sum(); + if distinct > 1 { + score += CO_OCCURRENCE_BONUS * (distinct - 1); + } + + let has_strong = counts.keys().any(|c| c.is_strong_identifier()); + let mut level = level_from_score(score); + if has_strong && level < RiskLevel::High { + level = RiskLevel::High; + } + + let categories: Vec = counts.keys().copied().collect(); + let hits: Vec = counts + .iter() + .map(|(&category, &count)| CategoryHit { category, count }) + .collect(); + + // Log level/score/categories only — never the matched content itself. + log::debug!( + "[security][pii] scan level={} score={} categories={:?}", + level.as_str(), + score, + categories.iter().map(|c| c.as_str()).collect::>() + ); + + PiiScanResult { + level, + score, + categories, + hits, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input_is_none() { + let r = scan(""); + assert_eq!(r.level, RiskLevel::None); + assert_eq!(r.score, 0); + assert!(r.categories.is_empty()); + assert!(!r.is_sensitive()); + } + + #[test] + fn level_thresholds_are_monotonic() { + assert_eq!(level_from_score(0), RiskLevel::None); + assert_eq!(level_from_score(1), RiskLevel::Low); + assert_eq!(level_from_score(19), RiskLevel::Low); + assert_eq!(level_from_score(20), RiskLevel::Medium); + assert_eq!(level_from_score(44), RiskLevel::Medium); + assert_eq!(level_from_score(45), RiskLevel::High); + assert_eq!(level_from_score(1000), RiskLevel::High); + } +} diff --git a/src/openhuman/security/pii/mod.rs b/src/openhuman/security/pii/mod.rs new file mode 100644 index 000000000..ee305e1c4 --- /dev/null +++ b/src/openhuman/security/pii/mod.rs @@ -0,0 +1,36 @@ +//! Local PII / identification-risk detector (privacy epic #4256, slice S5). +//! +//! Detects when a prompt or document may contain patient / legal / financial / +//! family or other identifiable information, and reports a risk **level**, a +//! numeric **score**, and the matched **categories** — so upstream gates can +//! decide whether content is safe to send off-device. +//! +//! ## Hard guarantee: detection is fully local +//! Classification is pure pattern + keyword matching over the input string. +//! There is **no** network call, no async, no external model — importing this +//! module pulls in nothing beyond `regex`. Sending content to a remote service +//! to classify it would defeat the entire purpose of the privacy epic, so the +//! shipped default is dependency-free and offline by construction. +//! +//! ## Recall over precision +//! A missed flag means sensitive data leaves the machine unnoticed, so the +//! detector is tuned to over-flag rather than under-flag. See [`detector::scan`] +//! for the scoring model. +//! +//! ``` +//! use openhuman_core::openhuman::security::pii::{scan, PiiCategory, RiskLevel}; +//! +//! let result = scan("patient John was diagnosed; SSN 123-45-6789"); +//! assert_eq!(result.level, RiskLevel::High); +//! assert!(result.has_category(PiiCategory::NationalId)); +//! ``` + +mod detector; +mod rules; +mod types; + +#[cfg(test)] +mod tests; + +pub use detector::scan; +pub use types::{CategoryHit, PiiCategory, PiiScanResult, RiskLevel}; diff --git a/src/openhuman/security/pii/rules.rs b/src/openhuman/security/pii/rules.rs new file mode 100644 index 000000000..f3671f15b --- /dev/null +++ b/src/openhuman/security/pii/rules.rs @@ -0,0 +1,250 @@ +//! Detection rules for the local PII detector. +//! +//! Two flavours of rule, both compiled once into a process-wide static: +//! +//! * **Pattern rules** — a [`Regex`] plus an optional structural `validator` +//! (Luhn for cards, octet range for IPv4) that rejects shape-matches that +//! aren't actually valid identifiers. +//! * **Keyword rules** — an alternation of domain terms wrapped in word +//! boundaries, used for the topical categories (medical / legal / financial / +//! family) where there is no single canonical value shape. +//! +//! All matching is case-insensitive and offline; nothing here performs I/O. + +use std::sync::LazyLock; + +use regex::Regex; + +use super::types::PiiCategory; + +/// A single detection rule: matches [`regex`](Rule::regex), attributes hits to +/// [`category`](Rule::category), and — if present — only counts a match when +/// [`validator`](Rule::validator) confirms it. +pub(crate) struct Rule { + pub category: PiiCategory, + pub regex: Regex, + /// Optional structural check applied to each raw match before it counts. + pub validator: Option bool>, +} + +/// Build a keyword rule: a case-insensitive, word-boundaried alternation over +/// `terms`, all attributed to `category`. +fn keyword_rule(category: PiiCategory, terms: &[&str]) -> Rule { + let alternation = terms.join("|"); + let pattern = format!(r"(?i)\b(?:{alternation})\b"); + Rule { + category, + regex: Regex::new(&pattern).expect("keyword rule regex is well-formed"), + validator: None, + } +} + +/// Build a pattern rule from a raw regex string. +fn pattern_rule(category: PiiCategory, pattern: &str, validator: Option bool>) -> Rule { + Rule { + category, + regex: Regex::new(pattern).expect("pattern rule regex is well-formed"), + validator, + } +} + +/// Luhn checksum validation for candidate payment-card numbers. Strips +/// separators first; requires 13–19 digits. +pub(crate) fn is_luhn_valid(raw: &str) -> bool { + let digits: Vec = raw + .bytes() + .filter(|b| b.is_ascii_digit()) + .map(|b| b - b'0') + .collect(); + if !(13..=19).contains(&digits.len()) { + return false; + } + let mut sum = 0u32; + // Double every second digit from the right. + for (i, &d) in digits.iter().rev().enumerate() { + let mut v = d as u32; + if i % 2 == 1 { + v *= 2; + if v > 9 { + v -= 9; + } + } + sum += v; + } + sum % 10 == 0 +} + +/// Validate that a dotted-quad match is a real IPv4 address (each octet 0–255). +pub(crate) fn is_ipv4(raw: &str) -> bool { + let octets: Vec<&str> = raw.split('.').collect(); + octets.len() == 4 + && octets + .iter() + .all(|o| !o.is_empty() && o.parse::().is_ok_and(|n| n <= 255)) +} + +/// The full, process-wide rule set. Compiled lazily on first [`scan`] call and +/// reused for the life of the process. +/// +/// [`scan`]: super::detector::scan +pub(crate) fn rules() -> &'static [Rule] { + static RULES: LazyLock> = LazyLock::new(build_rules); + &RULES +} + +fn build_rules() -> Vec { + vec![ + // --- structured identifiers ------------------------------------- + // Email. + pattern_rule( + PiiCategory::Email, + r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b", + None, + ), + // US Social Security Number (3-2-4, dash or space separated). The + // separator requirement keeps it distinct from phone numbers (3-3-4) + // and from bare 9-digit blobs. + pattern_rule( + PiiCategory::NationalId, + r"\b\d{3}[-\s]\d{2}[-\s]\d{4}\b", + None, + ), + // Payment card: 13–19 digits with optional single separators, then + // Luhn-validated so version strings / IDs don't false-positive. + pattern_rule( + PiiCategory::CreditCard, + r"\b\d(?:[ -]?\d){12,18}\b", + Some(is_luhn_valid), + ), + // IBAN bank account: 2-letter country + 2 check digits + 11–30 alnum. + pattern_rule( + PiiCategory::BankAccount, + r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b", + None, + ), + // US-style phone number (3-3-4 groups, requires a separator so it + // doesn't swallow arbitrary 10-digit numbers). + pattern_rule( + PiiCategory::PhoneNumber, + r"\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b", + None, + ), + // International E.164-ish phone number (leading + and 7–15 digits). + pattern_rule(PiiCategory::PhoneNumber, r"\+\d(?:[\d\s\-]{6,16}\d)", None), + // IPv4 address (octet-validated). + pattern_rule( + PiiCategory::IpAddress, + r"\b(?:\d{1,3}\.){3}\d{1,3}\b", + Some(is_ipv4), + ), + // Street / postal address (number + words + street-type keyword). + pattern_rule( + PiiCategory::PostalAddress, + r"(?i)\b\d{1,6}\s+[A-Za-z0-9.'\- ]{2,40}?\b(?:street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way|place|pl|terrace|ter|circle|cir|highway|hwy)\b", + None, + ), + // Date of birth (explicit context — pattern-based, not a keyword list). + pattern_rule( + PiiCategory::DateOfBirth, + r"(?i)\b(?:date of birth|d\.?o\.?b\.?|birth ?date|born on)\b", + None, + ), + // --- sensitive topical context (keyword-based) ------------------ + keyword_rule(PiiCategory::Passport, &["passport"]), + keyword_rule( + PiiCategory::Medical, + &[ + "patient", + "diagnosis", + "diagnosed", + "prescription", + "prescribed", + "symptom", + "symptoms", + "medication", + "medications", + "treatment", + "physician", + "hospital", + "clinic", + "disease", + "medical record", + "health record", + "mrn", + "blood pressure", + "dosage", + "therapy", + "biopsy", + "oncology", + "cardiology", + "psychiatric", + "mental health", + "allergy", + "allergies", + ], + ), + keyword_rule( + PiiCategory::Legal, + &[ + "plaintiff", + "defendant", + "attorney", + "lawsuit", + "litigation", + "case number", + "docket", + "subpoena", + "settlement", + "indictment", + "deposition", + "legal counsel", + "prosecution", + "testimony", + "affidavit", + "arraignment", + "felony", + "misdemeanor", + "custody", + "court order", + ], + ), + keyword_rule( + PiiCategory::Financial, + &[ + "bank account", + "account number", + "routing number", + "salary", + "annual income", + "credit score", + "tax id", + "invoice", + "sort code", + "swift code", + "wire transfer", + "mortgage", + "loan balance", + "net worth", + "iban", + ], + ), + keyword_rule( + PiiCategory::Family, + &[ + "spouse", + "wife", + "husband", + "daughter", + "son", + "children", + "minor child", + "maiden name", + "next of kin", + "dependent", + "marital status", + "guardian", + "sibling", + ], + ), + ] +} diff --git a/src/openhuman/security/pii/tests.rs b/src/openhuman/security/pii/tests.rs new file mode 100644 index 000000000..9ca8586bb --- /dev/null +++ b/src/openhuman/security/pii/tests.rs @@ -0,0 +1,318 @@ +//! Fixture + boundary tests for the local PII detector. +//! +//! Coverage intent: +//! * every category detects its canonical form, +//! * false-positive guards (benign text, invalid card/IP) do NOT flag, +//! * false-negative / recall guards (varied formats, in-sentence values) DO +//! flag — recall matters more than precision here, +//! * risk-level escalation behaves as specified, +//! * detection is deterministic + offline (no external egress on classify), +//! * category identifiers serialize to stable snake_case strings. + +use super::rules::{is_ipv4, is_luhn_valid}; +use super::{scan, PiiCategory, RiskLevel}; + +// --------------------------------------------------------------------------- +// Per-category detection (recall / true positives) +// --------------------------------------------------------------------------- + +#[test] +fn detects_email() { + let r = scan("reach me at alice.smith@example.com any time"); + assert!(r.has_category(PiiCategory::Email)); + assert!(r.is_sensitive()); +} + +#[test] +fn detects_national_id_ssn() { + // Dash- and space-separated both count (recall). + for raw in ["SSN 123-45-6789", "ssn is 123 45 6789"] { + let r = scan(raw); + assert!(r.has_category(PiiCategory::NationalId), "missed in {raw:?}"); + assert_eq!(r.level, RiskLevel::High, "strong id must be High: {raw:?}"); + } +} + +#[test] +fn detects_valid_credit_card() { + // 4111 1111 1111 1111 is the canonical Visa test number (Luhn-valid). + for raw in [ + "card 4111 1111 1111 1111 exp 12/26", + "4111-1111-1111-1111", + "4111111111111111", + ] { + let r = scan(raw); + assert!(r.has_category(PiiCategory::CreditCard), "missed in {raw:?}"); + assert_eq!(r.level, RiskLevel::High); + } +} + +#[test] +fn detects_iban_bank_account() { + let r = scan("transfer to GB82WEST12345698765432 please"); + assert!(r.has_category(PiiCategory::BankAccount)); + assert_eq!(r.level, RiskLevel::High); +} + +#[test] +fn detects_phone_numbers_varied_formats() { + for raw in [ + "call 415-555-1234", + "(415) 555 1234", + "+1 415.555.1234", + "reach +44 20 7946 0958 in london", + ] { + let r = scan(raw); + assert!( + r.has_category(PiiCategory::PhoneNumber), + "missed in {raw:?}" + ); + } +} + +#[test] +fn detects_ipv4_address() { + let r = scan("client connected from 192.168.10.24 last night"); + assert!(r.has_category(PiiCategory::IpAddress)); +} + +#[test] +fn detects_postal_address() { + let r = scan("ship to 1600 Pennsylvania Avenue, Washington"); + assert!(r.has_category(PiiCategory::PostalAddress)); +} + +#[test] +fn detects_date_of_birth_context() { + for raw in [ + "date of birth: 1990-04-12", + "DOB 04/12/1990", + "he was born on the 3rd", + ] { + let r = scan(raw); + assert!( + r.has_category(PiiCategory::DateOfBirth), + "missed in {raw:?}" + ); + } +} + +#[test] +fn detects_passport() { + let r = scan("upload a scan of your passport"); + assert!(r.has_category(PiiCategory::Passport)); + assert_eq!(r.level, RiskLevel::High); // strong identifier +} + +#[test] +fn detects_medical_context() { + let r = scan("the patient was diagnosed and prescribed medication"); + assert!(r.has_category(PiiCategory::Medical)); + assert!(r.level >= RiskLevel::Medium); +} + +#[test] +fn detects_legal_context() { + let r = scan("the plaintiff filed a lawsuit; the defendant's attorney responded"); + assert!(r.has_category(PiiCategory::Legal)); + assert!(r.level >= RiskLevel::Medium); +} + +#[test] +fn detects_financial_context() { + let r = scan("please send your bank account and routing number"); + assert!(r.has_category(PiiCategory::Financial)); + assert!(r.level >= RiskLevel::Medium); +} + +#[test] +fn detects_family_context() { + let r = scan("my spouse and our minor child are dependents"); + assert!(r.has_category(PiiCategory::Family)); + assert!(r.is_sensitive()); +} + +// --------------------------------------------------------------------------- +// False-positive guards +// --------------------------------------------------------------------------- + +#[test] +fn benign_text_is_not_flagged() { + for raw in [ + "let's grab coffee at 3pm tomorrow", + "the build passed and the tests are green", + "refactor the parser to be faster", + "version 1.2.3 shipped yesterday", // dotted, but only 3 octets — not IPv4 + ] { + let r = scan(raw); + assert_eq!(r.level, RiskLevel::None, "false positive on {raw:?}: {r:?}"); + assert!(r.categories.is_empty(), "categories on {raw:?}: {r:?}"); + } +} + +#[test] +fn luhn_invalid_number_is_not_a_card() { + // 16 digits but fails the Luhn checksum → must NOT be a CreditCard. + let r = scan("order id 1234 5678 9012 3456 confirmed"); + assert!( + !r.has_category(PiiCategory::CreditCard), + "Luhn-invalid number flagged as card: {r:?}" + ); +} + +#[test] +fn out_of_range_octets_are_not_ipv4() { + let r = scan("coords 300.400.500.600 are nonsense"); + assert!( + !r.has_category(PiiCategory::IpAddress), + "bad octets flagged: {r:?}" + ); +} + +#[test] +fn phone_shaped_ssn_not_double_counted_as_ssn() { + // 3-3-4 grouping is a phone, not a 3-2-4 SSN. + let r = scan("call 415-555-1234"); + assert!(r.has_category(PiiCategory::PhoneNumber)); + assert!(!r.has_category(PiiCategory::NationalId)); +} + +// --------------------------------------------------------------------------- +// Risk-level escalation +// --------------------------------------------------------------------------- + +#[test] +fn single_weak_identifier_is_low() { + let r = scan("email me: bob@example.org"); + assert_eq!(r.level, RiskLevel::Low); + assert_eq!(r.categories, vec![PiiCategory::Email]); +} + +#[test] +fn co_occurring_identifiers_escalate() { + // Email + phone together → beyond a single weak signal. + let r = scan("bob@example.org / 415-555-1234"); + assert!(r.has_category(PiiCategory::Email)); + assert!(r.has_category(PiiCategory::PhoneNumber)); + assert!( + r.level >= RiskLevel::Medium, + "expected escalation, got {r:?}" + ); +} + +#[test] +fn strong_identifier_forces_high_even_alone() { + let r = scan("123-45-6789"); + assert_eq!(r.level, RiskLevel::High); + assert!(r.score >= PiiCategory::NationalId.weight()); +} + +#[test] +fn medical_record_shape_is_high() { + let r = scan("patient John Doe, DOB 01/02/1980, diagnosed; SSN 111-22-3333"); + assert_eq!(r.level, RiskLevel::High); + assert!(r.has_category(PiiCategory::Medical)); + assert!(r.has_category(PiiCategory::NationalId)); + assert!(r.has_category(PiiCategory::DateOfBirth)); +} + +#[test] +fn score_increases_with_more_categories() { + let one = scan("bob@example.org").score; + let two = scan("bob@example.org and patient diagnosed").score; + assert!( + two > one, + "adding a category should raise the score: {one} !< {two}" + ); +} + +// --------------------------------------------------------------------------- +// Offline / determinism (no external egress on classify) +// --------------------------------------------------------------------------- + +#[test] +fn scan_is_deterministic_and_pure() { + // Detection performs zero I/O: it is a pure function of its input, so + // repeated calls must be byte-for-byte identical. This is the unit-test + // proxy for "no external egress occurs during classify" — there is no + // async, no network client, and nothing in this module to mock away. + let input = "patient bob@example.org, card 4111 1111 1111 1111, SSN 123-45-6789"; + let first = scan(input); + let second = scan(input); + assert_eq!(first, second); +} + +#[test] +fn scan_runs_without_a_tokio_runtime() { + // A plain `#[test]` has no Tokio runtime installed; the fact that `scan` + // returns here at all proves it does not spawn async work / touch the + // network to classify. + let r = scan("SSN 123-45-6789"); + assert_eq!(r.level, RiskLevel::High); +} + +// --------------------------------------------------------------------------- +// Stable serde identifiers (contract for S2 / RPC / event bus) +// --------------------------------------------------------------------------- + +#[test] +fn category_identifiers_are_stable_snake_case() { + let cases = [ + (PiiCategory::Email, "email"), + (PiiCategory::PhoneNumber, "phone_number"), + (PiiCategory::NationalId, "national_id"), + (PiiCategory::CreditCard, "credit_card"), + (PiiCategory::BankAccount, "bank_account"), + (PiiCategory::IpAddress, "ip_address"), + (PiiCategory::PostalAddress, "postal_address"), + (PiiCategory::DateOfBirth, "date_of_birth"), + (PiiCategory::Passport, "passport"), + (PiiCategory::Medical, "medical"), + (PiiCategory::Legal, "legal"), + (PiiCategory::Financial, "financial"), + (PiiCategory::Family, "family"), + ]; + for (cat, expected) in cases { + assert_eq!(cat.as_str(), expected); + assert_eq!( + serde_json::to_string(&cat).unwrap(), + format!("\"{expected}\"") + ); + } +} + +#[test] +fn risk_levels_are_ordered_and_stable() { + assert!(RiskLevel::None < RiskLevel::Low); + assert!(RiskLevel::Low < RiskLevel::Medium); + assert!(RiskLevel::Medium < RiskLevel::High); + for (lvl, s) in [ + (RiskLevel::None, "none"), + (RiskLevel::Low, "low"), + (RiskLevel::Medium, "medium"), + (RiskLevel::High, "high"), + ] { + assert_eq!(lvl.as_str(), s); + assert_eq!(serde_json::to_string(&lvl).unwrap(), format!("\"{s}\"")); + } +} + +// --------------------------------------------------------------------------- +// Validator unit tests +// --------------------------------------------------------------------------- + +#[test] +fn luhn_validator_accepts_known_good_and_rejects_bad() { + assert!(is_luhn_valid("4111111111111111")); // Visa test number + assert!(is_luhn_valid("4111 1111 1111 1111")); + assert!(!is_luhn_valid("1234567890123456")); + assert!(!is_luhn_valid("12345")); // too short +} + +#[test] +fn ipv4_validator_bounds_octets() { + assert!(is_ipv4("192.168.0.1")); + assert!(is_ipv4("255.255.255.255")); + assert!(!is_ipv4("256.1.1.1")); + assert!(!is_ipv4("1.2.3")); +} diff --git a/src/openhuman/security/pii/types.rs b/src/openhuman/security/pii/types.rs new file mode 100644 index 000000000..db224a1fd --- /dev/null +++ b/src/openhuman/security/pii/types.rs @@ -0,0 +1,205 @@ +//! Result + category types for the local PII / identification-risk detector +//! (privacy epic #4256, slice S5). +//! +//! ## Privacy note +//! The detector deliberately reports **categories and counts only** — never +//! the raw matched substrings. The whole point of S5 is to reason about +//! identification risk *without* copying the sensitive value anywhere, so the +//! result type itself must not become a new PII sink (mirrors the "no response +//! body in telemetry" rule the codebase already follows). + +use serde::{Deserialize, Serialize}; + +/// Identification-risk level assigned to a scanned piece of content. +/// +/// Ordered `None < Low < Medium < High` (declaration order drives the derived +/// `Ord`), so callers can gate behaviour with a plain comparison such as +/// `result.level >= RiskLevel::Medium`. +/// +/// The detector is tuned toward **recall**: a false positive (flagging benign +/// text) is far cheaper than a false negative (letting a real identifier leave +/// the machine unflagged), so the thresholds lean toward escalation. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum RiskLevel { + /// No identification-risk signal found. + #[default] + None, + /// A single weak signal (e.g. one email or phone number in isolation). + Low, + /// A moderate signal — sensitive topical context, or a couple of + /// co-occurring identifiers. + Medium, + /// A strong signal — a high-confidence identifier (SSN, card, passport, + /// bank account) or several co-occurring categories that together look + /// like a real record. + High, +} + +impl RiskLevel { + /// Stable lowercase identifier (matches the serde representation). + pub fn as_str(self) -> &'static str { + match self { + RiskLevel::None => "none", + RiskLevel::Low => "low", + RiskLevel::Medium => "medium", + RiskLevel::High => "high", + } + } + + /// `true` when the content carries any identification risk at all + /// (anything above [`RiskLevel::None`]). Downstream gates (S3 indicator, + /// S4 sensitive-transfer gate) use this as the coarse "should we care?" + /// signal. + pub fn is_sensitive(self) -> bool { + self > RiskLevel::None + } +} + +/// A single identification-risk category the detector knows how to recognise. +/// +/// The set covers both **structured identifiers** (pattern-matched: email, +/// phone, national ID/SSN, card, bank account, IP, postal address) and +/// **sensitive topical context** (keyword-matched: medical, legal, financial, +/// family, date-of-birth, passport) — the category set called out in the S5 +/// scope (patient / legal / financial / family / personal identifiers). +/// +/// Serde uses stable `snake_case` identifiers so the values are safe to carry +/// across the RPC / event-bus boundary and into the S2 egress descriptor +/// without a breaking reshape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PiiCategory { + // --- structured identifiers (pattern-based) --- + /// Email address (`alice@example.com`). + Email, + /// Telephone number (US or international formats). + PhoneNumber, + /// National identifier — US Social Security Number and similar + /// dash/space-separated government IDs. + NationalId, + /// Payment card number (validated with the Luhn checksum). + CreditCard, + /// Bank account number in IBAN form. + BankAccount, + /// IPv4 address (octet-validated). + IpAddress, + /// Street / postal address. + PostalAddress, + // --- sensitive topical context (keyword-based) --- + /// Date of birth (explicit "DOB" / "date of birth" context). + DateOfBirth, + /// Passport reference. + Passport, + /// Medical / patient / health information. + Medical, + /// Legal / litigation / case information. + Legal, + /// Financial / banking / income information. + Financial, + /// Family / next-of-kin / personal-relationship information. + Family, +} + +impl PiiCategory { + /// Stable lowercase identifier (matches the serde representation). Handy + /// for logs and for downstream code that keys off the string form. + pub fn as_str(self) -> &'static str { + match self { + PiiCategory::Email => "email", + PiiCategory::PhoneNumber => "phone_number", + PiiCategory::NationalId => "national_id", + PiiCategory::CreditCard => "credit_card", + PiiCategory::BankAccount => "bank_account", + PiiCategory::IpAddress => "ip_address", + PiiCategory::PostalAddress => "postal_address", + PiiCategory::DateOfBirth => "date_of_birth", + PiiCategory::Passport => "passport", + PiiCategory::Medical => "medical", + PiiCategory::Legal => "legal", + PiiCategory::Financial => "financial", + PiiCategory::Family => "family", + } + } + + /// Scoring weight contributed when this category is present. Higher = + /// stronger identification signal. Tuned toward recall — see + /// [`crate::openhuman::security::pii::detector`] for how weights combine. + pub(crate) fn weight(self) -> u32 { + match self { + // High-confidence direct identifiers — enough to flag on their own. + PiiCategory::NationalId => 50, + PiiCategory::CreditCard => 50, + PiiCategory::Passport => 45, + PiiCategory::BankAccount => 40, + // Sensitive topical context. + PiiCategory::Medical => 30, + PiiCategory::Legal => 25, + PiiCategory::Financial => 25, + // Moderate identifiers. + PiiCategory::DateOfBirth => 20, + PiiCategory::PostalAddress => 20, + PiiCategory::Family => 20, + // Weak / commonly-shared identifiers. + PiiCategory::Email => 15, + PiiCategory::PhoneNumber => 15, + PiiCategory::IpAddress => 10, + } + } + + /// A "strong identifier" is a high-confidence, directly-identifying value + /// whose mere presence should force [`RiskLevel::High`], independent of the + /// numeric score. Guards recall against future weight edits. + pub fn is_strong_identifier(self) -> bool { + matches!( + self, + PiiCategory::NationalId + | PiiCategory::CreditCard + | PiiCategory::Passport + | PiiCategory::BankAccount + ) + } +} + +/// Per-category match tally. Carries the category and how many times it was +/// seen — never the matched text itself. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CategoryHit { + /// Which category matched. + pub category: PiiCategory, + /// Number of matches for this category across the scanned content. + pub count: usize, +} + +/// Outcome of scanning a piece of content for identification risk. +/// +/// This is the public contract S3/S4 (and, after rebase, the S2 egress +/// descriptor) consume. It intentionally holds only the risk level, a numeric +/// score, and the matched categories/counts — no raw values. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct PiiScanResult { + /// Overall identification-risk level. + pub level: RiskLevel, + /// Numeric score behind [`level`](Self::level). Exposed for callers that + /// want a finer-grained ranking than the four-way enum. + pub score: u32, + /// Distinct categories detected, sorted and de-duplicated for + /// deterministic output. + pub categories: Vec, + /// Per-category match counts, sorted by category. + pub hits: Vec, +} + +impl PiiScanResult { + /// Convenience: content carries at least some identification risk. + pub fn is_sensitive(&self) -> bool { + self.level.is_sensitive() + } + + /// Convenience: was a specific category detected? + pub fn has_category(&self, category: PiiCategory) -> bool { + self.categories.contains(&category) + } +}