datafog-core 0.4.2__tar.gz → 0.4.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {datafog_core-0.4.2 → datafog_core-0.4.4}/Cargo.lock +4 -4
- {datafog_core-0.4.2 → datafog_core-0.4.4}/PKG-INFO +42 -2
- {datafog_core-0.4.2/crates/core → datafog_core-0.4.4}/README.md +41 -1
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/Cargo.toml +1 -1
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/test_installed.py +37 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/test_typing.py +3 -1
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/Cargo.toml +1 -1
- {datafog_core-0.4.2 → datafog_core-0.4.4/crates/core}/README.md +41 -1
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/capabilities.rs +52 -4
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/german.rs +9 -2
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/lib.rs +269 -5
- datafog_core-0.4.4/crates/core/src/scan_selection_tests.rs +155 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/pyproject.toml +1 -1
- {datafog_core-0.4.2 → datafog_core-0.4.4}/python/datafog_core/__init__.pyi +6 -2
- {datafog_core-0.4.2 → datafog_core-0.4.4}/Cargo.toml +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/src/lib.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/api_key_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/bearer_token_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/capabilities_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/credential_overlap_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/credential_uri_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/email_context_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/german_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/jwt_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/npi_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/private_key_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/requirements-typing.txt +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/synthetic_fixtures.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/test_python49_integration.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/us_routing_number_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/uuid_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/examples/german_benchmark.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/examples/scan_benchmark.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/api_key.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/bearer_token.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/credential_uri.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/email_context.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/jwt.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/npi.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/offsets.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/private_key.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/selection_tests.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/structured.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/us_routing_number.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/uuid.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/api_key.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/bearer_token.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/capabilities.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/credential_overlaps.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/credential_uri.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/email_context.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/german.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/jwt.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/npi.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/private_key.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/support/synthetic_fixtures.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/us_routing_number.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/uuid.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/python/datafog_core/__init__.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.4}/python/datafog_core/py.typed +0 -0
|
@@ -80,7 +80,7 @@ checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
|
|
80
80
|
|
|
81
81
|
[[package]]
|
|
82
82
|
name = "datafog-core"
|
|
83
|
-
version = "0.4.
|
|
83
|
+
version = "0.4.4"
|
|
84
84
|
dependencies = [
|
|
85
85
|
"base64",
|
|
86
86
|
"futures",
|
|
@@ -94,7 +94,7 @@ dependencies = [
|
|
|
94
94
|
|
|
95
95
|
[[package]]
|
|
96
96
|
name = "datafog-core-python"
|
|
97
|
-
version = "0.4.
|
|
97
|
+
version = "0.4.4"
|
|
98
98
|
dependencies = [
|
|
99
99
|
"datafog-core",
|
|
100
100
|
"pyo3",
|
|
@@ -104,7 +104,7 @@ dependencies = [
|
|
|
104
104
|
|
|
105
105
|
[[package]]
|
|
106
106
|
name = "datafog-node"
|
|
107
|
-
version = "0.4.
|
|
107
|
+
version = "0.4.4"
|
|
108
108
|
dependencies = [
|
|
109
109
|
"datafog-core",
|
|
110
110
|
"napi",
|
|
@@ -115,7 +115,7 @@ dependencies = [
|
|
|
115
115
|
|
|
116
116
|
[[package]]
|
|
117
117
|
name = "datafog-wasm"
|
|
118
|
-
version = "0.4.
|
|
118
|
+
version = "0.4.4"
|
|
119
119
|
dependencies = [
|
|
120
120
|
"datafog-core",
|
|
121
121
|
"serde",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datafog-core
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: License :: OSI Approved :: MIT License
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -38,6 +38,13 @@ existing detectors and returns field paths plus string-local findings. No model
|
|
|
38
38
|
or dictionary download is needed. See [person-field discovery](docs/guides/person-discovery.mdx)
|
|
39
39
|
for `scan_structured` / `scanStructured` and structured transformation APIs.
|
|
40
40
|
|
|
41
|
+
Phone candidates without `+` are interpreted as North American numbers: ten
|
|
42
|
+
digits, optionally preceded by `1`, with area and exchange codes starting with
|
|
43
|
+
`2` through `9`. Candidates starting with `+` retain the existing permissive
|
|
44
|
+
7–15 digit check, including shortened synthetic examples such as `+1-555-1002`.
|
|
45
|
+
These checks do not validate country calling codes or assigned phone numbers,
|
|
46
|
+
and numeric identifiers that fit the accepted patterns can still match `PHONE`.
|
|
47
|
+
|
|
41
48
|
Both ranges use zero-based, end-exclusive offsets. The byte range addresses the
|
|
42
49
|
UTF-8 input; the code-point range addresses Unicode scalar values. Rule-based
|
|
43
50
|
detectors currently report no confidence score. Node.js and browser WASM also
|
|
@@ -79,13 +86,26 @@ full-match regex values:
|
|
|
79
86
|
}
|
|
80
87
|
```
|
|
81
88
|
|
|
82
|
-
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
|
|
89
|
+
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
|
|
83
90
|
detection settings remain separate from transformation policy.
|
|
84
91
|
For `.env` assignments or standard SQL quoted strings, select `format: "env"`
|
|
85
92
|
or `format: "sql"` to preserve email boundaries. See the
|
|
86
93
|
[email source-context policy](docs/guides/email-boundaries.mdx) for supported
|
|
87
94
|
syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
|
|
88
95
|
|
|
96
|
+
## Credit-card detection and decimal numbers
|
|
97
|
+
|
|
98
|
+
To reduce decimal false positives, `CREDIT_CARD` detection suppresses contiguous
|
|
99
|
+
digit candidates immediately after a digit followed by `.` or `,`, such as
|
|
100
|
+
`3.4111111111111111` and `3,4111111111111111`. Cards containing spaces or hyphens,
|
|
101
|
+
such as `3.4111-1111-1111-1111`, still undergo the normal card checks.
|
|
102
|
+
|
|
103
|
+
In plain text, `3,4111111111111111` could also be two CSV fields: `3` and a card
|
|
104
|
+
number. The detector cannot distinguish that structure from a comma-decimal
|
|
105
|
+
number, so it suppresses the card candidate. For CSV input, parse the CSV and
|
|
106
|
+
scan each field separately so a card value is checked without the preceding
|
|
107
|
+
field and delimiter.
|
|
108
|
+
|
|
89
109
|
## Packages
|
|
90
110
|
|
|
91
111
|
| Runtime | Distribution | Import | Status |
|
|
@@ -328,6 +348,26 @@ and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
|
|
|
328
348
|
The higher-level Python adapter and its dependency update were merged in
|
|
329
349
|
[PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
|
|
330
350
|
|
|
351
|
+
## Text detector selection
|
|
352
|
+
|
|
353
|
+
Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
|
|
354
|
+
selected detectors, rather than detecting every type and filtering findings
|
|
355
|
+
later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
|
|
356
|
+
"SSN".into()])?`. The same selection works inside `scan_and_transform`'s
|
|
357
|
+
`scan` configuration; transformation selection remains independent.
|
|
358
|
+
|
|
359
|
+
Omitting `entities` preserves existing detection. An explicit selection must be
|
|
360
|
+
a nonempty array of unique, exact canonical text entity names. Unknown names,
|
|
361
|
+
lowercase names, duplicates, and structured-only `PERSON` are rejected using
|
|
362
|
+
configuration errors with `/entities` or `/entities/<index>` paths. Consult
|
|
363
|
+
`capabilities().entities` for types with the `text` scope.
|
|
364
|
+
|
|
365
|
+
Selection narrows enabled detectors: selecting `UUID` still requires
|
|
366
|
+
`detect_uuid=true`, and German types still require a German locale. Text format
|
|
367
|
+
handling, finding order, and byte/codepoint ranges are unchanged. Structured
|
|
368
|
+
scans do not accept this option. It avoids unselected detector work and findings,
|
|
369
|
+
but does not cap the selected findings or bound the size of a scan response.
|
|
370
|
+
|
|
331
371
|
## UUID identifiers
|
|
332
372
|
|
|
333
373
|
Canonical UUID detection is opt-in: pass `{"detect_uuid":true}` to text or
|
|
@@ -15,6 +15,13 @@ existing detectors and returns field paths plus string-local findings. No model
|
|
|
15
15
|
or dictionary download is needed. See [person-field discovery](docs/guides/person-discovery.mdx)
|
|
16
16
|
for `scan_structured` / `scanStructured` and structured transformation APIs.
|
|
17
17
|
|
|
18
|
+
Phone candidates without `+` are interpreted as North American numbers: ten
|
|
19
|
+
digits, optionally preceded by `1`, with area and exchange codes starting with
|
|
20
|
+
`2` through `9`. Candidates starting with `+` retain the existing permissive
|
|
21
|
+
7–15 digit check, including shortened synthetic examples such as `+1-555-1002`.
|
|
22
|
+
These checks do not validate country calling codes or assigned phone numbers,
|
|
23
|
+
and numeric identifiers that fit the accepted patterns can still match `PHONE`.
|
|
24
|
+
|
|
18
25
|
Both ranges use zero-based, end-exclusive offsets. The byte range addresses the
|
|
19
26
|
UTF-8 input; the code-point range addresses Unicode scalar values. Rule-based
|
|
20
27
|
detectors currently report no confidence score. Node.js and browser WASM also
|
|
@@ -56,13 +63,26 @@ full-match regex values:
|
|
|
56
63
|
}
|
|
57
64
|
```
|
|
58
65
|
|
|
59
|
-
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
|
|
66
|
+
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
|
|
60
67
|
detection settings remain separate from transformation policy.
|
|
61
68
|
For `.env` assignments or standard SQL quoted strings, select `format: "env"`
|
|
62
69
|
or `format: "sql"` to preserve email boundaries. See the
|
|
63
70
|
[email source-context policy](docs/guides/email-boundaries.mdx) for supported
|
|
64
71
|
syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
|
|
65
72
|
|
|
73
|
+
## Credit-card detection and decimal numbers
|
|
74
|
+
|
|
75
|
+
To reduce decimal false positives, `CREDIT_CARD` detection suppresses contiguous
|
|
76
|
+
digit candidates immediately after a digit followed by `.` or `,`, such as
|
|
77
|
+
`3.4111111111111111` and `3,4111111111111111`. Cards containing spaces or hyphens,
|
|
78
|
+
such as `3.4111-1111-1111-1111`, still undergo the normal card checks.
|
|
79
|
+
|
|
80
|
+
In plain text, `3,4111111111111111` could also be two CSV fields: `3` and a card
|
|
81
|
+
number. The detector cannot distinguish that structure from a comma-decimal
|
|
82
|
+
number, so it suppresses the card candidate. For CSV input, parse the CSV and
|
|
83
|
+
scan each field separately so a card value is checked without the preceding
|
|
84
|
+
field and delimiter.
|
|
85
|
+
|
|
66
86
|
## Packages
|
|
67
87
|
|
|
68
88
|
| Runtime | Distribution | Import | Status |
|
|
@@ -305,6 +325,26 @@ and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
|
|
|
305
325
|
The higher-level Python adapter and its dependency update were merged in
|
|
306
326
|
[PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
|
|
307
327
|
|
|
328
|
+
## Text detector selection
|
|
329
|
+
|
|
330
|
+
Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
|
|
331
|
+
selected detectors, rather than detecting every type and filtering findings
|
|
332
|
+
later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
|
|
333
|
+
"SSN".into()])?`. The same selection works inside `scan_and_transform`'s
|
|
334
|
+
`scan` configuration; transformation selection remains independent.
|
|
335
|
+
|
|
336
|
+
Omitting `entities` preserves existing detection. An explicit selection must be
|
|
337
|
+
a nonempty array of unique, exact canonical text entity names. Unknown names,
|
|
338
|
+
lowercase names, duplicates, and structured-only `PERSON` are rejected using
|
|
339
|
+
configuration errors with `/entities` or `/entities/<index>` paths. Consult
|
|
340
|
+
`capabilities().entities` for types with the `text` scope.
|
|
341
|
+
|
|
342
|
+
Selection narrows enabled detectors: selecting `UUID` still requires
|
|
343
|
+
`detect_uuid=true`, and German types still require a German locale. Text format
|
|
344
|
+
handling, finding order, and byte/codepoint ranges are unchanged. Structured
|
|
345
|
+
scans do not accept this option. It avoids unselected detector work and findings,
|
|
346
|
+
but does not cap the selected findings or bound the size of a scan response.
|
|
347
|
+
|
|
308
348
|
## UUID identifiers
|
|
309
349
|
|
|
310
350
|
Canonical UUID detection is opt-in: pass `{"detect_uuid":true}` to text or
|
|
@@ -126,7 +126,44 @@ def verify_structured() -> None:
|
|
|
126
126
|
raise AssertionError("invalid structured input accepted")
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
def verify_text_selection() -> None:
|
|
130
|
+
text = "👋 jane@example.com SSN 123-45-6789"
|
|
131
|
+
complete = scan(text)
|
|
132
|
+
selected = scan(text, {"entities": ["EMAIL"]})
|
|
133
|
+
expected = [finding for finding in complete if finding.entity_type == "EMAIL"]
|
|
134
|
+
assert len(selected) == len(expected) == 1
|
|
135
|
+
assert selected[0].codepoint_range.start == expected[0].codepoint_range.start
|
|
136
|
+
assert selected[0].byte_range.start == expected[0].byte_range.start
|
|
137
|
+
assert selected[0].matched_text == "jane@example.com"
|
|
138
|
+
transformed = scan_and_transform(text, {
|
|
139
|
+
"scan": {"entities": ["EMAIL"]},
|
|
140
|
+
"transform": {"default": {"strategy": "redact"}},
|
|
141
|
+
})
|
|
142
|
+
assert "123-45-6789" in transformed.text
|
|
143
|
+
assert "jane@example.com" not in transformed.text
|
|
144
|
+
for entities in ([], ["EMAIL", "EMAIL"], ["PERSON"], ["email"], [1], "EMAIL"):
|
|
145
|
+
try:
|
|
146
|
+
scan(text, {"entities": entities})
|
|
147
|
+
except DataFogConfigurationError as error:
|
|
148
|
+
assert error.code == "invalid_configuration"
|
|
149
|
+
assert error.path.startswith("/entities")
|
|
150
|
+
else:
|
|
151
|
+
raise AssertionError(f"invalid selection accepted: {entities!r}")
|
|
152
|
+
uuid = "550e8400-e29b-41d4-a716-446655440000"
|
|
153
|
+
assert not scan(uuid, {"entities": ["UUID"]})
|
|
154
|
+
assert [item.entity_type for item in scan(uuid, {"entities": ["UUID"], "detect_uuid": True})] == ["UUID"]
|
|
155
|
+
assert not scan(uuid, {"entities": ["EMAIL"], "detect_uuid": True})
|
|
156
|
+
assert scan("contact=jane@example.com", {"format": "env", "entities": ["EMAIL"]})[0].matched_text == "jane@example.com"
|
|
157
|
+
try:
|
|
158
|
+
scan_structured({"email": "jane@example.com"}, {"entities": ["EMAIL"]})
|
|
159
|
+
except DataFogConfigurationError as error:
|
|
160
|
+
assert error.reason == "unknown_field"
|
|
161
|
+
else:
|
|
162
|
+
raise AssertionError("text selection was accepted in structured scan")
|
|
163
|
+
|
|
164
|
+
|
|
129
165
|
def main() -> None:
|
|
166
|
+
verify_text_selection()
|
|
130
167
|
capabilities_conformance.verify()
|
|
131
168
|
german_conformance.verify()
|
|
132
169
|
credential_overlap_conformance.verify()
|
|
@@ -42,7 +42,7 @@ config: _TransformationConfig = {
|
|
|
42
42
|
},
|
|
43
43
|
"allow": {"exact": {"EMAIL": ["safe@example.com"]}, "regex": {"EMAIL": [{"pattern": "safe", "case_sensitive": False}]}},
|
|
44
44
|
}
|
|
45
|
-
findings = scan("jane@example.com", {"locale": "en-US", "detect_uuid": True, "format": "env"})
|
|
45
|
+
findings = scan("jane@example.com", {"locale": "en-US", "detect_uuid": True, "format": "env", "entities": ["EMAIL"]})
|
|
46
46
|
assert_type(findings, list[Finding])
|
|
47
47
|
finding = findings[0]
|
|
48
48
|
assert_type(finding.entity_type, str)
|
|
@@ -131,6 +131,8 @@ scan("text", {"format": "yaml"}) # error
|
|
|
131
131
|
scan("text", {"locale": 123}) # error
|
|
132
132
|
scan("text", {"detect_uuid": "true"}) # error
|
|
133
133
|
scan("text", {"max_bytes": 100}) # error
|
|
134
|
+
scan("text", {"entities": "EMAIL"}) # error
|
|
135
|
+
scan_structured({"email": "jane@example.com"}, {"entities": ["EMAIL"]}) # error
|
|
134
136
|
scan("text")[0].entity_type = "OTHER" # error
|
|
135
137
|
scan("text")[0].byte_range.start = 1 # error
|
|
136
138
|
transform("text", ["not a finding"], {"default": {"strategy": "redact"}}) # error
|
|
@@ -15,6 +15,13 @@ existing detectors and returns field paths plus string-local findings. No model
|
|
|
15
15
|
or dictionary download is needed. See [person-field discovery](docs/guides/person-discovery.mdx)
|
|
16
16
|
for `scan_structured` / `scanStructured` and structured transformation APIs.
|
|
17
17
|
|
|
18
|
+
Phone candidates without `+` are interpreted as North American numbers: ten
|
|
19
|
+
digits, optionally preceded by `1`, with area and exchange codes starting with
|
|
20
|
+
`2` through `9`. Candidates starting with `+` retain the existing permissive
|
|
21
|
+
7–15 digit check, including shortened synthetic examples such as `+1-555-1002`.
|
|
22
|
+
These checks do not validate country calling codes or assigned phone numbers,
|
|
23
|
+
and numeric identifiers that fit the accepted patterns can still match `PHONE`.
|
|
24
|
+
|
|
18
25
|
Both ranges use zero-based, end-exclusive offsets. The byte range addresses the
|
|
19
26
|
UTF-8 input; the code-point range addresses Unicode scalar values. Rule-based
|
|
20
27
|
detectors currently report no confidence score. Node.js and browser WASM also
|
|
@@ -56,13 +63,26 @@ full-match regex values:
|
|
|
56
63
|
}
|
|
57
64
|
```
|
|
58
65
|
|
|
59
|
-
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
|
|
66
|
+
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
|
|
60
67
|
detection settings remain separate from transformation policy.
|
|
61
68
|
For `.env` assignments or standard SQL quoted strings, select `format: "env"`
|
|
62
69
|
or `format: "sql"` to preserve email boundaries. See the
|
|
63
70
|
[email source-context policy](docs/guides/email-boundaries.mdx) for supported
|
|
64
71
|
syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
|
|
65
72
|
|
|
73
|
+
## Credit-card detection and decimal numbers
|
|
74
|
+
|
|
75
|
+
To reduce decimal false positives, `CREDIT_CARD` detection suppresses contiguous
|
|
76
|
+
digit candidates immediately after a digit followed by `.` or `,`, such as
|
|
77
|
+
`3.4111111111111111` and `3,4111111111111111`. Cards containing spaces or hyphens,
|
|
78
|
+
such as `3.4111-1111-1111-1111`, still undergo the normal card checks.
|
|
79
|
+
|
|
80
|
+
In plain text, `3,4111111111111111` could also be two CSV fields: `3` and a card
|
|
81
|
+
number. The detector cannot distinguish that structure from a comma-decimal
|
|
82
|
+
number, so it suppresses the card candidate. For CSV input, parse the CSV and
|
|
83
|
+
scan each field separately so a card value is checked without the preceding
|
|
84
|
+
field and delimiter.
|
|
85
|
+
|
|
66
86
|
## Packages
|
|
67
87
|
|
|
68
88
|
| Runtime | Distribution | Import | Status |
|
|
@@ -305,6 +325,26 @@ and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
|
|
|
305
325
|
The higher-level Python adapter and its dependency update were merged in
|
|
306
326
|
[PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
|
|
307
327
|
|
|
328
|
+
## Text detector selection
|
|
329
|
+
|
|
330
|
+
Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
|
|
331
|
+
selected detectors, rather than detecting every type and filtering findings
|
|
332
|
+
later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
|
|
333
|
+
"SSN".into()])?`. The same selection works inside `scan_and_transform`'s
|
|
334
|
+
`scan` configuration; transformation selection remains independent.
|
|
335
|
+
|
|
336
|
+
Omitting `entities` preserves existing detection. An explicit selection must be
|
|
337
|
+
a nonempty array of unique, exact canonical text entity names. Unknown names,
|
|
338
|
+
lowercase names, duplicates, and structured-only `PERSON` are rejected using
|
|
339
|
+
configuration errors with `/entities` or `/entities/<index>` paths. Consult
|
|
340
|
+
`capabilities().entities` for types with the `text` scope.
|
|
341
|
+
|
|
342
|
+
Selection narrows enabled detectors: selecting `UUID` still requires
|
|
343
|
+
`detect_uuid=true`, and German types still require a German locale. Text format
|
|
344
|
+
handling, finding order, and byte/codepoint ranges are unchanged. Structured
|
|
345
|
+
scans do not accept this option. It avoids unselected detector work and findings,
|
|
346
|
+
but does not cap the selected findings or bound the size of a scan response.
|
|
347
|
+
|
|
308
348
|
## UUID identifiers
|
|
309
349
|
|
|
310
350
|
Canonical UUID detection is opt-in: pass `{"detect_uuid":true}` to text or
|
|
@@ -88,6 +88,14 @@ struct Detector {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
impl Detector {
|
|
91
|
+
fn enabled(&self, config: &ScanConfig) -> bool {
|
|
92
|
+
self.activation.enabled(config)
|
|
93
|
+
&& self
|
|
94
|
+
.labels
|
|
95
|
+
.iter()
|
|
96
|
+
.any(|label| config.includes_entity(label.as_str()))
|
|
97
|
+
}
|
|
98
|
+
|
|
91
99
|
fn single(label: Label, detect: fn(&str, &mut Vec<Candidate>), activation: Activation) -> Self {
|
|
92
100
|
Self {
|
|
93
101
|
labels: vec![label],
|
|
@@ -147,13 +155,18 @@ pub(super) fn valid_locale(locale: &str) -> bool {
|
|
|
147
155
|
locale_activation(locale).is_some()
|
|
148
156
|
}
|
|
149
157
|
|
|
150
|
-
pub(super) fn
|
|
151
|
-
|
|
158
|
+
pub(super) fn text_entity_supported(entity: &str) -> bool {
|
|
159
|
+
REGISTRY
|
|
152
160
|
.iter()
|
|
153
|
-
.
|
|
154
|
-
|
|
161
|
+
.any(|detector| detector.labels.iter().any(|label| label.as_str() == entity))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
pub(super) fn detect(text: &str, config: &ScanConfig, candidates: &mut Vec<Candidate>) {
|
|
165
|
+
for detector in REGISTRY.iter().filter(|detector| detector.enabled(config)) {
|
|
155
166
|
if detector.labels == [Label::Email] {
|
|
156
167
|
crate::email_context::detect(text, config.format(), candidates);
|
|
168
|
+
} else if detector.activation == Activation::German {
|
|
169
|
+
german::detect_selected(text, config, candidates);
|
|
157
170
|
} else {
|
|
158
171
|
(detector.detect)(text, candidates);
|
|
159
172
|
}
|
|
@@ -218,3 +231,38 @@ pub fn capabilities() -> Capabilities {
|
|
|
218
231
|
entities,
|
|
219
232
|
}
|
|
220
233
|
}
|
|
234
|
+
|
|
235
|
+
#[cfg(test)]
|
|
236
|
+
mod selection_dispatch_tests {
|
|
237
|
+
use super::*;
|
|
238
|
+
|
|
239
|
+
#[test]
|
|
240
|
+
fn dispatch_skips_every_registry_detector_without_a_selected_label() {
|
|
241
|
+
let activated = ScanConfig::new()
|
|
242
|
+
.with_locale("de")
|
|
243
|
+
.unwrap()
|
|
244
|
+
.with_uuid_detection(true);
|
|
245
|
+
for entity in capabilities()
|
|
246
|
+
.supported_entities
|
|
247
|
+
.iter()
|
|
248
|
+
.filter(|name| name.as_str() != "PERSON")
|
|
249
|
+
{
|
|
250
|
+
let selected = activated
|
|
251
|
+
.clone()
|
|
252
|
+
.with_entities(vec![entity.clone()])
|
|
253
|
+
.unwrap();
|
|
254
|
+
let enabled: Vec<_> = REGISTRY
|
|
255
|
+
.iter()
|
|
256
|
+
.filter(|detector| detector.enabled(&selected))
|
|
257
|
+
.collect();
|
|
258
|
+
assert_eq!(enabled.len(), 1, "{entity}");
|
|
259
|
+
assert!(
|
|
260
|
+
enabled[0]
|
|
261
|
+
.labels
|
|
262
|
+
.iter()
|
|
263
|
+
.any(|label| label.as_str() == entity)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
assert!(REGISTRY.iter().all(|detector| detector.enabled(&activated)));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//! Locale-gated lexical detection, without official identifier validation.
|
|
2
|
-
use crate::{Candidate, Label};
|
|
2
|
+
use crate::{Candidate, Label, ScanConfig};
|
|
3
3
|
use regex::Regex;
|
|
4
4
|
use std::sync::LazyLock;
|
|
5
5
|
|
|
@@ -71,7 +71,14 @@ fn ascii_boundary(text: &str, start: usize, end: usize) -> bool {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
pub(super) fn detect(text: &str, candidates: &mut Vec<Candidate>) {
|
|
74
|
-
|
|
74
|
+
detect_selected(text, &ScanConfig::default(), candidates);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pub(super) fn detect_selected(text: &str, config: &ScanConfig, candidates: &mut Vec<Candidate>) {
|
|
78
|
+
for (label, regex) in DETECTORS
|
|
79
|
+
.iter()
|
|
80
|
+
.filter(|(label, _)| config.includes_entity(label.as_str()))
|
|
81
|
+
{
|
|
75
82
|
for captures in regex.captures_iter(text) {
|
|
76
83
|
// Both captures are unconditional in the statically compiled expressions.
|
|
77
84
|
let whole = captures.get(0).expect("whole regex match");
|
|
@@ -555,9 +555,54 @@ pub struct ScanConfig {
|
|
|
555
555
|
locale: Option<String>,
|
|
556
556
|
detect_uuid: bool,
|
|
557
557
|
format: ScanFormat,
|
|
558
|
+
entities: Option<BTreeSet<String>>,
|
|
558
559
|
}
|
|
559
560
|
|
|
560
561
|
impl ScanConfig {
|
|
562
|
+
/// Restrict text scanning to exact canonical entity types. Selection only
|
|
563
|
+
/// narrows enabled detectors; locale and UUID activation remain independent.
|
|
564
|
+
pub fn with_entities(mut self, entities: Vec<String>) -> Result<Self, PrivacyError> {
|
|
565
|
+
if entities.is_empty() {
|
|
566
|
+
return Err(PrivacyError::invalid_configuration(
|
|
567
|
+
PrivacyErrorReason::EmptyValue,
|
|
568
|
+
"/entities",
|
|
569
|
+
"entities must contain at least one entity type",
|
|
570
|
+
));
|
|
571
|
+
}
|
|
572
|
+
let mut selected = BTreeSet::new();
|
|
573
|
+
for (index, entity) in entities.into_iter().enumerate() {
|
|
574
|
+
let path = format!("/entities/{index}");
|
|
575
|
+
validate_entity_name(&entity, &path)?;
|
|
576
|
+
if !capabilities::text_entity_supported(&entity) {
|
|
577
|
+
return Err(PrivacyError::invalid_configuration(
|
|
578
|
+
PrivacyErrorReason::InvalidValue,
|
|
579
|
+
&path,
|
|
580
|
+
"entity type is not supported by text scanning",
|
|
581
|
+
));
|
|
582
|
+
}
|
|
583
|
+
if !selected.insert(entity) {
|
|
584
|
+
return Err(PrivacyError::invalid_configuration(
|
|
585
|
+
PrivacyErrorReason::DuplicateValue,
|
|
586
|
+
path,
|
|
587
|
+
"entity selection contains a duplicate entity type",
|
|
588
|
+
));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
self.entities = Some(selected);
|
|
592
|
+
Ok(self)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/// Explicit text entity selection, or None for the existing detector defaults.
|
|
596
|
+
pub fn entities(&self) -> Option<&BTreeSet<String>> {
|
|
597
|
+
self.entities.as_ref()
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
pub(crate) fn includes_entity(&self, entity: &str) -> bool {
|
|
601
|
+
self.entities
|
|
602
|
+
.as_ref()
|
|
603
|
+
.is_none_or(|entities| entities.contains(entity))
|
|
604
|
+
}
|
|
605
|
+
|
|
561
606
|
/// Select source syntax for email boundaries; defaults to unstructured text.
|
|
562
607
|
pub fn with_format(mut self, format: ScanFormat) -> Self {
|
|
563
608
|
self.format = format;
|
|
@@ -780,8 +825,20 @@ pub fn parse_scan_and_transform_config(
|
|
|
780
825
|
/// Parse canonical scanner configuration.
|
|
781
826
|
pub fn parse_scan_config(value: &serde_json::Value) -> Result<ScanConfig, PrivacyError> {
|
|
782
827
|
let object = require_object(value, "", "scan configuration must be an object")?;
|
|
783
|
-
reject_unknown_fields(object, &["locale", "detect_uuid", "format"], "")?;
|
|
828
|
+
reject_unknown_fields(object, &["locale", "detect_uuid", "format", "entities"], "")?;
|
|
784
829
|
let mut config = ScanConfig::new();
|
|
830
|
+
if let Some(entities) = object.get("entities") {
|
|
831
|
+
let entities = require_array(entities, "/entities", "entities must be an array")?;
|
|
832
|
+
let mut parsed = Vec::with_capacity(entities.len());
|
|
833
|
+
for (index, entity) in entities.iter().enumerate() {
|
|
834
|
+
parsed.push(require_string(
|
|
835
|
+
entity,
|
|
836
|
+
&format!("/entities/{index}"),
|
|
837
|
+
"entity type must be a string",
|
|
838
|
+
)?);
|
|
839
|
+
}
|
|
840
|
+
config = config.with_entities(parsed)?;
|
|
841
|
+
}
|
|
785
842
|
if let Some(format) = object.get("format") {
|
|
786
843
|
config = config.with_format(parse_scan_format(format)?);
|
|
787
844
|
}
|
|
@@ -3100,10 +3157,30 @@ fn is_valid_phone(candidate: &str) -> bool {
|
|
|
3100
3157
|
.collect();
|
|
3101
3158
|
|
|
3102
3159
|
if candidate.starts_with('+') {
|
|
3103
|
-
(7..=15).contains(&digits.len())
|
|
3104
|
-
} else {
|
|
3105
|
-
digits.len() == 10 || (digits.len() == 11 && digits.starts_with('1'))
|
|
3160
|
+
return (7..=15).contains(&digits.len());
|
|
3106
3161
|
}
|
|
3162
|
+
|
|
3163
|
+
let national_number = match digits.len() {
|
|
3164
|
+
10 => digits.as_str(),
|
|
3165
|
+
11 if digits.starts_with('1') => &digits[1..],
|
|
3166
|
+
_ => return false,
|
|
3167
|
+
};
|
|
3168
|
+
|
|
3169
|
+
follows_nanp_rule(national_number.as_bytes())
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
/// Checks that the 1st and 4th digits of a 10-digit North American number are
|
|
3173
|
+
/// between 2 and 9. These are the first digits of the area code and the
|
|
3174
|
+
/// exchange code, and the North American Numbering Plan does not allow either
|
|
3175
|
+
/// to start with 0 or 1.
|
|
3176
|
+
///
|
|
3177
|
+
/// This is a special case to stop Unix timestamps, such as `1800005580`, being
|
|
3178
|
+
/// detected as phone numbers. It only works while timestamps start with 1,
|
|
3179
|
+
/// which is true until 2033. It is not full phone number validation. If better
|
|
3180
|
+
/// vetting is needed, Google's libphonenumber is a good alternative.
|
|
3181
|
+
fn follows_nanp_rule(national_number: &[u8]) -> bool {
|
|
3182
|
+
let is_valid_code_start = |digit: u8| (b'2'..=b'9').contains(&digit);
|
|
3183
|
+
is_valid_code_start(national_number[0]) && is_valid_code_start(national_number[3])
|
|
3107
3184
|
}
|
|
3108
3185
|
|
|
3109
3186
|
fn detect_phone(text: &str, candidates: &mut Vec<Candidate>) {
|
|
@@ -3291,6 +3368,12 @@ fn card_parts_at(bytes: &[u8], start: usize) -> (usize, Vec<u8>) {
|
|
|
3291
3368
|
(last_digit_end, digits)
|
|
3292
3369
|
}
|
|
3293
3370
|
|
|
3371
|
+
/// A contiguous digit run after `3.` or `3,` may be a decimal fraction.
|
|
3372
|
+
/// Comma-separated fields are ambiguous without structured input context.
|
|
3373
|
+
fn follows_decimal_separator(bytes: &[u8], start: usize) -> bool {
|
|
3374
|
+
start >= 2 && matches!(bytes[start - 1], b'.' | b',') && bytes[start - 2].is_ascii_digit()
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3294
3377
|
fn detect_credit_card(text: &str, candidates: &mut Vec<Candidate>) {
|
|
3295
3378
|
let bytes = text.as_bytes();
|
|
3296
3379
|
let mut start = 0;
|
|
@@ -3304,7 +3387,10 @@ fn detect_credit_card(text: &str, candidates: &mut Vec<Candidate>) {
|
|
|
3304
3387
|
|
|
3305
3388
|
let (end, digits) = card_parts_at(bytes, start);
|
|
3306
3389
|
|
|
3307
|
-
if end == start
|
|
3390
|
+
if end == start
|
|
3391
|
+
|| (end < bytes.len() && bytes[end].is_ascii_alphanumeric())
|
|
3392
|
+
|| (follows_decimal_separator(bytes, start) && end - start == digits.len())
|
|
3393
|
+
{
|
|
3308
3394
|
start += 1;
|
|
3309
3395
|
continue;
|
|
3310
3396
|
}
|
|
@@ -3661,6 +3747,9 @@ fn detect_ip_address(text: &str, candidates: &mut Vec<Candidate>) {
|
|
|
3661
3747
|
#[cfg(test)]
|
|
3662
3748
|
mod selection_tests;
|
|
3663
3749
|
|
|
3750
|
+
#[cfg(test)]
|
|
3751
|
+
mod scan_selection_tests;
|
|
3752
|
+
|
|
3664
3753
|
#[cfg(test)]
|
|
3665
3754
|
mod tests {
|
|
3666
3755
|
mod synthetic_fixtures {
|
|
@@ -4305,6 +4394,122 @@ mod tests {
|
|
|
4305
4394
|
assert!(scan("order212-555-0100x").is_empty());
|
|
4306
4395
|
}
|
|
4307
4396
|
|
|
4397
|
+
#[test]
|
|
4398
|
+
fn detects_phone_numbers_following_nanp_rule() {
|
|
4399
|
+
for phone in [
|
|
4400
|
+
"2125550100",
|
|
4401
|
+
"212-555-0100",
|
|
4402
|
+
"212.555.0100",
|
|
4403
|
+
"1-212-555-0100",
|
|
4404
|
+
"12125550100",
|
|
4405
|
+
"9995550100",
|
|
4406
|
+
"2122220100",
|
|
4407
|
+
] {
|
|
4408
|
+
let text = format!("Call {phone}.");
|
|
4409
|
+
assert_eq!(
|
|
4410
|
+
scan(&text),
|
|
4411
|
+
vec![expected_ascii_finding(
|
|
4412
|
+
"PHONE",
|
|
4413
|
+
phone,
|
|
4414
|
+
5,
|
|
4415
|
+
5 + phone.len(),
|
|
4416
|
+
"datafog-core/phone",
|
|
4417
|
+
)],
|
|
4418
|
+
"{text}"
|
|
4419
|
+
);
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
|
|
4423
|
+
#[test]
|
|
4424
|
+
fn rejects_phone_candidates_breaking_nanp_rule() {
|
|
4425
|
+
for text in [
|
|
4426
|
+
"Call 0125550100.", // area code starts with 0
|
|
4427
|
+
"Call (112) 555-0100.", // area code starts with 1
|
|
4428
|
+
"Call 212-055-0100.", // exchange code starts with 0
|
|
4429
|
+
"Call 1-212-155-0100.", // exchange code starts with 1
|
|
4430
|
+
"\"start\": 1800005580,", // Unix timestamp
|
|
4431
|
+
"\"end\": 1800021600", // Unix timestamp
|
|
4432
|
+
] {
|
|
4433
|
+
assert!(scan(text).is_empty(), "{text}");
|
|
4434
|
+
}
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
#[test]
|
|
4438
|
+
fn preserves_permissive_plus_phone_detection() {
|
|
4439
|
+
for phone in [
|
|
4440
|
+
"+1234567",
|
|
4441
|
+
"+1-555-1002",
|
|
4442
|
+
"+1-112-555-0100",
|
|
4443
|
+
"+1-212-155-0100",
|
|
4444
|
+
"+44 20 7946 0958",
|
|
4445
|
+
"+999222333444555",
|
|
4446
|
+
] {
|
|
4447
|
+
let text = format!("Call {phone}.");
|
|
4448
|
+
assert_eq!(
|
|
4449
|
+
scan(&text),
|
|
4450
|
+
vec![expected_ascii_finding(
|
|
4451
|
+
"PHONE",
|
|
4452
|
+
phone,
|
|
4453
|
+
5,
|
|
4454
|
+
5 + phone.len(),
|
|
4455
|
+
"datafog-core/phone",
|
|
4456
|
+
)],
|
|
4457
|
+
"{text}"
|
|
4458
|
+
);
|
|
4459
|
+
}
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
#[test]
|
|
4463
|
+
fn rejects_plus_phone_candidates_outside_length_or_boundary_rules() {
|
|
4464
|
+
for text in [
|
|
4465
|
+
"Call +123456.",
|
|
4466
|
+
"Call +9992223334445556.",
|
|
4467
|
+
"order+44 20 7946 0958",
|
|
4468
|
+
"Call +44 20 7946 0958x",
|
|
4469
|
+
] {
|
|
4470
|
+
assert!(scan(text).is_empty(), "{text}");
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
|
|
4474
|
+
#[test]
|
|
4475
|
+
fn reports_phone_ranges_in_unicode_text() {
|
|
4476
|
+
for phone in ["212-555-0100", "1-212-555-0100", "+1-555-1002"] {
|
|
4477
|
+
let text = format!("☎👋 {phone}。");
|
|
4478
|
+
let findings = scan(&text);
|
|
4479
|
+
assert_eq!(
|
|
4480
|
+
findings,
|
|
4481
|
+
vec![expected_finding(
|
|
4482
|
+
"PHONE",
|
|
4483
|
+
phone,
|
|
4484
|
+
(8, 8 + phone.len()),
|
|
4485
|
+
(3, 3 + phone.len()),
|
|
4486
|
+
"datafog-core/phone",
|
|
4487
|
+
)],
|
|
4488
|
+
"{text}"
|
|
4489
|
+
);
|
|
4490
|
+
let finding = &findings[0];
|
|
4491
|
+
assert_eq!(
|
|
4492
|
+
&text[finding.byte_range.start..finding.byte_range.end],
|
|
4493
|
+
phone
|
|
4494
|
+
);
|
|
4495
|
+
assert_eq!(
|
|
4496
|
+
text.chars()
|
|
4497
|
+
.skip(finding.codepoint_range.start)
|
|
4498
|
+
.take(finding.codepoint_range.end - finding.codepoint_range.start)
|
|
4499
|
+
.collect::<String>(),
|
|
4500
|
+
phone
|
|
4501
|
+
);
|
|
4502
|
+
assert_eq!(
|
|
4503
|
+
utf16_range(&text, finding.byte_range).unwrap(),
|
|
4504
|
+
TextRange {
|
|
4505
|
+
start: 4,
|
|
4506
|
+
end: 4 + phone.len(),
|
|
4507
|
+
}
|
|
4508
|
+
);
|
|
4509
|
+
}
|
|
4510
|
+
assert!(scan("☎👋 1800005580。").is_empty());
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4308
4513
|
#[test]
|
|
4309
4514
|
fn detects_dashed_ssn() {
|
|
4310
4515
|
assert_eq!(
|
|
@@ -4355,11 +4560,70 @@ mod tests {
|
|
|
4355
4560
|
assert_eq!(scan("6011111111111117")[0].entity_type, "CREDIT_CARD");
|
|
4356
4561
|
}
|
|
4357
4562
|
|
|
4563
|
+
#[test]
|
|
4564
|
+
fn detects_credit_card_after_separator_without_leading_digit() {
|
|
4565
|
+
for prefix in [".", ",", "no.", "no,"] {
|
|
4566
|
+
for digits in ["4222222222222", "4111111111111111", "4111111111111111110"] {
|
|
4567
|
+
let text = format!("{prefix}{digits}");
|
|
4568
|
+
assert_eq!(
|
|
4569
|
+
scan(&text),
|
|
4570
|
+
vec![expected_ascii_finding(
|
|
4571
|
+
"CREDIT_CARD",
|
|
4572
|
+
digits,
|
|
4573
|
+
prefix.len(),
|
|
4574
|
+
prefix.len() + digits.len(),
|
|
4575
|
+
"datafog-core/credit-card",
|
|
4576
|
+
)],
|
|
4577
|
+
"{text}"
|
|
4578
|
+
);
|
|
4579
|
+
}
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
#[test]
|
|
4584
|
+
fn detects_formatted_credit_card_after_digit_and_separator() {
|
|
4585
|
+
for prefix in ["3.", "3,"] {
|
|
4586
|
+
for card in [
|
|
4587
|
+
"4222-2222-22222",
|
|
4588
|
+
"4111-1111-1111-1111",
|
|
4589
|
+
"4111 1111 1111 1111",
|
|
4590
|
+
"4111-1111-1111-1111110",
|
|
4591
|
+
] {
|
|
4592
|
+
let text = format!("{prefix}{card}");
|
|
4593
|
+
assert_eq!(
|
|
4594
|
+
scan(&text)
|
|
4595
|
+
.into_iter()
|
|
4596
|
+
.filter(|finding| finding.entity_type == "CREDIT_CARD")
|
|
4597
|
+
.collect::<Vec<_>>(),
|
|
4598
|
+
vec![expected_ascii_finding(
|
|
4599
|
+
"CREDIT_CARD",
|
|
4600
|
+
card,
|
|
4601
|
+
prefix.len(),
|
|
4602
|
+
prefix.len() + card.len(),
|
|
4603
|
+
"datafog-core/credit-card",
|
|
4604
|
+
)],
|
|
4605
|
+
"{text}"
|
|
4606
|
+
);
|
|
4607
|
+
}
|
|
4608
|
+
}
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4358
4611
|
#[test]
|
|
4359
4612
|
fn rejects_invalid_credit_card_candidates() {
|
|
4360
4613
|
assert!(scan("4111-1111-1111-1112").is_empty()); // bad Luhn checksum
|
|
4361
4614
|
assert!(scan("1234-5678-9012-3452").is_empty()); // unsupported prefix
|
|
4362
4615
|
assert!(scan("x4111-1111-1111-1111y").is_empty()); // embedded
|
|
4616
|
+
for fraction in [
|
|
4617
|
+
"4222222222222",
|
|
4618
|
+
"4111111111111111",
|
|
4619
|
+
"4111111111111111110",
|
|
4620
|
+
"41111111111111111100",
|
|
4621
|
+
] {
|
|
4622
|
+
for separator in [".", ","] {
|
|
4623
|
+
let text = format!("3{separator}{fraction}");
|
|
4624
|
+
assert!(scan(&text).is_empty(), "{text}"); // decimal fraction
|
|
4625
|
+
}
|
|
4626
|
+
}
|
|
4363
4627
|
}
|
|
4364
4628
|
|
|
4365
4629
|
#[test]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
use serde_json::json;
|
|
3
|
+
|
|
4
|
+
#[test]
|
|
5
|
+
fn selected_scans_match_filtered_defaults_for_every_text_detector() {
|
|
6
|
+
let fixtures = [
|
|
7
|
+
include_str!("../../../fixtures/development.jsonl"),
|
|
8
|
+
include_str!("../../../fixtures/german.jsonl"),
|
|
9
|
+
include_str!("../../../fixtures/uuid.jsonl"),
|
|
10
|
+
include_str!("../../../fixtures/credential-overlaps.jsonl"),
|
|
11
|
+
include_str!("../../../fixtures/credential-uri.jsonl"),
|
|
12
|
+
include_str!("../../../fixtures/api-key.jsonl"),
|
|
13
|
+
include_str!("../../../fixtures/bearer-token.jsonl"),
|
|
14
|
+
include_str!("../../../fixtures/jwt.jsonl"),
|
|
15
|
+
include_str!("../../../fixtures/npi.jsonl"),
|
|
16
|
+
include_str!("../../../fixtures/us-routing-number.jsonl"),
|
|
17
|
+
include_str!("../../../fixtures/private-key.jsonl"),
|
|
18
|
+
include_str!("../../../fixtures/email-context.jsonl"),
|
|
19
|
+
];
|
|
20
|
+
let mut covered = BTreeSet::new();
|
|
21
|
+
for fixture in fixtures {
|
|
22
|
+
for line in fixture.lines() {
|
|
23
|
+
let row: serde_json::Value = serde_json::from_str(line).unwrap();
|
|
24
|
+
let text = row["text"].as_str().unwrap();
|
|
25
|
+
let config = parse_scan_config(row.get("config").unwrap_or(&json!({}))).unwrap();
|
|
26
|
+
let baseline = scan_with_config(text, &config);
|
|
27
|
+
for name in capabilities()
|
|
28
|
+
.supported_entities
|
|
29
|
+
.into_iter()
|
|
30
|
+
.filter(|name| name != "PERSON")
|
|
31
|
+
{
|
|
32
|
+
let selected = config.clone().with_entities(vec![name.clone()]).unwrap();
|
|
33
|
+
let expected: Vec<_> = baseline
|
|
34
|
+
.iter()
|
|
35
|
+
.filter(|finding| finding.entity_type == name)
|
|
36
|
+
.cloned()
|
|
37
|
+
.collect();
|
|
38
|
+
if !expected.is_empty() {
|
|
39
|
+
covered.insert(name.clone());
|
|
40
|
+
}
|
|
41
|
+
assert_eq!(
|
|
42
|
+
scan_with_config(text, &selected),
|
|
43
|
+
expected,
|
|
44
|
+
"{}: {name}",
|
|
45
|
+
row["id"]
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let expected: BTreeSet<_> = capabilities()
|
|
51
|
+
.supported_entities
|
|
52
|
+
.into_iter()
|
|
53
|
+
.filter(|name| name != "PERSON")
|
|
54
|
+
.collect();
|
|
55
|
+
assert_eq!(covered, expected);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[test]
|
|
59
|
+
fn invalid_text_selections_have_precise_safe_errors() {
|
|
60
|
+
for (value, path, reason) in [
|
|
61
|
+
(
|
|
62
|
+
json!({"entities": []}),
|
|
63
|
+
"/entities",
|
|
64
|
+
PrivacyErrorReason::EmptyValue,
|
|
65
|
+
),
|
|
66
|
+
(
|
|
67
|
+
json!({"entities": null}),
|
|
68
|
+
"/entities",
|
|
69
|
+
PrivacyErrorReason::InvalidType,
|
|
70
|
+
),
|
|
71
|
+
(
|
|
72
|
+
json!({"entities": "EMAIL"}),
|
|
73
|
+
"/entities",
|
|
74
|
+
PrivacyErrorReason::InvalidType,
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
json!({"entities": [2]}),
|
|
78
|
+
"/entities/0",
|
|
79
|
+
PrivacyErrorReason::InvalidType,
|
|
80
|
+
),
|
|
81
|
+
(
|
|
82
|
+
json!({"entities": ["EMAIL", "EMAIL"]}),
|
|
83
|
+
"/entities/1",
|
|
84
|
+
PrivacyErrorReason::DuplicateValue,
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
json!({"entities": ["PERSON"]}),
|
|
88
|
+
"/entities/0",
|
|
89
|
+
PrivacyErrorReason::InvalidValue,
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
json!({"entities": ["NOT_SUPPORTED"]}),
|
|
93
|
+
"/entities/0",
|
|
94
|
+
PrivacyErrorReason::InvalidValue,
|
|
95
|
+
),
|
|
96
|
+
(
|
|
97
|
+
json!({"entities": ["email"]}),
|
|
98
|
+
"/entities/0",
|
|
99
|
+
PrivacyErrorReason::InvalidValue,
|
|
100
|
+
),
|
|
101
|
+
] {
|
|
102
|
+
let error = parse_scan_config(&value).unwrap_err();
|
|
103
|
+
assert_eq!(error.code(), PrivacyErrorCode::InvalidConfiguration);
|
|
104
|
+
assert_eq!(error.path(), Some(path));
|
|
105
|
+
assert_eq!(error.reason(), Some(reason));
|
|
106
|
+
}
|
|
107
|
+
let error = parse_scan_and_transform_config(
|
|
108
|
+
&json!({"scan": {"entities": []}, "transform": {"default": {"strategy": "redact"}}}),
|
|
109
|
+
)
|
|
110
|
+
.unwrap_err();
|
|
111
|
+
assert_eq!(error.path(), Some("/scan/entities"));
|
|
112
|
+
assert!(structured::parse_scan_config(&json!({"entities": ["EMAIL"]})).is_err());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#[test]
|
|
116
|
+
fn selection_does_not_activate_uuid_or_locale_detectors() {
|
|
117
|
+
for (entity, text, active) in [
|
|
118
|
+
(
|
|
119
|
+
"UUID",
|
|
120
|
+
"550e8400-e29b-41d4-a716-446655440000",
|
|
121
|
+
json!({"detect_uuid": true}),
|
|
122
|
+
),
|
|
123
|
+
(
|
|
124
|
+
"DE_IBAN",
|
|
125
|
+
"DE44 5001 0517 5407 3249 31",
|
|
126
|
+
json!({"locale": "de"}),
|
|
127
|
+
),
|
|
128
|
+
] {
|
|
129
|
+
let selected = ScanConfig::new()
|
|
130
|
+
.with_entities(vec![entity.into()])
|
|
131
|
+
.unwrap();
|
|
132
|
+
assert!(scan_with_config(text, &selected).is_empty());
|
|
133
|
+
let active = parse_scan_config(&active)
|
|
134
|
+
.unwrap()
|
|
135
|
+
.with_entities(vec![entity.into()])
|
|
136
|
+
.unwrap();
|
|
137
|
+
assert_eq!(scan_with_config(text, &active).len(), 1);
|
|
138
|
+
let excluded = active.with_entities(vec!["EMAIL".into()]).unwrap();
|
|
139
|
+
assert!(scan_with_config(text, &excluded).is_empty());
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[test]
|
|
144
|
+
fn scan_and_transform_selection_preserves_unselected_content_and_unicode_offsets() {
|
|
145
|
+
let text = "👋 person@example.com / 123-45-6789";
|
|
146
|
+
let config = parse_scan_and_transform_config(
|
|
147
|
+
&json!({"scan": {"entities": ["EMAIL"]}, "transform": {"default": {"strategy": "redact"}}}),
|
|
148
|
+
)
|
|
149
|
+
.unwrap();
|
|
150
|
+
let result = scan_and_transform(text, &config).unwrap();
|
|
151
|
+
assert_eq!(result.text, "👋 [EMAIL] / 123-45-6789");
|
|
152
|
+
let finding = &scan_with_config(text, config.scan_config())[0];
|
|
153
|
+
assert_eq!(finding.codepoint_range.start, 2);
|
|
154
|
+
assert_eq!(finding.byte_range.start, 5);
|
|
155
|
+
}
|
|
@@ -8,11 +8,15 @@ _JsonValue: TypeAlias = (
|
|
|
8
8
|
)
|
|
9
9
|
_JsonDocument: TypeAlias = list[_JsonValue] | dict[str, _JsonValue]
|
|
10
10
|
|
|
11
|
-
class
|
|
11
|
+
class _CommonScanConfig(TypedDict, total=False):
|
|
12
12
|
format: Literal["text", "env", "sql"]
|
|
13
13
|
locale: str
|
|
14
14
|
detect_uuid: bool
|
|
15
15
|
|
|
16
|
+
class _ScanConfig(_CommonScanConfig, total=False):
|
|
17
|
+
# Select activated text detectors; omission preserves the default scan.
|
|
18
|
+
entities: list[str] | tuple[str, ...]
|
|
19
|
+
|
|
16
20
|
class _CapabilityActivationRequired(TypedDict):
|
|
17
21
|
kind: Literal["default", "locale", "config", "structured"]
|
|
18
22
|
|
|
@@ -33,7 +37,7 @@ class _Capabilities(TypedDict):
|
|
|
33
37
|
locales: dict[str, _LocaleCapability]
|
|
34
38
|
entities: dict[str, _EntityCapability]
|
|
35
39
|
|
|
36
|
-
class _StructuredScanConfig(
|
|
40
|
+
class _StructuredScanConfig(_CommonScanConfig, total=False):
|
|
37
41
|
discover_person: bool
|
|
38
42
|
mappings: dict[str, Literal["PERSON"]]
|
|
39
43
|
exclude: list[str] | tuple[str, ...]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/credential_overlap_conformance.py
RENAMED
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/credential_uri_conformance.py
RENAMED
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/email_context_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/test_python49_integration.py
RENAMED
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/us_routing_number_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|