datafog-core 0.4.1__tar.gz → 0.4.3__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.1 → datafog_core-0.4.3}/Cargo.lock +4 -4
- {datafog_core-0.4.1 → datafog_core-0.4.3}/PKG-INFO +30 -6
- {datafog_core-0.4.1/crates/core → datafog_core-0.4.3}/README.md +29 -5
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/Cargo.toml +1 -1
- datafog_core-0.4.3/bindings/python/tests/email_context_conformance.py +49 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/test_installed.py +39 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/test_typing.py +4 -1
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/Cargo.toml +1 -1
- {datafog_core-0.4.1 → datafog_core-0.4.3/crates/core}/README.md +29 -5
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/capabilities.rs +57 -5
- datafog_core-0.4.3/crates/core/src/email_context.rs +172 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/german.rs +9 -2
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/lib.rs +105 -3
- datafog_core-0.4.3/crates/core/src/scan_selection_tests.rs +155 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/structured.rs +5 -1
- datafog_core-0.4.3/crates/core/tests/email_context.rs +107 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/pyproject.toml +1 -1
- {datafog_core-0.4.1 → datafog_core-0.4.3}/python/datafog_core/__init__.pyi +7 -2
- {datafog_core-0.4.1 → datafog_core-0.4.3}/Cargo.toml +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/src/lib.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/api_key_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/bearer_token_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/capabilities_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/credential_overlap_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/credential_uri_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/german_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/jwt_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/npi_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/private_key_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/requirements-typing.txt +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/synthetic_fixtures.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/test_python49_integration.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/us_routing_number_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/uuid_conformance.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/examples/german_benchmark.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/examples/scan_benchmark.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/api_key.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/bearer_token.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/credential_uri.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/jwt.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/npi.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/offsets.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/private_key.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/selection_tests.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/us_routing_number.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/uuid.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/api_key.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/bearer_token.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/capabilities.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/credential_overlaps.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/credential_uri.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/german.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/jwt.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/npi.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/private_key.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/support/synthetic_fixtures.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/us_routing_number.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/uuid.rs +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/python/datafog_core/__init__.py +0 -0
- {datafog_core-0.4.1 → datafog_core-0.4.3}/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.3"
|
|
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.3"
|
|
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.3"
|
|
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.3"
|
|
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.3
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: License :: OSI Approved :: MIT License
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -79,8 +79,12 @@ full-match regex values:
|
|
|
79
79
|
}
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean }, transform: ... }` so
|
|
82
|
+
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
|
|
83
83
|
detection settings remain separate from transformation policy.
|
|
84
|
+
For `.env` assignments or standard SQL quoted strings, select `format: "env"`
|
|
85
|
+
or `format: "sql"` to preserve email boundaries. See the
|
|
86
|
+
[email source-context policy](docs/guides/email-boundaries.mdx) for supported
|
|
87
|
+
syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
|
|
84
88
|
|
|
85
89
|
## Packages
|
|
86
90
|
|
|
@@ -321,8 +325,28 @@ boundaries, never newlines. Returned text and offsets preserve the source.
|
|
|
321
325
|
Passport and residence-permit patterns are legacy heuristics with limited
|
|
322
326
|
coverage. See the [German entity reference](docs/reference/german-entities.mdx)
|
|
323
327
|
and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
|
|
324
|
-
|
|
325
|
-
|
|
328
|
+
The higher-level Python adapter and its dependency update were merged in
|
|
329
|
+
[PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
|
|
330
|
+
|
|
331
|
+
## Text detector selection
|
|
332
|
+
|
|
333
|
+
Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
|
|
334
|
+
selected detectors, rather than detecting every type and filtering findings
|
|
335
|
+
later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
|
|
336
|
+
"SSN".into()])?`. The same selection works inside `scan_and_transform`'s
|
|
337
|
+
`scan` configuration; transformation selection remains independent.
|
|
338
|
+
|
|
339
|
+
Omitting `entities` preserves existing detection. An explicit selection must be
|
|
340
|
+
a nonempty array of unique, exact canonical text entity names. Unknown names,
|
|
341
|
+
lowercase names, duplicates, and structured-only `PERSON` are rejected using
|
|
342
|
+
configuration errors with `/entities` or `/entities/<index>` paths. Consult
|
|
343
|
+
`capabilities().entities` for types with the `text` scope.
|
|
344
|
+
|
|
345
|
+
Selection narrows enabled detectors: selecting `UUID` still requires
|
|
346
|
+
`detect_uuid=true`, and German types still require a German locale. Text format
|
|
347
|
+
handling, finding order, and byte/codepoint ranges are unchanged. Structured
|
|
348
|
+
scans do not accept this option. It avoids unselected detector work and findings,
|
|
349
|
+
but does not cap the selected findings or bound the size of a scan response.
|
|
326
350
|
|
|
327
351
|
## UUID identifiers
|
|
328
352
|
|
|
@@ -332,7 +356,7 @@ structured scans, independently of locale. It emits `UUID` findings for versions
|
|
|
332
356
|
sensitivity. See the [UUID reference](docs/reference/uuid.mdx) for boundaries,
|
|
333
357
|
excluded sentinel forms and transformation examples.
|
|
334
358
|
|
|
335
|
-
**0.4.
|
|
359
|
+
**0.4.1 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter is integrated in [merged PR #179](https://github.com/DataFog/datafog-python/pull/179); its package release is separate. See the [release notes](docs/releases/0-4-1.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
|
|
336
360
|
|
|
337
|
-
|
|
361
|
+
**Core 0.4.1** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the passed local exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
|
|
338
362
|
|
|
@@ -56,8 +56,12 @@ full-match regex values:
|
|
|
56
56
|
}
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean }, transform: ... }` so
|
|
59
|
+
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
|
|
60
60
|
detection settings remain separate from transformation policy.
|
|
61
|
+
For `.env` assignments or standard SQL quoted strings, select `format: "env"`
|
|
62
|
+
or `format: "sql"` to preserve email boundaries. See the
|
|
63
|
+
[email source-context policy](docs/guides/email-boundaries.mdx) for supported
|
|
64
|
+
syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
|
|
61
65
|
|
|
62
66
|
## Packages
|
|
63
67
|
|
|
@@ -298,8 +302,28 @@ boundaries, never newlines. Returned text and offsets preserve the source.
|
|
|
298
302
|
Passport and residence-permit patterns are legacy heuristics with limited
|
|
299
303
|
coverage. See the [German entity reference](docs/reference/german-entities.mdx)
|
|
300
304
|
and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
|
|
301
|
-
|
|
302
|
-
|
|
305
|
+
The higher-level Python adapter and its dependency update were merged in
|
|
306
|
+
[PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
|
|
307
|
+
|
|
308
|
+
## Text detector selection
|
|
309
|
+
|
|
310
|
+
Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
|
|
311
|
+
selected detectors, rather than detecting every type and filtering findings
|
|
312
|
+
later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
|
|
313
|
+
"SSN".into()])?`. The same selection works inside `scan_and_transform`'s
|
|
314
|
+
`scan` configuration; transformation selection remains independent.
|
|
315
|
+
|
|
316
|
+
Omitting `entities` preserves existing detection. An explicit selection must be
|
|
317
|
+
a nonempty array of unique, exact canonical text entity names. Unknown names,
|
|
318
|
+
lowercase names, duplicates, and structured-only `PERSON` are rejected using
|
|
319
|
+
configuration errors with `/entities` or `/entities/<index>` paths. Consult
|
|
320
|
+
`capabilities().entities` for types with the `text` scope.
|
|
321
|
+
|
|
322
|
+
Selection narrows enabled detectors: selecting `UUID` still requires
|
|
323
|
+
`detect_uuid=true`, and German types still require a German locale. Text format
|
|
324
|
+
handling, finding order, and byte/codepoint ranges are unchanged. Structured
|
|
325
|
+
scans do not accept this option. It avoids unselected detector work and findings,
|
|
326
|
+
but does not cap the selected findings or bound the size of a scan response.
|
|
303
327
|
|
|
304
328
|
## UUID identifiers
|
|
305
329
|
|
|
@@ -309,6 +333,6 @@ structured scans, independently of locale. It emits `UUID` findings for versions
|
|
|
309
333
|
sensitivity. See the [UUID reference](docs/reference/uuid.mdx) for boundaries,
|
|
310
334
|
excluded sentinel forms and transformation examples.
|
|
311
335
|
|
|
312
|
-
**0.4.
|
|
336
|
+
**0.4.1 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter is integrated in [merged PR #179](https://github.com/DataFog/datafog-python/pull/179); its package release is separate. See the [release notes](docs/releases/0-4-1.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
|
|
313
337
|
|
|
314
|
-
|
|
338
|
+
**Core 0.4.1** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the passed local exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Email source-context fixtures against the installed extension."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import datafog_core as api
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def verify():
|
|
10
|
+
fixture = Path(__file__).resolve().parents[3] / "fixtures/email-context.jsonl"
|
|
11
|
+
for line in fixture.read_text().splitlines():
|
|
12
|
+
row = json.loads(line)
|
|
13
|
+
text = row["text"]
|
|
14
|
+
findings = api.scan(text, row["config"])
|
|
15
|
+
emails = [f for f in findings if f.entity_type == "EMAIL"]
|
|
16
|
+
assert [
|
|
17
|
+
dict(text=f.matched_text, start=f.codepoint_range.start, end=f.codepoint_range.end)
|
|
18
|
+
for f in emails
|
|
19
|
+
] == row["matches"], row["id"]
|
|
20
|
+
for finding in emails:
|
|
21
|
+
assert text.encode()[finding.byte_range.start:finding.byte_range.end].decode() == finding.matched_text
|
|
22
|
+
assert text[finding.codepoint_range.start:finding.codepoint_range.end] == finding.matched_text
|
|
23
|
+
data = {"source": text}
|
|
24
|
+
located = api.scan_structured(data, row["config"]).findings
|
|
25
|
+
assert [f.finding for f in located] == findings
|
|
26
|
+
for strategy in ["redact", "mask", "remove"]:
|
|
27
|
+
policy = {"default": {"strategy": strategy}, "entities": ["EMAIL"]}
|
|
28
|
+
envelope = {"scan": row["config"], "transform": policy}
|
|
29
|
+
result = api.scan_and_transform(text, envelope)
|
|
30
|
+
assert result.text == row["outputs"][strategy], (row["id"], strategy)
|
|
31
|
+
assert api.transform(text, findings, policy) == result
|
|
32
|
+
for record, finding in zip(result.transformations, emails):
|
|
33
|
+
assert record.source_byte_range == finding.byte_range
|
|
34
|
+
assert record.source_codepoint_range == finding.codepoint_range
|
|
35
|
+
assert result.text.encode()[record.output_byte_range.start:record.output_byte_range.end].decode() == record.replacement
|
|
36
|
+
assert api.scan_and_transform_structured(data, envelope).data == {"source": result.text}
|
|
37
|
+
if row["config"]["format"] == "text":
|
|
38
|
+
assert api.scan(text) == findings
|
|
39
|
+
for invalid in [None, 1, True, {}, "yaml", "ENV"]:
|
|
40
|
+
for call in [
|
|
41
|
+
lambda: api.scan("", {"format": invalid}),
|
|
42
|
+
lambda: api.scan_structured({"source": ""}, {"format": invalid}),
|
|
43
|
+
]:
|
|
44
|
+
try:
|
|
45
|
+
call()
|
|
46
|
+
except api.DataFogConfigurationError as error:
|
|
47
|
+
assert error.path == "/format"
|
|
48
|
+
else:
|
|
49
|
+
raise AssertionError("Invalid source format accepted")
|
|
@@ -12,6 +12,7 @@ import jwt_conformance
|
|
|
12
12
|
import bearer_token_conformance
|
|
13
13
|
import private_key_conformance
|
|
14
14
|
import api_key_conformance
|
|
15
|
+
import email_context_conformance
|
|
15
16
|
import uuid_conformance
|
|
16
17
|
import credential_uri_conformance
|
|
17
18
|
import us_routing_number_conformance
|
|
@@ -125,7 +126,44 @@ def verify_structured() -> None:
|
|
|
125
126
|
raise AssertionError("invalid structured input accepted")
|
|
126
127
|
|
|
127
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
|
+
|
|
128
165
|
def main() -> None:
|
|
166
|
+
verify_text_selection()
|
|
129
167
|
capabilities_conformance.verify()
|
|
130
168
|
german_conformance.verify()
|
|
131
169
|
credential_overlap_conformance.verify()
|
|
@@ -133,6 +171,7 @@ def main() -> None:
|
|
|
133
171
|
bearer_token_conformance.verify()
|
|
134
172
|
private_key_conformance.verify()
|
|
135
173
|
api_key_conformance.verify()
|
|
174
|
+
email_context_conformance.verify()
|
|
136
175
|
uuid_conformance.verify()
|
|
137
176
|
credential_uri_conformance.verify()
|
|
138
177
|
us_routing_number_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})
|
|
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)
|
|
@@ -127,9 +127,12 @@ INVALID = """
|
|
|
127
127
|
from datafog_core import capabilities, scan, transform, scan_structured, PrivacyManager, TransformResult
|
|
128
128
|
capabilities(1) # error
|
|
129
129
|
scan(123) # error
|
|
130
|
+
scan("text", {"format": "yaml"}) # error
|
|
130
131
|
scan("text", {"locale": 123}) # error
|
|
131
132
|
scan("text", {"detect_uuid": "true"}) # error
|
|
132
133
|
scan("text", {"max_bytes": 100}) # error
|
|
134
|
+
scan("text", {"entities": "EMAIL"}) # error
|
|
135
|
+
scan_structured({"email": "jane@example.com"}, {"entities": ["EMAIL"]}) # error
|
|
133
136
|
scan("text")[0].entity_type = "OTHER" # error
|
|
134
137
|
scan("text")[0].byte_range.start = 1 # error
|
|
135
138
|
transform("text", ["not a finding"], {"default": {"strategy": "redact"}}) # error
|
|
@@ -56,8 +56,12 @@ full-match regex values:
|
|
|
56
56
|
}
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean }, transform: ... }` so
|
|
59
|
+
`scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
|
|
60
60
|
detection settings remain separate from transformation policy.
|
|
61
|
+
For `.env` assignments or standard SQL quoted strings, select `format: "env"`
|
|
62
|
+
or `format: "sql"` to preserve email boundaries. See the
|
|
63
|
+
[email source-context policy](docs/guides/email-boundaries.mdx) for supported
|
|
64
|
+
syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
|
|
61
65
|
|
|
62
66
|
## Packages
|
|
63
67
|
|
|
@@ -298,8 +302,28 @@ boundaries, never newlines. Returned text and offsets preserve the source.
|
|
|
298
302
|
Passport and residence-permit patterns are legacy heuristics with limited
|
|
299
303
|
coverage. See the [German entity reference](docs/reference/german-entities.mdx)
|
|
300
304
|
and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
|
|
301
|
-
|
|
302
|
-
|
|
305
|
+
The higher-level Python adapter and its dependency update were merged in
|
|
306
|
+
[PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
|
|
307
|
+
|
|
308
|
+
## Text detector selection
|
|
309
|
+
|
|
310
|
+
Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
|
|
311
|
+
selected detectors, rather than detecting every type and filtering findings
|
|
312
|
+
later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
|
|
313
|
+
"SSN".into()])?`. The same selection works inside `scan_and_transform`'s
|
|
314
|
+
`scan` configuration; transformation selection remains independent.
|
|
315
|
+
|
|
316
|
+
Omitting `entities` preserves existing detection. An explicit selection must be
|
|
317
|
+
a nonempty array of unique, exact canonical text entity names. Unknown names,
|
|
318
|
+
lowercase names, duplicates, and structured-only `PERSON` are rejected using
|
|
319
|
+
configuration errors with `/entities` or `/entities/<index>` paths. Consult
|
|
320
|
+
`capabilities().entities` for types with the `text` scope.
|
|
321
|
+
|
|
322
|
+
Selection narrows enabled detectors: selecting `UUID` still requires
|
|
323
|
+
`detect_uuid=true`, and German types still require a German locale. Text format
|
|
324
|
+
handling, finding order, and byte/codepoint ranges are unchanged. Structured
|
|
325
|
+
scans do not accept this option. It avoids unselected detector work and findings,
|
|
326
|
+
but does not cap the selected findings or bound the size of a scan response.
|
|
303
327
|
|
|
304
328
|
## UUID identifiers
|
|
305
329
|
|
|
@@ -309,6 +333,6 @@ structured scans, independently of locale. It emits `UUID` findings for versions
|
|
|
309
333
|
sensitivity. See the [UUID reference](docs/reference/uuid.mdx) for boundaries,
|
|
310
334
|
excluded sentinel forms and transformation examples.
|
|
311
335
|
|
|
312
|
-
**0.4.
|
|
336
|
+
**0.4.1 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter is integrated in [merged PR #179](https://github.com/DataFog/datafog-python/pull/179); its package release is separate. See the [release notes](docs/releases/0-4-1.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
|
|
313
337
|
|
|
314
|
-
|
|
338
|
+
**Core 0.4.1** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the passed local exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
|
|
@@ -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,12 +155,21 @@ 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
|
-
|
|
155
|
-
|
|
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)) {
|
|
166
|
+
if detector.labels == [Label::Email] {
|
|
167
|
+
crate::email_context::detect(text, config.format(), candidates);
|
|
168
|
+
} else if detector.activation == Activation::German {
|
|
169
|
+
german::detect_selected(text, config, candidates);
|
|
170
|
+
} else {
|
|
171
|
+
(detector.detect)(text, candidates);
|
|
172
|
+
}
|
|
156
173
|
}
|
|
157
174
|
}
|
|
158
175
|
|
|
@@ -214,3 +231,38 @@ pub fn capabilities() -> Capabilities {
|
|
|
214
231
|
entities,
|
|
215
232
|
}
|
|
216
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
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
//! Delimit original source slices before applying the shared email matcher.
|
|
2
|
+
use crate::{Candidate, ScanFormat, detect_email};
|
|
3
|
+
|
|
4
|
+
pub(super) fn detect(text: &str, format: ScanFormat, candidates: &mut Vec<Candidate>) {
|
|
5
|
+
match format {
|
|
6
|
+
ScanFormat::Text => detect_email(text, candidates),
|
|
7
|
+
ScanFormat::Env => detect_env(text, candidates),
|
|
8
|
+
ScanFormat::Sql => detect_sql(text, candidates),
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fn detect_slice(text: &str, start: usize, end: usize, candidates: &mut Vec<Candidate>) {
|
|
13
|
+
let first = candidates.len();
|
|
14
|
+
detect_email(&text[start..end], candidates);
|
|
15
|
+
for candidate in &mut candidates[first..] {
|
|
16
|
+
candidate.start_byte += start;
|
|
17
|
+
candidate.end_byte += start;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn line_end(text: &str, start: usize) -> usize {
|
|
22
|
+
text[start..]
|
|
23
|
+
.find('\n')
|
|
24
|
+
.map_or(text.len(), |end| start + end)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn skip_horizontal_space(bytes: &[u8], mut cursor: usize) -> usize {
|
|
28
|
+
while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\r')) {
|
|
29
|
+
cursor += 1;
|
|
30
|
+
}
|
|
31
|
+
cursor
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn env_value_start(line: &str) -> Option<usize> {
|
|
35
|
+
let bytes = line.as_bytes();
|
|
36
|
+
let mut cursor = skip_horizontal_space(bytes, 0);
|
|
37
|
+
if line[cursor..].starts_with("export") && matches!(bytes.get(cursor + 6), Some(b' ' | b'\t')) {
|
|
38
|
+
cursor = skip_horizontal_space(bytes, cursor + 6);
|
|
39
|
+
}
|
|
40
|
+
if !bytes
|
|
41
|
+
.get(cursor)
|
|
42
|
+
.is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_')
|
|
43
|
+
{
|
|
44
|
+
return None;
|
|
45
|
+
}
|
|
46
|
+
cursor += 1;
|
|
47
|
+
while bytes
|
|
48
|
+
.get(cursor)
|
|
49
|
+
.is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
|
|
50
|
+
{
|
|
51
|
+
cursor += 1;
|
|
52
|
+
}
|
|
53
|
+
cursor = skip_horizontal_space(bytes, cursor);
|
|
54
|
+
if bytes.get(cursor) != Some(&b'=') {
|
|
55
|
+
return None;
|
|
56
|
+
}
|
|
57
|
+
Some(skip_horizontal_space(bytes, cursor + 1))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Return the closing delimiter or EOF. No values are decoded, so offsets and
|
|
61
|
+
// escaped local parts remain contiguous spans of the caller's source text.
|
|
62
|
+
fn quote_end(bytes: &[u8], opening: usize, doubled: bool, backslash: bool) -> usize {
|
|
63
|
+
let quote = bytes[opening];
|
|
64
|
+
let mut cursor = opening + 1;
|
|
65
|
+
while cursor < bytes.len() {
|
|
66
|
+
if backslash && bytes[cursor] == b'\\' {
|
|
67
|
+
cursor += if cursor + 1 < bytes.len() { 2 } else { 1 };
|
|
68
|
+
} else if bytes[cursor] == quote {
|
|
69
|
+
if doubled && bytes.get(cursor + 1) == Some("e) {
|
|
70
|
+
cursor += 2;
|
|
71
|
+
} else {
|
|
72
|
+
return cursor;
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
cursor += 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
bytes.len()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fn detect_env(text: &str, candidates: &mut Vec<Candidate>) {
|
|
82
|
+
let bytes = text.as_bytes();
|
|
83
|
+
let mut cursor = 0;
|
|
84
|
+
while cursor < text.len() {
|
|
85
|
+
let end = line_end(text, cursor);
|
|
86
|
+
if let Some(value) = env_value_start(&text[cursor..end]) {
|
|
87
|
+
let start = cursor + value;
|
|
88
|
+
if matches!(bytes.get(start), Some(b'\'' | b'"')) {
|
|
89
|
+
let close = quote_end(bytes, start, false, bytes[start] == b'"');
|
|
90
|
+
detect_slice(text, start + 1, close, candidates);
|
|
91
|
+
if close == text.len() {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
let tail_end = line_end(text, close + 1);
|
|
95
|
+
detect_env_unquoted(text, close + 1, tail_end, candidates);
|
|
96
|
+
cursor = tail_end;
|
|
97
|
+
} else {
|
|
98
|
+
detect_env_unquoted(text, start, end, candidates);
|
|
99
|
+
cursor = end;
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
detect_env_unquoted(text, cursor, end, candidates);
|
|
103
|
+
cursor = end;
|
|
104
|
+
}
|
|
105
|
+
if cursor < text.len() {
|
|
106
|
+
cursor += 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fn detect_env_unquoted(text: &str, start: usize, end: usize, candidates: &mut Vec<Candidate>) {
|
|
112
|
+
let bytes = text.as_bytes();
|
|
113
|
+
for cursor in start..end {
|
|
114
|
+
if bytes[cursor] == b'#' && (cursor == start || bytes[cursor - 1].is_ascii_whitespace()) {
|
|
115
|
+
detect_slice(text, start, cursor, candidates);
|
|
116
|
+
detect_slice(text, cursor + 1, end, candidates);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
detect_slice(text, start, end, candidates);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fn detect_sql(text: &str, candidates: &mut Vec<Candidate>) {
|
|
124
|
+
let bytes = text.as_bytes();
|
|
125
|
+
let mut cursor = 0;
|
|
126
|
+
let mut plain_start = 0;
|
|
127
|
+
while cursor < bytes.len() {
|
|
128
|
+
let end = if matches!(bytes[cursor], b'\'' | b'"') {
|
|
129
|
+
detect_slice(text, plain_start, cursor, candidates);
|
|
130
|
+
let close = quote_end(bytes, cursor, true, false);
|
|
131
|
+
detect_slice(text, cursor + 1, close, candidates);
|
|
132
|
+
if close < bytes.len() {
|
|
133
|
+
close + 1
|
|
134
|
+
} else {
|
|
135
|
+
close
|
|
136
|
+
}
|
|
137
|
+
} else if bytes[cursor..].starts_with(b"--") {
|
|
138
|
+
detect_slice(text, plain_start, cursor, candidates);
|
|
139
|
+
let end = line_end(text, cursor);
|
|
140
|
+
detect_slice(text, cursor + 2, end, candidates);
|
|
141
|
+
end
|
|
142
|
+
} else if bytes[cursor..].starts_with(b"/*") {
|
|
143
|
+
detect_slice(text, plain_start, cursor, candidates);
|
|
144
|
+
let mut end = cursor + 2;
|
|
145
|
+
let mut comment_start = end;
|
|
146
|
+
let mut depth = 1;
|
|
147
|
+
while end < bytes.len() && depth > 0 {
|
|
148
|
+
if bytes[end..].starts_with(b"/*") {
|
|
149
|
+
detect_slice(text, comment_start, end, candidates);
|
|
150
|
+
depth += 1;
|
|
151
|
+
end += 2;
|
|
152
|
+
comment_start = end;
|
|
153
|
+
} else if bytes[end..].starts_with(b"*/") {
|
|
154
|
+
detect_slice(text, comment_start, end, candidates);
|
|
155
|
+
depth -= 1;
|
|
156
|
+
end += 2;
|
|
157
|
+
comment_start = end;
|
|
158
|
+
} else {
|
|
159
|
+
end += 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
detect_slice(text, comment_start, end, candidates);
|
|
163
|
+
end
|
|
164
|
+
} else {
|
|
165
|
+
cursor += 1;
|
|
166
|
+
continue;
|
|
167
|
+
};
|
|
168
|
+
cursor = end;
|
|
169
|
+
plain_start = end;
|
|
170
|
+
}
|
|
171
|
+
detect_slice(text, plain_start, text.len(), candidates);
|
|
172
|
+
}
|
|
@@ -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");
|