From b036076afb880a9d8dc033871a2f7fe5b87636fe Mon Sep 17 00:00:00 2001 From: lipikaramaswamy Date: Wed, 9 Sep 2026 14:42:19 -0400 Subject: [PATCH 1/3] feat(detection): add regex entity detection Signed-off-by: lipikaramaswamy --- docs/concepts/detection.md | 75 +- plans/262/hybrid-regex-detection.md | 660 ++++++++++++++++++ pyproject.toml | 2 + skills/anonymizer/SKILL.md | 4 + src/anonymizer/__init__.py | 5 + src/anonymizer/config/anonymizer_config.py | 25 + src/anonymizer/config/regex.py | 121 ++++ src/anonymizer/engine/constants.py | 2 + .../engine/detection/custom_columns.py | 33 +- .../engine/detection/detection_workflow.py | 59 +- .../engine/detection/postprocess.py | 49 +- .../engine/detection/regex_detection.py | 416 +++++++++++ .../workflow_columns/detection/config.py | 33 +- .../engine/workflow_columns/detection/impl.py | 23 + .../workflow_columns/detection/plugins.py | 6 + src/anonymizer/interface/anonymizer.py | 6 + tests/config/test_anonymizer_config.py | 92 ++- .../test_detection_config_serialization.py | 11 +- tests/engine/test_detection_custom_columns.py | 43 +- tests/engine/test_detection_workflow.py | 10 + tests/engine/test_regex_detection.py | 234 +++++++ uv.lock | 2 + 22 files changed, 1888 insertions(+), 23 deletions(-) create mode 100644 plans/262/hybrid-regex-detection.md create mode 100644 src/anonymizer/config/regex.py create mode 100644 src/anonymizer/engine/detection/regex_detection.py create mode 100644 tests/engine/test_regex_detection.py diff --git a/docs/concepts/detection.md b/docs/concepts/detection.md index 83fbd68d..51e65670 100644 --- a/docs/concepts/detection.md +++ b/docs/concepts/detection.md @@ -9,7 +9,7 @@ Entity detection is the first stage of every Anonymizer pipeline. Both replace a ## How it works -Detection combines a lightweight NER model (GLiNER-PII) with LLM-based refinement. GLiNER PII produces an initial set of entity spans, then an LLM augments it with entities the NER missed and validates each detection -- keeping, reclassifying, or dropping entities based on context. +Detection combines built-in regex recognizers, a lightweight NER model (GLiNER-PII), and LLM-based refinement. Regex and GLiNER candidates are merged, then an LLM augments them with entities the other detectors missed and validates each candidate -- keeping, reclassifying, or dropping entities based on context. When rewrite is configured, an additional step identifies **latent entities** -- sensitive information inferable from context but not explicitly stated in the text. @@ -47,6 +47,79 @@ config = AnonymizerConfig( | `gliner_threshold` | `0.3` | GLiNER confidence threshold (0.0--1.0). Lower values detect more entities but may increase false positives. | | `validation_max_entities_per_call` | `100` | Maximum candidate entities per validator LLM call. Rows with more candidates are split into chunks. See [Chunked validation](#chunked-validation). | | `validation_excerpt_window_chars` | `500` | Characters of context included before and after a chunk's entity spans in the validator prompt. Bounds per-chunk prompt size; not the model's context-window limit. | +| `builtin_regexes` | `True` | Run built-in regex recognizers when their labels are in the effective detection label set. | +| `regex_rules` | `[]` | Per-label `BuiltinRegex` settings and user-defined `RegexRule` recognizers. | + +## Regex recognition + +Built-in regex recognition is enabled by default. Users normally do not write `builtin_regexes=True`; selecting a supported label is enough: + +```python +Detect(entity_labels=["email", "url"]) +``` + +The initial built-in labels are `credit_debit_card`, `email`, `ipv4`, `ipv6`, `mac_address`, and `url`. These are jurisdiction-neutral technical and payment formats rather than country-issued identifiers. Email and URL matching supports Unicode domains, including IDNA-compatible and CJK domains. Each recognizer combines a regex candidate pattern with structural validation, such as Luhn for payment cards and address parsing for IP values. + +Built-in matches receive the same contextual LLM validation as GLiNER matches by default. Disable it for a specific built-in when its deterministic checks are sufficient for your application: + +```python +from anonymizer import BuiltinRegex, Detect + +detect = Detect( + entity_labels=["email", "ipv4"], + regex_rules=[BuiltinRegex(label="ipv4", validate_with_llm=False)], +) +``` + +Set `builtin_regexes=False` on `Detect` to disable all built-in recognizers while retaining GLiNER and LLM detection. + +To replace one built-in while keeping the others, disable that label and add a +custom rule with the same label: + +```python +from anonymizer import BuiltinRegex, Detect, RegexRule + +detect = Detect( + regex_rules=[ + BuiltinRegex(label="email", enabled=False), + RegexRule( + label="email", + pattern=MY_EMAIL_PATTERN, + validator=my_email_validator, + ) + ], +) +``` + +### Custom regex rules and validators + +`regex_rules` is the single collection for built-in settings and custom recognizers. Use `BuiltinRegex` to configure one curated recognizer and `RegexRule` for domain identifiers. `validate_with_llm` defaults to `True` on both types, so a regex match still receives contextual review unless explicitly disabled. + +```python +from anonymizer import Detect, RegexCandidate, RegexRule, RegexValidationResult + + +def validate_support_case(candidate: RegexCandidate) -> RegexValidationResult: + number = candidate.groups["number"] + return RegexValidationResult(valid=not number.startswith("000")) + + +detect = Detect( + entity_labels=["support_case"], + regex_rules=[ + RegexRule( + label="support_case", + pattern=r"CASE-(?P\d{6})", + validator=validate_support_case, + # validate_with_llm=True is the default + ) + ], +) +``` + +A validator receives the matched value, character offsets, named capture groups, nearby context, and the rule ID. It returns `bool` or `RegexValidationResult`. Direct callables work for in-process `run()` and `preview()` calls. Exported detection configurations require a validator package registered under the `nemo_anonymizer.regex_validators` Python entry-point group; pass that entry-point name as `validator` so every worker resolves the same code. + +When `entity_labels` is explicit, it must include every custom rule label. With `entity_labels=None`, custom rule labels are added to the default detection scope automatically. --- diff --git a/plans/262/hybrid-regex-detection.md b/plans/262/hybrid-regex-detection.md new file mode 100644 index 00000000..6c1a44ab --- /dev/null +++ b/plans/262/hybrid-regex-detection.md @@ -0,0 +1,660 @@ + + + +# Hybrid Regex Detection + +## Status + +Implementation plan for +[issue #262](https://github.com/NVIDIA-NeMo/Anonymizer/issues/262). This +document scopes the deterministic regex and validator feature as a focused +slice of the broader +[Multi-Pole Detection](../detection-poles/multi-pole-detection.md) design. + +## Summary + +Add deterministic entity detection as a second seed source alongside GLiNER. +Anonymizer will provide curated built-in regex rules for structured entity +types and allow users to configure a custom entity label and regex. Built-in +rules may apply trusted local structural checks such as IP parsing or a Luhn +checksum. Each rule controls whether surviving candidates enter the existing +chunked LLM validation path or are accepted deterministically before +augmentation and finalization. + +The target user experience is: + +```python +from anonymizer import AnonymizerConfig, Detect, Redact, RegexRule + +config = AnonymizerConfig( + detect=Detect( + entity_labels=["email", "support_case_id"], + regex_rules=[ + RegexRule( + label="support_case_id", + pattern=r"(? COL_RAW_DETECTED # GLiNER via LLMTextColumnConfig + -> COL_GLINER_ENTITIES # parse GLiNER response + +COL_TEXT + -> COL_REGEX_ENTITIES # rules requiring LLM validation + -> COL_REGEX_ACCEPTED_ENTITIES # rules accepting local validation + +COL_GLINER_ENTITIES + COL_REGEX_ENTITIES + -> COL_SEED_ENTITIES # deduplicate + resolve overlaps + -> COL_SEED_VALIDATION_CANDIDATES + -> COL_VALIDATION_DECISIONS # existing chunked LLM validation + -> COL_VALIDATED_SEED_ENTITIES + +COL_VALIDATED_SEED_ENTITIES + COL_REGEX_ACCEPTED_ENTITIES + -> COL_ACCEPTED_SEED_ENTITIES # source-aware deduplication + -> COL_AUGMENTED_ENTITIES # existing LLM augmentation + -> COL_MERGED_ENTITIES + -> COL_DETECTED_ENTITIES + -> COL_FINAL_ENTITIES +``` + +Add `COL_GLINER_ENTITIES`, `COL_REGEX_ENTITIES`, +`COL_REGEX_ACCEPTED_ENTITIES`, and `COL_ACCEPTED_SEED_ENTITIES` to +`engine/constants.py`. Keep the current public final-entity schema unchanged. + +### DataDesigner Plugin + +Implement a new serializable workflow column rather than preprocessing the +DataFrame outside `NddAdapter`: + +```python +class RegexDetectionConfig(SingleColumnConfig): + column_type: Literal["anonymizer-regex-detection"] + rules: list[ResolvedRegexRule] + timeout_seconds: float + max_matches_per_rule: int +``` + +Add an `anonymizer-regex-detection` DataDesigner entry point. The plugin +generator should compile resolved patterns once when it is initialized on the +worker and reuse them across rows. Configuration contains pattern strings, +validation-route flags, and validator IDs. Direct local callables resolve in +the invoking runtime; exported builders require a stable validator name +provided by an installed package. + +Keep matching and guard functions pure and independently testable. The plugin +generator should only adapt row input/output and error behavior. + +### Workflow Integration Points + +Thread the resolved rules through all current construction paths: + +- `EntityDetectionWorkflow.detect_and_validate_entities()`; +- `_build_detection_spec()`; +- `build_detection_config()`; +- `build_detection_builder_for_seed()`; +- `EntityDetectionWorkflow.run()`; +- `Anonymizer` run and preview paths; +- exported builder interfaces. + +This plumbing is required for local/exported parity and must not be implemented +only in the interface layer. + +## Safe Regex Execution + +Use a regex engine that supports execution timeouts. The Python standard +library `re` module does not provide a per-match timeout. The third-party +`regex` package is a reasonable candidate and is also used by Presidio, but the +dependency and supported syntax must be reviewed before implementation. + +Required safeguards: + +1. Compile patterns during Pydantic validation and again when reconstructing a + worker-side generator. +2. Enforce a per-rule/per-record timeout. +3. Enforce a maximum match count per rule and record. +4. Reject empty-string matches during configuration and ignore them + defensively at runtime. +5. Bound pattern length and document the supported regex dialect. +6. Avoid silently skipping a rule after timeout or match-limit exhaustion. +7. Convert runtime failures into the normal failed-record path for detection. + +Failing the row is safer than treating a timed-out privacy rule as though it +found no sensitive data. + +## Merge and Overlap Policy + +Add a source-aware `merge_detection_sources()` helper rather than globally +changing `resolve_overlaps()`, because the latter also affects augmentation, +name splitting, and occurrence propagation. + +Merge policy: + +1. Reject malformed or out-of-bounds spans. +2. Deduplicate identical `(label, start, end)` candidates. +3. For identical boundaries with conflicting labels, prefer: + + ```text + regex_user > regex_builtin > detector + ``` + +4. Resolve remaining partial overlaps with the existing longest-span, then + earliest-position behavior. +5. Preserve the winning source and rule identity for tracing. +6. Send candidates requiring contextual validation through the normal LLM + path and merge deterministically accepted candidates afterward. + +Explicit user regexes receive highest same-span precedence because they encode +direct user intent. Contextual validation can still drop or reclassify rules +configured with `validate_with_llm=True`. When an exact same-label/span GLiNER +candidate duplicates a rule configured with `False`, deterministic acceptance +is preserved and provenance records both sources. + +## Error Semantics + +### Configuration Errors + +Raise Pydantic validation errors for: + +- invalid regex syntax; +- empty or zero-width patterns; +- empty labels; +- duplicate custom rules; +- custom labels missing from explicit `entity_labels`; +- unsupported pattern length or options. + +### Runtime Errors + +The following conditions should fail the affected row through the existing +detection `FailedRecord` mechanism: + +- regex timeout; +- match-count limit exceeded; +- internal validator failure; +- malformed rule configuration reconstructed on a worker. + +Do not log full matched sensitive values in warning or error messages. Include +the rule ID, label, text length, and safe exception details. + +## Provenance and Measurement + +Preserve `COL_REGEX_ENTITIES` in the trace DataFrame and record: + +- candidate count by rule ID and source; +- local-guard acceptance and rejection counts; +- exact duplicates against GLiNER; +- overlap losses by source and label; +- LLM keep/drop/reclass counts for regex candidates; +- accepted candidate counts by `validate_with_llm` route; +- final entity counts by source and label; +- local detection duration; +- regex timeout and match-limit failures; +- validation token/call changes relative to baseline. + +Avoid logging raw entity values in aggregate telemetry. + +The source presentation asks whether deterministic and model detection can run +in parallel. Treat actual scheduler parallelism as a later optimization. The +local regex pass should be small relative to a remote GLiNER call, and +correctness plus distributed portability are more important in the first +release. + +## Testing Strategy + +### Configuration Tests + +- Defaults and `builtin_regexes=False`. +- Label and pattern normalization. +- Invalid syntax and zero-width patterns. +- Duplicate rules. +- Custom labels under `entity_labels=None`. +- Missing custom labels under explicit `entity_labels`. +- Public serialization and re-export. +- `validate_with_llm=True` by default and explicit `False`. +- Direct callable and installed-name validator forms. + +### Matcher and Guard Tests + +For each built-in rule: + +- canonical valid examples; +- international variants supported by the rule; +- invalid checksums and impossible structured values; +- substring and boundary false positives; +- punctuation at document and sentence boundaries; +- mixed case and Unicode surrounding text; +- values directly adjacent to Han characters without whitespace; +- named capture groups passed to user validators; +- boolean and `RegexValidationResult` return values; +- custom validator failures without logging candidate values; +- repeated-digit and test-number cases where relevant; +- large and adversarial input; +- timeout and maximum-match behavior. + +### Merge Tests + +- GLiNER-only candidate. +- Regex-only candidate. +- Exact duplicate from GLiNER and a built-in rule. +- Exact duplicate whose regex route bypasses LLM validation. +- Exact-span conflict between model, built-in, and user rule. +- Partial overlap, including an IP address inside a URL. +- Stable ordering and IDs. +- Source and rule provenance after deduplication. + +### Workflow Tests + +- Regex candidates appear in seed validation candidates. +- Deterministically accepted candidates bypass the LLM candidate payload. +- LLM decisions can keep, drop, and reclass regex candidates. +- Locally invalid candidates never reach the LLM validator. +- Augmentation receives tagged, validated regex entities. +- Replace and rewrite modes consume unchanged final schemas. +- No-regex configurations preserve baseline behavior. + +### Serialization Tests + +- Detection builder JSON contains the new plugin type and resolved rules. +- Reconstructing the builder restores user and built-in patterns. +- No compiled regex or Python callback is serialized. +- Local callable validators resolve in-process. +- Installed validator names reconstruct on workers and missing names fail + preflight. +- In-process and reconstructed/exported workflows produce equivalent spans. +- Plugin discovery works when `nemo-anonymizer` is installed on a worker. + +### Quality Evaluation + +Compare current and hybrid detection on representative positive and hard +negative datasets. Report per label: + +- precision, recall, and F1; +- regex-only recoveries; +- GLiNER/regex agreement; +- locally rejected candidates; +- validator reversals; +- latency, validation candidate count, and token usage. +- language, script, region, and locale slices where source metadata permits. + +Default-enable a built-in rule only when it demonstrates very high precision +(target at least 99% on the agreed evaluation sets), useful recall, and no +material end-to-end privacy regression. Evaluate each rule independently; +passing one rule does not justify enabling the whole registry. + +## Documentation + +Update: + +- `docs/concepts/detection.md` with the hybrid pipeline and configuration; +- the generated API reference for `Detect` and `RegexRule`; +- `skills/anonymizer/SKILL.md` because the public detection surface changes; +- examples showing default built-ins, an explicit label subset, and a custom + organization identifier; +- security guidance for regex timeouts, match limits, and trusted + configuration. + +Document the distinction between regex-only, locally validated, and +contextually LLM-validated matches, including the effect of setting +`validate_with_llm=False`. + +## Implementation Map + +Expected files and responsibilities: + +| Area | Expected changes | +| --- | --- | +| `config/anonymizer_config.py` | Add `RegexRule`, `Detect` fields, and cross-field validation | +| `anonymizer/__init__.py` | Re-export regex rule and validator public types | +| `engine/constants.py` | Add GLiNER and regex intermediate column constants | +| `engine/detection/regex_rules.py` | Built-in registry, rule resolution, and activation | +| `engine/detection/regex_validators.py` | Built-in validators and custom callable contract | +| `engine/detection/regex_detection.py` | Safe matching and canonical candidate creation | +| `engine/detection/postprocess.py` | Source-aware seed fan-in helper | +| `engine/detection/custom_columns.py` | GLiNER parse/fan-in transform integration | +| `engine/detection/detection_workflow.py` | Add regex column and thread configuration | +| `engine/workflow_columns/detection/` | Add regex config, generator, and plugin | +| `interface/anonymizer.py` | Thread public configuration through all run/export paths | +| `pyproject.toml` | Add regex runtime dependency and DataDesigner plugin entry point | +| `tests/config/` | Public configuration tests | +| `tests/engine/` | Matcher, guards, merge, workflow, and serialization tests | +| `tests/interface/` | Replace/rewrite and preview/run plumbing tests | +| `docs/` and `skills/anonymizer/` | Public documentation and skill updates | + +Exact module names may change during implementation, but matching, validation, +rule resolution, workflow adaptation, and merging should remain separate +responsibilities. + +## Delivery Plan + +### Stage 0: Contract and Measurement + +1. Finalize public names (`RegexRule`, `builtin_regexes`, `regex_rules`). +2. Add source-aware baseline measurement needed for comparison. +3. Assemble per-rule positive, negative, and adversarial evaluation fixtures. +4. Confirm dependency and regex-dialect choice. + +Exit criteria: + +- The public configuration and activation truth table are approved. +- Baseline metrics can attribute current candidates and final entities. +- Evaluation datasets and rule-level success criteria are agreed. + +### Stage 1: Safe Custom Regex Path + +1. Add `RegexRule` and configuration validation. +2. Implement safe matching, timeouts, and match limits. +3. Add direct callable and installed-name custom validator resolution. +4. Add the serializable DataDesigner regex column. +5. Merge custom candidates with GLiNER seeds according to each rule's + `validate_with_llm` value. +6. Add local/exported parity and CJK-boundary tests. + +Exit criteria: + +- A user-provided label and regex work in replace and rewrite modes. +- Invalid and pathological patterns fail safely. +- Exported workflow reconstruction preserves the rule. +- Baseline behavior is unchanged with no rules and built-ins disabled. + +### Stage 2: Initial Built-In Registry + +1. Add the six benchmark-backed initial rules and pure validators. +2. Add rule provenance and aggregate measurement. +3. Run per-rule precision/recall and performance evaluation. +4. Revise patterns and guards based on hard negatives. +5. Enable only rules that meet their individual quality gates. + +Exit criteria: + +- Every enabled built-in passes its unit, adversarial, serialization, and + quality gates. +- Hybrid detection does not materially regress end-to-end privacy outcomes. +- Default behavior and migration impact are documented. + +## Risks and Mitigations + +| Risk | Mitigation | +| --- | --- | +| Catastrophic regex runtime | Timeout-capable engine, pattern validation, and match limits | +| False confidence from syntax alone | Default `validate_with_llm=True` and document deterministic bypass | +| Numeric false positives | Conservative patterns, checksums, hard-negative datasets, per-rule rollout | +| Duplicate model and regex candidates | Explicit deduplication and source precedence | +| Exported workflow divergence | Dedicated serializable plugin and reconstruction tests | +| Hidden PII in logs | Aggregate telemetry without raw values | +| Public API growth | Keep one `RegexRule` object for pattern and validation policy | +| Validator code injection | Resolve serialized names only from trusted installed registrations | +| Default behavior change | Per-rule quality gates and staged default-on rollout | + +## Decisions Made for Version 1 + +1. Implement deterministic detection natively rather than depending on + Presidio. +2. Provide custom `label` + `pattern` with an optional direct validator + callable or trusted installed validator name. +3. Keep GLiNER active for regex-covered labels. +4. Default `validate_with_llm=True` and allow an explicit per-rule `False`. +5. Use internal named validators for curated built-ins. +6. Fail a row on regex timeout or match-limit exhaustion. +7. Preserve the current public final-entity schema. +8. Activate built-in rules by default only when their labels are in the + effective detection scope; `Detect(builtin_regexes=False)` opts out. +9. Start with `credit_debit_card`, `email`, `ipv4`, `ipv6`, `mac_address`, and + `url`. +10. Derive versioned custom rule IDs from normalized label and pattern content, + so reordering configuration does not change provenance or result ordering. +11. Keep per-label built-in settings and custom recognizers in one + `regex_rules` collection. + +## Open Questions + +1. Should an optional decorator attach stable name/version metadata to reusable + validators, or should installed package registration be the only naming + mechanism? + +## External Design References + +- [Presidio recognizers](https://presidio.dataprivacystack.org/analyzer/adding_recognizers/): + regex patterns, context, confidence, country metadata, and code-based + validator hooks. +- [Microsoft Purview sensitive information types](https://learn.microsoft.com/en-us/purview/sit-sensitive-information-type-learn-about): + primary matches, checksums/functions, supporting evidence, proximity, and + confidence tiers. +- [Google Sensitive Data Protection hotword rules](https://cloud.google.com/sensitive-data-protection/docs/creating-custom-infotypes-likelihood): + proximity-aware contextual likelihood adjustment. +- [Amazon Macie custom data identifiers](https://docs.aws.amazon.com/macie/latest/user/cdis-options.html): + regex safety checks, keywords, proximity, exclusions, and pre-deployment + testing. +- [TruffleHog custom detectors](https://github.com/trufflesecurity/trufflehog/blob/main/pkg/custom_detectors/CUSTOM_DETECTORS.md): + regex candidates, keywords, exclusions, entropy, validations, and optional + verification for machine credentials. diff --git a/pyproject.toml b/pyproject.toml index 4c5c4b82..b585576c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "cryptography>=46.0.6", "httpx>=0.27.0", "tiktoken>=0.9.0", + "regex>=2025.11.3", ] [project.scripts] @@ -23,6 +24,7 @@ anonymizer = "anonymizer.interface.cli.main:main" [project.entry-points."data_designer.plugins"] anonymizer-detection-transform = "anonymizer.engine.workflow_columns.detection.plugins:detection_transform_plugin" anonymizer-chunked-validation = "anonymizer.engine.workflow_columns.detection.plugins:chunked_validation_plugin" +anonymizer-regex-detection = "anonymizer.engine.workflow_columns.detection.plugins:regex_detection_plugin" [dependency-groups] dev = [ diff --git a/skills/anonymizer/SKILL.md b/skills/anonymizer/SKILL.md index 30642118..233fce1d 100644 --- a/skills/anonymizer/SKILL.md +++ b/skills/anonymizer/SKILL.md @@ -44,6 +44,7 @@ regulatory and business context. - **`Detect.entity_labels=None` (the default) is permissive** — the augmenter LLM may invent labels not in `DEFAULT_ENTITY_LABELS`. Setting an explicit list switches to **strict mode** where *only* the listed labels are detected. To add domain labels, *extend* the default, don't replace it: `entity_labels=[*DEFAULT_ENTITY_LABELS, "clinical_facility", ...]` (`DEFAULT_ENTITY_LABELS` is a tuple, so unpack it into a list). Match the snake_case convention of `DEFAULT_ENTITY_LABELS`. - **GLiNER is zero-shot** — entity labels are natural-language concept names (e.g. `"clinical_facility"`, `"internal_project_codename"`), not codes or enum values. Any concept you can name in English is a label GLiNER can detect. +- **Built-in regex recognition is on by default** for `credit_debit_card`, `email`, `ipv4`, `ipv6`, `mac_address`, and `url` whenever those labels are in scope. Users normally omit `builtin_regexes=True`; use `builtin_regexes=False` to disable all built-ins. The single `regex_rules` list accepts `BuiltinRegex(label="email", enabled=False)` to configure one built-in and `RegexRule(...)` for custom patterns. Both default `validate_with_llm` to `True`. - **`Rewrite.instructions` is a dead field today** — it exists on the model but the rewrite engine never reads it. Do not use it. Put rewriter guidance in `privacy_goal.protect` / `privacy_goal.preserve` instead. - **`risk_tolerance` only applies to Rewrite mode**, not Replace. - **`PrivacyGoal.protect` and `.preserve` must each be 10–1000 chars and at least 3 words.** Be specific (categories, named identifiers, structural facets); avoid generic phrasing like "preserve meaning". @@ -104,6 +105,7 @@ from anonymizer import ( AnonymizerInput, DEFAULT_ENTITY_LABELS, Detect, + BuiltinRegex, RegexCandidate, RegexRule, RegexValidationResult, # Pick what you need: # Replace mode: Substitute, Redact, Annotate, Hash, @@ -124,6 +126,8 @@ def build_config() -> tuple[AnonymizerInput, AnonymizerConfig]: # Add domain labels by *extending* the default, not replacing it. # entity_labels=[*DEFAULT_ENTITY_LABELS, "clinical_facility", "diagnosis_code"], gliner_threshold=0.3, # default; lower (0.2) for recall, raise (0.5) for cost savings + # Built-in regexes are enabled by default for supported labels in scope. + # regex_rules=[RegexRule(label="ticket_id", pattern=r"TKT-\d+", validate_with_llm=True)], ) # ---- Pick ONE of the two strategies below ---- diff --git a/src/anonymizer/__init__.py b/src/anonymizer/__init__.py index 4a63168a..0f34226a 100644 --- a/src/anonymizer/__init__.py +++ b/src/anonymizer/__init__.py @@ -19,6 +19,7 @@ Rewrite, RiskTolerance, ) +from anonymizer.config.regex import BuiltinRegex, RegexCandidate, RegexRule, RegexValidationResult from anonymizer.config.replace_strategies import Annotate, Hash, Redact, Substitute from anonymizer.config.rewrite import PrivacyGoal from anonymizer.engine.constants import DEFAULT_ENTITY_LABELS as _DEFAULT_ENTITY_LABELS @@ -53,6 +54,7 @@ def __getattr__(name: str) -> object: "AnonymizerInput", "AnonymizerIOError", "Annotate", + "BuiltinRegex", "DEFAULT_ENTITY_LABELS", "Detect", "EvaluateConfig", @@ -63,6 +65,9 @@ def __getattr__(name: str) -> object: "ModelProvider", "PrivacyGoal", "Redact", + "RegexCandidate", + "RegexRule", + "RegexValidationResult", "Rewrite", "RiskTolerance", "RunConfig", diff --git a/src/anonymizer/config/anonymizer_config.py b/src/anonymizer/config/anonymizer_config.py index 3afdea1c..63b9f28f 100644 --- a/src/anonymizer/config/anonymizer_config.py +++ b/src/anonymizer/config/anonymizer_config.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator +from anonymizer.config.regex import BuiltinRegex, RegexRule from anonymizer.config.replace_strategies import ReplaceMethod from anonymizer.config.rewrite import ( DEFAULT_PRESERVE_TEXT, @@ -100,6 +101,14 @@ class Detect(BaseModel): "validator sees per chunk; it is NOT the LLM's context window limit." ), ) + builtin_regexes: bool = Field( + default=True, + description="Run built-in regex recognizers for labels in the effective detection scope.", + ) + regex_rules: list[BuiltinRegex | RegexRule] = Field( + default_factory=list, + description="Per-label built-in settings and user-defined regex candidate rules.", + ) @field_validator("entity_labels") @classmethod @@ -114,6 +123,22 @@ def validate_entity_labels(cls, value: list[str] | None) -> list[str] | None: logger.warning("entity_labels contained duplicates, removed automatically.") return deduped + @model_validator(mode="after") + def validate_regex_rule_scope(self) -> Detect: + custom_rules = [rule for rule in self.regex_rules if isinstance(rule, RegexRule)] + builtin_rules = [rule for rule in self.regex_rules if isinstance(rule, BuiltinRegex)] + identities = [(rule.label, rule.pattern) for rule in custom_rules] + if len(set(identities)) != len(identities): + raise ValueError("regex_rules contains duplicate label and pattern pairs.") + builtin_labels = [rule.label for rule in builtin_rules] + if len(set(builtin_labels)) != len(builtin_labels): + raise ValueError("regex_rules contains duplicate built-in labels.") + if self.entity_labels is not None: + missing = sorted({rule.label for rule in custom_rules} - set(self.entity_labels)) + if missing: + raise ValueError(f"Regex rule labels {missing!r} are missing from explicit entity_labels.") + return self + class Rewrite(BaseModel): """Configuration for rewrite-mode execution.""" diff --git a/src/anonymizer/config/regex.py b/src/anonymizer/config/regex.py new file mode 100644 index 00000000..91e79cfc --- /dev/null +++ b/src/anonymizer/config/regex.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, TypeAlias + +import regex +from pydantic import BaseModel, ConfigDict, field_serializer, field_validator + +MAX_REGEX_PATTERN_LENGTH = 4096 +BUILTIN_REGEX_LABELS: tuple[str, ...] = ( + "credit_debit_card", + "email", + "ipv4", + "ipv6", + "mac_address", + "url", +) + + +@dataclass(frozen=True) +class RegexCandidate: + """A regex match supplied to a user-defined local validator.""" + + value: str + start: int + end: int + groups: Mapping[str, str] + context: str + rule_id: str + + +@dataclass(frozen=True) +class RegexValidationResult: + """Result returned by a user-defined local regex validator.""" + + valid: bool + reason: str | None = None + normalized_value: str | None = None + + +RegexValidatorReturn: TypeAlias = bool | RegexValidationResult +RegexValidatorCallable: TypeAlias = Callable[[RegexCandidate], RegexValidatorReturn] + + +class BuiltinRegex(BaseModel): + """Configuration for one recognizer from the built-in regex registry.""" + + label: str + enabled: bool = True + validate_with_llm: bool = True + + @field_validator("label") + @classmethod + def validate_label(cls, value: str) -> str: + cleaned = value.strip().lower() + if cleaned not in BUILTIN_REGEX_LABELS: + raise ValueError( + f"Unsupported built-in regex label {cleaned!r}. Supported labels are {list(BUILTIN_REGEX_LABELS)!r}." + ) + return cleaned + + +class RegexRule(BaseModel): + """A user-defined regex rule for producing entity candidates.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + label: str + pattern: str + validator: RegexValidatorCallable | str | None = None + enabled: bool = True + validate_with_llm: bool = True + + @field_validator("label") + @classmethod + def validate_label(cls, value: str) -> str: + cleaned = value.strip().lower() + if not cleaned: + raise ValueError("Regex rule label must not be empty.") + return cleaned + + @field_validator("pattern") + @classmethod + def validate_pattern(cls, value: str) -> str: + if not value: + raise ValueError("Regex rule pattern must not be empty.") + if len(value) > MAX_REGEX_PATTERN_LENGTH: + raise ValueError( + f"Regex rule pattern length {len(value)} exceeds the maximum of {MAX_REGEX_PATTERN_LENGTH}." + ) + try: + compiled = regex.compile(value) + except regex.error as exc: + raise ValueError(f"Invalid regex pattern {value!r}: {exc}") from exc + match = compiled.search("") + if match is not None and match.start() == match.end(): + raise ValueError(f"Regex pattern must not match an empty string: {value!r}") + return value + + @field_validator("validator") + @classmethod + def validate_validator(cls, value: Any) -> RegexValidatorCallable | str | None: + if value is None or callable(value): + return value + if isinstance(value, str) and value.strip(): + return value.strip() + raise ValueError("Regex rule validator must be a callable or non-empty registered name.") + + @field_serializer("validator") + def serialize_validator(self, value: RegexValidatorCallable | str | None) -> str | None: + if value is None or isinstance(value, str): + return value + module = getattr(value, "__module__", "") + qualified_name = getattr(value, "__qualname__", "") + if not module or not qualified_name or "" in qualified_name or qualified_name == "": + raise ValueError("Custom regex validator must be a top-level named function to serialize.") + return f"{module}:{qualified_name}" diff --git a/src/anonymizer/engine/constants.py b/src/anonymizer/engine/constants.py index c0c47dec..250e1d6a 100644 --- a/src/anonymizer/engine/constants.py +++ b/src/anonymizer/engine/constants.py @@ -14,6 +14,8 @@ # Step 1: GLiNER detection COL_RAW_DETECTED = "_raw_detected_entities" +COL_REGEX_ENTITIES = "_regex_entities" +COL_REGEX_ACCEPTED_ENTITIES = "_regex_accepted_entities" # Step 2: parse_detected_entities COL_SEED_ENTITIES = "_seed_entities" diff --git a/src/anonymizer/engine/detection/custom_columns.py b/src/anonymizer/engine/detection/custom_columns.py index 059d82ac..93a9ada9 100644 --- a/src/anonymizer/engine/detection/custom_columns.py +++ b/src/anonymizer/engine/detection/custom_columns.py @@ -23,6 +23,8 @@ COL_MERGED_ENTITIES, COL_MERGED_TAGGED_TEXT, COL_RAW_DETECTED, + COL_REGEX_ACCEPTED_ENTITIES, + COL_REGEX_ENTITIES, COL_SEED_ENTITIES, COL_SEED_ENTITIES_JSON, COL_SEED_TAGGED_TEXT, @@ -43,6 +45,7 @@ build_validation_candidates, expand_entity_occurrences, get_tag_notation, + merge_entity_sources, parse_raw_entities, ) from anonymizer.engine.schemas import ( @@ -55,16 +58,30 @@ @custom_column_generator( - required_columns=[COL_TEXT, COL_RAW_DETECTED], + required_columns=[COL_TEXT, COL_RAW_DETECTED, COL_REGEX_ENTITIES, COL_REGEX_ACCEPTED_ENTITIES], side_effect_columns=[COL_TAG_NOTATION], ) def parse_detected_entities(row: dict[str, Any]) -> dict[str, Any]: """Parse detector payload and produce seed entities.""" text = str(row.get(COL_TEXT, "")) - entities = parse_raw_entities( + detector_entities = parse_raw_entities( raw_response=str(row.get(COL_RAW_DETECTED, "")), text=text, ) + regex_entities = _parse_entity_spans(row.get(COL_REGEX_ENTITIES, {})) + accepted_regex = _parse_entity_spans(row.get(COL_REGEX_ACCEPTED_ENTITIES, {})) + accepted_identities = {(entity.label, entity.start_position, entity.end_position) for entity in accepted_regex} + detector_entities = [ + entity + for entity in detector_entities + if (entity.label, entity.start_position, entity.end_position) not in accepted_identities + ] + regex_entities = [ + entity + for entity in regex_entities + if (entity.label, entity.start_position, entity.end_position) not in accepted_identities + ] + entities = merge_entity_sources(regex_entities, detector_entities) seed_entities = [entity.as_dict() for entity in entities] row[COL_SEED_ENTITIES] = EntitiesSchema(entities=seed_entities).model_dump(mode="json") row[COL_TAG_NOTATION] = get_tag_notation(text=text) @@ -99,17 +116,19 @@ def merge_and_build_candidates(row: dict[str, Any]) -> dict[str, Any]: @custom_column_generator( - required_columns=[COL_TEXT, COL_SEED_ENTITIES, COL_VALIDATED_ENTITIES], + required_columns=[COL_TEXT, COL_SEED_ENTITIES, COL_VALIDATED_ENTITIES, COL_REGEX_ACCEPTED_ENTITIES], side_effect_columns=[COL_INITIAL_TAGGED_TEXT, COL_SEED_ENTITIES_JSON, COL_VALIDATED_SEED_ENTITIES], ) def apply_validation_to_seed_entities(row: dict[str, Any]) -> dict[str, Any]: """Apply validation decisions to detector entities before augmentation.""" text = str(row.get(COL_TEXT, "")) seed_spans = _parse_entity_spans(row.get(COL_SEED_ENTITIES, {})) - validated_seed = apply_validation_decisions( + llm_validated_seed = apply_validation_decisions( entities=seed_spans, validation_output=row.get(COL_VALIDATED_ENTITIES, {}), ) + accepted_regex = _parse_entity_spans(row.get(COL_REGEX_ACCEPTED_ENTITIES, {})) + validated_seed = merge_entity_sources(accepted_regex, llm_validated_seed) seed_entities = [entity.as_dict() for entity in validated_seed] row[COL_VALIDATED_SEED_ENTITIES] = EntitiesSchema(entities=seed_entities).model_dump(mode="json") row[COL_SEED_ENTITIES_JSON] = json.dumps(seed_entities) @@ -161,7 +180,7 @@ def enrich_validation_decisions(row: dict[str, Any]) -> dict[str, Any]: @custom_column_generator( - required_columns=[COL_TEXT, COL_MERGED_ENTITIES, COL_VALIDATED_ENTITIES], + required_columns=[COL_TEXT, COL_MERGED_ENTITIES, COL_VALIDATED_ENTITIES, COL_REGEX_ACCEPTED_ENTITIES], side_effect_columns=[COL_TAGGED_TEXT], ) def apply_validation_and_finalize(row: dict[str, Any]) -> dict[str, Any]: @@ -172,7 +191,9 @@ def apply_validation_and_finalize(row: dict[str, Any]) -> dict[str, Any]: entities=merged, validation_output=row.get(COL_VALIDATED_ENTITIES, {}), ) - expanded = expand_entity_occurrences(text=text, entities=validated) + accepted_regex = _parse_entity_spans(row.get(COL_REGEX_ACCEPTED_ENTITIES, {})) + protected = merge_entity_sources(accepted_regex, validated) + expanded = expand_entity_occurrences(text=text, entities=protected) row[COL_DETECTED_ENTITIES] = EntitiesSchema(entities=[entity.as_dict() for entity in expanded]).model_dump( mode="json" ) diff --git a/src/anonymizer/engine/detection/detection_workflow.py b/src/anonymizer/engine/detection/detection_workflow.py index a577a47b..8bbd44ba 100644 --- a/src/anonymizer/engine/detection/detection_workflow.py +++ b/src/anonymizer/engine/detection/detection_workflow.py @@ -17,6 +17,7 @@ from anonymizer.config.anonymizer_config import Detect as AnonymizerDetectConfig from anonymizer.config.models import DetectionModelSelection +from anonymizer.config.regex import BuiltinRegex, RegexRule from anonymizer.config.rewrite import PrivacyGoal from anonymizer.engine.constants import ( COL_AUGMENTED_ENTITIES, @@ -27,6 +28,7 @@ COL_LATENT_ENTITIES, COL_MERGED_ENTITIES, COL_RAW_DETECTED, + COL_REGEX_ENTITIES, COL_SEED_ENTITIES, COL_SEED_ENTITIES_JSON, COL_SEED_TAGGED_TEXT, @@ -42,6 +44,12 @@ _jinja, ) from anonymizer.engine.detection.postprocess import EntitySpan, group_entities_by_value +from anonymizer.engine.detection.regex_detection import ( + DEFAULT_MAX_MATCHES_PER_RULE, + DEFAULT_REGEX_TIMEOUT_SECONDS, + resolve_regex_rules, + validate_exportable_regex_rules, +) from anonymizer.engine.ndd.adapter import FailedRecord, NddAdapter from anonymizer.engine.ndd.model_loader import resolve_model_alias, resolve_model_aliases from anonymizer.engine.prompt_utils import substitute_placeholders @@ -55,6 +63,7 @@ ChunkedValidationConfig, DetectionTransformConfig, DetectionTransformOperation, + RegexDetectionConfig, ) from anonymizer.measurement import stage_timer @@ -94,6 +103,8 @@ def detect_and_validate_entities( validation_excerpt_window_chars: int = _DEFAULT_VALIDATION_EXCERPT_WINDOW_CHARS, validation_single_chunk_full_text: bool = True, entity_labels: list[str] | None = None, + builtin_regexes: bool = True, + regex_rules: list[BuiltinRegex | RegexRule] | None = None, data_summary: str | None = None, preview_num_records: int | None = None, ) -> EntityDetectionResult: @@ -113,6 +124,8 @@ def detect_and_validate_entities( validation_excerpt_window_chars=validation_excerpt_window_chars, validation_single_chunk_full_text=validation_single_chunk_full_text, entity_labels=entity_labels, + builtin_regexes=builtin_regexes, + regex_rules=regex_rules, data_summary=data_summary, ) detection_result = self._adapter.run_workflow( @@ -135,6 +148,8 @@ def _build_detection_spec( validation_excerpt_window_chars: int = _DEFAULT_VALIDATION_EXCERPT_WINDOW_CHARS, validation_single_chunk_full_text: bool = True, entity_labels: list[str] | None = None, + builtin_regexes: bool = True, + regex_rules: list[BuiltinRegex | RegexRule] | None = None, data_summary: str | None = None, ) -> tuple[list[ModelConfig], list[ColumnConfigT]]: """Build the (model_configs, columns) for the core detection workflow. @@ -143,7 +158,13 @@ def _build_detection_spec( and :meth:`build_detection_config` (which exports it for an external runtime), so both paths run exactly the same workflow. """ - labels = _resolve_detection_labels(entity_labels) + custom_rules = regex_rules or [] + labels = _resolve_detection_labels(entity_labels, regex_rules=custom_rules) + resolved_regex_rules = resolve_regex_rules( + labels=labels, + builtin_regexes=builtin_regexes, + rules=custom_rules, + ) workflow_model_configs = self._inject_detector_params( model_configs=model_configs, selected_models=selected_models, @@ -178,6 +199,12 @@ def _build_detection_spec( columns = cast( list[ColumnConfigT], [ + RegexDetectionConfig( + name=COL_REGEX_ENTITIES, + rules=resolved_regex_rules, + timeout_seconds=DEFAULT_REGEX_TIMEOUT_SECONDS, + max_matches_per_rule=DEFAULT_MAX_MATCHES_PER_RULE, + ), LLMTextColumnConfig( name=COL_RAW_DETECTED, prompt=_jinja(COL_TEXT), @@ -240,6 +267,8 @@ def build_detection_config( validation_excerpt_window_chars: int = _DEFAULT_VALIDATION_EXCERPT_WINDOW_CHARS, validation_single_chunk_full_text: bool = True, entity_labels: list[str] | None = None, + builtin_regexes: bool = True, + regex_rules: list[BuiltinRegex | RegexRule] | None = None, data_summary: str | None = None, ) -> DataDesignerConfigBuilder: """Build (without executing) the core detection workflow as a DataDesigner @@ -247,6 +276,7 @@ def build_detection_config( as :meth:`detect_and_validate_entities` (culminating in final entities); the external runtime supplies the model providers and the seed dataset. """ + validate_exportable_regex_rules(regex_rules) workflow_model_configs, columns = self._build_detection_spec( model_configs=model_configs, selected_models=selected_models, @@ -255,6 +285,8 @@ def build_detection_config( validation_excerpt_window_chars=validation_excerpt_window_chars, validation_single_chunk_full_text=validation_single_chunk_full_text, entity_labels=entity_labels, + builtin_regexes=builtin_regexes, + regex_rules=regex_rules, data_summary=data_summary, ) return self._adapter.build_config( @@ -275,6 +307,8 @@ def build_detection_builder_for_seed( validation_excerpt_window_chars: int = _DEFAULT_VALIDATION_EXCERPT_WINDOW_CHARS, validation_single_chunk_full_text: bool = True, entity_labels: list[str] | None = None, + builtin_regexes: bool = True, + regex_rules: list[BuiltinRegex | RegexRule] | None = None, data_summary: str | None = None, job_index: int = 0, num_jobs: int = 1, @@ -287,6 +321,7 @@ def build_detection_builder_for_seed( orchestrator), the plugin column configs remain serializable and the model aliases are resolved by the runtime's providers. """ + validate_exportable_regex_rules(regex_rules) workflow_model_configs, columns = self._build_detection_spec( model_configs=model_configs, selected_models=selected_models, @@ -295,6 +330,8 @@ def build_detection_builder_for_seed( validation_excerpt_window_chars=validation_excerpt_window_chars, validation_single_chunk_full_text=validation_single_chunk_full_text, entity_labels=entity_labels, + builtin_regexes=builtin_regexes, + regex_rules=regex_rules, data_summary=data_summary, ) return self._adapter.build_config_for_seed( @@ -360,6 +397,8 @@ def run( validation_excerpt_window_chars: int = _DEFAULT_VALIDATION_EXCERPT_WINDOW_CHARS, validation_single_chunk_full_text: bool = True, entity_labels: list[str] | None = None, + builtin_regexes: bool = True, + regex_rules: list[BuiltinRegex | RegexRule] | None = None, privacy_goal: PrivacyGoal | None = None, data_summary: str | None = None, tag_latent_entities: bool = True, @@ -390,6 +429,8 @@ def run( validation_excerpt_window_chars=validation_excerpt_window_chars, validation_single_chunk_full_text=validation_single_chunk_full_text, entity_labels=entity_labels, + builtin_regexes=builtin_regexes, + regex_rules=regex_rules, data_summary=data_summary, preview_num_records=preview_num_records, ) @@ -455,9 +496,21 @@ def _inject_detector_params( return resolved -def _resolve_detection_labels(entity_labels: list[str] | None) -> list[str]: +def _resolve_detection_labels( + entity_labels: list[str] | None, + *, + regex_rules: list[BuiltinRegex | RegexRule] | None = None, +) -> list[str]: if entity_labels is None: - return list(DEFAULT_ENTITY_LABELS) + labels = list(DEFAULT_ENTITY_LABELS) + known = set(labels) + for rule in regex_rules or []: + if isinstance(rule, BuiltinRegex): + continue + if rule.label not in known: + labels.append(rule.label) + known.add(rule.label) + return labels return list(entity_labels) diff --git a/src/anonymizer/engine/detection/postprocess.py b/src/anonymizer/engine/detection/postprocess.py index 2af2b300..a3d19036 100644 --- a/src/anonymizer/engine/detection/postprocess.py +++ b/src/anonymizer/engine/detection/postprocess.py @@ -263,6 +263,40 @@ def resolve_overlaps(entities: list[EntitySpan], *, prefer_highest_score: bool = return sorted(accepted, key=lambda item: (item.start_position, item.end_position, item.label)) +def merge_entity_sources(*sources: list[EntitySpan]) -> list[EntitySpan]: + """Merge detection sources with deterministic provenance-aware tie breaking. + + Source order is priority order. Longer spans still win genuine overlap + conflicts; source priority decides otherwise-identical spans. + """ + ranked: list[tuple[int, EntitySpan]] = [] + seen: set[tuple[str, int, int]] = set() + for priority, entities in enumerate(sources): + for entity in entities: + identity = (entity.label, entity.start_position, entity.end_position) + if identity in seen: + continue + seen.add(identity) + ranked.append((priority, entity)) + + ordered = sorted( + ranked, + key=lambda item: ( + -(item[1].end_position - item[1].start_position), + item[1].start_position, + item[1].end_position, + item[0], + item[1].label, + ), + ) + accepted: list[EntitySpan] = [] + for _, candidate in ordered: + if any(_spans_overlap(candidate, existing) for existing in accepted): + continue + accepted.append(candidate) + return sorted(accepted, key=lambda item: (item.start_position, item.end_position, item.label)) + + def build_tagged_text( text: str, entities: list[EntitySpan], @@ -311,16 +345,15 @@ def get_tag_notation(text: str) -> str: def expand_entity_occurrences(text: str, entities: list[EntitySpan]) -> list[EntitySpan]: - """Expand each validated entity to ALL its occurrences in the text. + """Expand validated non-regex entities to all occurrences in the text. - After validation, entities only have the positions where the detector - originally found them. This function finds every word-boundary-matched - occurrence of each unique entity value in the text, creating new spans - for positions not already covered. Overlaps are resolved by preferring - longer spans. + Regex spans are not propagated because their pattern or validator may + intentionally accept only some occurrences. Other detected values are + expanded as before, and overlaps prefer longer spans. """ entity_map: dict[str, str] = {} - for entity in entities: + propagatable_entities = [entity for entity in entities if not entity.source.startswith("regex_")] + for entity in propagatable_entities: key = entity.value.lower() if key not in entity_map: entity_map[key] = entity.label @@ -328,7 +361,7 @@ def expand_entity_occurrences(text: str, entities: list[EntitySpan]) -> list[Ent original_positions: set[tuple[int, int]] = {(e.start_position, e.end_position) for e in entities} expanded: list[EntitySpan] = [] for idx, (key, label) in enumerate(entity_map.items()): - original_value = next(e.value for e in entities if e.value.lower() == key) + original_value = next(e.value for e in propagatable_entities if e.value.lower() == key) for start, end in _find_all_occurrences(text=text, needle=original_value): if (start, end) in original_positions: continue # already covered by a detector span; skip to preserve its provenance diff --git a/src/anonymizer/engine/detection/regex_detection.py b/src/anonymizer/engine/detection/regex_detection.py new file mode 100644 index 00000000..6dbd12c3 --- /dev/null +++ b/src/anonymizer/engine/detection/regex_detection.py @@ -0,0 +1,416 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import ipaddress +from collections.abc import Iterable +from dataclasses import dataclass +from functools import lru_cache +from hashlib import sha256 +from importlib.metadata import entry_points +from typing import Any +from urllib.parse import urlsplit + +import regex +from pydantic import BaseModel + +from anonymizer.config.regex import ( + BuiltinRegex, + RegexCandidate, + RegexRule, + RegexValidationResult, + RegexValidatorCallable, +) +from anonymizer.engine.detection.postprocess import EntitySpan, merge_entity_sources + +DEFAULT_REGEX_TIMEOUT_SECONDS = 0.05 +DEFAULT_MAX_MATCHES_PER_RULE = 1000 +REGEX_VALIDATOR_ENTRYPOINT_GROUP = "nemo_anonymizer.regex_validators" +_REGEX_SCORE = 1.0 +_CONTEXT_WINDOW = 64 +_URL_TRAILING_PUNCTUATION = ".,;:!?)]}>'\"。,、;:!?)】》」』" + + +class ResolvedRegexRule(BaseModel): + """Serializable rule consumed by the DataDesigner regex column.""" + + rule_id: str + label: str + pattern: str + validator_id: str | None = None + validate_with_llm: bool = True + source: str + + +@dataclass(frozen=True) +class RegexDetectionResult: + """Regex candidates partitioned by contextual validation route.""" + + llm_entities: list[EntitySpan] + accepted_entities: list[EntitySpan] + + +_LOCAL_VALIDATORS: dict[str, RegexValidatorCallable] = {} + + +def resolve_regex_rules( + *, + labels: Iterable[str], + builtin_regexes: bool, + rules: list[BuiltinRegex | RegexRule], +) -> list[ResolvedRegexRule]: + """Resolve active built-in and custom rules into a serializable form.""" + active_labels = set(labels) + resolved: list[ResolvedRegexRule] = [] + custom_rules = [rule for rule in rules if isinstance(rule, RegexRule)] + builtin_settings = {rule.label: rule for rule in rules if isinstance(rule, BuiltinRegex)} + for rule in custom_rules: + if not rule.enabled: + continue + validator_id = _register_or_resolve_validator(rule.validator) + resolved.append( + ResolvedRegexRule( + rule_id=_build_user_rule_id(rule), + label=rule.label, + pattern=rule.pattern, + validator_id=validator_id, + validate_with_llm=rule.validate_with_llm, + source="regex_user", + ) + ) + + if builtin_regexes: + for rule in _BUILTIN_RULES: + if rule.label not in active_labels: + continue + override = builtin_settings.get(rule.label) + if override is not None and not override.enabled: + continue + resolved.append( + rule.model_copy( + update={"validate_with_llm": (override.validate_with_llm if override is not None else True)} + ) + ) + return resolved + + +def _build_user_rule_id(rule: RegexRule) -> str: + digest = sha256(rule.pattern.encode("utf-8")).hexdigest()[:12] + return f"user:{rule.label}:v1:{digest}" + + +def detect_regex_entities( + text: str, + *, + rules: list[ResolvedRegexRule], + timeout_seconds: float = DEFAULT_REGEX_TIMEOUT_SECONDS, + max_matches_per_rule: int = DEFAULT_MAX_MATCHES_PER_RULE, +) -> RegexDetectionResult: + """Match, locally validate, and route regex entity candidates.""" + llm_entities: list[EntitySpan] = [] + accepted_entities: list[EntitySpan] = [] + + for rule in rules: + pattern = _compile_pattern(rule.pattern) + match_count = 0 + try: + matches = pattern.finditer(text, timeout=timeout_seconds) + for match in matches: + start, end = match.span() + if end <= start: + continue + match_count += 1 + if match_count > max_matches_per_rule: + raise RuntimeError( + f"Regex rule {rule.rule_id!r} exceeded the maximum of " + f"{max_matches_per_rule} matches for one record." + ) + if rule.label == "url": + trimmed = text[start:end].rstrip(_URL_TRAILING_PUNCTUATION) + end = start + len(trimmed) + if end <= start: + continue + value = text[start:end] + if not _passes_validator( + text=text, + rule=rule, + value=value, + start=start, + end=end, + groups={key: val or "" for key, val in match.groupdict().items()}, + ): + continue + entity = EntitySpan( + entity_id=f"{rule.label}_{start}_{end}", + value=value, + label=rule.label, + start_position=start, + end_position=end, + score=_REGEX_SCORE, + source=f"{rule.source}:{rule.rule_id}", + ) + if rule.validate_with_llm: + llm_entities.append(entity) + else: + accepted_entities.append(entity) + except TimeoutError as exc: + raise RuntimeError(f"Regex rule {rule.rule_id!r} timed out after {timeout_seconds} seconds.") from exc + + return RegexDetectionResult( + llm_entities=_merge_regex_sources(llm_entities), + accepted_entities=_merge_regex_sources(accepted_entities), + ) + + +@lru_cache(maxsize=256) +def _compile_pattern(pattern: str) -> Any: + return regex.compile(pattern) + + +def _merge_regex_sources(entities: list[EntitySpan]) -> list[EntitySpan]: + users = [entity for entity in entities if entity.source.startswith("regex_user:")] + builtins = [entity for entity in entities if entity.source.startswith("regex_builtin:")] + return merge_entity_sources(_deduplicate(users), _deduplicate(builtins)) + + +def _passes_validator( + *, + text: str, + rule: ResolvedRegexRule, + value: str, + start: int, + end: int, + groups: dict[str, str], +) -> bool: + if rule.validator_id is None: + return True + validator = _resolve_validator(rule.validator_id) + before = max(0, start - _CONTEXT_WINDOW) + after = min(len(text), end + _CONTEXT_WINDOW) + candidate = RegexCandidate( + value=value, + start=start, + end=end, + groups=groups, + context=text[before:after], + rule_id=rule.rule_id, + ) + try: + result = validator(candidate) + except Exception as exc: + raise RuntimeError( + f"Regex validator {rule.validator_id!r} failed for rule {rule.rule_id!r} with {type(exc).__name__}." + ) from exc + if isinstance(result, bool): + return result + if isinstance(result, RegexValidationResult): + return result.valid + raise TypeError(f"Regex validator {rule.validator_id!r} returned unsupported type {type(result)!r}.") + + +def _register_or_resolve_validator( + validator: RegexValidatorCallable | str | None, +) -> str | None: + if validator is None: + return None + if isinstance(validator, str): + _resolve_validator(validator) + return validator + module = getattr(validator, "__module__", "") + qualified_name = getattr(validator, "__qualname__", "") + validator_id = f"{module}:{qualified_name}" + existing = _LOCAL_VALIDATORS.get(validator_id) + if existing is not None and existing is not validator: + raise ValueError(f"Duplicate regex validator registration for {validator_id!r}.") + _LOCAL_VALIDATORS[validator_id] = validator + return validator_id + + +def _resolve_validator(validator_id: str) -> RegexValidatorCallable: + validator = _VALIDATORS.get(validator_id) or _LOCAL_VALIDATORS.get(validator_id) + if validator is not None: + return validator + for entry_point in entry_points(group=REGEX_VALIDATOR_ENTRYPOINT_GROUP): + if entry_point.name != validator_id: + continue + loaded = entry_point.load() + if not callable(loaded): + raise TypeError(f"Regex validator entry point {validator_id!r} is not callable.") + _LOCAL_VALIDATORS[validator_id] = loaded + return loaded + raise ValueError(f"Unknown regex validator {validator_id!r}.") + + +def validate_exportable_regex_rules(rules: list[BuiltinRegex | RegexRule] | None) -> None: + """Require installed validator names for portable workflow exports.""" + callable_labels = sorted( + {rule.label for rule in rules or [] if isinstance(rule, RegexRule) and callable(rule.validator)} + ) + if callable_labels: + raise ValueError( + "Exported detection workflows require registered validator names for regex rules " + f"{callable_labels!r}; install validators through the " + f"{REGEX_VALIDATOR_ENTRYPOINT_GROUP!r} entry-point group and pass their names." + ) + + +def _validate_credit_card(candidate: RegexCandidate) -> bool: + digits = "".join(char for char in candidate.value if char in "0123456789") + if not 13 <= len(digits) <= 19 or len(set(digits)) == 1: + return False + total = 0 + parity = len(digits) % 2 + for index, character in enumerate(digits): + digit = int(character) + if index % 2 == parity: + digit *= 2 + if digit > 9: + digit -= 9 + total += digit + return total % 10 == 0 + + +def _validate_email(candidate: RegexCandidate) -> bool: + value = candidate.value + if len(value) > 254 or value.count("@") != 1: + return False + local, domain = value.rsplit("@", 1) + if not local or len(local.encode("utf-8")) > 64: + return False + if local.startswith(".") or local.endswith(".") or ".." in local: + return False + try: + encoded_domain = domain.encode("idna").decode("ascii") + except UnicodeError: + return False + if len(encoded_domain) > 253 or "." not in encoded_domain: + return False + labels = encoded_domain.split(".") + return all(label and len(label) <= 63 and not label.startswith("-") and not label.endswith("-") for label in labels) + + +def _validate_ipv4(candidate: RegexCandidate) -> bool: + try: + ipaddress.IPv4Address(candidate.value) + except ValueError: + return False + return True + + +def _validate_ipv6(candidate: RegexCandidate) -> bool: + try: + ipaddress.IPv6Address(candidate.value.strip("[]")) + except ValueError: + return False + return True + + +_MAC_VALIDATION_RE = regex.compile( + r"^(?:[0-9A-Fa-f]{2}([:-]))(?:[0-9A-Fa-f]{2}\1){4}[0-9A-Fa-f]{2}$" + r"|^(?:[0-9A-Fa-f]{4}\.){2}[0-9A-Fa-f]{4}$" +) + + +def _validate_mac(candidate: RegexCandidate) -> bool: + return _MAC_VALIDATION_RE.fullmatch(candidate.value) is not None + + +def _validate_url(candidate: RegexCandidate) -> bool: + target = candidate.value if not candidate.value.startswith("www.") else f"https://{candidate.value}" + try: + parsed = urlsplit(target) + port = parsed.port + except ValueError: + return False + if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname: + return False + if port is not None and not 1 <= port <= 65535: + return False + try: + host = parsed.hostname.encode("idna").decode("ascii") + except UnicodeError: + return False + if "." in host: + return True + try: + ipaddress.ip_address(host) + except ValueError: + return False + return True + + +_VALIDATORS: dict[str, RegexValidatorCallable] = { + "nemo.credit-card-luhn.v1": _validate_credit_card, + "nemo.email.v1": _validate_email, + "nemo.ipv4.v1": _validate_ipv4, + "nemo.ipv6.v1": _validate_ipv6, + "nemo.mac-address.v1": _validate_mac, + "nemo.url.v1": _validate_url, +} + +_BUILTIN_RULES: tuple[ResolvedRegexRule, ...] = ( + ResolvedRegexRule( + rule_id="nemo.credit-debit-card.v1", + label="credit_debit_card", + pattern=r"(?\"']+", + validator_id="nemo.url.v1", + source="regex_builtin", + ), +) + + +def _deduplicate(entities: list[EntitySpan]) -> list[EntitySpan]: + seen: set[tuple[str, int, int]] = set() + result: list[EntitySpan] = [] + for entity in entities: + key = (entity.label, entity.start_position, entity.end_position) + if key in seen: + continue + seen.add(key) + result.append(entity) + return result diff --git a/src/anonymizer/engine/workflow_columns/detection/config.py b/src/anonymizer/engine/workflow_columns/detection/config.py index a9661818..1863a451 100644 --- a/src/anonymizer/engine/workflow_columns/detection/config.py +++ b/src/anonymizer/engine/workflow_columns/detection/config.py @@ -15,6 +15,8 @@ COL_MERGED_ENTITIES, COL_MERGED_TAGGED_TEXT, COL_RAW_DETECTED, + COL_REGEX_ACCEPTED_ENTITIES, + COL_REGEX_ENTITIES, COL_SEED_ENTITIES, COL_SEED_ENTITIES_JSON, COL_SEED_TAGGED_TEXT, @@ -27,6 +29,7 @@ COL_VALIDATION_CANDIDATES, COL_VALIDATION_DECISIONS, ) +from anonymizer.engine.detection.regex_detection import ResolvedRegexRule class DetectionTransformOperation(str, Enum): @@ -43,7 +46,12 @@ class DetectionTransformConfig(SingleColumnConfig): operation: DetectionTransformOperation _REQUIRED_COLUMNS: ClassVar[dict[DetectionTransformOperation, list[str]]] = { - DetectionTransformOperation.PARSE_DETECTED_ENTITIES: [COL_TEXT, COL_RAW_DETECTED], + DetectionTransformOperation.PARSE_DETECTED_ENTITIES: [ + COL_TEXT, + COL_RAW_DETECTED, + COL_REGEX_ENTITIES, + COL_REGEX_ACCEPTED_ENTITIES, + ], DetectionTransformOperation.PREPARE_VALIDATION_INPUTS: [COL_TEXT, COL_SEED_ENTITIES], DetectionTransformOperation.ENRICH_VALIDATION_DECISIONS: [ COL_VALIDATION_DECISIONS, @@ -53,6 +61,7 @@ class DetectionTransformConfig(SingleColumnConfig): COL_TEXT, COL_SEED_ENTITIES, COL_VALIDATED_ENTITIES, + COL_REGEX_ACCEPTED_ENTITIES, ], DetectionTransformOperation.MERGE_AND_BUILD_CANDIDATES: [ COL_TEXT, @@ -63,6 +72,7 @@ class DetectionTransformConfig(SingleColumnConfig): COL_TEXT, COL_MERGED_ENTITIES, COL_VALIDATED_ENTITIES, + COL_REGEX_ACCEPTED_ENTITIES, ], } _SIDE_EFFECT_COLUMNS: ClassVar[dict[DetectionTransformOperation, list[str]]] = { @@ -123,3 +133,24 @@ def side_effect_columns(self) -> list[str]: def get_model_aliases(self) -> list[str]: return list(self.pool) + + +class RegexDetectionConfig(SingleColumnConfig): + """Serializable local regex-detection column configuration.""" + + column_type: Literal["anonymizer-regex-detection"] = "anonymizer-regex-detection" + rules: list[ResolvedRegexRule] + timeout_seconds: float = Field(gt=0) + max_matches_per_rule: int = Field(gt=0) + + @staticmethod + def get_column_emoji() -> str: + return "A" + + @property + def required_columns(self) -> list[str]: + return [COL_TEXT] + + @property + def side_effect_columns(self) -> list[str]: + return [COL_REGEX_ACCEPTED_ENTITIES] diff --git a/src/anonymizer/engine/workflow_columns/detection/impl.py b/src/anonymizer/engine/workflow_columns/detection/impl.py index 54a92405..c2ba0337 100644 --- a/src/anonymizer/engine/workflow_columns/detection/impl.py +++ b/src/anonymizer/engine/workflow_columns/detection/impl.py @@ -14,6 +14,7 @@ ColumnGeneratorWithModelRegistry, ) +from anonymizer.engine.constants import COL_REGEX_ACCEPTED_ENTITIES, COL_REGEX_ENTITIES, COL_TEXT from anonymizer.engine.detection.chunked_validation import ( ChunkedValidationParams, chunked_validate_row, @@ -27,10 +28,13 @@ parse_detected_entities, prepare_validation_inputs, ) +from anonymizer.engine.detection.regex_detection import detect_regex_entities +from anonymizer.engine.schemas import EntitiesSchema from anonymizer.engine.workflow_columns.detection.config import ( ChunkedValidationConfig, DetectionTransformConfig, DetectionTransformOperation, + RegexDetectionConfig, ) _TRANSFORMS: dict[DetectionTransformOperation, Callable[[dict[str, Any]], dict[str, Any]]] = { @@ -129,5 +133,24 @@ async def agenerate(self, data: dict[str, Any]) -> dict[str, Any]: # ty: ignore return await chunked_validate_row_async(data, self._params(models), models) +class RegexDetectionGenerator(ColumnGeneratorCellByCell[RegexDetectionConfig]): + """Run deterministic regex recognition for one input row.""" + + def generate(self, data: dict[str, Any]) -> dict[str, Any]: + result = detect_regex_entities( + str(data.get(COL_TEXT, "")), + rules=self.config.rules, + timeout_seconds=self.config.timeout_seconds, + max_matches_per_rule=self.config.max_matches_per_rule, + ) + data[COL_REGEX_ENTITIES] = EntitiesSchema( + entities=[entity.as_dict() for entity in result.llm_entities] + ).model_dump(mode="json") + data[COL_REGEX_ACCEPTED_ENTITIES] = EntitiesSchema( + entities=[entity.as_dict() for entity in result.accepted_entities] + ).model_dump(mode="json") + return data + + def _derive_max_parallel_chunks(models: dict[str, Any]) -> int: return max(1, sum(max(1, int(getattr(model, "max_parallel_requests", 1) or 1)) for model in models.values())) diff --git a/src/anonymizer/engine/workflow_columns/detection/plugins.py b/src/anonymizer/engine/workflow_columns/detection/plugins.py index 6509e85d..73883fd7 100644 --- a/src/anonymizer/engine/workflow_columns/detection/plugins.py +++ b/src/anonymizer/engine/workflow_columns/detection/plugins.py @@ -16,3 +16,9 @@ impl_qualified_name="anonymizer.engine.workflow_columns.detection.impl.ChunkedValidationGenerator", plugin_type=PluginType.COLUMN_GENERATOR, ) + +regex_detection_plugin = Plugin( + config_qualified_name="anonymizer.engine.workflow_columns.detection.config.RegexDetectionConfig", + impl_qualified_name="anonymizer.engine.workflow_columns.detection.impl.RegexDetectionGenerator", + plugin_type=PluginType.COLUMN_GENERATOR, +) diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index 1df2351f..9fde0d29 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -306,6 +306,8 @@ def export_detection_config( validation_max_entities_per_call=config.detect.validation_max_entities_per_call, validation_excerpt_window_chars=config.detect.validation_excerpt_window_chars, entity_labels=config.detect.entity_labels, + builtin_regexes=config.detect.builtin_regexes, + regex_rules=config.detect.regex_rules, data_summary=data.data_summary, ) @@ -338,6 +340,8 @@ def export_detection_builder_for_seed( validation_max_entities_per_call=config.detect.validation_max_entities_per_call, validation_excerpt_window_chars=config.detect.validation_excerpt_window_chars, entity_labels=config.detect.entity_labels, + builtin_regexes=config.detect.builtin_regexes, + regex_rules=config.detect.regex_rules, data_summary=data_summary, job_index=job_index, num_jobs=num_jobs, @@ -715,6 +719,8 @@ def _run_internal_impl( validation_max_entities_per_call=config.detect.validation_max_entities_per_call, validation_excerpt_window_chars=config.detect.validation_excerpt_window_chars, entity_labels=config.detect.entity_labels, + builtin_regexes=config.detect.builtin_regexes, + regex_rules=config.detect.regex_rules, privacy_goal=config.rewrite.privacy_goal if config.rewrite else None, data_summary=data.data_summary, tag_latent_entities=config.rewrite is not None, diff --git a/tests/config/test_anonymizer_config.py b/tests/config/test_anonymizer_config.py index 0738208c..fa8086c8 100644 --- a/tests/config/test_anonymizer_config.py +++ b/tests/config/test_anonymizer_config.py @@ -8,7 +8,14 @@ import pytest from pydantic import ValidationError -from anonymizer.config.anonymizer_config import AnonymizerConfig, AnonymizerInput, Rewrite, infer_input_source_suffix +from anonymizer.config.anonymizer_config import ( + AnonymizerConfig, + AnonymizerInput, + Detect, + Rewrite, + infer_input_source_suffix, +) +from anonymizer.config.regex import BuiltinRegex, RegexCandidate, RegexRule from anonymizer.config.replace_strategies import ( Annotate, Hash, @@ -147,3 +154,86 @@ def test_detect_validation_max_entities_per_call_must_be_positive() -> None: def test_detect_validation_excerpt_window_chars_must_be_positive() -> None: with pytest.raises(ValidationError): AnonymizerConfig(detect={"validation_excerpt_window_chars": 0}, replace=Redact()) + + +def test_detect_enables_builtin_regexes_by_default() -> None: + assert Detect().builtin_regexes is True + + +def test_regex_rule_defaults_to_llm_validation() -> None: + rule = RegexRule(label="CASE_ID", pattern=r"CASE-[0-9]{8}") + + assert rule.label == "case_id" + assert rule.validate_with_llm is True + + +def test_regex_rule_accepts_direct_validator_callable() -> None: + def validate(candidate: RegexCandidate) -> bool: + return candidate.value != "CASE-00000000" + + rule = RegexRule(label="case_id", pattern=r"CASE-[0-9]{8}", validator=validate) + + assert rule.validator is validate + + +def test_regex_rule_rejects_invalid_and_empty_matching_patterns() -> None: + with pytest.raises(ValidationError, match="Invalid regex pattern"): + RegexRule(label="case_id", pattern="[") + with pytest.raises(ValidationError, match="must not match an empty string"): + RegexRule(label="case_id", pattern=".*") + + +def test_detect_rejects_custom_rule_missing_from_explicit_labels() -> None: + with pytest.raises(ValidationError, match="missing from explicit entity_labels"): + Detect( + entity_labels=["email"], + regex_rules=[RegexRule(label="case_id", pattern=r"CASE-[0-9]{8}")], + ) + + +def test_detect_accepts_builtin_regex_llm_override() -> None: + detect = Detect(regex_rules=[BuiltinRegex(label="credit_debit_card", validate_with_llm=False)]) + + rule = detect.regex_rules[0] + assert isinstance(rule, BuiltinRegex) + assert rule.enabled is True + assert rule.validate_with_llm is False + + +def test_detect_accepts_builtin_regex_enabled_override() -> None: + detect = Detect(regex_rules=[BuiltinRegex(label="email", enabled=False)]) + + rule = detect.regex_rules[0] + assert isinstance(rule, BuiltinRegex) + assert rule.enabled is False + assert rule.validate_with_llm is True + + +def test_detect_rejects_duplicate_builtin_regex_entries() -> None: + with pytest.raises(ValidationError, match="duplicate built-in labels"): + Detect( + regex_rules=[ + BuiltinRegex(label="email", enabled=False), + BuiltinRegex(label="email", validate_with_llm=False), + ] + ) + + +def test_detect_parses_builtin_and_custom_rules_from_serialized_config() -> None: + original = Detect( + regex_rules=[ + BuiltinRegex(label="email", enabled=False), + RegexRule(label="support_case", pattern=r"CASE-\d+"), + ] + ) + + restored = Detect.model_validate_json(original.model_dump_json()) + + assert isinstance(restored.regex_rules[0], BuiltinRegex) + assert isinstance(restored.regex_rules[1], RegexRule) + assert restored == original + + +def test_builtin_regex_rejects_unknown_label() -> None: + with pytest.raises(ValidationError, match="Unsupported built-in regex label"): + BuiltinRegex(label="support_case") diff --git a/tests/engine/test_detection_config_serialization.py b/tests/engine/test_detection_config_serialization.py index 0acda66a..c6a3a0aa 100644 --- a/tests/engine/test_detection_config_serialization.py +++ b/tests/engine/test_detection_config_serialization.py @@ -27,14 +27,16 @@ ChunkedValidationConfig, DetectionTransformConfig, DetectionTransformOperation, + RegexDetectionConfig, ) from anonymizer.engine.workflow_columns.detection.plugins import ( chunked_validation_plugin, detection_transform_plugin, + regex_detection_plugin, ) -@pytest.mark.parametrize("plugin", [detection_transform_plugin, chunked_validation_plugin]) +@pytest.mark.parametrize("plugin", [detection_transform_plugin, chunked_validation_plugin, regex_detection_plugin]) def test_detection_plugin_satisfies_data_designer_contract(plugin: Plugin) -> None: assert_valid_plugin(plugin) @@ -72,7 +74,7 @@ def test_detection_builder_round_trips_through_native_data_designer_config(tmp_p assert seed_config.selection_strategy == PartitionBlock(index=1, num_partitions=3) columns = restored.get_column_configs() - assert len(columns) == 9 + assert len(columns) == 10 assert all(column.column_type != "custom" for column in columns) transforms = [column for column in columns if isinstance(column, DetectionTransformConfig)] @@ -85,10 +87,14 @@ def test_detection_builder_round_trips_through_native_data_designer_config(tmp_p assert validation.pool == parsed_models.selected_models.detection.entity_validator assert "Customer support messages" in validation.prompt_template + regex_detection = next(column for column in columns if isinstance(column, RegexDetectionConfig)) + assert [rule.label for rule in regex_detection.rules] == ["email"] + serialized = json.loads(payload) serialized_text = json.dumps(serialized) assert "anonymizer-detection-transform" in serialized_text assert "anonymizer-chunked-validation" in serialized_text + assert "anonymizer-regex-detection" in serialized_text assert "generator_function" not in serialized_text assert "generator_params" not in serialized_text @@ -138,3 +144,4 @@ def test_fresh_process_discovers_plugins_when_loading_native_config(tmp_path: Pa restored_types = {(column["column_type"], column["class_name"]) for column in restored_columns} assert ("anonymizer-detection-transform", "DetectionTransformConfig") in restored_types assert ("anonymizer-chunked-validation", "ChunkedValidationConfig") in restored_types + assert ("anonymizer-regex-detection", "RegexDetectionConfig") in restored_types diff --git a/tests/engine/test_detection_custom_columns.py b/tests/engine/test_detection_custom_columns.py index e6975c91..e6e231bc 100644 --- a/tests/engine/test_detection_custom_columns.py +++ b/tests/engine/test_detection_custom_columns.py @@ -18,6 +18,8 @@ COL_DETECTED_ENTITIES, COL_MERGED_ENTITIES, COL_RAW_DETECTED, + COL_REGEX_ACCEPTED_ENTITIES, + COL_REGEX_ENTITIES, COL_SEED_ENTITIES, COL_SEED_VALIDATION_CANDIDATES, COL_TAG_NOTATION, @@ -30,6 +32,7 @@ from anonymizer.engine.detection.custom_columns import ( _parse_entity_spans, apply_validation_and_finalize, + apply_validation_to_seed_entities, enrich_validation_decisions, merge_and_build_candidates, parse_detected_entities, @@ -66,13 +69,51 @@ def test_parse_produces_seed_entities_and_notation() -> None: }, ] ) - row: dict[str, Any] = {COL_TEXT: text, COL_RAW_DETECTED: raw} + row: dict[str, Any] = { + COL_TEXT: text, + COL_RAW_DETECTED: raw, + COL_REGEX_ENTITIES: {"entities": []}, + COL_REGEX_ACCEPTED_ENTITIES: {"entities": []}, + } result = parse_detected_entities(row) assert len(result[COL_SEED_ENTITIES]["entities"]) == 1 assert result[COL_SEED_ENTITIES]["entities"][0]["value"] == "(555) 123-4567" assert result[COL_TAG_NOTATION] in {"xml", "bracket", "paren", "sentinel"} +def test_regex_candidate_bypassing_llm_survives_a_drop_decision() -> None: + entity = { + "id": "email_6_23", + "value": "alice@example.com", + "label": "email", + "start_position": 6, + "end_position": 23, + "score": 1.0, + "source": "regex_builtin:nemo.email.v1", + } + row: dict[str, Any] = { + COL_TEXT: "Email alice@example.com", + COL_SEED_ENTITIES: {"entities": [entity]}, + COL_VALIDATED_ENTITIES: { + "decisions": [ + { + "id": "email_6_23", + "value": "alice@example.com", + "label": "email", + "decision": "drop", + "proposed_label": "", + "reason": "test", + } + ] + }, + COL_REGEX_ACCEPTED_ENTITIES: {"entities": [entity]}, + } + + result = apply_validation_to_seed_entities(row) + + assert result[COL_VALIDATED_SEED_ENTITIES]["entities"] == [entity] + + def test_merge_and_build_candidates_writes_schema_shaped_payloads() -> None: row: dict[str, Any] = { COL_TEXT: "Alice works at Acme in Seattle.", diff --git a/tests/engine/test_detection_workflow.py b/tests/engine/test_detection_workflow.py index aed0928a..66236b6a 100644 --- a/tests/engine/test_detection_workflow.py +++ b/tests/engine/test_detection_workflow.py @@ -14,6 +14,7 @@ from data_designer.plugins.registry import PluginRegistry from anonymizer.config.models import DetectionModelSelection +from anonymizer.config.regex import RegexRule from anonymizer.config.rewrite import PrivacyGoal from anonymizer.engine.constants import ( COL_DETECTED_ENTITIES, @@ -336,6 +337,15 @@ def test_resolve_detection_labels_none_uses_defaults() -> None: assert merged == DEFAULT_ENTITY_LABELS +def test_resolve_detection_labels_adds_custom_regex_labels_to_defaults() -> None: + merged = _resolve_detection_labels( + None, + regex_rules=[RegexRule(label="support_case", pattern=r"CASE-\d+")], + ) + + assert merged == [*DEFAULT_ENTITY_LABELS, "support_case"] + + def test_resolve_detection_labels_does_not_append_defaults_when_custom_labels_provided() -> None: merged = _resolve_detection_labels(["custom_label"]) assert merged == ["custom_label"] diff --git a/tests/engine/test_regex_detection.py b/tests/engine/test_regex_detection.py new file mode 100644 index 00000000..0756127a --- /dev/null +++ b/tests/engine/test_regex_detection.py @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pytest + +from anonymizer import BuiltinRegex, RegexCandidate, RegexRule, RegexValidationResult +from anonymizer.engine.detection.postprocess import expand_entity_occurrences +from anonymizer.engine.detection.regex_detection import ( + detect_regex_entities, + resolve_regex_rules, + validate_exportable_regex_rules, +) + + +@pytest.mark.parametrize( + ("label", "text", "expected"), + [ + ("credit_debit_card", "Card 4111 1111 1111 1111.", "4111 1111 1111 1111"), + ("email", "联系:用户@example.公司,处理", "用户@example.公司"), + ("ipv4", "Address 192.168.1.10.", "192.168.1.10"), + ("ipv6", "地址2001:db8::1结束", "2001:db8::1"), + ("mac_address", "MAC 00:1A:2B:3C:4D:5E", "00:1A:2B:3C:4D:5E"), + ("url", "请访问https://例子.公司/路径?值=一。", "https://例子.公司/路径?值=一"), + ], +) +def test_builtin_regexes_detect_valid_values(label: str, text: str, expected: str) -> None: + rules = resolve_regex_rules( + labels=[label], + builtin_regexes=True, + rules=[], + ) + + result = detect_regex_entities(text, rules=rules) + + assert [entity.value for entity in result.llm_entities] == [expected] + assert result.accepted_entities == [] + + +@pytest.mark.parametrize( + ("label", "text"), + [ + ("credit_debit_card", "Card 4111 1111 1111 1112."), + ("ipv4", "Address 999.168.1.10."), + ("url", "Visit http://localhost/path."), + ], +) +def test_builtin_validators_reject_invalid_values(label: str, text: str) -> None: + rules = resolve_regex_rules( + labels=[label], + builtin_regexes=True, + rules=[], + ) + + result = detect_regex_entities(text, rules=rules) + + assert result.llm_entities == [] + assert result.accepted_entities == [] + + +def test_builtin_rules_only_activate_for_requested_labels() -> None: + rules = resolve_regex_rules( + labels=["email"], + builtin_regexes=True, + rules=[], + ) + + assert [rule.label for rule in rules] == ["email"] + + +def test_builtin_regexes_can_be_disabled() -> None: + rules = resolve_regex_rules( + labels=["email"], + builtin_regexes=False, + rules=[], + ) + + assert rules == [] + + +def test_one_builtin_can_be_disabled_without_disabling_the_registry() -> None: + rules = resolve_regex_rules( + labels=["email", "ipv4"], + builtin_regexes=True, + rules=[BuiltinRegex(label="email", enabled=False)], + ) + + assert [rule.label for rule in rules] == ["ipv4"] + + +def test_disabled_builtin_can_be_replaced_by_custom_rule_with_same_label() -> None: + rules = resolve_regex_rules( + labels=["email"], + builtin_regexes=True, + rules=[ + BuiltinRegex(label="email", enabled=False), + RegexRule(label="email", pattern=r"internal:[a-z]+"), + ], + ) + + result = detect_regex_entities("internal:alice alice@example.com", rules=rules) + + assert [(entity.value, entity.source.split(":", 1)[0]) for entity in result.llm_entities] == [ + ("internal:alice", "regex_user") + ] + + +def test_llm_validation_can_be_disabled_per_builtin() -> None: + rules = resolve_regex_rules( + labels=["email"], + builtin_regexes=True, + rules=[BuiltinRegex(label="email", validate_with_llm=False)], + ) + + result = detect_regex_entities("Email alice@example.com", rules=rules) + + assert result.llm_entities == [] + assert [entity.value for entity in result.accepted_entities] == ["alice@example.com"] + + +def test_custom_rule_uses_callable_validator_and_defaults_to_llm_validation() -> None: + def is_valid(candidate: RegexCandidate) -> RegexValidationResult: + return RegexValidationResult(valid=candidate.groups["number"] == "42") + + rules = resolve_regex_rules( + labels=["support_case"], + builtin_regexes=True, + rules=[ + RegexRule( + label="support_case", + pattern=r"CASE-(?P\d+)", + validator=is_valid, + ) + ], + ) + + result = detect_regex_entities("CASE-41 then CASE-42", rules=rules) + + assert [entity.value for entity in result.llm_entities] == ["CASE-42"] + assert result.accepted_entities == [] + + +def test_custom_rule_can_bypass_llm_validation() -> None: + rules = resolve_regex_rules( + labels=["ticket"], + builtin_regexes=False, + rules=[RegexRule(label="ticket", pattern=r"TKT-\d+", validate_with_llm=False)], + ) + + result = detect_regex_entities("TKT-123", rules=rules) + + assert result.llm_entities == [] + assert [entity.value for entity in result.accepted_entities] == ["TKT-123"] + + +def test_custom_rule_ids_and_results_are_stable_when_rules_are_reordered() -> None: + first = RegexRule(label="ticket", pattern=r"TKT-\d+") + second = RegexRule(label="case", pattern=r"CASE-\d+") + + forward_rules = resolve_regex_rules( + labels=["ticket", "case"], + builtin_regexes=False, + rules=[first, second], + ) + reverse_rules = resolve_regex_rules( + labels=["ticket", "case"], + builtin_regexes=False, + rules=[second, first], + ) + + forward_ids = {rule.label: rule.rule_id for rule in forward_rules} + reverse_ids = {rule.label: rule.rule_id for rule in reverse_rules} + assert forward_ids == reverse_ids + + text = "CASE-20 TKT-10" + forward = detect_regex_entities(text, rules=forward_rules) + reverse = detect_regex_entities(text, rules=reverse_rules) + assert [entity.as_dict() for entity in forward.llm_entities] == [ + entity.as_dict() for entity in reverse.llm_entities + ] + + +def test_user_rule_wins_an_identical_span_conflict_with_builtin() -> None: + rules = resolve_regex_rules( + labels=["email", "company_contact"], + builtin_regexes=True, + rules=[RegexRule(label="company_contact", pattern=r"alice@example\.com")], + ) + + result = detect_regex_entities("alice@example.com", rules=rules) + + assert [(entity.value, entity.label) for entity in result.llm_entities] == [ + ("alice@example.com", "company_contact") + ] + + +def test_match_cap_fails_without_including_matched_values_in_error() -> None: + rules = resolve_regex_rules( + labels=["token"], + builtin_regexes=False, + rules=[RegexRule(label="token", pattern=r"secret\d")], + ) + + with pytest.raises(RuntimeError, match="exceeded the maximum") as exc_info: + detect_regex_entities("secret1 secret2", rules=rules, max_matches_per_rule=1) + + assert "secret1" not in str(exc_info.value) + + +def test_regex_entities_are_not_propagated_to_unvalidated_occurrences() -> None: + rules = resolve_regex_rules( + labels=["token"], + builtin_regexes=False, + rules=[RegexRule(label="token", pattern=r"(?<=allow:)ABC", validate_with_llm=False)], + ) + result = detect_regex_entities("allow:ABC deny:ABC", rules=rules) + + expanded = expand_entity_occurrences( + text="allow:ABC deny:ABC", + entities=result.accepted_entities, + ) + + assert [(entity.start_position, entity.end_position) for entity in expanded] == [(6, 9)] + + +def test_export_requires_registered_name_instead_of_direct_callable() -> None: + def validator(candidate: RegexCandidate) -> bool: + return bool(candidate.value) + + with pytest.raises(ValueError, match="registered validator names"): + validate_exportable_regex_rules([RegexRule(label="ticket", pattern=r"TKT-\d+", validator=validator)]) + + validate_exportable_regex_rules([RegexRule(label="ticket", pattern=r"TKT-\d+", validator="installed.ticket.v1")]) diff --git a/uv.lock b/uv.lock index 1cdea812..7852e7a1 100644 --- a/uv.lock +++ b/uv.lock @@ -2464,6 +2464,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pygments" }, + { name = "regex" }, { name = "tiktoken" }, ] @@ -2506,6 +2507,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.9,<3" }, { name = "pydantic-settings", specifier = ">=2.12,<3" }, { name = "pygments", specifier = ">=2.20.0" }, + { name = "regex", specifier = ">=2025.11.3" }, { name = "tiktoken", specifier = ">=0.9.0" }, ] From 46907f814433c52b4abc99fb15864273232df7af Mon Sep 17 00:00:00 2001 From: lipikaramaswamy Date: Wed, 9 Sep 2026 17:52:21 -0400 Subject: [PATCH 2/3] fix(detection): preserve regex source precedence Signed-off-by: lipikaramaswamy --- .../engine/detection/custom_columns.py | 17 ++++- .../engine/detection/regex_detection.py | 26 ++++++- tests/engine/test_detection_custom_columns.py | 68 +++++++++++++++++++ tests/engine/test_regex_detection.py | 21 ++++++ 4 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/anonymizer/engine/detection/custom_columns.py b/src/anonymizer/engine/detection/custom_columns.py index 93a9ada9..b5744c4a 100644 --- a/src/anonymizer/engine/detection/custom_columns.py +++ b/src/anonymizer/engine/detection/custom_columns.py @@ -128,7 +128,7 @@ def apply_validation_to_seed_entities(row: dict[str, Any]) -> dict[str, Any]: validation_output=row.get(COL_VALIDATED_ENTITIES, {}), ) accepted_regex = _parse_entity_spans(row.get(COL_REGEX_ACCEPTED_ENTITIES, {})) - validated_seed = merge_entity_sources(accepted_regex, llm_validated_seed) + validated_seed = _merge_detection_routes(accepted_regex, llm_validated_seed) seed_entities = [entity.as_dict() for entity in validated_seed] row[COL_VALIDATED_SEED_ENTITIES] = EntitiesSchema(entities=seed_entities).model_dump(mode="json") row[COL_SEED_ENTITIES_JSON] = json.dumps(seed_entities) @@ -192,7 +192,7 @@ def apply_validation_and_finalize(row: dict[str, Any]) -> dict[str, Any]: validation_output=row.get(COL_VALIDATED_ENTITIES, {}), ) accepted_regex = _parse_entity_spans(row.get(COL_REGEX_ACCEPTED_ENTITIES, {})) - protected = merge_entity_sources(accepted_regex, validated) + protected = _merge_detection_routes(accepted_regex, validated) expanded = expand_entity_occurrences(text=text, entities=protected) row[COL_DETECTED_ENTITIES] = EntitiesSchema(entities=[entity.as_dict() for entity in expanded]).model_dump( mode="json" @@ -215,3 +215,16 @@ def _parse_entity_spans(raw_payload: object) -> list[EntitySpan]: ) for e in parsed.entities ] + + +def _merge_detection_routes(*routes: list[EntitySpan]) -> list[EntitySpan]: + """Merge validation routes without allowing route order to change source precedence.""" + entities = [entity for route in routes for entity in route] + user_regex = [entity for entity in entities if entity.source.startswith("regex_user:")] + builtin_regex = [entity for entity in entities if entity.source.startswith("regex_builtin:")] + other_sources = [ + entity + for entity in entities + if not entity.source.startswith("regex_user:") and not entity.source.startswith("regex_builtin:") + ] + return merge_entity_sources(user_regex, builtin_regex, other_sources) diff --git a/src/anonymizer/engine/detection/regex_detection.py b/src/anonymizer/engine/detection/regex_detection.py index 6dbd12c3..b4dc728e 100644 --- a/src/anonymizer/engine/detection/regex_detection.py +++ b/src/anonymizer/engine/detection/regex_detection.py @@ -29,7 +29,17 @@ REGEX_VALIDATOR_ENTRYPOINT_GROUP = "nemo_anonymizer.regex_validators" _REGEX_SCORE = 1.0 _CONTEXT_WINDOW = 64 -_URL_TRAILING_PUNCTUATION = ".,;:!?)]}>'\"。,、;:!?)】》」』" +_URL_TRAILING_PUNCTUATION = ".,;:!?>'\"。,、;:!?" +_URL_DELIMITER_PAIRS = { + ")": "(", + "]": "[", + "}": "{", + ")": "(", + "】": "【", + "》": "《", + "」": "「", + "』": "『", +} class ResolvedRegexRule(BaseModel): @@ -127,7 +137,7 @@ def detect_regex_entities( f"{max_matches_per_rule} matches for one record." ) if rule.label == "url": - trimmed = text[start:end].rstrip(_URL_TRAILING_PUNCTUATION) + trimmed = _trim_url_trailing_punctuation(text[start:end]) end = start + len(trimmed) if end <= start: continue @@ -174,6 +184,18 @@ def _merge_regex_sources(entities: list[EntitySpan]) -> list[EntitySpan]: return merge_entity_sources(_deduplicate(users), _deduplicate(builtins)) +def _trim_url_trailing_punctuation(value: str) -> str: + """Remove prose punctuation while preserving balanced URL delimiters.""" + trimmed = value.rstrip(_URL_TRAILING_PUNCTUATION) + while trimmed: + closing = trimmed[-1] + opening = _URL_DELIMITER_PAIRS.get(closing) + if opening is None or trimmed.count(closing) <= trimmed.count(opening): + break + trimmed = trimmed[:-1].rstrip(_URL_TRAILING_PUNCTUATION) + return trimmed + + def _passes_validator( *, text: str, diff --git a/tests/engine/test_detection_custom_columns.py b/tests/engine/test_detection_custom_columns.py index e6e231bc..1d1473db 100644 --- a/tests/engine/test_detection_custom_columns.py +++ b/tests/engine/test_detection_custom_columns.py @@ -13,6 +13,8 @@ import json from typing import Any +import pytest + from anonymizer.engine.constants import ( COL_AUGMENTED_ENTITIES, COL_DETECTED_ENTITIES, @@ -114,6 +116,72 @@ def test_regex_candidate_bypassing_llm_survives_a_drop_decision() -> None: assert result[COL_VALIDATED_SEED_ENTITIES]["entities"] == [entity] +@pytest.mark.parametrize( + ("accepted_source", "validated_source"), + [ + ("regex_builtin:nemo.email.v1", "regex_user:user:contact:v1"), + ("regex_user:user:contact:v1", "regex_builtin:nemo.email.v1"), + ], +) +def test_user_regex_wins_same_span_across_validation_routes( + accepted_source: str, + validated_source: str, +) -> None: + def entity(label: str, source: str) -> dict[str, Any]: + return { + "id": f"{label}_0_17", + "value": "alice@example.com", + "label": label, + "start_position": 0, + "end_position": 17, + "score": 1.0, + "source": source, + } + + accepted_label = "user_contact" if accepted_source.startswith("regex_user:") else "email" + validated_label = "user_contact" if validated_source.startswith("regex_user:") else "email" + accepted = entity(accepted_label, accepted_source) + validated = entity(validated_label, validated_source) + row: dict[str, Any] = { + COL_TEXT: "alice@example.com", + COL_SEED_ENTITIES: {"entities": [validated]}, + COL_VALIDATED_ENTITIES: {"decisions": []}, + COL_REGEX_ACCEPTED_ENTITIES: {"entities": [accepted]}, + } + + result = apply_validation_to_seed_entities(row) + + assert result[COL_VALIDATED_SEED_ENTITIES]["entities"] == [entity("user_contact", "regex_user:user:contact:v1")] + + +def test_user_regex_wins_same_span_during_finalization() -> None: + user_entity = { + "id": "user_contact_0_17", + "value": "alice@example.com", + "label": "user_contact", + "start_position": 0, + "end_position": 17, + "score": 1.0, + "source": "regex_user:user:contact:v1", + } + builtin_entity = { + **user_entity, + "id": "email_0_17", + "label": "email", + "source": "regex_builtin:nemo.email.v1", + } + row: dict[str, Any] = { + COL_TEXT: "alice@example.com", + COL_MERGED_ENTITIES: {"entities": [user_entity]}, + COL_VALIDATED_ENTITIES: {"decisions": []}, + COL_REGEX_ACCEPTED_ENTITIES: {"entities": [builtin_entity]}, + } + + result = apply_validation_and_finalize(row) + + assert result[COL_DETECTED_ENTITIES]["entities"] == [user_entity] + + def test_merge_and_build_candidates_writes_schema_shaped_payloads() -> None: row: dict[str, Any] = { COL_TEXT: "Alice works at Acme in Seattle.", diff --git a/tests/engine/test_regex_detection.py b/tests/engine/test_regex_detection.py index 0756127a..5753d72b 100644 --- a/tests/engine/test_regex_detection.py +++ b/tests/engine/test_regex_detection.py @@ -59,6 +59,27 @@ def test_builtin_validators_reject_invalid_values(label: str, text: str) -> None assert result.accepted_entities == [] +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("See https://en.wikipedia.org/wiki/Foo_(bar).", "https://en.wikipedia.org/wiki/Foo_(bar)"), + ("See https://example.com/Foo_(bar)).", "https://example.com/Foo_(bar)"), + ("See https://example.com/a_(b_(c)).", "https://example.com/a_(b_(c))"), + ("请访问https://例子.公司/路径(内部)。", "https://例子.公司/路径(内部)"), + ("请访问https://例子.公司/路径【内部】】。", "https://例子.公司/路径【内部】"), + ("See http://[2001:db8::1]/docs.", "http://[2001:db8::1]/docs"), + ], +) +def test_url_trimming_preserves_balanced_delimiters(text: str, expected: str) -> None: + rules = resolve_regex_rules(labels=["url"], builtin_regexes=True, rules=[]) + + result = detect_regex_entities(text, rules=rules) + + assert [(entity.value, text[entity.start_position : entity.end_position]) for entity in result.llm_entities] == [ + (expected, expected) + ] + + def test_builtin_rules_only_activate_for_requested_labels() -> None: rules = resolve_regex_rules( labels=["email"], From 05c1a9ccfd68c4aaf56e4a84c0b3d9acc07bacac Mon Sep 17 00:00:00 2001 From: nvskills-svc-account Date: Wed, 9 Sep 2026 22:59:49 +0000 Subject: [PATCH 3/3] Attach NVSkills validation signatures Signed-off-by: nvskills-svc-account --- skills/anonymizer/BENCHMARK.md | 192 +++++++++++++++++++------------- skills/anonymizer/skill-card.md | 182 +++++++++++------------------- skills/anonymizer/skill.oms.sig | 1 + 3 files changed, 183 insertions(+), 192 deletions(-) create mode 100644 skills/anonymizer/skill.oms.sig diff --git a/skills/anonymizer/BENCHMARK.md b/skills/anonymizer/BENCHMARK.md index 027917f8..869e47da 100644 --- a/skills/anonymizer/BENCHMARK.md +++ b/skills/anonymizer/BENCHMARK.md @@ -1,85 +1,125 @@ - - +# Skill Benchmark: anonymizer -# Evaluation Report +> ⚠️ **Overall verdict: INCOMPLETE — Required evidence is missing** -Evaluation report for the `anonymizer` skill before publication through -NVSkills-Eval. +One or more required evaluation tiers did not complete, so this benchmark is not publication-complete. -This benchmark file records the publication-ready evaluation plan and task -composition for NeMo Anonymizer. The external NVSkills-Eval run has not been -executed in this local workspace, so this branch intentionally reports no -Anonymizer scores. - -## Evaluation Summary +## Evaluation Metadata - Skill: `anonymizer` -- Evaluation date: pending external `/nvskills-ci` run -- NVSkills-Eval profile: external -- Environment: external NVSkills-Eval runner -- Dataset: 6 evaluation tasks -- Attempts per task: recorded by external NVSkills-Eval after execution -- Pass threshold: recorded by external NVSkills-Eval after execution -- Overall verdict: pending external NVSkills-Eval run - -## Agents Used - -Agent-level measured results are pending the external NVSkills-Eval run. - -## Metrics Used - -Reported benchmark dimensions: - -- Security: checks whether skill-assisted execution avoids unsafe behavior such - as secret leakage, destructive commands, or unauthorized access. -- Correctness: checks whether the agent follows the expected workflow and - produces the correct final output. -- Discoverability: checks whether the agent loads the skill when relevant and - avoids using it when irrelevant. -- Effectiveness: checks whether the agent performs measurably better with the - skill than without it. -- Efficiency: checks whether the agent uses fewer tokens and avoids redundant - work. - -Underlying evaluation signals will be recorded from the external -NVSkills-Eval output after execution. - -## Test Tasks - -The benchmark dataset contains 6 evaluation tasks: - -- Positive tasks: 4 tasks where the skill is expected to activate. -- Negative tasks: 2 tasks where no skill is expected. -- Unlabeled tasks: 0 tasks where positive/negative intent cannot be inferred. - -Entries with `should_trigger: true` and `expected_skill: "anonymizer"` are -positive skill-activation cases. Entries with `should_trigger: false` and -`expected_skill: null` are negative activation cases. - -## Results - -External NVSkills-Eval execution is pending. No copied or locally inferred -Anonymizer results are reported here. - -| Dimension | Tasks | Result | -|---|---:|---| -| Security | 6 | Pending external NVSkills-Eval run | -| Correctness | 6 | Pending external NVSkills-Eval run | -| Discoverability | 6 | Pending external NVSkills-Eval run | -| Effectiveness | 6 | Pending external NVSkills-Eval run | -| Efficiency | 6 | Pending external NVSkills-Eval run | - -## Tier 1: Static Validation Summary - -Local static validation is covered by this branch's validation evidence. The -external NVSkills-Eval Tier 1 result is pending the `/nvskills-ci` run. +- Evaluation date: 2026-09-09 +- Evaluator version: `1.5.5` +- Agents: Claude Code (`aws/anthropic/bedrock-claude-opus-4-8`), Codex (`openai/openai/gpt-5.5`) +- Tasks: 6 evaluation tasks (4 positive, 2 negative) +- Dataset digest: `sha256:c2c13b2d794c6117dac0402f1261bd2d80972085c5e716426bedff6b3d59b8ae` (skill-evaluator-dataset-snapshot/1) +- Attempts per task: 3 +- Environment: `k8s-sandbox` +- Tier 2 evidence: required for publication +- Tier 3 evidence: required for publication + +Each task attempt ran in its own isolated sandbox pod. + +## What This Report Answers + +The three-tier evaluation checks whether the skill: + +- is safe to use; +- produces correct answers; +- is discovered and activated when needed; +- helps the agent complete the user's goal and expected workflow; and +- avoids wasted skill and tool usage. + +## Results at a Glance + +| Measure | Claude Code (Baseline → Skill Uplift) | Codex (Baseline → Skill Uplift) | +|---|---:|---:| +| Overall | 88.6% — baseline ran, but no comparable score was available; uplift unavailable | 90.9% — baseline ran, but no comparable score was available; uplift unavailable | +| Security | 85.0% → 100.0% (+15.0 points) | 93.8% → 83.3% (-10.5 points) | +| Correctness | 48.0% → 83.3% (+35.3 points) | 77.5% → 100.0% (+22.5 points) | +| Discoverability | 100.0% — baseline ran, but no comparable score was available; uplift unavailable | 95.0% — baseline ran, but no comparable score was available; uplift unavailable | +| Effectiveness | 40.1% → 83.7% (+43.6 points) | 50.0% → 91.8% (+41.8 points) | +| Efficiency | 76.2% — baseline ran, but no comparable score was available; uplift unavailable | 84.5% — baseline ran, but no comparable score was available; uplift unavailable | + +**How to read this table:** baseline is the same task attempted without the target skill. Scores are rounded to one decimal; threshold-adjacent values use additional precision so their displayed band matches the verdict. Uplift is derived from those displayed scores and shown in percentage points. + +Example: `47.0% → 92.0% (+45.0 points)` means the skill-assisted run scored 92.0%, 45.0 percentage points above its 47.0% no-skill baseline. + +A partial dimension was calculated from only the available configured signals; review the detailed report before relying on it. + +## Token Usage + +Actual Tier 3 execution usage is reported for every observed agent/case pair and both conditions. + +| Agent | Dataset case | With skill | Without skill | Delta | Change | Coverage | +|---|---|---:|---:|---:|---:|---| +| claude-code | All cases | 1,247,748 | 4,042,293 | N/A | N/A | skill 6/6; base 10/10 | +| claude-code | anonymizer-negative-general-privacy-explainer | 30,342 | 30,714 | -372 | -1.21% | skill 1/1; base 1/1 | +| claude-code | anonymizer-negative-repository-source-development | 251,307 | 120,401 | +130,906 | +108.73% | skill 1/1; base 1/1 | +| claude-code | anonymizer-positive-failed-records-first | 268,245 | 539,659 | N/A | N/A | skill 1/1; base 3/3 | +| claude-code | anonymizer-positive-hash-cross-record-consistency | 318,336 | 32,524 | +285,812 | +878.77% | skill 1/1; base 1/1 | +| claude-code | anonymizer-positive-mode-choice | 155,373 | 71,490 | +83,883 | +117.34% | skill 1/1; base 1/1 | +| claude-code | anonymizer-positive-self-hosted-gliner | 224,145 | 3,247,505 | N/A | N/A | skill 1/1; base 3/3 | +| codex | All cases | 730,887 | 474,349 | N/A | N/A | skill 6/6; base 8/8 | +| codex | anonymizer-negative-general-privacy-explainer | 13,779 | 13,559 | +220 | +1.62% | skill 1/1; base 1/1 | +| codex | anonymizer-negative-repository-source-development | 567,652 | 292,046 | +275,606 | +94.37% | skill 1/1; base 1/1 | +| codex | anonymizer-positive-failed-records-first | 30,037 | 96,462 | N/A | N/A | skill 1/1; base 3/3 | +| codex | anonymizer-positive-hash-cross-record-consistency | 29,939 | 17,788 | +12,151 | +68.31% | skill 1/1; base 1/1 | +| codex | anonymizer-positive-mode-choice | 30,545 | 18,470 | +12,075 | +65.38% | skill 1/1; base 1/1 | +| codex | anonymizer-positive-self-hosted-gliner | 58,935 | 36,024 | +22,911 | +63.60% | skill 1/1; base 1/1 | +| ALL AGENTS | Dataset aggregate | 1,978,635 | 4,516,642 | N/A | N/A | skill 12/12; base 18/18 | + +Prompt tokens include cached reads, so total tokens are `prompt + completion` (cached is not added twice). The Efficiency score uses `(prompt - cached) + completion`. N/A means the relevant trajectory counters were not available; coverage is never estimated. + +## Tier Status + +| Tier | Purpose | Status | Evidence | +|---|---|---|---| +| Tier 1 | Static validation | **PASSED WITH OBSERVATIONS** | 1 validator(s); 2 finding(s) | +| Tier 2 | Semantic deduplication | **NOT RUN** | No result was recorded | +| Tier 3 | Live agent evaluation | **PASS** | 2 agent(s); 6 task(s) | + +## Findings and Observations + +
+Show detailed findings and successful checks + +- **MEDIUM** SCHEMA/body_recommended_section: Missing recommended section: '## Instructions' (`skills/anonymizer/SKILL.md`) +- **MEDIUM** SCHEMA/body_recommended_section: Missing recommended section: '## Examples' (`skills/anonymizer/SKILL.md`) + +
+ +## Scoring Methodology + +
+Show dimension definitions, source signals, and thresholds + +| Dimension | Question | Scored signals | +|---|---|---| +| Security | Is it safe to use? | `security` (100%) | +| Correctness | Is the answer correct? | `accuracy` (100%) | +| Discoverability | Was the right skill loaded when needed? | `skill_execution` (100%) | +| Effectiveness | Did the skill help complete the task? | `goal_accuracy` (50%) + `behavior_check` (50%) | +| Efficiency | Did it avoid wasted tool calls and token usage? | `skill_efficiency` (50%) + `token_efficiency` (50%) | + +- Dimension bands: PASS at 50% or above; NEUTRAL from 40% to below 50%; FAIL below 40%. +- Overall Tier 3 lift: PASS at +5 points or more; FAIL at -10 points or less; values between those bands are NEUTRAL. +- Overall verdict: PASS only when every configured dimension passes for at least one supported agent. Lift is reported as diagnostic evidence and does not override this gate. +- The 50% attempt pass threshold is a separate per-task gate; it is not the dimension pass threshold. +- Effectiveness is the equal-weight mean of goal completion (`goal_accuracy`) and expected workflow adherence (`behavior_check`). +- Efficiency is 50% tool-call productivity (the backward-compatible `skill_efficiency` wire id) and 50% `token_efficiency`. Positive-case skill routing is scored under Discoverability, not Efficiency; a negative case without a routing target is N/A. N/A sources are omitted, remaining weights are renormalized, and the dimension is marked partial. + +Signals present in this run: -## Tier 2: Deduplication Summary +- `security` (Security): unsafe operations, secret leakage, and unauthorized access. +- `skill_execution` (Skill Execution): whether the expected skill was selected, decoys were avoided, and the workflow executed. +- `skill_efficiency` (Tool Productivity): tool-call productivity (legacy wire id; routing is scored under Discoverability). +- `accuracy` (Accuracy): final-answer correctness against the reference answer. +- `goal_accuracy` (Goal Accuracy): whether the user's goal was achieved. +- `behavior_check` (Behavior Check): whether the expected workflow behavior was followed. +- `token_efficiency` (Token Efficiency): actual uncached prompt plus completion usage (50% of Efficiency). -External NVSkills-Eval deduplication results are pending. +
-## Publication Recommendation +## Freshness -Proceed to external NVSkills-Eval and signing. Publication should depend on the -external evaluation and signing results rather than this local preparation -branch alone. +Regenerate this benchmark when the skill, evaluation dataset, target agent/model, evaluator version, environment, or scoring policy changes. diff --git a/skills/anonymizer/skill-card.md b/skills/anonymizer/skill-card.md index 57b5aa7c..f3bbcfa7 100644 --- a/skills/anonymizer/skill-card.md +++ b/skills/anonymizer/skill-card.md @@ -1,139 +1,89 @@ - - +## Description:
+Use when the user wants to anonymize a text dataset, redact PII, de-identify free-text data, or rewrite text to remove sensitive or inferable identifying information. Produces a runnable Python script that calls the NeMo Anonymizer pipeline (detection → replace or rewrite).
-## Description - -Use NeMo Anonymizer through an interactive agent workflow: inspect text data, -choose Replace or Rewrite, select a replacement strategy, draft a runnable -Python script, preview before full execution, diagnose failed records first, and -configure self-hosted GLiNER when detection must stay local. - -This skill package is prepared for NVSkills publication review. External -NVSkills-Eval results are pending and no Anonymizer scores are reported in this -branch. +This skill is ready for commercial/non-commercial use.
## Owner +NVIDIA
-NVIDIA - -### License/Terms of Use - -Apache 2.0 - -## Use Case - -Developers, privacy engineers, and data practitioners using NeMo Anonymizer to -detect, replace, redact, hash, annotate, or rewrite sensitive entities in text -datasets while keeping a durable script for review and reruns. - -### Deployment Geography for Use - -Global - -## Known Risks and Mitigations - -Risk: Users may overinterpret anonymized output as a privacy guarantee. - -Mitigation: The skill instructs agents to describe Anonymizer as best-effort, -preview before full execution, inspect failed records, and call out human review -for rewrite outputs that need it. - -Risk: Agent-generated scripts may target the wrong source file, text column, or -model-provider configuration. - -Mitigation: The workflow requires data inspection, explicit user confirmation -of mode and key configuration choices, and preview execution before a full run. - -Risk: An incorrect provider or model alias may send detection requests to an -unintended endpoint. - -Mitigation: The skill directs agents to configure the local GLiNER provider -explicitly, keep the full model pool, verify the endpoint, preview, and consult -the self-hosting documentation. - -## Reference(s) - -- [Interactive workflow](references/interactive.md) -- [Choosing a Strategy](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/choosing-a-strategy/) -- [Detection](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/detection/) -- [Evaluation](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/evaluation/) -- [Models](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/models/) -- [Self-hosting GLiNER](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/self-hosting-gliner/) -- [Troubleshooting](https://nvidia-nemo.github.io/Anonymizer/dev/troubleshooting/) - -## Skill Output - -**Output Type(s):** Python scripts, shell commands, configuration guidance, -diagnostic guidance +### License/Terms of Use:
+Apache 2.0
+## Use Case:
+Developers and data engineers who need to anonymize text datasets containing PII, using LLM-powered entity detection and replacement or context-aware rewriting to de-identify free-text data.
-**Output Format:** A runnable Python script plus concise Markdown guidance for -previewing, diagnosing failures, and running the full pipeline +### Deployment Geography for Use:
+Global
-**Output Parameters:** Dataset path, text column, data summary, mode -(`Replace` or `Rewrite`), replacement strategy when applicable, privacy goal, -risk tolerance, entity labels, and optional model-provider paths +## Requirements / Dependencies:
+**Requires API Key or External Credential:** [Yes]
+**Credential Type(s):** [API key]
-**Other Properties Related to Output:** The generated script previews by -default, exits on failed records, optionally evaluates output with -LLM-as-judge, and leaves full dataset execution under explicit user control. +Do not include secrets in prompts/logs/output; use least-privilege credentials; rotate keys as appropriate.
-## Evaluation Agents Used +## Known Risks and Mitigations:
+Risk: Review before execution as proposals could introduce incorrect or misleading guidance into skills.
+Mitigation: Review and scan skill before deployment.
-The external NVSkills-Eval run is pending. Agent-level measured results will be -reported from the external `/nvskills-ci` evaluation output after it runs. +## Reference(s):
+- [NeMo Anonymizer Documentation](https://nvidia-nemo.github.io/Anonymizer/)
+- [Choosing a Strategy](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/choosing-a-strategy/)
+- [Detection](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/detection/)
+- [Evaluation](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/evaluation/)
+- [Models](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/models/)
+- [Self-hosting GLiNER](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/self-hosting-gliner/)
+- [Troubleshooting](https://nvidia-nemo.github.io/Anonymizer/dev/troubleshooting/)
+- [GitHub Repository](https://github.com/NVIDIA-NeMo/Anonymizer)
-## Evaluation Tasks -The prepared evaluation dataset contains 6 NVSkills-Eval tasks: 4 positive -activation cases and 2 negative activation cases. The positive tasks cover mode -choice, stable cross-record replacement with `Hash`, failed-record-first -diagnosis, and self-hosted GLiNER. The negative tasks cover a general privacy -explainer and repository source development. +## Skill Output:
+**Output Type(s):** [Code]
+**Output Format:** [Python script]
+**Output Parameters:** [1D]
+**Other Properties Related to Output:** [None]
-## Evaluation Metrics Used +## Evaluation Agents Used:
+- Claude Code (`aws/anthropic/bedrock-claude-opus-4-8`)
+- Codex (`openai/openai/gpt-5.5`)
-Metrics will be reported by the external NVSkills-Eval run. Expected benchmark -dimensions are: -- Security: Checks whether skill-assisted execution avoids unsafe behavior such - as secret leakage, destructive commands, or unauthorized access. -- Correctness: Checks whether the agent follows the expected workflow and - produces the correct final output. -- Discoverability: Checks whether the agent loads the skill when relevant and - avoids using it when irrelevant. -- Effectiveness: Checks whether the agent performs measurably better with the - skill than without it. -- Efficiency: Checks whether the agent uses fewer tokens and avoids redundant - work. -## Evaluation Results +## Evaluation Tasks:
+6 evaluation tasks (4 positive, 2 negative) with 3 attempts per task in isolated sandbox pods.
-External NVSkills-Eval execution is pending. This publication branch does not -include local or copied Anonymizer benchmark scores. +## Evaluation Metrics Used:
+Reported benchmark dimensions:
+- Security: Whether the skill avoids unsafe operations, secret leakage, and unauthorized access.
+- Correctness: Whether the final answer is correct against the reference answer.
+- Discoverability: Whether the right skill was selected and activated when needed.
+- Effectiveness: Whether the skill helped complete the user's goal (goal completion 50% + expected workflow adherence 50%).
+- Efficiency: Whether the skill avoided wasted tool calls and token usage (tool productivity 50% + token efficiency 50%).
-| Dimension | Tasks | Result | -|---|---:|---| -| Security | 6 | Pending external NVSkills-Eval run | -| Correctness | 6 | Pending external NVSkills-Eval run | -| Discoverability | 6 | Pending external NVSkills-Eval run | -| Effectiveness | 6 | Pending external NVSkills-Eval run | -| Efficiency | 6 | Pending external NVSkills-Eval run | +Underlying evaluation signals used in this run:
+- `security`: Checks for unsafe operations, secret leakage, and unauthorized access.
+- `skill_execution`: Whether the expected skill was selected, decoys were avoided, and the workflow executed.
+- `accuracy`: Final-answer correctness against the reference answer.
+- `goal_accuracy`: Whether the user's goal was achieved.
+- `behavior_check`: Whether the expected workflow behavior was followed.
+- `skill_efficiency`: Tool-call productivity; routing is scored under Discoverability.
+- `token_efficiency`: Actual uncached prompt plus completion token usage.
-## Skill Version(s) -Publication candidate from this repository branch. The released skill version -should be recorded after review, external evaluation, and signing. -## Ethical Considerations +## Evaluation Results:
+| Measure | Claude Code (Baseline → Skill Uplift) | Codex (Baseline → Skill Uplift) | +|---|---:|---:| +| Overall | 88.6% | 90.9% | +| Security | 85.0% → 100.0% (+15.0 points) | 93.8% → 83.3% (-10.5 points) | +| Correctness | 48.0% → 83.3% (+35.3 points) | 77.5% → 100.0% (+22.5 points) | +| Discoverability | 100.0% | 95.0% | +| Effectiveness | 40.1% → 83.7% (+43.6 points) | 50.0% → 91.8% (+41.8 points) | +| Efficiency | 76.2% | 84.5% | -NVIDIA believes Trustworthy AI is a shared responsibility and has established -policies and practices to enable development for a wide array of AI -applications. When downloaded or used in accordance with our terms of service, -developers should work with their internal team to ensure this skill meets -requirements for the relevant industry and use case and addresses foreseeable -product misuse. +## Skill Version(s):
+46907f8 (source: git SHA, committed 2026-09-09)
-(For Release on NVIDIA Platforms Only) +## Ethical Considerations:
+NVIDIA believes Trustworthy AI is a shared responsibility and we have established policies and practices to enable development for a wide array of AI applications. When downloaded or used in accordance with our terms of service, developers should work with their internal team to ensure this skill meets requirements for the relevant industry and use case and addresses unforeseen product misuse.
-Please report quality, risk, security vulnerabilities or NVIDIA AI Concerns -[here](https://app.intigriti.com/programs/nvidia/nvidiavdp/detail). +(For Release on NVIDIA Platforms Only)
+Please report quality, risk, security vulnerabilities or NVIDIA AI Concerns [here](https://app.intigriti.com/programs/nvidia/nvidiavdp/detail).
diff --git a/skills/anonymizer/skill.oms.sig b/skills/anonymizer/skill.oms.sig new file mode 100644 index 00000000..a4892a95 --- /dev/null +++ b/skills/anonymizer/skill.oms.sig @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"x509CertificateChain":{"certificates":[{"rawBytes":"MIICgzCCAgmgAwIBAgIUKIyS7SxNteQIiWzK1dWj85E6520wCgYIKoZIzj0EAwMwVTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjEpMCcGA1UEAwwgTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBJQ0EgMDEwHhcNMjYwNDAxMDAwMDAwWhcNMjgwNDIyMTUzMzA5WjBUMQswCQYDVQQGEwJVUzEbMBkGA1UECgwSTlZJRElBIENvcnBvcmF0aW9uMSgwJgYDVQQDDB9OVklESUEgQWdlbnQgU2tpbGxzIFNpZ25pbmcgMDAxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEYoRM9bQl/dGlwSRNi6bTpIJUXH8Nv9GciP6LSflJYYMLCc296kpyuTSsk5ddbAWiDcFX3C/ydX3jwc+qCLYP6uHy9XphyLjOQ27Yb2J6rBLVtRBS1mgGco/Gr7fL6ODco4GaMIGXMB0GA1UdDgQWBBRQ/5ZW3nJ6lmo9SVk7I15o7UGmpTAfBgNVHSMEGDAWgBRPGpILxMBBleJSsBGjrMKsby1CgjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDA3BggrBgEFBQcBAQQrMCkwJwYIKwYBBQUHMAGGG2h0dHA6Ly9vY3NwLm5kaXMubnZpZGlhLmNvbTAKBggqhkjOPQQDAwNoADBlAjAUygu/GiOCIXrgGr4SmLgeEVDcEitfFUv7ALbvLVGVyMysB3mxmO/uInZfXzWcJZsCMQDxuoxj4ZmO30jhkPIcCxGFCOvnUsnfU3TfGcouYm4M6iRpbKvtVnHPiy4bi6pcKf0="},{"rawBytes":"MIICiDCCAg6gAwIBAgIUZsIuSv9NkpJCNqtYEfCouVv5BzowCgYIKoZIzj0EAwMwUTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBDQTAgFw0yNjA0MDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowVTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjEpMCcGA1UEAwwgTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBJQ0EgMDEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASI72cR3ctKGg4VWnB3bNja6g1Z2PnOmFEopkPof+QeIcPk9rT+g9MjJnq51EQXL93a7C2GJ9J985G4o2V85VD7wJ1RaXhluHW2rf3y8bQGeAYaKMr5s/hUgn+M3/9WlWejgaAwgZ0wHQYDVR0OBBYEFE8akgvEwEGV4lKwEaOswqxvLUKCMB8GA1UdIwQYMBaAFItnoAjjfuCEUvzyvWyI2vOGvwPjMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMDcGCCsGAQUFBwEBBCswKTAnBggrBgEFBQcwAYYbaHR0cDovL29jc3AubmRpcy5udmlkaWEuY29tMAoGCCqGSM49BAMDA2gAMGUCMQCeIMMfAbyzPDacw2MxG+Yt1cikrJX/DVxiGfXuHmkkXn6VgSzE79+lkqDErpVO2gYCMCNEColOyvUvkzZGUEI1hQ3PfMgi3FIo9tHoBKMw4/wGBLFpu/0ubtmbBXM6/UMOEw=="},{"rawBytes":"MIICRTCCAcygAwIBAgIUeJdY3rV86EdvFmG7L8LJBsyQFYkwCgYIKoZIzj0EAwMwUTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBDQTAgFw0yNjA0MDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowUTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBDQTB2MBAGByqGSM49AgEGBSuBBAAiA2IABAYpiXCDjJ9NT2eSDhyHJVSw1Tbze18cGG2F/578oWvHxg23eQAhNRYdq88i1iOshZSO6C29doKui5Xpmo/7Ctw9Sx4PP2RzOmIuOLCuTdNtKcTRwi4GEsd5BAFvWj42M6NjMGEwHQYDVR0OBBYEFItnoAjjfuCEUvzyvWyI2vOGvwPjMB8GA1UdIwQYMBaAFItnoAjjfuCEUvzyvWyI2vOGvwPjMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cAMGQCMCwtAjWLaNwgGWNCgdyNoTyvNhqWRECRJV2r3+7w8g0PL6NHLOsbkgE09BH95h8XlgIwTaQmbbUh2ChAJ5TA1wRiVDnCcvbzHlZl2jM2FcwQQZlk19LOAbyGMRixbu2Ww/rj"}]},"tlogEntries":[]},"dsseEnvelope":{"payload":"ewogICJfdHlwZSI6ICJodHRwczovL2luLXRvdG8uaW8vU3RhdGVtZW50L3YxIiwKICAic3ViamVjdCI6IFsKICAgIHsKICAgICAgIm5hbWUiOiAiYW5vbnltaXplciIsCiAgICAgICJkaWdlc3QiOiB7CiAgICAgICAgInNoYTI1NiI6ICI5YzllN2JlM2E0MWQwMWM4YzFiMmU1MzY1NmY5ZTgzYTZlYjM2NTI4MjE2OWZkNTM3NzU1YTA5NTI5MmUxNGY5IgogICAgICB9CiAgICB9CiAgXSwKICAicHJlZGljYXRlVHlwZSI6ICJodHRwczovL21vZGVsX3NpZ25pbmcvc2lnbmF0dXJlL3YxLjAiLAogICJwcmVkaWNhdGUiOiB7CiAgICAic2VyaWFsaXphdGlvbiI6IHsKICAgICAgImFsbG93X3N5bWxpbmtzIjogZmFsc2UsCiAgICAgICJtZXRob2QiOiAiZmlsZXMiLAogICAgICAiaWdub3JlX3BhdGhzIjogWwogICAgICAgICIuZ2l0aHViIiwKICAgICAgICAiLmdpdGlnbm9yZSIsCiAgICAgICAgIi5naXRhdHRyaWJ1dGVzIiwKICAgICAgICAiLmdpdCIKICAgICAgXSwKICAgICAgImhhc2hfdHlwZSI6ICJzaGEyNTYiCiAgICB9LAogICAgInJlc291cmNlcyI6IFsKICAgICAgewogICAgICAgICJhbGdvcml0aG0iOiAic2hhMjU2IiwKICAgICAgICAibmFtZSI6ICJCRU5DSE1BUksubWQiLAogICAgICAgICJkaWdlc3QiOiAiOGM1YTNiNTQzNjdkYjBhNjRiODc4MjkwZDBkODBiNDgxZjUwMGIzYmI2NGQ0OWNiNTAyNzRjYzIzYzU2NzNjYSIKICAgICAgfSwKICAgICAgewogICAgICAgICJhbGdvcml0aG0iOiAic2hhMjU2IiwKICAgICAgICAibmFtZSI6ICJTS0lMTC5tZCIsCiAgICAgICAgImRpZ2VzdCI6ICJlNGM2YmEyMTQyYjA1NjM3YmZjYmEwYzRlZTdhMjI2N2ZlZTgyMjI0M2RiYWY0ZTMwN2FiMDlhMzI5YjY1MDBlIgogICAgICB9LAogICAgICB7CiAgICAgICAgImFsZ29yaXRobSI6ICJzaGEyNTYiLAogICAgICAgICJuYW1lIjogImV2YWxzL2V2YWxzLmpzb24iLAogICAgICAgICJkaWdlc3QiOiAiY2E0N2I4MmRjMzEyZmM2NDA3NzBiZjY3MzNiYTQ2MjRkY2M4ZjcwODA3ZWQzNmY3M2U5ZWU1MGExZDQ3ZTBjMiIKICAgICAgfSwKICAgICAgewogICAgICAgICJhbGdvcml0aG0iOiAic2hhMjU2IiwKICAgICAgICAibmFtZSI6ICJyZWZlcmVuY2VzL2ludGVyYWN0aXZlLm1kIiwKICAgICAgICAiZGlnZXN0IjogImQ3NTVhYTQ1NzQwN2UzOTAxNWM3MTFhMGNiODAyODJkZWU5Mjc2YWFiYmM0YTE2MzNmMWQ1M2QxMTBlMzNmMDgiCiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiYWxnb3JpdGhtIjogInNoYTI1NiIsCiAgICAgICAgIm5hbWUiOiAic2tpbGwtY2FyZC5tZCIsCiAgICAgICAgImRpZ2VzdCI6ICJkYzM1OTFkODMzMmU5ODRkYjQwZGM4ZWQ5OTQ2MTRhYzJhOWRjNGM5Y2QxMTU2MDY3NWUzNThjMDQyZjUzYzFjIgogICAgICB9CiAgICBdCiAgfQp9","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MGUCMQDOv8Fk+9VMunufh+6ez7XvOB6uboRlarUCmQeu8/Yys4moOP7XSo7GN6T1dKJcmKQCME+gZ9xCOWANerXNXRny+T4ScTfgk+1n4yQavvmsR0d/CYaRswn5ZHrBhA4vT/M7KA==","keyid":""}]}} \ No newline at end of file