Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions crates/aisix-core/src/models/guardrail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,12 +445,26 @@ pub struct PiiCustomPattern {
pub name: String,
/// Regular expression AISIX compiles when building the guardrail chain.
/// An invalid pattern makes AISIX log and skip the guardrail.
///
/// When the expression declares at least one capture group, a `mask`
/// action rewrites only the first capture group of each match and keeps
/// the rest of the match unchanged. Use this to replace a value while
/// preserving its surrounding key or label, for example
/// `"version"\s*:\s*"([^"]*)"`. Without capture groups, the whole match
/// is rewritten.
#[schemars(length(min = 1))]
pub regex: String,
/// Per-pattern action override. Falls back to the guardrail's
/// `default_action` when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
/// Literal text that replaces the masked span, such as `***`. When
/// omitted, the span is rewritten to `[<NAME>_REDACTED]`. An empty
/// string removes the span. Only valid when the pattern's effective
/// action is `mask`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(length(max = 256))]
pub replacement: Option<String>,
}

/// Config block for `kind: "pii"`. Built-in sensitive-data detection and
Expand Down Expand Up @@ -1464,7 +1478,8 @@ mod tests {
"kind": "pii",
"default_action": "block",
"custom_patterns": [
{ "name": "employee_id", "regex": "\\bEMP-\\d{6}\\b", "action": "mask" }
{ "name": "employee_id", "regex": "\\bEMP-\\d{6}\\b", "action": "mask" },
{ "name": "eda_version", "regex": "version: (\\S+)", "action": "mask", "replacement": "***" }
],
"max_buffer_bytes": 1024,
"on_buffer_exceeded": "fail_open"
Expand All @@ -1473,15 +1488,23 @@ mod tests {
match g.config {
GuardrailKind::Pii(ref c) => {
assert!(c.detectors.is_empty());
assert_eq!(c.custom_patterns.len(), 1);
assert_eq!(c.custom_patterns.len(), 2);
assert_eq!(c.custom_patterns[0].name, "employee_id");
assert_eq!(c.custom_patterns[0].action.as_deref(), Some("mask"));
assert_eq!(c.custom_patterns[0].replacement, None);
assert_eq!(c.custom_patterns[1].replacement.as_deref(), Some("***"));
assert_eq!(c.default_action, "block");
assert_eq!(c.max_buffer_bytes, 1024);
assert_eq!(c.on_buffer_exceeded, "fail_open");
}
_ => panic!("expected Pii variant"),
}
// A row without `replacement` serialises without the key
// (skip_serializing_if), so older documents round-trip untouched.
let ser = serde_json::to_value(&g).unwrap();
let pats = ser["custom_patterns"].as_array().unwrap();
assert!(pats[0].get("replacement").is_none());
assert_eq!(pats[1]["replacement"], "***");
}

#[test]
Expand Down
87 changes: 83 additions & 4 deletions crates/aisix-guardrails/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,12 +249,22 @@ fn build_one_inner(
value: s.to_owned(),
})?,
};
let rule = PiiRule::new(p.name.clone(), &p.regex, action, None).map_err(|e| {
BuildError::InvalidRegex {
// `replacement` only means something to a mask rewrite; on a
// block-action pattern it would be accepted-but-unread config
// (the #962 class), so the row is rejected instead. cp-api
// validates the same combination at write time; this covers
// the declarative-file source and defends the invariant.
if p.replacement.is_some() && action == PiiAction::Block {
return Err(BuildError::ReplacementOnBlock {
name: p.name.clone(),
});
}
let rule = PiiRule::new(p.name.clone(), &p.regex, action, None)
.map_err(|e| BuildError::InvalidRegex {
pattern: p.regex.clone(),
source: e,
}
})?;
})?
.with_replacement(p.replacement.clone());
rules.push(rule);
}
let on_exceeded_fail_open = cfg.on_buffer_exceeded == "fail_open";
Expand Down Expand Up @@ -459,6 +469,14 @@ enum BuildError {
/// skipped + warned rather than silently running a weaker policy.
#[error("invalid {field} value {value:?}")]
InvalidValue { field: &'static str, value: String },
/// A `custom_patterns[].replacement` on a pattern whose effective
/// action is `block` — the replacement would never be read (never
/// half-honor a knob, #963). Carries the pattern NAME only, never
/// the replacement text or a matched value.
#[error(
"custom_patterns[].replacement requires action=mask (pattern {name:?} resolves to block)"
)]
ReplacementOnBlock { name: String },
/// A guardrail kind whose runtime dispatch was compiled out via
/// feature flags (e.g. a pruned build that excluded `--features bedrock`
/// or `--features azure-content-safety`). The chain treats the row as
Expand Down Expand Up @@ -1459,6 +1477,67 @@ mod tests {
);
}

/// AISIX-Cloud#1334: a capture-group custom pattern with a
/// `replacement` builds and rewrites only group 1 through the chain.
#[test]
fn pii_custom_pattern_replacement_and_group_build_from_row() {
let table: ResourceTable<DomainGuardrail> = ResourceTable::default();
table.insert(entry(
"eda",
"g-1",
parse(
r#"{
"name": "eda",
"kind": "pii",
"custom_patterns": [{
"name": "eda_version",
"regex": "version\\s*:\\s*(\\d+(?:\\.\\d+)+)",
"action": "mask",
"replacement": "***"
}]
}"#,
),
));
let chain = build_chain_from_snapshot(&table, None);
assert_eq!(chain.len(), 1);
let r = chain.redact_input_text("tool version: 12.1 done").unwrap();
assert_eq!(r.text, "tool version: *** done");
}

/// A `replacement` on a pattern whose effective action is `block`
/// (explicit or via `default_action`) rejects the row — the knob
/// would otherwise be accepted but never read (#963).
#[test]
fn pii_replacement_on_block_action_rejects_row() {
for row in [
// Explicit per-pattern block.
r#"{
"name": "bad-explicit",
"kind": "pii",
"custom_patterns": [{
"name": "p", "regex": "x(y)", "action": "block", "replacement": "*"
}]
}"#,
// Inherited block via default_action.
r#"{
"name": "bad-inherited",
"kind": "pii",
"default_action": "block",
"custom_patterns": [{
"name": "p", "regex": "x(y)", "replacement": "*"
}]
}"#,
] {
let table: ResourceTable<DomainGuardrail> = ResourceTable::default();
table.insert(entry("bad", "g-1", parse(row)));
let chain = build_chain_from_snapshot(&table, None);
assert!(
chain.is_empty(),
"replacement+block row must be skipped, not half-honored",
);
}
}

/// A stub remote guardrail that always fails open (returns `Bypass`),
/// standing in for a Bedrock/Azure guardrail whose upstream is down.
struct AlwaysBypass;
Expand Down
Loading