Repository navigation
Conversation
e029906 to
9c1aaab
Compare
| "presidio-anonymizer (>=2.2) ; python_version<'3.13'", | ||
| # 2.2.350 is the first release with the `keep` operator, which the masking rail uses | ||
| # to resolve which overlapping detections survived. | ||
| "presidio-anonymizer (>=2.2.350) ; python_version<'3.13'", |
There was a problem hiding this comment.
nemoguardrails[all] still permits presidio-anonymizer >=2.2 implying a user installing nemoguardrails[all] could receive an older anonymizer and then fail only when selecting masking_strategy: per_value
|
|
||
| masked = text | ||
| # From the end, so each replacement keeps the offsets of the ones before it. | ||
| for span in reversed(spans): |
There was a problem hiding this comment.
IIUC, each iteration copies most of masked and may be suboptimal. Perhaps a better might be write something along the lines of
from collections import defaultdict
spans = sorted(kept.items, key=lambda item: item.start)
counts: dict[str, int] = defaultdict(int)
tokens: dict[tuple[str, str], str] = {}
parts: list[str] = []
cursor = 0
for span in spans:
value = text[span.start : span.end]
key = (span.entity_type, value)
token = tokens.get(key)
if token is None:
counts[span.entity_type] += 1
token = f"<{span.entity_type}_{counts[span.entity_type]}>"
tokens[key] = token
parts.append(text[cursor : span.start])
parts.append(token)
cursor = span.end
parts.append(text[cursor:])
return "".join(parts)There was a problem hiding this comment.
I think it'd be good to add a unit test for overlapping detections
|
|
||
|
|
||
| @pytest.mark.unit | ||
| @pytest.mark.parametrize("masking_strategy", ["per_type", None]) |
There was a problem hiding this comment.
None is not a valid configured value
| """Mask every detected entity, allocating placeholders per the strategy.""" | ||
| anonymizer = AnonymizerEngine() | ||
|
|
||
| if masking_strategy != "per_value": |
There was a problem hiding this comment.
a better approach might be to make the helper's accepted values explicit and then fail fast, e.g. something along the lines of:
MaskingStrategy = Literal["per_type", "per_value"]
def _anonymize(
text: str,
analyzer_results: list,
entities: list,
masking_strategy: MaskingStrategy,
) -> str:
if masking_strategy == "per_type":
operators = {entity: OperatorConfig("replace") for entity in entities}
return AnonymizerEngine().anonymize(
text=text,
analyzer_results=analyzer_results,
operators=operators,
).text
if masking_strategy == "per_value":
# existing per_value implementation
...
raise ValueError(f"Unsupported masking strategy: {masking_strategy!r}")Ideally define MaskingStrategy once, perhaps in the rail configuration module, and use it for both
| # reverse text order, which would number right to left. `keep` leaves the text | ||
| # alone and returns the matches that survived Presidio's own conflict resolution, | ||
| # as offsets into `text`, so the spans are resolved first and rewritten below. | ||
| kept = anonymizer.anonymize( |
There was a problem hiding this comment.
I am worried that default conflict strategy can retain partially overlapping spans and with keep that can duplicate the overlapping text. So kept.items offsets refer to the expanded output and not the original input. This mean we can sometimes leak PII; a likely fix might be to use ConflictResolutionStrategy.REMOVE_INTERSECTIONS for the keep pass
Ideall we we need also test this behaviour
Signed-off-by: RHRolun <rlundber@redhat.com>
e1472f6 to
e4471d1
Compare
Description
Adds an optional
masking_strategyto the Presidio sensitive-data rail, configurable per source (input, output, retrieval).per_type(default) keeps today's behaviour: every detected value of an entity type is replaced with the same token, e.g.<PERSON>.per_valuenumbers the distinct values of a type in order of appearance, so two people become<PERSON_1>and<PERSON_2>while one person mentioned twice keeps<PERSON_1>. Counters run per entity type, soPERSONandLOCATIONare numbered independently.Tokens are scoped to a single masking call: they are not stable across requests, and no mapping back to the original values is retained.
Implementation note
The number cannot be assigned inside a Presidio operator.
replacetakes onenew_valueper entity type, and acustomoperator's callback is invoked in reverse text order -- Presidio rewrites from the end so earlier offsets stay valid -- so a counter incremented in the callback numbers right to left, making the last name in a sentence<PERSON_1>.So the spans are resolved first and the text is rewritten afterwards. The spans still come from Presidio: anonymizing with the
keepoperator leaves the text unchanged and returns the matches that survived Presidio's own conflict resolution, as offsets into the original text, so overlap handling (aPERSONinside a longerLOCATION, anEMAIL_ADDRESSinside aURL) is not re-implemented here.keepfirst shipped inpresidio-anonymizer2.2.350, so thesddextra's floor moves from>=2.2to>=2.2.350.uv.lockis regenerated; the already-resolved version satisfies it, so the lock diff is one line.Related Issue(s)
Please see the following GH Issue for more information: GH#2546
Verification
tests/test_sensitive_data_detection_masking_strategy.pyadds 14 unit tests driving_anonymizewith hand-built analyzer results, so they need onlypresidio-anonymizerand run without the spaCy model. They coverper_typebeing unchanged, per-value numbering, numbering in text order when spans arrive out of order, token reuse for a repeated value, independent counters per entity type, empty detections, the default, and rejection of unknown strategy values.tests/test_sensitive_data_detection.pyadds one end-to-end test through the rail.Beyond the unit tests, a server was started from this branch with two configs:
The same request against each config:
schemas/rails_config.snapshot.jsonis regenerated withscripts/generate_rails_config_schema_snapshot.py; the diff is additive only.AI Assistance
Checklist
cc @Pouyanpi @m-misiura -- naming follows the
masking_strategy: per_value/per_typesuggestion from the issue thread.