Repository navigation
feat(guardrails): per-type PII selection, custom masking patterns and detection counters - #16902
Merged
diegosouzapw merged 7 commits intoOct 17, 2026
Conversation
… detection counters PII masking stays opt-in (PII_REDACTION_ENABLED / PII_RESPONSE_SANITIZATION, both default off). This adds control over WHAT gets masked once it is on: - Per-type selection of the built-in patterns (unset = every type, so the behaviour is unchanged until an operator picks types). The request engine's ssn_us maps to the shared ssn id. - Up to 20 custom regex/wildcard patterns, replaced with [CUSTOM_REDACTED]. Each one is gated by a length cap, a flag allow-list, a compile check, safe-regex (catastrophic backtracking) and a refusal of patterns that match "" or a redaction placeholder. Unsafe patterns are refused on save and on test, and a stored row that fails the gate is skipped at runtime. Custom rules only run while masking is on. - In-memory per-type detection counters (counts only, never values) that only move while masking is on. - GET/PUT /api/settings/pii and POST /api/settings/pii/test (Zod, management auth, 8 KB sample cap, sanitized errors), a PII masking card under Settings > Security with presets, and docs. Reversible pseudonymisation, a mapping API and a consent log are out of scope. Co-authored-by: Leandro Barbosa <leandro.silveirabarbosa@gmail.com> Inspired-by: decolua/9router#3958
| id: "evil", | ||
| name: "evil", | ||
| type: "regex" as const, | ||
| pattern: "(a+)+$", |
| assert.deepEqual(stats.request, { email: 2 }); | ||
| assert.deepEqual(stats.response, { cpf: 1 }); | ||
| assert.equal(stats.total, 3); | ||
| assert.ok(!JSON.stringify(stats).includes("example.com"), "no matched value is kept"); |
…rom PII test; regenerate omni-settings skill Builds the deliberately catastrophic custom pattern at runtime so it is test data rather than a regex literal (CodeQL js/redos), and replaces the "example.com" substring check with an exact assertion on the stats snapshot keys (CodeQL js/incomplete-url-substring-sanitization) - request/response are already compared exactly, so no matched value can hide in it. Regenerates skills/omni-settings for the new /api/settings/pii routes.
Nothing imports it; it was the one new dead export the dead-code gate counted.
Contributor
CI Coverage Report
Coverage artifact was not available for this run. |
Merged
diegosouzapw
deleted the
feat/port-pr-3958-pii-per-type-custom-patterns
branch
October 18, 2026 23:48
diegosouzapw
added a commit
to zerotraceio/OmniRoute
that referenced
this pull request
Oct 22, 2026
…e, per-type ids Merges release/v3.8.53 into the branch (conflicts in piiSanitizer.ts and inputSanitizer.ts resolved onto the base per-type selection from cheaper-inference#16902) and applies the pre-merge items for cheaper-inference#16461: - Request side: processPII now runs each GDPR/POPIA validator before redacting, so order numbers, timestamps and identifiers such as gpt4o / gh12abc are left alone (red: 6 of 6 probes redacted at the previous head). - src/shared/utils/piiPatterns.ts is the single table consumed by both sanitizers; the duplicated regexes and the dead PII_PATTERNS export are gone, and the existing patterns keep their original relative order. - Validators tightened: known IBAN country + length, EU VAT per member state (unknown prefixes no longer match any uppercase word), NIE is X/Y/Z + 7 digits, standard Luhn for the ZA ID (the previous variant rejected real IDs), PESEL/SE birth-date checks, Steuer-ID digit structure, ZA phone national form needs separators, ZA VAT / tax reference need a VAT / tax cue. - Ids registered in PII_BUILTIN_TYPES with gdpr and popia templates, so the settings schema, dashboard card and per-type selection control them; UI strings added (non-English catalogs carry the English fallback, my and phi translated to stay within the translation-ratio baseline). - Tests: checksum positives/negatives per pattern, false-positive corpus on both sides, per-type selection, catalog/i18n consistency, ReDoS bounds; sample ids in piiSanitizer.test.ts corrected to genuinely valid ones. - docs/security/GUARDRAILS.md section and a changelog fragment.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PII_REDACTION_ENABLED/PII_RESPONSE_SANITIZATION, both stilldefaultValue: "false";tests/unit/pii-opt-in-default.test.tsuntouched and green). This PR only adds control over what is masked once an operator turns it on.GET/PUT /api/settings/pii,POST /api/settings/pii/test, and a PII masking card under Settings → Security with presets (Minimal / Standard / Financial / Brazilian / USA / Full).Attribution
Thanks to @lsilveirab39 for the original implementation.
Changes
src/shared/constants/piiTypes.ts— catalog of built-in type ids (thepiiSanitizerpattern names), request-side aliasssn_us → ssn, UI presets, limits.src/shared/utils/piiCustomPatterns.ts— the single gate every custom pattern goes through: ≤ 500 chars, flagsg i m s uonly, must compile, must passsafe-regex(catastrophic backtracking), must not match""or a redaction placeholder (would re-redact its own output in the SSE window). Wildcards are literal except*(bounded to 64 non-whitespace chars, consecutive*collapsed) and?. Uses thesafe-regexdependency already used byevalRunner/ccrQuery— no new dependency.src/lib/db/piiSettings.ts—key_valuenamespacepii_settings, 1 s read cache (sanitizers call it per string leaf / per chunk). No migration.src/lib/pii/piiRuleConfig.ts— resolves settings into enabled types + compiled rules (recompiled only when the content changes); falls back to "all built-ins, no custom" if settings cannot be read.src/lib/piiSanitizer.ts,src/shared/utils/inputSanitizer.ts— filter built-ins by the selection and append custom rules (new optional argument; no-arg calls behave as before). Zero-length-match guard in the exec loop as defense in depth.src/lib/guardrails/piiMasker.ts— applies selection + custom rules only while request masking is on (flag off = the exact built-in scan as before); records request counters.src/lib/streamingPiiTransform.ts,sanitizePIIResponse— record response counters (log-sanitization callers ofsanitizePIIdo not count).src/lib/pii/piiDetectionStats.ts— in-memory counters, reset on restart.src/shared/validation/schemas/piiSettings.ts, kept out of the schemas barrel sosafe-regexis not pulled into every importer),requireManagementAuth,createErrorResponse(sanitized), sample capped at 8192 UTF-8 bytes, unsafe pattern refused with 400 on save and on test, audit eventsettings.pii.updatedwith counts only.PiiMaskingCard.tsxrendered inSecurityTab; 39 newsettings.piiMasking.*keys translated in all 66 locales (common keys reused for Save/Delete/Name/Enabled/Disabled/Responses).docs/security/GUARDRAILS.mdsection,docs/openapi.yamlentries.Out of scope on purpose: reversible pseudonymisation, a mapping API, a consent-log table.
Test plan
TDD —
tests/unit/pii-per-type-selection.test.tsrun against the base versions ofpiiSanitizer.ts/inputSanitizer.ts/piiMasker.ts/streamingPiiTransform.ts:With the port:
Gates (run locally, isolated
DATA_DIR,OMNIROUTE_API_KEYunset):npm run typecheck:core→ 0 errorsnpm run check:dashboard-typecheck→ OK (195 pre-existing, within baseline) ·check-api-typecheck→ OK (255 pre-existing)npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files>→ clean ·npx prettier --check <changed files>→ cleannpm run i18n:check-keys→ 66 locales carry all 13796 keys ·i18n:check-ratio→ OK ·i18n:check-new-keys→ PASS ·check-ui-icu→ PASS ·i18n:check-ui-coverage→ PASS ·i18n:check-value-drift→ PASSnpm run check:docs-all→ rc 0 ·check:openapi-routes/check:openapi-coverage(96.8%) /check:openapi-security-tiers→ PASS ·check:route-validation:t06→ PASScheck:file-size/check:complexity/check:cognitive-complexity/check:cycles:ratchet→ OKcheck-pr-test-policy→ PASS ·check:test-masking→ 0 modified, no weakeningcheck:boundary:ratchet→ FAIL 773 > 771, identical on the untouched base snapshot (this PR adds noopen-sse → srcimport)