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
5 changes: 5 additions & 0 deletions crates/aisix-admin/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3679,6 +3679,7 @@ fn add_variant_titles(doc: &mut Value) {
"Azure AI Content Safety Prompt Shield",
"Azure AI Content Safety Text Moderation",
"Aliyun Text Moderation",
"PII Detection & Redaction",
],
),
(
Expand Down Expand Up @@ -3803,6 +3804,10 @@ fn add_missing_property_descriptions(doc: &mut Value) {
"/components/schemas/Guardrail/oneOf/4/properties/kind",
"Guardrail provider type. Use `aliyun_text_moderation` for Aliyun text moderation.",
),
(
"/components/schemas/Guardrail/oneOf/5/properties/kind",
"Guardrail provider type. Use `pii` for in-process sensitive-data detection and redaction.",
),
(
"/components/schemas/KeywordPattern/oneOf/0/properties/kind",
"Pattern type. Use `literal` to match the value as plain text.",
Expand Down
141 changes: 141 additions & 0 deletions crates/aisix-core/src/models/guardrail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,87 @@ fn default_aliyun_risk_level_threshold() -> String {
"high".to_owned()
}

/// One built-in detector selection for `kind: "pii"`. The `type` names a
/// detector compiled into the DP (`aisix-guardrails::pii::BUILTIN_DETECTORS`);
/// `action` optionally overrides the guardrail-level `default_action` for
/// this detector only.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PiiDetectorConfig {
/// Built-in detector id: `email`, `china_mobile`, `china_id_card`,
/// `bank_card`, `us_ssn`, `ip_address`, `api_key`, `jwt`, `private_key`.
#[serde(rename = "type")]
#[schemars(length(min = 1))]
pub detector_type: String,
/// Per-detector action override: `mask` or `block`. Falls back to the
/// guardrail's `default_action` when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
}
Comment on lines +334 to +344

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A5 -B2 '"action"' crates/aisix-guardrails/src/pii.rs
rg -n -A5 -B2 'on_buffer_exceeded' crates/aisix-guardrails/src/pii.rs

Repository: api7/aisix

Length of output: 1349


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== guardrail model slice =="
sed -n '300,430p' crates/aisix-core/src/models/guardrail.rs

echo
echo "== search for PII enums / validation helpers =="
rg -n "Pii(Action|Config|CustomPattern|DetectorConfig|on_buffer_exceeded|default_action|warn|skip|invalid|enum)" crates/aisix-core/src/models crates/aisix-guardrails/src

echo
echo "== generated schema PII section =="
rg -n -A8 -B4 '"pii"|on_buffer_exceeded|default_action|detector_type|action' schemas/resources/guardrail.schema.json

Repository: api7/aisix

Length of output: 16164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== build.rs PII parsing slice =="
sed -n '200,260p' crates/aisix-guardrails/src/build.rs

echo
echo "== PiiAction parser slice =="
sed -n '1,90p' crates/aisix-guardrails/src/pii.rs

echo
echo "== search for on_buffer_exceeded parsing =="
rg -n -A4 -B4 "on_buffer_exceeded|fail_open|fail_closed|parse\\(" crates/aisix-guardrails/src/build.rs crates/aisix-guardrails/src/pii.rs

Repository: api7/aisix

Length of output: 35620


Add enums for the PII policy fields
action and default_action already fail fast in the build layer, but the schema still leaves them as free-form strings. PiiConfig.on_buffer_exceeded is the bigger gap: any value other than fail_open is treated as fail_closed. Constrain these wire values so admin-side validation matches the runtime contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-core/src/models/guardrail.rs` around lines 334 - 344, The PII
policy fields are still modeled as free-form strings, so admin validation does
not match the runtime behavior. Update the guardrail schema around
PiiDetectorConfig and the related PiiConfig fields to use enums instead of
String for action, default_action, and on_buffer_exceeded. Make the enum
variants reflect the allowed wire values (mask/block and fail_open/fail_closed),
and keep the serde names aligned so existing JSON stays compatible.


/// One operator-supplied regex detector for `kind: "pii"`.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PiiCustomPattern {
/// Detector name surfaced in the mask token (`[<NAME>_REDACTED]`),
/// telemetry counts, and block reasons. Never the matched value.
#[schemars(length(min = 1, max = 64))]
pub name: String,
/// Regular expression the DP compiles at chain build. Invalid patterns
/// are logged and the row is skipped (same contract as keyword rules).
#[schemars(length(min = 1))]
pub regex: String,
/// Per-pattern action override: `mask` or `block`. Falls back to the
/// guardrail's `default_action` when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
}

/// Config block for `kind: "pii"`. In-process sensitive-data detection and
/// redaction (#932): built-in detectors + custom regex patterns, each with a
/// `mask` (redact-and-continue) or `block` action, applied to request and/or
/// response text per the row's `hook_point`.
///
/// `mask` rewrites each matched span to `[<DETECTOR>_REDACTED]` and lets the
/// request/response continue; `block` rejects with the standard 422
/// content-filter envelope. Matched values never appear in logs, telemetry,
/// or error envelopes — only detector names and match counts do.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PiiConfig {
/// Built-in detectors to enable. Unknown detector ids are rejected at
/// build time (row skipped + warn), so a typo can't silently disarm
/// the policy.
#[serde(default)]
pub detectors: Vec<PiiDetectorConfig>,
/// Operator-supplied regex detectors, evaluated after the built-ins.
#[serde(default)]
pub custom_patterns: Vec<PiiCustomPattern>,
/// Action for detectors that don't set their own: `mask` (default) or
/// `block`.
#[serde(default = "default_pii_action")]
pub default_action: String,

// --- streaming-output controls (consumed by aisix-proxy) ---
// Masking a streamed response requires the whole response to be held
// back (the mask spans chunk boundaries), so kind=pii always uses the
// buffer_full policy on the output hook. These knobs mirror the ACS/
// Aliyun buffer_full parameters.
/// Max bytes buffered for a streamed response before `on_buffer_exceeded` applies.
#[serde(default = "default_acs_max_buffer_bytes")]
#[schemars(range(min = 1))]
pub max_buffer_bytes: u64,
/// Buffer-overflow policy. Use `fail_open` to release output unscanned
/// (and unmasked) when the buffer cap is hit; the default `fail_closed`
/// blocks the response instead.
#[serde(default = "default_acs_on_buffer_exceeded")]
pub on_buffer_exceeded: String,
}

fn default_pii_action() -> String {
"mask".to_owned()
}

fn default_aliyun_window_size() -> u32 {
2_000
}
Expand Down Expand Up @@ -380,6 +461,10 @@ pub enum GuardrailKind {
/// `TextModerationPlus` action on `green-cip.<region>.aliyuncs.com`, on
/// input and/or output, including streaming output.
AliyunTextModeration(AliyunTextModerationConfig),
/// In-process sensitive-data detection + redaction (#932): built-in
/// detectors + custom regex, per-detector `mask`/`block` actions, on
/// input and/or output, including streaming output. Always available.
Pii(PiiConfig),
}

impl GuardrailKind {
Expand All @@ -394,6 +479,7 @@ impl GuardrailKind {
"azure_content_safety_text_moderation"
}
GuardrailKind::AliyunTextModeration(_) => "aliyun_text_moderation",
GuardrailKind::Pii(_) => "pii",
}
}
}
Expand Down Expand Up @@ -903,6 +989,61 @@ mod tests {
}
}

#[test]
fn pii_kind_parses_with_defaults() {
// cp-api omits unset fields (omitempty); the DP must apply the
// documented defaults so a minimal row still works.
let v = json!({
"name": "mask-pii",
"kind": "pii",
"detectors": [
{ "type": "email" },
{ "type": "china_id_card", "action": "block" }
]
});
let g: Guardrail = serde_json::from_value(v).unwrap();
match g.config {
GuardrailKind::Pii(ref c) => {
assert_eq!(c.detectors.len(), 2);
assert_eq!(c.detectors[0].detector_type, "email");
assert_eq!(c.detectors[0].action, None);
assert_eq!(c.detectors[1].action.as_deref(), Some("block"));
assert!(c.custom_patterns.is_empty());
assert_eq!(c.default_action, "mask");
assert_eq!(c.max_buffer_bytes, 262_144);
assert_eq!(c.on_buffer_exceeded, "fail_closed");
}
_ => panic!("expected Pii variant"),
}
}

#[test]
fn pii_kind_round_trips_custom_patterns_and_overrides() {
let v = json!({
"name": "mask-pii",
"kind": "pii",
"default_action": "block",
"custom_patterns": [
{ "name": "employee_id", "regex": "\\bEMP-\\d{6}\\b", "action": "mask" }
],
"max_buffer_bytes": 1024,
"on_buffer_exceeded": "fail_open"
});
let g: Guardrail = serde_json::from_value(v).unwrap();
match g.config {
GuardrailKind::Pii(ref c) => {
assert!(c.detectors.is_empty());
assert_eq!(c.custom_patterns.len(), 1);
assert_eq!(c.custom_patterns[0].name, "employee_id");
assert_eq!(c.custom_patterns[0].action.as_deref(), Some("mask"));
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"),
}
}

#[test]
fn resource_trait_uses_name_and_guardrails_kind() {
let mut g: Guardrail = serde_json::from_value(json!({
Expand Down
3 changes: 2 additions & 1 deletion crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ pub use guardrail::{
AliyunTextModerationConfig, AppliedGuardrail, AzureContentSafetyConfig,
AzureContentSafetyTextModerationConfig, BedrockAWSCredentials, BedrockConfig,
BedrockLatencyMode, Guardrail, GuardrailAttachment, GuardrailHookPoint, GuardrailKind,
GuardrailScopeType, KeywordConfig, KeywordPattern,
GuardrailScopeType, KeywordConfig, KeywordPattern, PiiConfig, PiiCustomPattern,
PiiDetectorConfig,
};
pub use mcp_server::{McpAuthType, McpServer, McpTransport};
pub use model::{
Expand Down
129 changes: 128 additions & 1 deletion crates/aisix-guardrails/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ use async_trait::async_trait;

use crate::index::{GuardrailIndex, RequestContext, ScopeKind};
use crate::keyword::{KeywordBlocklist, KeywordRule};
use crate::{Guardrail, GuardrailChain, GuardrailVerdict, StreamOutputPolicy};
use crate::pii::{builtin_rule, PiiAction, PiiGuardrail, PiiRule};
use crate::{Guardrail, GuardrailChain, GuardrailVerdict, Redaction, StreamOutputPolicy};

/// A snapshot table's guardrail entries in deterministic chain order:
/// `created_at` ascending (RFC3339 strings in a fixed offset compare
Expand Down Expand Up @@ -211,6 +212,58 @@ fn build_one_inner(
};
Ok(Some(Arc::new(blocklist)))
}
GuardrailKind::Pii(cfg) => {
if cfg.detectors.is_empty() && cfg.custom_patterns.is_empty() {
return Ok(None);
}
let default_action =
PiiAction::parse(&cfg.default_action).ok_or_else(|| BuildError::InvalidValue {
field: "default_action",
value: cfg.default_action.clone(),
})?;
let mut rules: Vec<PiiRule> =
Vec::with_capacity(cfg.detectors.len() + cfg.custom_patterns.len());
for d in &cfg.detectors {
let action = match d.action.as_deref() {
None => default_action,
Some(s) => PiiAction::parse(s).ok_or_else(|| BuildError::InvalidValue {
field: "detectors[].action",
value: s.to_owned(),
})?,
};
let rule = builtin_rule(&d.detector_type, action).ok_or_else(|| {
BuildError::InvalidValue {
field: "detectors[].type",
value: d.detector_type.clone(),
}
})?;
rules.push(rule);
}
for p in &cfg.custom_patterns {
let action = match p.action.as_deref() {
None => default_action,
Some(s) => PiiAction::parse(s).ok_or_else(|| BuildError::InvalidValue {
field: "custom_patterns[].action",
value: s.to_owned(),
})?,
};
let rule = PiiRule::new(p.name.clone(), &p.regex, action, None).map_err(|e| {
BuildError::InvalidRegex {
pattern: p.regex.clone(),
source: e,
}
})?;
rules.push(rule);
}
let on_exceeded_fail_open = cfg.on_buffer_exceeded == "fail_open";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate on_buffer_exceeded instead of silently defaulting.

Unknown values currently become fail-closed via == "fail_open", which bypasses the new InvalidValue path and can make a misconfigured row behave differently than intended. Parse both accepted values explicitly.

Proposed fix
-            let on_exceeded_fail_open = cfg.on_buffer_exceeded == "fail_open";
+            let on_exceeded_fail_open = match cfg.on_buffer_exceeded.as_str() {
+                "fail_open" => true,
+                "fail_closed" => false,
+                other => {
+                    return Err(BuildError::InvalidValue {
+                        field: "on_buffer_exceeded",
+                        value: other.to_owned(),
+                    });
+                }
+            };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let on_exceeded_fail_open = cfg.on_buffer_exceeded == "fail_open";
let on_exceeded_fail_open = match cfg.on_buffer_exceeded.as_str() {
"fail_open" => true,
"fail_closed" => false,
other => {
return Err(BuildError::InvalidValue {
field: "on_buffer_exceeded",
value: other.to_owned(),
});
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-guardrails/src/build.rs` at line 258, The
`on_exceeded_fail_open` assignment in `build.rs` currently treats every
non-"fail_open" value as fail-closed, which skips validation and can hide
misconfigurations. Update the parsing logic around `cfg.on_buffer_exceeded` to
explicitly accept only the two supported values and route anything else through
the existing `InvalidValue` handling path. Keep the fix localized to the
`on_exceeded_fail_open`/`on_buffer_exceeded` parsing branch so the behavior is
enforced consistently.

let g = PiiGuardrail::new(
rules,
row.hook_point,
usize::try_from(cfg.max_buffer_bytes).unwrap_or(usize::MAX),
on_exceeded_fail_open,
);
Ok(Some(Arc::new(g)))
}
#[cfg(feature = "bedrock")]
GuardrailKind::Bedrock(cfg) => {
// Phase 2: build the AWS-SDK-backed dispatcher. cp-api
Expand Down Expand Up @@ -299,6 +352,11 @@ enum BuildError {
pattern: String,
source: regex::Error,
},
/// An enum-ish config field carrying a value the DP doesn't know
/// (unknown built-in detector id, unrecognised action). The row is
/// skipped + warned rather than silently running a weaker policy.
#[error("invalid {field} value {value:?}")]
InvalidValue { field: &'static str, value: 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 @@ -331,6 +389,19 @@ struct MonitorGuardrail {
}

impl MonitorGuardrail {
/// Log the mask counts a monitor-mode redacting guardrail WOULD have
/// applied. Counts carry detector names only, never matched values.
fn observe_redaction(&self, hook: &'static str, r: Option<Redaction>) {
if let Some(r) = r {
tracing::info!(
guardrail_name = %self.row_name,
hook,
counts = ?r.counts,
"guardrail in monitor mode observed maskable spans; not redacting (enforcement_mode=monitor)",
);
}
}

fn observe(&self, hook: &'static str, verdict: GuardrailVerdict) -> GuardrailVerdict {
match verdict {
GuardrailVerdict::Block { reason, .. } => {
Expand Down Expand Up @@ -372,6 +443,28 @@ impl Guardrail for MonitorGuardrail {
fn runs_on_output(&self) -> bool {
self.inner.runs_on_output()
}

// Monitor mode observes without modifying: redaction is suppressed the
// same way Block verdicts are downgraded. The would-be mask counts are
// logged so operators can stage a redaction rule and audit its impact
// before enforcing it.
fn redacts_input(&self) -> bool {
false
}

fn redacts_output(&self) -> bool {
false
}

fn redact_input_text(&self, text: &str) -> Option<Redaction> {
self.observe_redaction("input", self.inner.redact_input_text(text));
None
}

fn redact_output_text(&self, text: &str) -> Option<Redaction> {
self.observe_redaction("output", self.inner.redact_output_text(text));
None
}
Comment on lines +451 to +467

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep monitor redaction discoverable so observation runs.

Returning false from redacts_input/redacts_output makes downstream redaction walkers skip the body, so observe_redaction(...) is never called. Mirror the inner probes and keep returning None from redact_*_text to observe without mutating.

Proposed fix
     fn redacts_input(&self) -> bool {
-        false
+        self.inner.redacts_input()
     }
 
     fn redacts_output(&self) -> bool {
-        false
+        self.inner.redacts_output()
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn redacts_input(&self) -> bool {
false
}
fn redacts_output(&self) -> bool {
false
}
fn redact_input_text(&self, text: &str) -> Option<Redaction> {
self.observe_redaction("input", self.inner.redact_input_text(text));
None
}
fn redact_output_text(&self, text: &str) -> Option<Redaction> {
self.observe_redaction("output", self.inner.redact_output_text(text));
None
}
fn redacts_input(&self) -> bool {
self.inner.redacts_input()
}
fn redacts_output(&self) -> bool {
self.inner.redacts_output()
}
fn redact_input_text(&self, text: &str) -> Option<Redaction> {
self.observe_redaction("input", self.inner.redact_input_text(text));
None
}
fn redact_output_text(&self, text: &str) -> Option<Redaction> {
self.observe_redaction("output", self.inner.redact_output_text(text));
None
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-guardrails/src/build.rs` around lines 451 - 467, The monitor
wrapper is not being discovered by downstream redaction walkers because
redacts_input and redacts_output always return false, so observe_redaction in
redact_input_text and redact_output_text never runs. Update the monitor
implementation to mirror the inner probe behavior by reporting that it
participates in redaction discovery while still returning None from
redact_input_text and redact_output_text, and keep the existing
observe_redaction calls on the inner redaction results.

}

/// `mandatory: true` decorator. A remote guardrail that can't reach its
Expand Down Expand Up @@ -440,6 +533,24 @@ impl Guardrail for MandatoryGuardrail {
fn runs_on_output(&self) -> bool {
self.inner.runs_on_output()
}

// Mandatory only rewrites the failure (Bypass) verdict; redaction
// passes straight through to the inner guardrail.
fn redacts_input(&self) -> bool {
self.inner.redacts_input()
}

fn redacts_output(&self) -> bool {
self.inner.redacts_output()
}

fn redact_input_text(&self, text: &str) -> Option<Redaction> {
self.inner.redact_input_text(text)
}

fn redact_output_text(&self, text: &str) -> Option<Redaction> {
self.inner.redact_output_text(text)
}
}

/// Adapter that wraps a snapshot handle and rebuilds the runtime
Expand Down Expand Up @@ -538,6 +649,22 @@ impl Guardrail for LiveGuardrailChain {
fn runs_on_output(&self) -> bool {
self.current().runs_on_output()
}

fn redacts_input(&self) -> bool {
self.current().redacts_input()
}

fn redacts_output(&self) -> bool {
self.current().redacts_output()
}

fn redact_input_text(&self, text: &str) -> Option<Redaction> {
self.current().redact_input_text(text)
}

fn redact_output_text(&self, text: &str) -> Option<Redaction> {
self.current().redact_output_text(text)
}
}

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading