Fixes 30868: stop Auto Classification tagging plain code columns as PII - #30869
Fixes 30868: stop Auto Classification tagging plain code columns as PII#30869pmbrull wants to merge 2 commits into
Conversation
Two independent defects made the CVV recognizer fire on ordinary columns.
The `cvv_pattern` regex `\b\d{3,4}\b` matched a 3-4 digit run *inside* a
value, so `SCN-125` in a `scenario_code` column matched on `125`. Anchor it
to the whole value with `\A\d{3,4}\Z` -- not `^\d{3,4}$`, because the
recognizer sets the MULTILINE flag, under which `^..$` still matches one line
of a multi-line value.
`enhance_using_context` tested each of the recognizer's context words with a
plain `ctx_word in " ".join(context)`, i.e. a substring match against the
column name parts. `"cid" in "acid level"` is true, so `acid_level`,
`incident_count` and `decoder_ring` all matched the CVV context. Compare
whole tokens instead; multi-word entries keep substring semantics since they
have no single token to compare against.
Either defect alone was enough to tag a column: the context boost sets the
score straight to MAX_SCORE, so a 0.5 "3-4 digits" pattern became a certain
match, well past both the 0.6 classification minimum and the default
workflow confidence of 80.
The seed JSON change only affects fresh installs, so the 2.0.0 migration
rewrites the stored regex on existing `PII.Sensitive` rows. Verified on
PostgreSQL 17 and MySQL 8 against a real tag row: recognizer count, order and
context list preserved, and a re-run updates 0 rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ PR checks passedThe linked issue has a description and all required Shipping project fields set. Thanks! |
There was a problem hiding this comment.
Pull request overview
This PR fixes a false-positive PII auto-classification path in ingestion by (1) anchoring the CVV regex so it only matches whole values and (2) tightening context-word matching so context hits are based on whole tokens (avoiding substring matches like cid in acid_level). It also includes SQL data migrations to update existing PII.Sensitive tag recognizer config in both Postgres and MySQL, plus unit tests covering the new context-matching behavior.
Changes:
- Anchor the CVV recognizer pattern from
\b\d{3,4}\bto\A\d{3,4}\Zin the shipped tag/recognizer JSON and via DB migrations. - Replace substring-based context-word matching with token-based matching via a new
context_matches()helper. - Add unit tests for
context_matches()and additionalenhance_using_contextbranches.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| openmetadata-service/src/main/resources/json/data/tags/piiTagsWithRecognizers.json | Anchors CVV regex in the default shipped recognizer configuration. |
| ingestion/src/metadata/pii/algorithms/presidio_utils.py | Adds context_matches() and switches context boosting to token-based matching. |
| ingestion/tests/unit/pii/algorithms/test_presidio_utils.py | Adds targeted unit tests for token-vs-substring context matching and enhancement edge cases. |
| bootstrap/sql/migrations/native/2.0.0/postgres/postDataMigrationSQLScript.sql | Updates existing Postgres tag JSON to the anchored CVV regex while preserving recognizer order. |
| bootstrap/sql/migrations/native/2.0.0/mysql/postDataMigrationSQLScript.sql | Updates existing MySQL tag JSON to the anchored CVV regex using JSON_TABLE + join for reliability. |
Suppressed comments (1)
ingestion/src/metadata/pii/algorithms/presidio_utils.py:483
- In the
enhance_using_contextdecorator wrapper,context_matches(rec.context, context)is recomputed once per result even though it depends only on the column context and recognizer context. Compute it once before the loop and reuse it to avoid repeated tokenization work.
if context_matches(rec.context, context):
| continue | ||
|
|
||
| if any(ctx_word.lower() in context_lower for ctx_word in self.context): | ||
| if context_matches(self.context, context): |
🔴 Playwright Results — workflow failedValidated commit ✅ 608 passed · ❌ 0 failed · 🟡 0 flaky · ⏭️ 3 skipped · 🧰 0 lifecycle flaky Pipeline and setup failures (1)
PerformanceBlocking targets: ❌ unmet · Optimization targets: 🟡 in progress Shard-job maxima below are not the full workflow wall time; the linked run includes build, fixture, planning, and reporting. 🕒 Full workflow signal wall (to summary) 1h 56m 16s ⏱️ Max setup 3m 15s · max shard execution 17m 47s · max shard-job elapsed before upload 31m 24s · reporting 5s 🌐 208.23 requests/attempt · 2.76 app boots/UI scenario · 8.25% common-shard skew Optimization targets still in progress:
How to debug locally# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip # view trace |
…ult loop `context_matches()` was called once per recognizer result, re-joining and re-tokenizing the column name every time. The match depends only on the recognizer's context list and the column name, neither of which changes inside the loop, so resolve it once and bail out early when it does not match. Also guard `self.context` explicitly on the UsBank path, matching the guard the decorator already had. Presidio's `PatternRecognizer.__init__` coerces a falsy `context` to the class-level `CONTEXT`, so it cannot actually be empty here, but the guard costs nothing and makes the precondition local rather than inherited from a base class two libraries away. No behaviour change: same 235 unit tests pass, and the false-positive matrix (`scenario_code`, `error_code`, `acid_level`, `decoder_ring`, `incident_count`, multi-line `note_code`) and true-positive set (`cvv`, `security_code`, `card_verification_code`, `credit_card_number`) are unchanged, as is the 19-tag regression table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — half of this is right, and I've pushed a fix for that half in 2d94236. The crash risk is not real, on either line. Line 483 (the if not rec.context or not context:
return resultsSo Line 349 ( context = context if context else self.CONTEXTVerified against the actual factory: Even hypothetically it would not be a regression from this PR: the code being replaced iterated the same attribute — "Compute the match once per call" is a fair point, and I've applied it.
if not rec.context or not context or not context_matches(rec.context, context):
return resultsThat also removes a level of nesting from the loop body. I added the explicit No behaviour change: 235 unit tests still pass, and the false-positive set ( |
Code Review ✅ ApprovedAnchors the CVV pattern regex and refines context word matching to whole tokens, stopping plain code columns like scenario_code from being incorrectly tagged as PII. No issues found. OptionsDisplay: compact → Showing less information. Comment with these commands to change the behavior for this request:
Was this helpful? React with 👍 / 👎 | Gitar | Powered by Gitar — free for open source |
|



Describe your changes:
Fixes #30868
A column named
scenario_codeholdingSCN-125,SCN-113, … was auto-classified asPII.Sensitiveat confidence 1.00. Two independent defects combine to cause it, and either one alone is enough to tag the column.1. The
cvv_patternregex was not anchored.\b\d{3,4}\bmatches a 3-4 digit run inside a value, so the125inSCN-125matched. Changed to\A\d{3,4}\Z.Not
^\d{3,4}$— the recognizer setsregexFlags.multiline, under which^..$still matches a single line of a multi-line value.\A..\Zwas the only anchor that closed that case in testing.2. Context words were matched as substrings, not tokens.
presidio_utils.enhance_using_contexttested each context word withctx_word.lower() in " ".join(context).lower(), wherecontextis the column name split into parts."cid" in "acid level"is true, soacid_level,incident_countanddecoder_ringall matched the CVV context list. Extracted the comparison intocontext_matches(), which compares whole tokens; multi-word entries ("indian passport") keep substring semantics since they have no single token to compare against.These stack because OpenMetadata's
enhance_using_contextsets a matching result's score straight toMAX_SCOREinstead of Presidio's additive+0.35. That turns the weakest pattern in the set (0.5, "any 3-4 digits") into a certain match — past the 0.6 classification minimum and past the default workflow confidence of 80. I left that behaviour alone: changing it affects every recognizer, and both defects above are fixable without it.Type of change:
High-level design:
N/A — small change.
Tests:
Use cases covered
scenario_code/error_codecolumn ofSCN-125-style values is no longer tagged PIIacid_level/incident_count/decoder_ringcolumn of bare 3-digit values is no longer tagged PIIcvv,security_codeorcard_verification_codecolumn of bare 3-digit values is still tagged PIIcredit_card_numbercolumn is still tagged PIIUnit tests
ingestion/tests/unit/pii/algorithms/test_presidio_utils.pyTestContextMatches— whole-token matches, substring non-matches (acid/incident/decoder), multi-word entries, case-insensitivityTestEnhanceUsingContext::test_context_word_that_is_only_a_substring_does_not_boostTestEnhanceUsingContext::test_score_below_minimum_is_not_boosted— first coverage of theMIN_SCORE_FOR_ENHANCEMENTbranch, which had noneingestion/tests/unit/pii+ingestion/tests/unit/metadata/pii: 235 passed (222 before, +13 new), 0 failures.Backend integration tests
Ingestion integration tests
Playwright (UI) tests
Manual testing performed
Replayed the real scoring path (
TagAnalyzer→ScoreTagsForColumnService→ConflictResolver, Presidio +en_core_web_md) offline against the shipped recognizer definitions:scenario_codeSCN-100…SCN-125error_codeERR-200…acid_leveldecoder_ringincident_countnote_code"line one\n125"cvvsecurity_codecard_verification_codecredit_card_numberAlso ran a 20-column regression table covering every auto-classifiable tag (Person, Email, PhoneNumber, Address, Location, NRP, Gender, BirthDate, DateTime, CreditCardNumber, BankNumber, IBANCode, Crypto, IPAddress, URL, NationalID, VATCode, DriverLicense, MedicalLicense) at the default confidence of 80: 0 regressions.
Migration verified against a real tag row on PostgreSQL 15 and MySQL 8:
Note on the MySQL migration: locating the recognizer with
EXISTS (SELECT ... FROM JSON_TABLE(json, ...))in theWHEREclause of anUPDATEdoes not correlate reliably — it evaluated correctly in aSELECTbut silently matched 0 rows in theUPDATEas soon as the table had more than one row, which would have shipped a no-op migration. It uses a join instead, which is where the idempotency gate lives.UI screen recording / screenshots:
Not applicable.
Checklist:
Fixes <issue-number>: <short explanation>Fixes #<issue-number>above.Bug fix
Migration re-verified by running the committed statement against a live OpenMetadata database (the
dev PostgreSQL 15.18 instance, connected with the
conf/openmetadata.yamldefault credentials) ratherthan only a restored fixture:
UPDATE 1on the first run,UPDATE 0on a second runbyte-identical before and after
tagconfirmed as the only table holding recognizer config;tag_usagestores applied labels andclassificationstores only classification-levelautoClassificationConfigCvvRecognizer:PII.Sensitive(this PR) andGeneral.CreditCardNumber(the Collate PR), so between the two migrations there is no overlap andno row is missed
After migrating, re-scoring a live
scenario_codecolumn ofSCN-100…SCN-125against thedatabase's own tag definitions returns no tag, while
cvvandsecurity_codestill score 1.00.🤖 Generated with Claude Code