Skip to content

feat(library): add per_value masking strategy to the Presidio rail - #2547

Open
RHRolun wants to merge 2 commits into
NVIDIA-NeMo:developfrom
RHRolun:feat/sdd-per-value-masking
Open

RHRolun wants to merge 2 commits into
NVIDIA-NeMo:developfrom
RHRolun:feat/sdd-per-value-masking

Conversation

@RHRolun

@RHRolun RHRolun commented Nov 2, 2026

Copy link
Copy Markdown

Description

Adds an optional masking_strategy to 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_value numbers 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, so PERSON and LOCATION are 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. replace takes one new_value per entity type, and a custom operator'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 keep operator leaves the text unchanged and returns the matches that survived Presidio's own conflict resolution, as offsets into the original text, so overlap handling (a PERSON inside a longer LOCATION, an EMAIL_ADDRESS inside a URL) is not re-implemented here.

keep first shipped in presidio-anonymizer 2.2.350, so the sdd extra's floor moves from >=2.2 to >=2.2.350. uv.lock is 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.py adds 14 unit tests driving _anonymize with hand-built analyzer results, so they need only presidio-anonymizer and run without the spaCy model. They cover per_type being 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.py adds one end-to-end test through the rail.

Beyond the unit tests, a server was started from this branch with two configs:

models: []
rails:
  config:
    sensitive_data_detection:
      input:
        entities: [PERSON, LOCATION]
        masking_strategy: per_value      # and per_type in the second config
  input:
    flows:
      - mask sensitive data on input
nemoguardrails server --config ./configs --port 8097

The same request against each config:

curl -sS http://127.0.0.1:8097/v1/checks \
  -H 'Content-Type: application/json' \
  -d '{"model":"","guardrails":{"config_ids":["per_type"],"rail_types":["input"]},
       "messages":[{"role":"user","content":"Alice met Bob in Paris. Alice liked Paris."}]}'
{"status":"modified","content":"<PERSON> met <PERSON> in <LOCATION>. <PERSON> liked <LOCATION>."}
curl -sS http://127.0.0.1:8097/v1/checks \
  -H 'Content-Type: application/json' \
  -d '{"model":"","guardrails":{"config_ids":["per_value"],"rail_types":["input"]},
       "messages":[{"role":"user","content":"Alice met Bob in Paris. Alice liked Paris."}]}'
{"status":"modified","content":"<PERSON_1> met <PERSON_2> in <LOCATION_1>. <PERSON_1> liked <LOCATION_1>."}

schemas/rails_config.snapshot.json is regenerated with scripts/generate_rails_config_schema_snapshot.py; the diff is additive only.

AI Assistance

  • No AI tools were used.
  • AI tools were used; a human reviewed and can explain every change (tool: Claude Code).

Checklist

  • I've read the CONTRIBUTING guidelines.
  • This PR links to a triaged issue assigned to me.
  • My PR title follows the project commit convention.
  • I've updated the documentation if applicable.
  • I've added tests if applicable.
  • I've noted any verification beyond CI and any checks I couldn't run.
  • I did not update generated changelog files manually.
  • I addressed all CodeRabbit, Greptile, and other review comments, or replied with why no change is needed.
  • @mentions of the person or team responsible for reviewing proposed changes.

cc @Pouyanpi @m-misiura -- naming follows the masking_strategy: per_value / per_type suggestion from the issue thread.

@github-actions github-actions Bot added status: needs triage New issues that have not yet been reviewed or categorized. needs: signing size: M labels Nov 2, 2026
@RHRolun
RHRolun force-pushed the feat/sdd-per-value-masking branch from e029906 to 9c1aaab Compare November 2, 2026 17:54
Comment thread pyproject.toml Outdated
"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'",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

None is not a valid configured value

"""Mask every detected entity, allocating placeholders per the strategy."""
anonymizer = AnonymizerEngine()

if masking_strategy != "per_value":

@m-misiura m-misiura Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@m-misiura m-misiura left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi @RHRolun

Many thanks for the contribution! Could you please:

  1. have a GPG signed commit; for more info PTAL here
  2. address the initial PR feedback

Looking forward to the next iteration!

@m-misiura

Copy link
Copy Markdown
Contributor

@Pouyanpi; since @RHRolun is on PTO, I made changes in this feature branch and this PR is ready for your review

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size: M status: needs triage New issues that have not yet been reviewed or categorized.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants