///|
pub(all) struct RedactionConfig {
policy : RedactionPolicy
salt : String
token_prefix : String
keep_first : Int
keep_last : Int
keep_whitespace : Bool
} derive(Debug, Eq)
///|
pub fn RedactionConfig::default() -> RedactionConfig {
{
policy: RedactionPolicy::default(),
salt: "moonbit-deid",
token_prefix: "",
keep_first: 1,
keep_last: 1,
keep_whitespace: false,
}
}
///|
pub fn config_with_policy(policy : RedactionPolicy) -> RedactionConfig {
{ ..RedactionConfig::default(), policy, }
}
///|
pub fn config_with_salt(salt : String) -> RedactionConfig {
{ ..RedactionConfig::default(), salt, }
}
///|
pub fn mode_name(mode : ReplacementMode) -> String {
match mode {
Token => "token"
PreserveLength => "preserve-length"
Partial => "partial"
StableHash => "stable-hash"
}
}
///|
pub fn policy_allows_kind(policy : RedactionPolicy, kind : PhiKind) -> Bool {
policy.allowed_kinds.is_empty() || policy.allowed_kinds.contains(kind)
}
///|
pub fn policy_blocks_rule(policy : RedactionPolicy, rule_id : String) -> Bool {
policy.denied_rule_ids.contains(rule_id)
}
///|
pub fn policy_protects(policy : RedactionPolicy, finding : Finding) -> Bool {
finding_intersects_any(
finding,
policy.protected_ranges.map(fn(item) { item.as_span() }),
)
}
///|
pub fn policy_filter_findings(
findings : Array[Finding],
policy : RedactionPolicy,
) -> Array[Finding] {
findings.filter(fn(item) {
policy_allows_kind(policy, item.kind) &&
!policy_blocks_rule(policy, item.rule_id) &&
!policy_protects(policy, item)
})
}
///|
pub fn policy_decisions(
findings : Array[Finding],
policy : RedactionPolicy,
) -> Array[PolicyDecision] {
findings.map(fn(item) {
let action = if policy_protects(policy, item) ||
policy_blocks_rule(policy, item.rule_id) {
Keep
} else if !policy_allows_kind(policy, item.kind) {
Keep
} else {
policy.action
}
{ finding_id: item.id, action, reason: policy.name }
})
}
///|
fn policy_rules(policy : RedactionPolicy, rules : Array[Rule]) -> Array[Rule] {
rules_for_locale(rules, policy.locale).filter(fn(item) {
item.enabled && !policy.denied_rule_ids.contains(item.id)
})
}
///|
fn replacement_with_config(
finding : Finding,
config : RedactionConfig,
) -> String {
let value = finding.text
let base = match config.policy.mode {
Token => finding.replacement
PreserveLength => preserve_shape(value, config.keep_whitespace)
Partial =>
edge_mask(
value,
config.keep_first,
config.keep_last,
config.keep_whitespace,
)
StableHash =>
"[\{phi_kind_name(finding.kind)}]#\{salted_hash(value, config.salt)}"
}
config.token_prefix + base
}
///|
fn preserve_shape(value : String, keep_whitespace : Bool) -> String {
let builder = StringBuilder()
for c in value {
if keep_whitespace && is_blank_char(c) {
builder.write_char(c)
} else if c.is_ascii_digit() {
builder.write_char('0')
} else if c.is_ascii_alphabetic() {
builder.write_char('X')
} else if is_cjk_char(c) {
builder.write_char('*')
} else {
builder.write_char('*')
}
}
builder.to_string()
}
///|
fn edge_mask(
value : String,
first : Int,
last : Int,
keep_whitespace : Bool,
) -> String {
let chars = value.to_array()
let safe_first = if first < 0 {
0
} else if first > chars.length() {
chars.length()
} else {
first
}
let safe_last = if last < 0 {
0
} else if last > chars.length() {
chars.length()
} else {
last
}
if safe_first + safe_last >= chars.length() {
preserve_shape(value, keep_whitespace)
} else {
let builder = StringBuilder()
for i in 0..= chars.length() - safe_last
if keep {
builder.write_char(chars[i])
} else if keep_whitespace && is_blank_char(chars[i]) {
builder.write_char(chars[i])
} else {
builder.write_char('*')
}
}
builder.to_string()
}
}
///|
fn salted_hash(value : String, salt : String) -> String {
let combined = salt + "\u{1f}" + value
stable_hash(combined)
}
///|
pub fn redact_with_config(
input : String,
config : RedactionConfig,
rules? : Array[Rule] = comprehensive_rules(),
) -> DeidResult raise DeidError {
let selected_rules = policy_rules(config.policy, rules)
let scan_options = {
mode: config.policy.mode,
min_confidence: config.policy.min_confidence,
include_disabled_rules: false,
}
let candidates = scan(input, rules=selected_rules, options=scan_options)
let findings = policy_filter_findings(candidates, config.policy)
let replacement_findings = findings.map(fn(item) {
{ ..item, replacement: replacement_with_config(item, config) }
})
match config.policy.action {
Redact => {
let (text, offsets) = apply_findings(input, replacement_findings)
{
text,
findings: replacement_findings,
offsets,
audit: build_audit(input, text, replacement_findings, offsets),
}
}
ReviewOnly | Keep =>
{
text: input,
findings: replacement_findings,
offsets: [],
audit: build_audit(input, input, replacement_findings, []),
}
}
}
///|
pub fn redact_with_policy(
input : String,
policy : RedactionPolicy,
rules? : Array[Rule] = comprehensive_rules(),
) -> DeidResult raise DeidError {
redact_with_config(input, config_with_policy(policy), rules~)
}
///|
pub fn redact_with_salt(
input : String,
salt : String,
rules? : Array[Rule] = comprehensive_rules(),
) -> DeidResult raise DeidError {
redact_with_config(input, config_with_salt(salt), rules~)
}
///|
pub fn mask_value(value : String, mode : ReplacementMode) -> String {
let placeholder = "[REDACTED]"
match mode {
Token => placeholder
PreserveLength => preserve_shape(value, false)
Partial => edge_mask(value, 1, 1, false)
StableHash => placeholder + "#" + stable_hash(value)
}
}
///|
pub fn replacement_preview(finding : Finding, mode : ReplacementMode) -> String {
mask_value(finding.text, mode)
}
///|
pub fn policy_summary(policy : RedactionPolicy) -> String {
[
"name=\{policy.name}",
"locale=\{locale_name(policy.locale)}",
"action=\{action_name(policy.action)}",
"mode=\{mode_name(policy.mode)}",
"min_confidence=\{policy.min_confidence}",
"protected=\{policy.protected_ranges.length()}",
"denied_rules=\{policy.denied_rule_ids.length()}",
].join("\n")
}
///|
pub fn add_protected_range(
policy : RedactionPolicy,
start : Int,
end : Int,
reason : String,
) -> RedactionPolicy {
let ranges = policy.protected_ranges.copy()
ranges.push({ start, end, reason })
{ ..policy, protected_ranges: ranges }
}
///|
pub fn allow_only_kinds(
policy : RedactionPolicy,
kinds : Array[PhiKind],
) -> RedactionPolicy {
{ ..policy, allowed_kinds: kinds.copy() }
}
///|
pub fn deny_rules(
policy : RedactionPolicy,
rule_ids : Array[String],
) -> RedactionPolicy {
let denied = policy.denied_rule_ids.copy()
denied.append(rule_ids)
{ ..policy, denied_rule_ids: denied }
}
///|
pub fn with_policy_mode(
policy : RedactionPolicy,
mode : ReplacementMode,
) -> RedactionPolicy {
{ ..policy, mode, }
}
///|
pub fn with_policy_confidence(
policy : RedactionPolicy,
min_confidence : Int,
) -> RedactionPolicy {
{ ..policy, min_confidence, }
}
///|
pub fn with_policy_locale(
policy : RedactionPolicy,
locale : LocaleHint,
) -> RedactionPolicy {
{ ..policy, locale, }
}