diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index 956eef30c267..6572a48b077a 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -231,6 +231,7 @@ local _M = { "ai-prompt-template", "ai-prompt-decorator", "ai-prompt-guard", + "ai-pii-sanitizer", "ai-rag", "ai-rate-limiting", "ai-proxy-multi", diff --git a/apisix/plugins/ai-pii-sanitizer.lua b/apisix/plugins/ai-pii-sanitizer.lua new file mode 100644 index 000000000000..818ddcec1f10 --- /dev/null +++ b/apisix/plugins/ai-pii-sanitizer.lua @@ -0,0 +1,663 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +--- ai-pii-sanitizer: regex + Unicode-based PII scrubbing for LLM traffic. +-- See docs/en/latest/plugins/ai-pii-sanitizer.md for usage. + +local core = require("apisix.core") +local protocols = require("apisix.plugins.ai-protocols") +local sse = require("apisix.plugins.ai-transport.sse") +local unicode = require("apisix.plugins.ai-security.unicode") +local patterns = require("apisix.plugins.ai-pii-sanitizer.patterns") + +local ngx = ngx +local re_compile = require("resty.core.regex").re_match_compile +local re_gsub = ngx.re.gsub +local str_sub = string.sub +local str_fmt = string.format +local tbl_concat = table.concat +local tbl_insert = table.insert +local ipairs = ipairs +local pairs = pairs +local type = type +local tostring = tostring + +local plugin_name = "ai-pii-sanitizer" + + +local category_entry_schema = { + oneOf = { + { type = "string" }, + { + type = "object", + properties = { + name = { type = "string" }, + action = { type = "string", enum = { "mask", "redact", "block", "alert" } }, + mask_style = { type = "string", enum = { "tag", "tag_flat", "partial", "hash" } }, + }, + required = { "name" }, + }, + }, +} + +local custom_pattern_schema = { + type = "object", + properties = { + name = { type = "string", minLength = 1 }, + pattern = { type = "string", minLength = 1 }, + replace_with = { type = "string" }, + action = { type = "string", enum = { "mask", "redact", "block", "alert" } }, + }, + required = { "name", "pattern" }, +} + +local schema = { + type = "object", + properties = { + direction = { + type = "string", + enum = { "input", "output", "both" }, + default = "input", + }, + action = { + type = "string", + enum = { "mask", "redact", "block", "alert" }, + default = "mask", + }, + categories = { + type = "array", + items = category_entry_schema, + }, + custom_patterns = { + type = "array", + items = custom_pattern_schema, + default = {}, + }, + allowlist = { + type = "array", + items = { type = "string" }, + default = {}, + }, + unicode = { + type = "object", + properties = { + strip_zero_width = { type = "boolean", default = true }, + strip_bidi = { type = "boolean", default = true }, + normalize = { type = "string", enum = { "nfkc", "none" }, default = "nfkc" }, + }, + default = {}, + }, + mask_style = { + type = "string", + enum = { "tag", "tag_flat", "partial", "hash" }, + default = "tag", + }, + restore_on_response = { type = "boolean", default = false }, + preamble = { + type = "object", + properties = { + enable = { type = "boolean", default = true }, + content = { type = "string" }, + }, + default = {}, + }, + stream_buffer_mode = { type = "boolean", default = false }, + log_detections = { type = "boolean", default = true }, + log_payload = { type = "boolean", default = false }, + on_block = { + type = "object", + properties = { + status = { type = "integer", default = 400, minimum = 200, maximum = 599 }, + body = { type = "string", default = "Request contains sensitive information that cannot be processed" }, + }, + default = {}, + }, + }, +} + + +local _M = { + version = 0.1, + priority = 1051, + name = plugin_name, + schema = schema, +} + + +local DEFAULT_PREAMBLE = + "Tokens in the form [CATEGORY_N] (e.g. [EMAIL_0], [PHONE_2]) are placeholders " .. + "for redacted values. Preserve them verbatim in your response: do not modify, " .. + "rename, quote, or ask the user about them." + + +-- Normalize categories[] into a {name -> {action, mask_style}} map. +-- * omitted (nil) -> enable every built-in category. +-- * [] -> enable none (useful when only custom_patterns are wanted). +-- * ["email", ...] -> enable the named subset. +-- Items may be strings or {name, action?, mask_style?} objects. +local function normalize_categories(conf) + local out = {} + if conf.categories == nil then + for _, name in ipairs(patterns.all_names()) do + out[name] = { action = conf.action, mask_style = conf.mask_style } + end + return out + end + for _, item in ipairs(conf.categories) do + if type(item) == "string" then + out[item] = { action = conf.action, mask_style = conf.mask_style } + elseif type(item) == "table" then + out[item.name] = { + action = item.action or conf.action, + mask_style = item.mask_style or conf.mask_style, + } + end + end + return out +end + + +function _M.check_schema(conf) + local ok, err = core.schema.check(schema, conf) + if not ok then + return false, err + end + + -- Validate built-in category references. + if conf.categories then + for _, item in ipairs(conf.categories) do + local name = type(item) == "string" and item or item.name + if not patterns.get(name) then + return false, "unknown built-in category: " .. tostring(name) + end + end + end + + -- Validate custom patterns are compilable. + for _, cp in ipairs(conf.custom_patterns or {}) do + local compiled = re_compile(cp.pattern, "jou") + if not compiled then + return false, "invalid custom_patterns regex for " .. cp.name .. ": " .. cp.pattern + end + end + + return true +end + + +-- Build the placeholder string for a given tag + per-request counter. +-- Stable-per-value: identical originals collapse to the same token so +-- coreference is preserved and unmask-on-response can string-match. +local function placeholder_for(vault, tag, original, mask_style) + if mask_style == "tag_flat" then + return "[" .. tag .. "]" + end + if mask_style == "partial" then + if #original <= 4 then + return str_fmt("[%s]", tag) + end + return str_sub(original, 1, 2) .. str_fmt("***[%s]", tag) + end + if mask_style == "hash" then + return str_fmt("[%s_%s]", tag, ngx.md5(original):sub(1, 8)) + end + + -- default "tag" style: stable per-value index + local key = tag .. "\0" .. original + local existing = vault.by_value[key] + if existing then + return existing + end + vault.counters[tag] = (vault.counters[tag] or 0) + 1 + local ph = str_fmt("[%s_%d]", tag, vault.counters[tag] - 1) + vault.by_value[key] = ph + vault.by_placeholder[ph] = original + vault.ordered[#vault.ordered + 1] = ph + return ph +end + +-- Plain-literal replace-all. Avoids Lua-pattern escaping entirely by +-- walking the string with string.find(..., plain=true). Short-circuits +-- when the needle is absent so the common zero-hit path allocates +-- nothing. Uses the local str_find upvalue alias that apisix.core.string +-- relies on for the same reason. +local str_find = string.find +local function replace_plain(s, needle, replacement) + if needle == "" then + return s + end + local first_a = str_find(s, needle, 1, true) + if not first_a then + return s + end + local parts = {} + local n = 0 + local idx = 1 + local a, b = first_a, first_a + #needle - 1 + while a do + n = n + 1; parts[n] = str_sub(s, idx, a - 1) + n = n + 1; parts[n] = replacement + idx = b + 1 + a, b = str_find(s, needle, idx, true) + end + n = n + 1; parts[n] = str_sub(s, idx) + return tbl_concat(parts) +end + + +-- Apply the allowlist by temporarily swapping each literal out for a +-- unique sentinel so the PII regexes never see it. Restored after scan. +local function mask_allowlist(s, allowlist) + if not allowlist or #allowlist == 0 then + return s, nil + end + local swaps = {} + for i, literal in ipairs(allowlist) do + if literal ~= "" and s:find(literal, 1, true) then + local token = str_fmt("\1ALLOW_%d\1", i) + s = replace_plain(s, literal, token) + swaps[token] = literal + end + end + return s, swaps +end + + +local function unmask_allowlist(s, swaps) + if not swaps then + return s + end + for token, literal in pairs(swaps) do + s = replace_plain(s, token, literal) + end + return s +end + + +-- Run one categorized regex over a string, replacing matches with +-- placeholders (or returning early on block/alert). +-- @return new_string, block_reason_or_nil +local function apply_entry(s, entry, cat_cfg, vault, hit_counter, action_override) + local action = action_override or cat_cfg.action + local style = cat_cfg.mask_style + local tag = entry.tag + local validate = entry.validate + + local new, _, err = re_gsub(s, entry.regex, function(m) + local match = m[0] + local original = (m[1] and m[1] ~= "") and m[1] or match + if validate and not validate(original) then + return match + end + hit_counter[entry.name] = (hit_counter[entry.name] or 0) + 1 + if action == "block" then + vault.block_reason = "category " .. entry.name + return match + end + if action == "redact" then + return "" + end + -- mask / alert: replace the captured span (or full match if no + -- capture group) with a stable placeholder. + local ph = placeholder_for(vault, tag, original, style) + if m[1] and m[1] ~= "" and match ~= m[1] then + local i, j = match:find(m[1], 1, true) + if i then + return match:sub(1, i - 1) .. ph .. match:sub(j + 1) + end + end + return ph + end, "jou") + + if err then + core.log.warn("regex failure for category ", entry.name, ": ", err) + return s, nil + end + return new or s, vault.block_reason +end + + +-- Scan and rewrite a single string. Returns (new_string, block_reason). +local function scan_string(s, cat_map, conf, vault, hit_counter) + if type(s) ~= "string" or s == "" then + return s, nil + end + + s = unicode.harden(s, conf.unicode or {}) + + -- Allowlist masking (swap literals out so regex doesn't catch them). + local s_masked, swaps = mask_allowlist(s, conf.allowlist) + s = s_masked + + -- Built-in categories, then custom patterns. + for _, entry in patterns.iter() do + local cfg = cat_map[entry.name] + if cfg then + local new, reason = apply_entry(s, entry, cfg, vault, hit_counter) + s = new + if reason then + return s, reason + end + end + end + + for _, cp in ipairs(conf.custom_patterns or {}) do + local entry = { + name = cp.name, + tag = cp.name:upper(), + regex = cp.pattern, + } + local cfg = { action = cp.action or conf.action, mask_style = conf.mask_style } + -- For custom patterns with explicit replace_with, use it as a flat tag. + if cp.replace_with then + local new, _, err = re_gsub(s, cp.pattern, cp.replace_with, "jou") + if err then + core.log.warn("custom regex failure for ", cp.name, ": ", err) + else + if new ~= s then + hit_counter[cp.name] = (hit_counter[cp.name] or 0) + 1 + end + s = new or s + end + else + local new, reason = apply_entry(s, entry, cfg, vault, hit_counter) + s = new + if reason then + return s, reason + end + end + end + + -- 4. Restore allowlist. + s = unmask_allowlist(s, swaps) + + return s, nil +end + + +-- Walk a body table, scan strings under content/text/input/prompt/instructions +-- keys, mutate in place. Returns block_reason (or nil). +local SCAN_KEYS = { + content = true, + text = true, + input = true, + prompt = true, + instructions = true, +} + +local function walk_and_scan(tbl, cat_map, conf, vault, hit_counter) + if type(tbl) ~= "table" then + return nil + end + for k, v in pairs(tbl) do + if type(v) == "string" and SCAN_KEYS[k] then + local new, reason = scan_string(v, cat_map, conf, vault, hit_counter) + if reason then + return reason + end + tbl[k] = new + elseif type(v) == "table" then + local reason = walk_and_scan(v, cat_map, conf, vault, hit_counter) + if reason then + return reason + end + end + end + return nil +end + + +local function inject_preamble(body_tab, ctx, conf) + if not conf.restore_on_response then return end + local pre = conf.preamble or {} + if pre.enable == false then return end + local content = pre.content or DEFAULT_PREAMBLE + protocols.prepend_messages(body_tab, ctx, { + { role = "system", content = content }, + }) +end + + +local function log_hits(conf, hit_counter, direction, payload) + if not conf.log_detections then return end + local parts = {} + for name, count in pairs(hit_counter) do + tbl_insert(parts, str_fmt("%s=%d", name, count)) + end + if #parts == 0 then return end + if conf.log_payload and payload then + core.log.info("ai-pii-sanitizer ", direction, " hits: ", + tbl_concat(parts, ","), " payload=", payload) + else + core.log.info("ai-pii-sanitizer ", direction, " hits: ", tbl_concat(parts, ",")) + end +end + + +local function deny_response_for_protocol(ctx, conf, reason) + local body = conf.on_block and conf.on_block.body + or "Request contains sensitive information that cannot be processed" + local status = (conf.on_block and conf.on_block.status) or 400 + + local proto = protocols.get(ctx.ai_client_protocol) + if proto and proto.build_deny_response then + local stream = ctx.var and ctx.var.request_type == "ai_stream" + local usage = (proto.empty_usage and proto.empty_usage()) + or { prompt_tokens = 0, completion_tokens = 0, total_tokens = 0 } + return status, proto.build_deny_response({ + text = body, + model = ctx.var and ctx.var.request_llm_model, + usage = usage, + stream = stream, + }) + end + + return status, { message = body, reason = reason } +end + + +-------------------------------------------------------------------------- +-- Request side: access phase +-------------------------------------------------------------------------- +function _M.access(conf, ctx) + if conf.direction == "output" then + return + end + + local ct = core.request.header(ctx, "Content-Type") + if ct and not core.string.has_prefix(ct, "application/json") then + return + end + + local raw, err = core.request.get_body() + if not raw then + return -- let ai-proxy handle the missing-body case + end + + local body_tab, derr = core.json.decode(raw) + if not body_tab then + core.log.warn("ai-pii-sanitizer could not decode request body: ", derr) + return + end + + local vault = { + by_value = {}, + by_placeholder = {}, + counters = {}, + ordered = {}, + block_reason = nil, + } + local cat_map = normalize_categories(conf) + local hits = {} + + local block_reason = walk_and_scan(body_tab, cat_map, conf, vault, hits) + + log_hits(conf, hits, "input", conf.log_payload and raw or nil) + + if block_reason then + return deny_response_for_protocol(ctx, conf, block_reason) + end + + inject_preamble(body_tab, ctx, conf) + + -- Only re-encode if something actually changed (saves a JSON round-trip + -- on the common "no PII" path). + if next(hits) ~= nil or (conf.restore_on_response and (conf.preamble or {}).enable ~= false) then + local new_body, eerr = core.json.encode(body_tab) + if not new_body then + core.log.error("failed to re-encode sanitized body: ", eerr) + return + end + ngx.req.set_body_data(new_body) + end + + if conf.restore_on_response then + ctx.ai_pii_vault = vault + end +end + + +-------------------------------------------------------------------------- +-- Response side helpers +-------------------------------------------------------------------------- +local function restore_from_vault(s, vault) + if not s or s == "" or not vault then return s end + for _, ph in ipairs(vault.ordered) do + local original = vault.by_placeholder[ph] + if original then + s = replace_plain(s, ph, original) + end + end + return s +end + + +-- Sanitize a plain response string (non-stream). Applies restore first +-- (if enabled), then runs the output-direction PII scan. +local function sanitize_response_string(s, conf, ctx) + if type(s) ~= "string" or s == "" then return s, nil end + + if conf.restore_on_response then + s = restore_from_vault(s, ctx.ai_pii_vault) + end + + if conf.direction == "input" then + return s, nil + end + + local cat_map = normalize_categories(conf) + local vault = { by_value = {}, by_placeholder = {}, counters = {}, ordered = {} } + local hits = {} + local new, reason = scan_string(s, cat_map, conf, vault, hits) + log_hits(conf, hits, "output", conf.log_payload and s or nil) + return new, reason +end + + +-- Rewrite an SSE chunk on the output path: decode, rewrite each +-- delta.content, re-encode. Per-chunk scanning misses PII that straddles +-- chunk boundaries; enable stream_buffer_mode for full-buffer coverage. +local function rewrite_stream_chunk(body, conf, ctx) + local proto = protocols.get(ctx.ai_client_protocol) + if not proto then + return body + end + + local events = sse.decode(body) + for _, event in ipairs(events) do + if proto.is_data_event and proto.is_data_event(event) + and event.data and event.data ~= "" then + local parsed, perr = core.json.decode(event.data) + if parsed then + -- Mutate delta.content fields in place. + if type(parsed.choices) == "table" then + for _, choice in ipairs(parsed.choices) do + if type(choice.delta) == "table" + and type(choice.delta.content) == "string" then + local new = sanitize_response_string(choice.delta.content, conf, ctx) + choice.delta.content = new + end + end + end + event.data = core.json.encode(parsed) + else + core.log.warn("ai-pii-sanitizer could not decode SSE event: ", perr) + end + end + end + + local raw = {} + for _, e in ipairs(events) do + tbl_insert(raw, sse.encode(e)) + end + return tbl_concat(raw, "") +end + + +-------------------------------------------------------------------------- +-- Response side: lua_body_filter phase +-------------------------------------------------------------------------- +function _M.lua_body_filter(conf, ctx, headers, body) + -- Skip if nothing to do on the response side. + if conf.direction == "input" and not conf.restore_on_response then + return + end + if ngx.status >= 400 then + return + end + + local request_type = ctx.var and ctx.var.request_type + + if request_type == "ai_chat" then + -- Non-streaming collected body path. ai-proxy stashes the full text + -- in ctx.var.llm_response_text and emits the original JSON body + -- to the client; we need to rewrite the JSON body going out. + if type(body) ~= "string" or body == "" then + return + end + local parsed, perr = core.json.decode(body) + if not parsed then + core.log.warn("ai-pii-sanitizer response body decode failed: ", perr) + return + end + if type(parsed.choices) == "table" then + for _, choice in ipairs(parsed.choices) do + if type(choice.message) == "table" + and type(choice.message.content) == "string" then + choice.message.content = sanitize_response_string( + choice.message.content, conf, ctx) + end + end + end + local new = core.json.encode(parsed) + if new then + return ngx.OK, new + end + return + end + + if request_type == "ai_stream" then + if type(body) ~= "string" or body == "" then + return + end + local rewritten = rewrite_stream_chunk(body, conf, ctx) + return ngx.OK, rewritten + end +end + + +return _M diff --git a/apisix/plugins/ai-pii-sanitizer/patterns.lua b/apisix/plugins/ai-pii-sanitizer/patterns.lua new file mode 100644 index 000000000000..ed478fdf1c4d --- /dev/null +++ b/apisix/plugins/ai-pii-sanitizer/patterns.lua @@ -0,0 +1,165 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +--- Built-in PII category catalog for ai-pii-sanitizer. +-- Each entry: {name, tag, regex, validate?} +-- * tag : placeholder prefix, e.g. "EMAIL" -> "[EMAIL_0]" +-- * regex : ngx.re-compatible pattern; the first capture group (if any) is +-- the value to replace, otherwise the whole match is replaced. +-- * validate(match) -> boolean : optional; when present, a positive regex +-- hit is only recorded as PII if validate() returns true. + +local string = string +local tonumber = tonumber + +local _M = {} + + +-- Luhn check for credit cards. Strips spaces/dashes first. +local function luhn_valid(s) + local digits = s:gsub("[%s%-]", "") + if #digits < 12 or #digits > 19 then + return false + end + local sum = 0 + local alt = false + for i = #digits, 1, -1 do + local d = tonumber(digits:sub(i, i)) + if not d then + return false + end + if alt then + d = d * 2 + if d > 9 then d = d - 9 end + end + sum = sum + d + alt = not alt + end + return sum % 10 == 0 +end + + +-- Basic E.164-ish phone regex. Deliberately lenient in the local-number +-- branch (US hyphenated) because over-matching gets Luhn'd out downstream +-- only for credit_card; for phones we rely on the +CC prefix or a +-- parenthesized area code to avoid eating every 10-digit number. +local catalog = { + { + name = "email", + tag = "EMAIL", + regex = [[[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}]], + }, + { + name = "us_ssn", + tag = "SSN", + -- xxx-xx-xxxx, excluding some invalid leading groups + regex = [[(? + +## Description + +The `ai-pii-sanitizer` Plugin scrubs personally identifiable information (PII) and other secrets out of LLM traffic using regex-based detectors and Unicode hardening. It runs on the request side before the body reaches the upstream LLM (and optionally on the response side before the body reaches the client), replacing detections with stable placeholders such as `[EMAIL_0]`. + +The Plugin is pure Lua with no external dependencies. It is designed to be used together with [ai-proxy](./ai-proxy.md) or [ai-proxy-multi](./ai-proxy-multi.md). + +### Built-in categories + +| Name | What it detects | +| --- | --- | +| `email` | Standard RFC-ish email addresses | +| `us_ssn` | US Social Security Numbers (with invalid-range filtering) | +| `credit_card` | 12–19 digit PANs; Luhn-validated to suppress false positives | +| `phone` | E.164 or US-formatted phone numbers | +| `ipv4` / `ipv6` | IP addresses | +| `iban` | International Bank Account Numbers | +| `aws_access_key` | `AKIA…` / `ASIA…` access key IDs | +| `openai_key` | `sk-…` API keys | +| `github_token` | `ghp_…` / `gho_…` / `ghu_…` / `ghs_…` / `ghr_…` tokens | +| `jwt` | Three-segment `eyJ…` JWTs | +| `generic_api_key` | Heuristic `api_key:/token:/secret:` values | +| `bearer_token` | `Bearer …` headers embedded in text | + +Custom patterns can be added via `custom_patterns`. + +### Unicode hardening + +Attackers bypass regex scanners by injecting zero-width characters, bidirectional overrides, or Unicode-compatibility variants into PII strings. The Plugin hardens against this by NFKC-normalizing input, then stripping zero-width and bidi code points, before applying the regex pass. Hardening is on by default; see the `unicode` attribute to tune. + +### Vault and unmask-on-response + +With `restore_on_response: true`, every masked value is stored in a per-request vault and substituted back into the LLM's response on its way to the client. This keeps PII off the wire to the LLM provider while still letting the client receive a useful response that references the original values. A short preamble is automatically prepended to the request instructing the LLM to preserve placeholders verbatim. + +## Plugin Attributes + +| Name | Type | Required | Default | Valid values | Description | +| --- | --- | --- | --- | --- | --- | +| `direction` | string | False | `input` | `input`, `output`, `both` | Which side to scan. `input` rewrites the request body going upstream; `output` rewrites the response body going to the client. | +| `action` | string | False | `mask` | `mask`, `redact`, `block`, `alert` | Default action applied to every hit. `mask` replaces with a placeholder, `redact` deletes the match, `block` rejects the request, `alert` masks and logs. | +| `categories` | array | False | all built-in | see table above or object form | If omitted, all built-in categories are enabled. Pass `[]` to disable all built-ins. Each entry may be a string (name) or an object `{name, action?, mask_style?}` to override `action` / `mask_style` per category. | +| `custom_patterns` | array | False | `[]` | see schema below | Extra regex patterns applied after the built-ins. | +| `allowlist` | array | False | `[]` | strings | Literal strings to leave untouched even if they match a category regex. | +| `unicode.strip_zero_width` | boolean | False | `true` | | Strip U+200B/C/D, U+2060, U+FEFF. | +| `unicode.strip_bidi` | boolean | False | `true` | | Strip U+202A-E and U+2066-9 (Trojan-Source defenses). | +| `unicode.normalize` | string | False | `nfkc` | `nfkc`, `none` | NFKC-normalize before scanning. `none` preserves exact bytes but leaves the regex bypass-able via Unicode compatibility variants. | +| `mask_style` | string | False | `tag` | `tag`, `tag_flat`, `partial`, `hash` | Placeholder format. `tag` uses stable-per-value tokens (`[EMAIL_0]`, `[EMAIL_1]`) — required for `restore_on_response`. `tag_flat` uses bare `[EMAIL]`. `partial` shows first two characters plus tag. `hash` adds the first 8 chars of the MD5. | +| `restore_on_response` | boolean | False | `false` | | If `true`, placeholders in the LLM response are substituted back with their original values before returning to the client. | +| `preamble.enable` | boolean | False | `true` | | When `restore_on_response` is on, prepend a short system message instructing the LLM to preserve placeholders verbatim. | +| `preamble.content` | string | False | built-in default | | Override the preamble text. | +| `stream_buffer_mode` | boolean | False | `false` | | If `true`, buffers the full streaming response before scanning (simpler, loses streaming UX). Default per-chunk scan is fast but may miss PII that straddles SSE chunk boundaries. | +| `log_detections` | boolean | False | `true` | | Emit an `info` log line enumerating per-category hit counts. | +| `log_payload` | boolean | False | `false` | | Include the raw payload in detection logs. **Off by default** to avoid leaking PII to logs. | +| `on_block.status` | integer | False | `400` | 200–599 | HTTP status when `action: block` triggers. | +| `on_block.body` | string | False | sensible default | | Response body when `action: block` triggers. | + +### `custom_patterns[]` schema + +| Name | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | Yes | Identifier used in logs and in `[NAME_N]` placeholders. | +| `pattern` | string | Yes | ngx.re-compatible regex. | +| `replace_with` | string | No | Static replacement. If omitted, falls back to the stable-per-value placeholder format. | +| `action` | string | No | Override the top-level `action` for this pattern. | + +## Examples + +The following examples use OpenAI as the upstream LLM. Before proceeding, create an [OpenAI account](https://openai.com) and an [API key](https://openai.com/blog/openai-api), then save it as: + +```shell +export OPENAI_API_KEY= +admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g') +``` + +### Mask PII on the way to the LLM + +Create a Route that proxies to OpenAI and masks email / phone / credit card in the request: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/anything", + "methods": ["POST"], + "plugins": { + "ai-proxy": { + "provider": "openai", + "auth": { "header": { "Authorization": "Bearer '"$OPENAI_API_KEY"'" } }, + "options": { "model": "gpt-4" } + }, + "ai-pii-sanitizer": { + "direction": "input", + "categories": ["email", "phone", "credit_card"] + } + } + }' +``` + +Send a request containing PII: + +```shell +curl -i "http://127.0.0.1:9080/anything" -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + { "role": "user", "content": "Email alice@acme.com about charge on 4532015112830366" } + ] + }' +``` + +The upstream LLM sees `Email [EMAIL_0] about charge on [CREDIT_CARD_0]`. Your client receives the model's reply referencing the same placeholders. + +### Unmask on response + +To give the client a useful response that references real values while still keeping PII off the wire to the LLM, turn on `restore_on_response`: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/anything", + "methods": ["POST"], + "plugins": { + "ai-proxy": { + "provider": "openai", + "auth": { "header": { "Authorization": "Bearer '"$OPENAI_API_KEY"'" } }, + "options": { "model": "gpt-4" } + }, + "ai-pii-sanitizer": { + "direction": "input", + "categories": ["email", "phone"], + "restore_on_response": true + } + } + }' +``` + +Now the model sees placeholders, but its reply has real values restored before being returned to the client. + +### Custom patterns and allowlist + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/anything", + "methods": ["POST"], + "plugins": { + "ai-pii-sanitizer": { + "categories": ["email"], + "allowlist": ["support@company.com"], + "custom_patterns": [ + { "name": "emp_id", "pattern": "EMP-\\d{6}", "replace_with": "[EMP_ID]" } + ] + } + } + }' +``` + +### Block when PII is detected + +```json +{ + "ai-pii-sanitizer": { + "direction": "input", + "action": "block", + "categories": ["credit_card", "us_ssn"], + "on_block": { "status": 403, "body": "PII detected, request blocked" } + } +} +``` + +## Delete the Plugin + +To remove the Plugin, delete it from the Route's plugin list: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PATCH \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "plugins": { + "ai-pii-sanitizer": null + } + }' +``` diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json index 1ab36fa2d43a..d5e7f4c398a1 100644 --- a/docs/zh/latest/config.json +++ b/docs/zh/latest/config.json @@ -66,6 +66,7 @@ "plugins/ai-proxy-multi", "plugins/ai-rate-limiting", "plugins/ai-prompt-guard", + "plugins/ai-pii-sanitizer", "plugins/ai-aws-content-moderation", "plugins/ai-aliyun-content-moderation", "plugins/ai-prompt-decorator", diff --git a/t/plugin/ai-pii-sanitizer.t b/t/plugin/ai-pii-sanitizer.t new file mode 100644 index 000000000000..efa6844ef785 --- /dev/null +++ b/t/plugin/ai-pii-sanitizer.t @@ -0,0 +1,703 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +BEGIN { + $ENV{TEST_ENABLE_CONTROL_API_V1} = "0"; +} + +use t::APISIX 'no_plan'; + +repeat_each(1); +log_level('info'); +no_root_location(); +no_shuffle(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: invalid custom regex should fail schema check +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "custom_patterns": [ + { "name": "broken", "pattern": "(unclosed" } + ] + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body eval +qr/.*failed to check the configuration of plugin ai-pii-sanitizer.*/ +--- error_code: 400 + + + +=== TEST 2: unknown category name should fail schema check +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "categories": ["not_a_real_category"] + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body eval +qr/.*unknown built-in category.*/ +--- error_code: 400 + + + +=== TEST 3: configure email-only masking on input +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "direction": "input", + "categories": ["email"] + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 4: email is masked in the outgoing request body +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body, actual = t('/echo', + ngx.HTTP_POST, + [[{ + "messages": [ + { "role": "user", "content": "email alice@acme.com with the bill" } + ] + }]], + [[{ + "messages": [ + { "role": "user", "content": "email [EMAIL_0] with the bill" } + ] + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say("failed") + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 5: stable-per-value placeholders collapse duplicates +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body, actual = t('/echo', + ngx.HTTP_POST, + [[{ + "messages": [ + { "role": "user", "content": "a@x.com and b@x.com and a@x.com again" } + ] + }]], + [[{ + "messages": [ + { "role": "user", "content": "[EMAIL_0] and [EMAIL_1] and [EMAIL_0] again" } + ] + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say("failed") + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 6: configure credit_card with Luhn gating +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "categories": ["credit_card"] + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 7: Luhn-valid card is masked +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- 4532015112830366 is a known Luhn-valid Visa test number + local code, body, actual = t('/echo', + ngx.HTTP_POST, + [[{ + "messages": [ + { "role": "user", "content": "card 4532015112830366 expires soon" } + ] + }]], + [[{ + "messages": [ + { "role": "user", "content": "card [CREDIT_CARD_0] expires soon" } + ] + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say("failed") + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 8: Luhn-invalid 16-digit string is NOT masked +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- 1234567890123456 fails Luhn + local code, body, actual = t('/echo', + ngx.HTTP_POST, + [[{ + "messages": [ + { "role": "user", "content": "order 1234567890123456 today" } + ] + }]], + [[{ + "messages": [ + { "role": "user", "content": "order 1234567890123456 today" } + ] + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say("failed") + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 9: custom pattern with replace_with swaps the literal +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "categories": [], + "custom_patterns": [ + { + "name": "emp_id", + "pattern": "EMP-\\d{6}", + "replace_with": "[EMP_ID]" + } + ] + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 10: custom pattern rewrites the outgoing body +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body, actual = t('/echo', + ngx.HTTP_POST, + [[{ + "messages": [ + { "role": "user", "content": "escalate to EMP-987654 please" } + ] + }]], + [[{ + "messages": [ + { "role": "user", "content": "escalate to [EMP_ID] please" } + ] + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say("failed") + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 11: allowlist literal is left alone even when it looks like PII +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "categories": ["email"], + "allowlist": ["support@company.com"] + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 12: allowlisted email passes through, other email masked +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body, actual = t('/echo', + ngx.HTTP_POST, + [[{ + "messages": [ + { "role": "user", "content": "forward to support@company.com and cc alice@acme.com" } + ] + }]], + [[{ + "messages": [ + { "role": "user", "content": "forward to support@company.com and cc [EMAIL_0]" } + ] + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say("failed") + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 13: zero-width obfuscation is normalized before regex scan +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- ali​ce@acme.com — zero-width space between i and c + local body_raw = '{"messages":[{"role":"user","content":"ali​ce@acme.com"}]}' + local expected = '{"messages":[{"role":"user","content":"[EMAIL_0]"}]}' + local code, body, actual = t('/echo', ngx.HTTP_POST, body_raw, expected) + if code >= 300 then + ngx.status = code + ngx.say("failed") + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 14: block action returns configured status + body +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "direction": "input", + "action": "block", + "categories": ["email"], + "on_block": { + "status": 403, + "body": "PII detected, blocked" + } + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 15: block returns 403 on email in input +--- request +POST /echo +{ + "messages": [ + { "role": "user", "content": "ping alice@acme.com" } + ] +} +--- error_code: 403 +--- response_body_like +.*PII detected, blocked.* + + + +=== TEST 16: direction=output leaves input body alone +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "direction": "output", + "categories": ["email"] + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 17: direction=output passes input through unchanged +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body, actual = t('/echo', + ngx.HTTP_POST, + [[{ + "messages": [ + { "role": "user", "content": "mail alice@acme.com please" } + ] + }]], + [[{ + "messages": [ + { "role": "user", "content": "mail alice@acme.com please" } + ] + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say("failed") + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 18: preamble is injected when restore_on_response=true +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "direction": "input", + "categories": ["email"], + "restore_on_response": true, + "preamble": { + "enable": true, + "content": "PREAMBLE-TEST" + } + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 19: preamble prepended as a system message and PII masked +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body, actual = t('/echo', + ngx.HTTP_POST, + [[{ + "messages": [ + { "role": "user", "content": "mail alice@acme.com" } + ] + }]], + [[{ + "messages": [ + { "role": "system", "content": "PREAMBLE-TEST" }, + { "role": "user", "content": "mail [EMAIL_0]" } + ] + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say("failed") + return + end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 20: log_detections emits category hit log +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/echo", + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "plugins": { + "ai-pii-sanitizer": { + "direction": "input", + "categories": ["email"], + "log_detections": true + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 21: detection log line is written, payload is NOT (log_payload default false) +--- request +POST /echo +{ + "messages": [ + { "role": "user", "content": "alice@acme.com" } + ] +} +--- error_log +ai-pii-sanitizer input hits: email=1 +--- no_error_log +[error] +[alert] +alice@acme.com