deliverd-screen 0.1.0__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.
- deliverd_screen-0.1.0/.gitignore +16 -0
- deliverd_screen-0.1.0/LICENSE +21 -0
- deliverd_screen-0.1.0/PKG-INFO +84 -0
- deliverd_screen-0.1.0/README.md +61 -0
- deliverd_screen-0.1.0/pyproject.toml +43 -0
- deliverd_screen-0.1.0/src/deliverd_screen/__init__.py +168 -0
- deliverd_screen-0.1.0/src/deliverd_screen/__main__.py +3 -0
- deliverd_screen-0.1.0/src/deliverd_screen/adapters.py +121 -0
- deliverd_screen-0.1.0/src/deliverd_screen/cli.py +67 -0
- deliverd_screen-0.1.0/src/deliverd_screen/data_screen.py +526 -0
- deliverd_screen-0.1.0/src/deliverd_screen/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deliverd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: deliverd-screen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A pre-send check for every prompt. Finds and redacts personal data, secrets and your own confidential terms before text goes to an AI model. Runs locally, no account, no network calls.
|
|
5
|
+
Project-URL: Homepage, https://deliverd.dev/docs/developers/screen?utm_source=pypi&utm_medium=package&utm_campaign=screen&utm_content=metadata
|
|
6
|
+
Project-URL: Source, https://github.com/deliverd-dev/deliverd-sdk/tree/main/packages/screen-python
|
|
7
|
+
Project-URL: Changelog, https://github.com/deliverd-dev/deliverd-sdk/blob/main/packages/screen-python/CHANGELOG.md
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,deliverd,dlp,gdpr,llm,pii,privacy,prompt,redaction
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Deliverd Screen for Python
|
|
25
|
+
|
|
26
|
+
**A pre-send check for every prompt.** Find and redact personal data, secrets
|
|
27
|
+
and your own confidential terms before text goes to an AI model.
|
|
28
|
+
|
|
29
|
+
It runs locally, with no account, no key and no network call. It needs no dependencies
|
|
30
|
+
and works on Python 3.9 and up. MIT.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install deliverd-screen
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from deliverd_screen import screen
|
|
38
|
+
|
|
39
|
+
checked = screen("Reply to mary.byrne@example.ie about IBAN IE29AIBK93115212345678")
|
|
40
|
+
checked.verdict # "redact"
|
|
41
|
+
checked.text # "Reply to [EMAIL_1] about IBAN [IBAN_1]"
|
|
42
|
+
|
|
43
|
+
answer = model.generate(checked.text)
|
|
44
|
+
checked.restore(answer) # the real values, back in the answer
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`allow` sends the original; `redact` sends placeholders in place of what the
|
|
48
|
+
model may not receive; `ask` means a person has to decide; `deny` sends nothing.
|
|
49
|
+
By default a model may receive internal data and no personal data. Pass
|
|
50
|
+
`personal_data=True, classification="confidential"` for one approved for more.
|
|
51
|
+
|
|
52
|
+
## Names: plug in a model
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from transformers import pipeline
|
|
56
|
+
from deliverd_screen.adapters import token_classifier, presidio
|
|
57
|
+
|
|
58
|
+
ner = pipeline("token-classification", model="dslim/bert-base-NER", aggregation_strategy="simple")
|
|
59
|
+
checked = screen(prompt, detectors=[token_classifier(ner)])
|
|
60
|
+
|
|
61
|
+
# or Presidio, in process:
|
|
62
|
+
from presidio_analyzer import AnalyzerEngine
|
|
63
|
+
checked = screen(prompt, detectors=[presidio(AnalyzerEngine())])
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Any model with the same output works, including Perplexity's PII-Tracer and
|
|
67
|
+
GLiNER. Check its licence before you ship it.
|
|
68
|
+
|
|
69
|
+
## CLI
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
cat prompt.txt | deliverd-screen --redact
|
|
73
|
+
deliverd-screen --fail --json fixtures/*.json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## A net, not a certificate
|
|
77
|
+
|
|
78
|
+
Structured identifiers are found well. Names are found only as well as the
|
|
79
|
+
model you plug in, and redacted text about a person is still personal data
|
|
80
|
+
under GDPR.
|
|
81
|
+
|
|
82
|
+
When a person has to decide, [Deliverd](https://deliverd.dev/docs/developers/screen?utm_source=pypi&utm_medium=package&utm_campaign=screen&utm_content=readme)'s
|
|
83
|
+
Data Gate asks them. `deliverd.screen()` in the `deliverd` SDK runs these
|
|
84
|
+
same detectors and sends only counts. It takes the same `detectors`.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Deliverd Screen for Python
|
|
2
|
+
|
|
3
|
+
**A pre-send check for every prompt.** Find and redact personal data, secrets
|
|
4
|
+
and your own confidential terms before text goes to an AI model.
|
|
5
|
+
|
|
6
|
+
It runs locally, with no account, no key and no network call. It needs no dependencies
|
|
7
|
+
and works on Python 3.9 and up. MIT.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install deliverd-screen
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from deliverd_screen import screen
|
|
15
|
+
|
|
16
|
+
checked = screen("Reply to mary.byrne@example.ie about IBAN IE29AIBK93115212345678")
|
|
17
|
+
checked.verdict # "redact"
|
|
18
|
+
checked.text # "Reply to [EMAIL_1] about IBAN [IBAN_1]"
|
|
19
|
+
|
|
20
|
+
answer = model.generate(checked.text)
|
|
21
|
+
checked.restore(answer) # the real values, back in the answer
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`allow` sends the original; `redact` sends placeholders in place of what the
|
|
25
|
+
model may not receive; `ask` means a person has to decide; `deny` sends nothing.
|
|
26
|
+
By default a model may receive internal data and no personal data. Pass
|
|
27
|
+
`personal_data=True, classification="confidential"` for one approved for more.
|
|
28
|
+
|
|
29
|
+
## Names: plug in a model
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from transformers import pipeline
|
|
33
|
+
from deliverd_screen.adapters import token_classifier, presidio
|
|
34
|
+
|
|
35
|
+
ner = pipeline("token-classification", model="dslim/bert-base-NER", aggregation_strategy="simple")
|
|
36
|
+
checked = screen(prompt, detectors=[token_classifier(ner)])
|
|
37
|
+
|
|
38
|
+
# or Presidio, in process:
|
|
39
|
+
from presidio_analyzer import AnalyzerEngine
|
|
40
|
+
checked = screen(prompt, detectors=[presidio(AnalyzerEngine())])
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Any model with the same output works, including Perplexity's PII-Tracer and
|
|
44
|
+
GLiNER. Check its licence before you ship it.
|
|
45
|
+
|
|
46
|
+
## CLI
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cat prompt.txt | deliverd-screen --redact
|
|
50
|
+
deliverd-screen --fail --json fixtures/*.json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## A net, not a certificate
|
|
54
|
+
|
|
55
|
+
Structured identifiers are found well. Names are found only as well as the
|
|
56
|
+
model you plug in, and redacted text about a person is still personal data
|
|
57
|
+
under GDPR.
|
|
58
|
+
|
|
59
|
+
When a person has to decide, [Deliverd](https://deliverd.dev/docs/developers/screen?utm_source=pypi&utm_medium=package&utm_campaign=screen&utm_content=readme)'s
|
|
60
|
+
Data Gate asks them. `deliverd.screen()` in the `deliverd` SDK runs these
|
|
61
|
+
same detectors and sends only counts. It takes the same `detectors`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "deliverd-screen"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A pre-send check for every prompt. Finds and redacts personal data, secrets and your own confidential terms before text goes to an AI model. Runs locally, no account, no network calls."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = ["pii", "redaction", "privacy", "dlp", "llm", "prompt", "ai", "gdpr", "deliverd"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
# None. The detectors are the standard library's re; a model for names is
|
|
27
|
+
# yours to install and pass in (deliverd_screen.adapters).
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
deliverd-screen = "deliverd_screen.cli:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://deliverd.dev/docs/developers/screen?utm_source=pypi&utm_medium=package&utm_campaign=screen&utm_content=metadata"
|
|
35
|
+
Source = "https://github.com/deliverd-dev/deliverd-sdk/tree/main/packages/screen-python"
|
|
36
|
+
Changelog = "https://github.com/deliverd-dev/deliverd-sdk/blob/main/packages/screen-python/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/deliverd_screen"]
|
|
40
|
+
artifacts = ["src/deliverd_screen/py.typed"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
include = ["src/deliverd_screen", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Deliverd Screen: a pre-send check for every prompt.
|
|
2
|
+
|
|
3
|
+
from deliverd_screen import screen
|
|
4
|
+
|
|
5
|
+
checked = screen(prompt)
|
|
6
|
+
if not checked.allowed:
|
|
7
|
+
raise RuntimeError(" ".join(checked.reasons))
|
|
8
|
+
answer = model.generate(checked.text)
|
|
9
|
+
return checked.restore(answer)
|
|
10
|
+
|
|
11
|
+
Everything runs here. Nothing is sent anywhere: no account, no key, no network
|
|
12
|
+
call. The detectors are the same rules Deliverd's servers and SDKs run, held to
|
|
13
|
+
the shared vectors in ``packages/conformance``.
|
|
14
|
+
|
|
15
|
+
Patterns find structured identifiers well and people's names hardly at all.
|
|
16
|
+
Plug in a model for names with ``detectors``; :mod:`deliverd_screen.adapters`
|
|
17
|
+
turns a Hugging Face pipeline or a Presidio analyzer into one.
|
|
18
|
+
|
|
19
|
+
A net, not a guarantee. Never tell anyone a text is clean because this allowed
|
|
20
|
+
it; pseudonymised text is still personal data in law.
|
|
21
|
+
|
|
22
|
+
When a person has to decide (``ask``), nobody here can: Deliverd's Data Gate
|
|
23
|
+
takes the policy from your AI register, asks the right person and keeps the
|
|
24
|
+
record. ``deliverd.screen()`` in the ``deliverd`` package takes the same
|
|
25
|
+
``detectors``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Callable, Dict, List, Mapping, Optional, Sequence
|
|
32
|
+
|
|
33
|
+
from .data_screen import (
|
|
34
|
+
DATA_KIND_KEYS,
|
|
35
|
+
DATA_KINDS,
|
|
36
|
+
DEFAULT_SCREEN_POLICY,
|
|
37
|
+
SCREEN_CLASSIFICATIONS,
|
|
38
|
+
DetectedSpan,
|
|
39
|
+
Redaction,
|
|
40
|
+
ScreenFinding,
|
|
41
|
+
ScreenTarget,
|
|
42
|
+
ScreenTerm,
|
|
43
|
+
assess_counts,
|
|
44
|
+
describe_counts,
|
|
45
|
+
redact,
|
|
46
|
+
redact_for,
|
|
47
|
+
restore,
|
|
48
|
+
screen_text,
|
|
49
|
+
summarise,
|
|
50
|
+
term_classification_of,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
|
54
|
+
|
|
55
|
+
#: A detector: a callable from the text to spans (``DetectedSpan`` or dicts of kind, start, end).
|
|
56
|
+
SpanDetector = Callable[[str], Sequence[object]]
|
|
57
|
+
|
|
58
|
+
_ASK_REASON = (
|
|
59
|
+
"A person has to decide whether this may be sent, and nobody can be asked from here. "
|
|
60
|
+
"Deliverd's Data Gate routes the question to one: https://deliverd.dev/docs/developers/screen"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Screened:
|
|
66
|
+
"""What may be sent, and why."""
|
|
67
|
+
|
|
68
|
+
verdict: str
|
|
69
|
+
#: Whether to send ``text``. False for a deny, and for an ask: nobody here can say yes.
|
|
70
|
+
allowed: bool
|
|
71
|
+
#: The original, the redacted text, or None when nothing may be sent.
|
|
72
|
+
text: Optional[str]
|
|
73
|
+
#: The redacted text, also for an ask, to show whoever decides. None for a deny.
|
|
74
|
+
redacted: Optional[str]
|
|
75
|
+
findings: List[ScreenFinding]
|
|
76
|
+
#: How many of each kind: all Deliverd's Data Gate is ever sent.
|
|
77
|
+
counts: Dict[str, int]
|
|
78
|
+
reasons: List[str]
|
|
79
|
+
#: Placeholder to original value. Yours alone; never sent anywhere.
|
|
80
|
+
replacements: Dict[str, str] = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
def restore(self, answer: str) -> str:
|
|
83
|
+
"""Put the original values back into text a model wrote using the placeholders."""
|
|
84
|
+
return restore(answer, self.replacements)
|
|
85
|
+
|
|
86
|
+
def __bool__(self) -> bool:
|
|
87
|
+
return self.allowed
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def detect(
|
|
91
|
+
text: str,
|
|
92
|
+
terms: Sequence[object] = (),
|
|
93
|
+
detectors: Sequence[SpanDetector] = (),
|
|
94
|
+
spans: Sequence[object] = (),
|
|
95
|
+
) -> "tuple[List[ScreenFinding], Dict[str, int]]":
|
|
96
|
+
"""Find what is in a text: the findings and their counts, no verdict."""
|
|
97
|
+
found = list(spans)
|
|
98
|
+
for d in detectors:
|
|
99
|
+
found.extend(d(text))
|
|
100
|
+
findings = screen_text(text, terms, found)
|
|
101
|
+
return findings, summarise(findings)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def screen(
|
|
105
|
+
text: str,
|
|
106
|
+
*,
|
|
107
|
+
model: str = "the model",
|
|
108
|
+
classification: str = "internal",
|
|
109
|
+
personal_data: bool = False,
|
|
110
|
+
policy: Optional[Mapping[str, object]] = None,
|
|
111
|
+
terms: Sequence[object] = (),
|
|
112
|
+
detectors: Sequence[SpanDetector] = (),
|
|
113
|
+
spans: Sequence[object] = (),
|
|
114
|
+
) -> Screened:
|
|
115
|
+
"""Check text before it goes to a model, and decide what may be sent.
|
|
116
|
+
|
|
117
|
+
``classification`` and ``personal_data`` say what the model may receive.
|
|
118
|
+
``allow`` sends it as it is; ``redact`` replaces what it may not receive
|
|
119
|
+
with placeholders, and :meth:`Screened.restore` puts them back in the
|
|
120
|
+
answer; ``ask`` needs a person; ``deny`` sends nothing.
|
|
121
|
+
"""
|
|
122
|
+
findings, counts = detect(text, terms, detectors, spans)
|
|
123
|
+
p = dict(policy or {})
|
|
124
|
+
p.pop("unregistered", None)
|
|
125
|
+
a = assess_counts(
|
|
126
|
+
counts,
|
|
127
|
+
ScreenTarget(name=model, approved_classification=classification, personal_data=personal_data),
|
|
128
|
+
p,
|
|
129
|
+
term_classification_of(findings),
|
|
130
|
+
)
|
|
131
|
+
redaction = redact_for(text, findings, a.verdict, a.offending)
|
|
132
|
+
if a.verdict == "allow":
|
|
133
|
+
sent: Optional[str] = text
|
|
134
|
+
elif a.verdict == "redact":
|
|
135
|
+
sent = redaction.text if redaction else None
|
|
136
|
+
else:
|
|
137
|
+
sent = None
|
|
138
|
+
return Screened(
|
|
139
|
+
verdict=a.verdict,
|
|
140
|
+
allowed=sent is not None,
|
|
141
|
+
text=sent,
|
|
142
|
+
redacted=text if a.verdict == "allow" else (redaction.text if redaction else None),
|
|
143
|
+
findings=findings,
|
|
144
|
+
counts=counts,
|
|
145
|
+
reasons=a.reasons + [_ASK_REASON] if a.verdict == "ask" else a.reasons,
|
|
146
|
+
replacements=dict(redaction.replacements) if redaction and a.verdict == "redact" else {},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = [
|
|
151
|
+
"screen",
|
|
152
|
+
"detect",
|
|
153
|
+
"Screened",
|
|
154
|
+
"SpanDetector",
|
|
155
|
+
"DetectedSpan",
|
|
156
|
+
"ScreenFinding",
|
|
157
|
+
"ScreenTerm",
|
|
158
|
+
"Redaction",
|
|
159
|
+
"screen_text",
|
|
160
|
+
"summarise",
|
|
161
|
+
"redact",
|
|
162
|
+
"restore",
|
|
163
|
+
"describe_counts",
|
|
164
|
+
"DATA_KINDS",
|
|
165
|
+
"DATA_KIND_KEYS",
|
|
166
|
+
"SCREEN_CLASSIFICATIONS",
|
|
167
|
+
"DEFAULT_SCREEN_POLICY",
|
|
168
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Detectors to plug in beside the patterns, for what patterns cannot find:
|
|
2
|
+
people's names above all.
|
|
3
|
+
|
|
4
|
+
Each adapter returns a callable from the text to spans, for
|
|
5
|
+
``screen(text, detectors=[...])`` here or ``deliverd.screen(text,
|
|
6
|
+
detectors=[...])`` in the ``deliverd`` SDK. This package depends on none of
|
|
7
|
+
them: you install the model or the analyzer and pass it in.
|
|
8
|
+
|
|
9
|
+
Run the model where the text already is. A detector that sends the text to a
|
|
10
|
+
hosted service has sent it somewhere, which is what this check exists to
|
|
11
|
+
prevent.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
from .data_screen import DATA_KINDS, DetectedSpan
|
|
20
|
+
|
|
21
|
+
LabelMap = Callable[[str], Optional[str]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_labels(label: str) -> Optional[str]:
|
|
25
|
+
"""What the common label sets mean; None to ignore a label."""
|
|
26
|
+
l = label.lower()
|
|
27
|
+
if re.fullmatch(r"per|person|private_person|name|full_?name|first_?name|last_?name|given_?name|surname|username", l):
|
|
28
|
+
return "person_name"
|
|
29
|
+
if "email" in l:
|
|
30
|
+
return "email"
|
|
31
|
+
if "phone" in l or "telephone" in l:
|
|
32
|
+
return "phone"
|
|
33
|
+
if "iban" in l:
|
|
34
|
+
return "iban"
|
|
35
|
+
if re.search(r"credit_?card|card_?number", l):
|
|
36
|
+
return "payment_card"
|
|
37
|
+
if re.fullmatch(r"ip(_?address)?", l):
|
|
38
|
+
return "ip_address"
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _field(t: Any, name: str) -> Any:
|
|
43
|
+
return t.get(name) if isinstance(t, dict) else getattr(t, name, None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def token_classifier(
|
|
47
|
+
classify: Callable[[str], Sequence[Any]],
|
|
48
|
+
min_score: float = 0.5,
|
|
49
|
+
labels: LabelMap = default_labels,
|
|
50
|
+
) -> Callable[[str], List[DetectedSpan]]:
|
|
51
|
+
"""A Hugging Face token-classification pipeline as a detector.
|
|
52
|
+
|
|
53
|
+
from transformers import pipeline
|
|
54
|
+
ner = pipeline("token-classification", model="dslim/bert-base-NER", aggregation_strategy="simple")
|
|
55
|
+
names = token_classifier(ner)
|
|
56
|
+
|
|
57
|
+
Works with anything returning the same dicts (``entity_group`` or
|
|
58
|
+
``entity``, ``score``, ``start``, ``end``, ``word``), such as Perplexity's
|
|
59
|
+
PII-Tracer or a GLiNER model. Check the model's licence before you ship it.
|
|
60
|
+
Adjacent tokens of one kind become one span.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def detect(text: str) -> List[DetectedSpan]:
|
|
64
|
+
spans: List[DetectedSpan] = []
|
|
65
|
+
cursor = 0
|
|
66
|
+
for t in classify(text):
|
|
67
|
+
raw = _field(t, "entity_group") or _field(t, "entity") or _field(t, "label")
|
|
68
|
+
score = _field(t, "score")
|
|
69
|
+
if not raw or (score is not None and score < min_score):
|
|
70
|
+
continue
|
|
71
|
+
kind = labels(re.sub(r"^[BIESL]-", "", str(raw)))
|
|
72
|
+
start, end = _field(t, "start"), _field(t, "end")
|
|
73
|
+
if not isinstance(start, int) or not isinstance(end, int) or end <= start:
|
|
74
|
+
word = re.sub(r"^(##|▁|Ġ)", "", str(_field(t, "word") or "")).strip()
|
|
75
|
+
if not word:
|
|
76
|
+
continue
|
|
77
|
+
start = text.find(word, cursor)
|
|
78
|
+
if start < 0:
|
|
79
|
+
continue
|
|
80
|
+
end = start + len(word)
|
|
81
|
+
cursor = end
|
|
82
|
+
if not kind or kind not in DATA_KINDS:
|
|
83
|
+
continue
|
|
84
|
+
last = spans[-1] if spans else None
|
|
85
|
+
if last and last.kind == kind and re.fullmatch(r"[\s-]?", text[last.end : start]):
|
|
86
|
+
last.end = end
|
|
87
|
+
else:
|
|
88
|
+
spans.append(DetectedSpan(kind=kind, start=start, end=end))
|
|
89
|
+
return spans
|
|
90
|
+
|
|
91
|
+
return detect
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_PRESIDIO: Dict[str, str] = {
|
|
95
|
+
"PERSON": "person_name",
|
|
96
|
+
"EMAIL_ADDRESS": "email",
|
|
97
|
+
"PHONE_NUMBER": "phone",
|
|
98
|
+
"IBAN_CODE": "iban",
|
|
99
|
+
"CREDIT_CARD": "payment_card",
|
|
100
|
+
"IP_ADDRESS": "ip_address",
|
|
101
|
+
"US_SSN": "us_ssn",
|
|
102
|
+
"UK_NINO": "uk_national_insurance",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def presidio(analyzer: Any, language: str = "en", min_score: float = 0.5) -> Callable[[str], List[DetectedSpan]]:
|
|
107
|
+
"""A Presidio ``AnalyzerEngine`` as a detector, in process.
|
|
108
|
+
|
|
109
|
+
from presidio_analyzer import AnalyzerEngine
|
|
110
|
+
names = presidio(AnalyzerEngine())
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def detect(text: str) -> List[DetectedSpan]:
|
|
114
|
+
results = analyzer.analyze(text=text, language=language, entities=list(_PRESIDIO))
|
|
115
|
+
return [
|
|
116
|
+
DetectedSpan(kind=_PRESIDIO[r.entity_type], start=r.start, end=r.end)
|
|
117
|
+
for r in results
|
|
118
|
+
if r.entity_type in _PRESIDIO and r.score >= min_score
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
return detect
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""deliverd-screen: the check from a terminal, a pre-commit hook or CI.
|
|
2
|
+
|
|
3
|
+
deliverd-screen notes.txt
|
|
4
|
+
cat prompt.txt | deliverd-screen --redact
|
|
5
|
+
deliverd-screen --fail fixtures/*.json
|
|
6
|
+
|
|
7
|
+
Reads files, or standard input when there are none. Nothing is sent anywhere.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import List, Optional, TextIO
|
|
16
|
+
|
|
17
|
+
from . import __version__, detect
|
|
18
|
+
from .data_screen import describe_counts, redact
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: Optional[List[str]] = None, stdin: TextIO = sys.stdin, stdout: TextIO = sys.stdout, stderr: TextIO = sys.stderr) -> int:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="deliverd-screen",
|
|
24
|
+
description="Find personal data, secrets and confidential terms in text before it goes to an AI model. Runs locally; nothing is sent anywhere.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("files", nargs="*", help="files to check; standard input when none")
|
|
27
|
+
parser.add_argument("--redact", action="store_true", help="print the text with what was found replaced by placeholders (one input)")
|
|
28
|
+
parser.add_argument("--json", action="store_true", help="print what was found as JSON: kinds, offsets and counts, never values")
|
|
29
|
+
parser.add_argument("--fail", action="store_true", help="exit 1 when anything is found")
|
|
30
|
+
parser.add_argument("--terms", metavar="FILE", help="your own words not to send, one per line")
|
|
31
|
+
parser.add_argument("-v", "--version", action="version", version=__version__)
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
if args.redact and len(args.files) > 1:
|
|
34
|
+
stderr.write("--redact takes one input.\n")
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
terms: List[str] = []
|
|
38
|
+
if args.terms:
|
|
39
|
+
with open(args.terms, encoding="utf-8") as f:
|
|
40
|
+
terms = [t.strip() for t in f if t.strip() and not t.strip().startswith("#")]
|
|
41
|
+
|
|
42
|
+
if not args.files or args.files == ["-"]:
|
|
43
|
+
inputs = [("stdin", stdin.read())]
|
|
44
|
+
else:
|
|
45
|
+
inputs = []
|
|
46
|
+
for name in args.files:
|
|
47
|
+
with open(name, encoding="utf-8") as f:
|
|
48
|
+
inputs.append((name, f.read()))
|
|
49
|
+
|
|
50
|
+
found = 0
|
|
51
|
+
report = []
|
|
52
|
+
for name, text in inputs:
|
|
53
|
+
findings, counts = detect(text, terms)
|
|
54
|
+
found += len(findings)
|
|
55
|
+
if args.redact:
|
|
56
|
+
stdout.write(redact(text, findings).text)
|
|
57
|
+
if args.json:
|
|
58
|
+
report.append(
|
|
59
|
+
{"file": name, "counts": counts, "findings": [{"kind": f.kind, "label": f.label, "start": f.start, "end": f.end} for f in findings]}
|
|
60
|
+
)
|
|
61
|
+
elif findings:
|
|
62
|
+
stderr.write("{}: {}\n".format(name, describe_counts(counts)))
|
|
63
|
+
if args.json:
|
|
64
|
+
stdout.write(json.dumps(report[0] if len(report) == 1 else report, indent=2) + "\n")
|
|
65
|
+
elif found == 0 and not args.redact:
|
|
66
|
+
stderr.write("Nothing found.\n")
|
|
67
|
+
return 1 if args.fail and found else 0
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Screening what an agent is about to send to an AI model.
|
|
2
|
+
|
|
3
|
+
The Python half of ``packages/core/src/data-screen.ts``, rule for rule. Before
|
|
4
|
+
a prompt, a document or a retrieved record goes to a model, this finds the
|
|
5
|
+
personal data, secrets and confidential terms in it, and decides — against what
|
|
6
|
+
the organisation's AI register approves that model to receive — whether it may
|
|
7
|
+
go as it is, go with the findings replaced by placeholders, go only once a
|
|
8
|
+
person agrees, or not go.
|
|
9
|
+
|
|
10
|
+
**The text need not leave the caller.** Everything here is pure, so
|
|
11
|
+
:meth:`deliverd.Deliverd.screen` runs it where the agent runs and sends
|
|
12
|
+
Deliverd only counts. ``packages/conformance/screening.json`` holds this file
|
|
13
|
+
and the TypeScript one to the same findings, redactions and verdicts.
|
|
14
|
+
|
|
15
|
+
**A net, not a guarantee.** Structured identifiers are found well and names
|
|
16
|
+
hardly at all; pseudonymised text is still personal data in law. Never tell
|
|
17
|
+
anyone a text is clean because this allowed it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Dict, List, Mapping, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
#: Ordered: the stricter of two classifications is the later one.
|
|
27
|
+
SCREEN_CLASSIFICATIONS = ("public", "internal", "confidential", "restricted")
|
|
28
|
+
SCREEN_CATEGORIES = ("personal", "financial", "special", "secret", "confidential")
|
|
29
|
+
#: ``allow`` | ``redact`` | ``ask`` | ``deny``
|
|
30
|
+
SCREEN_VERDICTS = ("allow", "redact", "ask", "deny")
|
|
31
|
+
|
|
32
|
+
#: kind → (label, category, placeholder word, whether redacting it removes the concern)
|
|
33
|
+
DATA_KINDS: Dict[str, Dict[str, object]] = {
|
|
34
|
+
"email": {"label": "Email address", "category": "personal", "placeholder": "EMAIL", "redactable": True},
|
|
35
|
+
"phone": {"label": "Phone number", "category": "personal", "placeholder": "PHONE", "redactable": True},
|
|
36
|
+
"iban": {"label": "IBAN", "category": "financial", "placeholder": "IBAN", "redactable": True},
|
|
37
|
+
"payment_card": {"label": "Payment card number", "category": "financial", "placeholder": "CARD", "redactable": True},
|
|
38
|
+
"uk_national_insurance": {"label": "UK National Insurance number", "category": "personal", "placeholder": "NINO", "redactable": True},
|
|
39
|
+
"ie_pps": {"label": "Irish PPS number", "category": "personal", "placeholder": "PPS", "redactable": True},
|
|
40
|
+
"us_ssn": {"label": "US Social Security number", "category": "personal", "placeholder": "SSN", "redactable": True},
|
|
41
|
+
"ip_address": {"label": "IP address", "category": "personal", "placeholder": "IP", "redactable": True},
|
|
42
|
+
"date_of_birth": {"label": "Date of birth", "category": "personal", "placeholder": "DOB", "redactable": True},
|
|
43
|
+
# Never found by a pattern here: only by a model detector the caller plugs in (``spans``).
|
|
44
|
+
"person_name": {"label": "Person's name", "category": "personal", "placeholder": "NAME", "redactable": True},
|
|
45
|
+
"special_category": {"label": "Special-category data", "category": "special", "placeholder": "SPECIAL", "redactable": False},
|
|
46
|
+
"secret": {"label": "Secret or credential", "category": "secret", "placeholder": "SECRET", "redactable": True},
|
|
47
|
+
"term": {"label": "Confidential term", "category": "confidential", "placeholder": "TERM", "redactable": True},
|
|
48
|
+
}
|
|
49
|
+
DATA_KIND_KEYS = tuple(DATA_KINDS)
|
|
50
|
+
|
|
51
|
+
_CATEGORY_CLASSIFICATION = {
|
|
52
|
+
"personal": "confidential",
|
|
53
|
+
"financial": "confidential",
|
|
54
|
+
"confidential": "confidential",
|
|
55
|
+
"special": "restricted",
|
|
56
|
+
"secret": "restricted",
|
|
57
|
+
}
|
|
58
|
+
_PERSONAL_CATEGORIES = {"personal", "financial", "special"}
|
|
59
|
+
|
|
60
|
+
#: Terms past this are ignored, so a pasted list cannot make every screen slow.
|
|
61
|
+
MAX_SCREEN_TERMS = 500
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ScreenTerm:
|
|
66
|
+
"""A word or phrase not to send: a client, a code name. Whole-word, any case."""
|
|
67
|
+
|
|
68
|
+
term: str
|
|
69
|
+
#: Shown to people and used in the placeholder: ``[CLIENT_1]``.
|
|
70
|
+
label: Optional[str] = None
|
|
71
|
+
#: ``confidential`` (default) or ``restricted``.
|
|
72
|
+
classification: Optional[str] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class DetectedSpan:
|
|
77
|
+
"""What another detector found, as offsets into the same text."""
|
|
78
|
+
|
|
79
|
+
kind: str
|
|
80
|
+
start: int
|
|
81
|
+
end: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ScreenFinding:
|
|
86
|
+
"""Where one finding is in the screened text. The value is never copied."""
|
|
87
|
+
|
|
88
|
+
kind: str
|
|
89
|
+
category: str
|
|
90
|
+
classification: str
|
|
91
|
+
start: int
|
|
92
|
+
end: int
|
|
93
|
+
label: str
|
|
94
|
+
redactable: bool
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Detectors ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _digits(s: str) -> str:
|
|
101
|
+
return re.sub(r"\D", "", s)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _luhn(digits: str) -> bool:
|
|
105
|
+
total = 0
|
|
106
|
+
double = False
|
|
107
|
+
for ch in reversed(digits):
|
|
108
|
+
d = ord(ch) - 48
|
|
109
|
+
if double:
|
|
110
|
+
d *= 2
|
|
111
|
+
if d > 9:
|
|
112
|
+
d -= 9
|
|
113
|
+
total += d
|
|
114
|
+
double = not double
|
|
115
|
+
return total % 10 == 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _iban_valid(raw: str) -> bool:
|
|
119
|
+
iban = re.sub(r"\s", "", raw).upper()
|
|
120
|
+
if len(iban) < 15 or len(iban) > 34:
|
|
121
|
+
return False
|
|
122
|
+
moved = iban[4:] + iban[:4]
|
|
123
|
+
remainder = 0
|
|
124
|
+
for ch in moved:
|
|
125
|
+
value = str(ord(ch) - 55) if "A" <= ch <= "Z" else ch
|
|
126
|
+
for digit in value:
|
|
127
|
+
remainder = (remainder * 10 + (ord(digit) - 48)) % 97
|
|
128
|
+
return remainder == 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pps_valid(raw: str) -> bool:
|
|
132
|
+
m = re.fullmatch(r"(\d{7})([A-W])([A-IW]?)", raw.upper())
|
|
133
|
+
if not m:
|
|
134
|
+
return False
|
|
135
|
+
total = sum((ord(m.group(1)[i]) - 48) * (8 - i) for i in range(7))
|
|
136
|
+
if m.group(3):
|
|
137
|
+
total += (0 if m.group(3) == "W" else ord(m.group(3)) - 64) * 9
|
|
138
|
+
r = total % 23
|
|
139
|
+
return m.group(2) == ("W" if r == 0 else chr(64 + r))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _ipv4_valid(raw: str) -> bool:
|
|
143
|
+
parts = [int(p) for p in raw.split(".")]
|
|
144
|
+
if len(parts) != 4 or any(p > 255 for p in parts):
|
|
145
|
+
return False
|
|
146
|
+
# Loopback and the unspecified address identify nobody.
|
|
147
|
+
return parts[0] != 127 and parts[0] != 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _card_valid(m: str) -> bool:
|
|
151
|
+
d = _digits(m)
|
|
152
|
+
return 13 <= len(d) <= 19 and _luhn(d)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _phone_valid(m: str) -> bool:
|
|
156
|
+
return 9 <= len(_digits(m)) <= 15
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_A = re.ASCII
|
|
160
|
+
_AI = re.ASCII | re.IGNORECASE
|
|
161
|
+
|
|
162
|
+
#: The same credential patterns the publish scanner blocks a report for.
|
|
163
|
+
_SECRET_PATTERNS = [
|
|
164
|
+
re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b", _A),
|
|
165
|
+
re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----", _A),
|
|
166
|
+
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b", _A),
|
|
167
|
+
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b", _A),
|
|
168
|
+
re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}\b", _A),
|
|
169
|
+
re.compile(r"\bsk-ant-[A-Za-z0-9_-]{24,}\b", _A),
|
|
170
|
+
re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b", _A),
|
|
171
|
+
re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b", _A),
|
|
172
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b", _A),
|
|
173
|
+
re.compile(r"""\b(?:password|passwd|secret_key|api_key|apikey|auth_token)\s*[:=]\s*["'][^"']{8,}["']""", _AI),
|
|
174
|
+
re.compile(r"\b(?:dlv|dlvk)_[A-Za-z0-9]{24,}\b", _A),
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
_SPECIAL_PATTERN = re.compile(
|
|
178
|
+
r"\b(?:diagnos(?:is|ed|es)|medical (?:record|history|condition)s?|mental health|prescri(?:bed|ption)s?|HIV|pregnan(?:t|cy)|disabilit(?:y|ies)|sexual orientation|religious belief|ethnic origin|racial origin|trade union member(?:ship)?|biometric|genetic data|criminal (?:record|conviction|offence)s?)\b",
|
|
179
|
+
_AI,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# (kind, pattern, validator, group)
|
|
183
|
+
_DETECTORS = [
|
|
184
|
+
*[("secret", p, None, 0) for p in _SECRET_PATTERNS],
|
|
185
|
+
("iban", re.compile(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b", _A), _iban_valid, 0),
|
|
186
|
+
("payment_card", re.compile(r"\b[2-6]\d{3}(?:[ -]?\d{4}){2}[ -]?\d{1,7}\b", _A), _card_valid, 0),
|
|
187
|
+
("us_ssn", re.compile(r"\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b", _A), None, 0),
|
|
188
|
+
(
|
|
189
|
+
"uk_national_insurance",
|
|
190
|
+
re.compile(r"\b(?!BG|GB|NK|KN|TN|NT|ZZ)[A-CEGHJ-PR-TW-Z][A-CEGHJ-NPR-TW-Z] ?\d{2} ?\d{2} ?\d{2} ?[A-D]\b", _A),
|
|
191
|
+
None,
|
|
192
|
+
0,
|
|
193
|
+
),
|
|
194
|
+
("ie_pps", re.compile(r"\b\d{7}[A-W][A-IW]?\b", _A), _pps_valid, 0),
|
|
195
|
+
("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b", _A), None, 0),
|
|
196
|
+
(
|
|
197
|
+
"date_of_birth",
|
|
198
|
+
re.compile(
|
|
199
|
+
r"\b(?:date of birth|d\.?o\.?b\.?|born(?: on)?)\s*[:\-]?\s*(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4}|\d{4}-\d{2}-\d{2}|\d{1,2} (?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]* \d{4})",
|
|
200
|
+
_AI,
|
|
201
|
+
),
|
|
202
|
+
None,
|
|
203
|
+
1,
|
|
204
|
+
),
|
|
205
|
+
(
|
|
206
|
+
"phone",
|
|
207
|
+
re.compile(r"(?<![\w+])(?:\+\d{1,3}[ .-]?(?:\(0\)[ .-]?)?|\(?0)\d{1,4}\)?(?:[ .-]?\d{2,4}){2,4}(?!\w)", _A),
|
|
208
|
+
_phone_valid,
|
|
209
|
+
0,
|
|
210
|
+
),
|
|
211
|
+
("ip_address", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", _A), _ipv4_valid, 0),
|
|
212
|
+
("special_category", _SPECIAL_PATTERN, None, 0),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
#: Earlier wins an overlap: a card number is not also a phone number.
|
|
216
|
+
_PRIORITY = {
|
|
217
|
+
k: i
|
|
218
|
+
for i, k in enumerate(
|
|
219
|
+
[
|
|
220
|
+
"secret",
|
|
221
|
+
"iban",
|
|
222
|
+
"payment_card",
|
|
223
|
+
"us_ssn",
|
|
224
|
+
"uk_national_insurance",
|
|
225
|
+
"ie_pps",
|
|
226
|
+
"email",
|
|
227
|
+
"date_of_birth",
|
|
228
|
+
"phone",
|
|
229
|
+
"ip_address",
|
|
230
|
+
"term",
|
|
231
|
+
"person_name",
|
|
232
|
+
"special_category",
|
|
233
|
+
]
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _term_of(t: object) -> ScreenTerm:
|
|
239
|
+
if isinstance(t, ScreenTerm):
|
|
240
|
+
return t
|
|
241
|
+
if isinstance(t, Mapping):
|
|
242
|
+
return ScreenTerm(term=str(t.get("term", "")), label=t.get("label"), classification=t.get("classification"))
|
|
243
|
+
return ScreenTerm(term=str(t))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _span_of(s: object) -> Optional[DetectedSpan]:
|
|
247
|
+
if isinstance(s, DetectedSpan):
|
|
248
|
+
return s
|
|
249
|
+
if isinstance(s, Mapping):
|
|
250
|
+
try:
|
|
251
|
+
return DetectedSpan(kind=str(s.get("kind", "")), start=s["start"], end=s["end"])
|
|
252
|
+
except KeyError:
|
|
253
|
+
return None
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def screen_text(text: str, terms: Sequence[object] = (), spans: Sequence[object] = ()) -> List[ScreenFinding]:
|
|
258
|
+
"""Find what is in a text. Each character belongs to at most one finding.
|
|
259
|
+
|
|
260
|
+
``terms`` are :class:`ScreenTerm`, dicts with the same keys, or strings.
|
|
261
|
+
``spans`` are another detector's findings — a named-entity model, a DLP
|
|
262
|
+
service — as :class:`DetectedSpan` or dicts of ``kind``, ``start`` and
|
|
263
|
+
``end``, held to the same rules; one outside the text or of no known kind
|
|
264
|
+
is dropped.
|
|
265
|
+
"""
|
|
266
|
+
raw: List[ScreenFinding] = []
|
|
267
|
+
|
|
268
|
+
def add(kind: str, start: int, end: int, label: Optional[str] = None, classification: Optional[str] = None) -> None:
|
|
269
|
+
spec = DATA_KINDS[kind]
|
|
270
|
+
raw.append(
|
|
271
|
+
ScreenFinding(
|
|
272
|
+
kind=kind,
|
|
273
|
+
category=str(spec["category"]),
|
|
274
|
+
classification=classification or _CATEGORY_CLASSIFICATION[str(spec["category"])],
|
|
275
|
+
start=start,
|
|
276
|
+
end=end,
|
|
277
|
+
label=label or str(spec["label"]),
|
|
278
|
+
redactable=bool(spec["redactable"]),
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
for kind, pattern, valid, group in _DETECTORS:
|
|
283
|
+
for m in pattern.finditer(text):
|
|
284
|
+
value = m.group(group) if group else m.group(0)
|
|
285
|
+
if not value or (valid is not None and not valid(value)):
|
|
286
|
+
continue
|
|
287
|
+
start = m.start() + m.group(0).rfind(value) if group else m.start()
|
|
288
|
+
add(kind, start, start + len(value))
|
|
289
|
+
|
|
290
|
+
for t in [_term_of(t) for t in list(terms)[:MAX_SCREEN_TERMS]]:
|
|
291
|
+
term = t.term.strip()
|
|
292
|
+
if len(term) < 2:
|
|
293
|
+
continue
|
|
294
|
+
pattern = re.compile(r"(?<![^\W_])" + re.escape(term) + r"(?![^\W_])", re.IGNORECASE)
|
|
295
|
+
for m in pattern.finditer(text):
|
|
296
|
+
add("term", m.start(), m.end(), (t.label or "").strip() or None, t.classification or "confidential")
|
|
297
|
+
|
|
298
|
+
for sp in (_span_of(s) for s in spans):
|
|
299
|
+
if sp is None or sp.kind not in DATA_KINDS or sp.kind == "term":
|
|
300
|
+
continue
|
|
301
|
+
if not isinstance(sp.start, int) or not isinstance(sp.end, int) or isinstance(sp.start, bool) or isinstance(sp.end, bool):
|
|
302
|
+
continue
|
|
303
|
+
if sp.start < 0 or sp.end > len(text) or sp.end <= sp.start:
|
|
304
|
+
continue
|
|
305
|
+
add(sp.kind, sp.start, sp.end)
|
|
306
|
+
|
|
307
|
+
raw.sort(key=lambda f: (_PRIORITY[f.kind], -(f.end - f.start), f.start))
|
|
308
|
+
kept: List[ScreenFinding] = []
|
|
309
|
+
for f in raw:
|
|
310
|
+
if any(f.start < k.end and k.start < f.end for k in kept):
|
|
311
|
+
continue
|
|
312
|
+
kept.append(f)
|
|
313
|
+
return sorted(kept, key=lambda f: f.start)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def summarise(findings: Sequence[ScreenFinding]) -> Dict[str, int]:
|
|
317
|
+
"""Counts only: what goes over the wire when the text stays where it is."""
|
|
318
|
+
counts: Dict[str, int] = {}
|
|
319
|
+
for f in findings:
|
|
320
|
+
counts[f.kind] = counts.get(f.kind, 0) + 1
|
|
321
|
+
return counts
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ── Redaction ──────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass
|
|
328
|
+
class Redaction:
|
|
329
|
+
text: str
|
|
330
|
+
#: Placeholder → original value. Stays with the caller; never sent anywhere.
|
|
331
|
+
replacements: Dict[str, str] = field(default_factory=dict)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _placeholder_word(f: ScreenFinding) -> str:
|
|
335
|
+
if f.kind == "term" and f.label != DATA_KINDS["term"]["label"]:
|
|
336
|
+
word = re.sub(r"^_|_$", "", re.sub(r"[^A-Z0-9]+", "_", f.label.upper()))[:24]
|
|
337
|
+
return word or "TERM"
|
|
338
|
+
return str(DATA_KINDS[f.kind]["placeholder"])
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def redact(text: str, findings: Sequence[ScreenFinding]) -> Redaction:
|
|
342
|
+
"""Replace each redactable finding with a placeholder, one per distinct value."""
|
|
343
|
+
replacements: Dict[str, str] = {}
|
|
344
|
+
by_value: Dict[str, str] = {}
|
|
345
|
+
counters: Dict[str, int] = {}
|
|
346
|
+
out = []
|
|
347
|
+
at = 0
|
|
348
|
+
for f in sorted(findings, key=lambda f: f.start):
|
|
349
|
+
if not f.redactable or f.start < at:
|
|
350
|
+
continue
|
|
351
|
+
value = text[f.start : f.end]
|
|
352
|
+
key = "{}\0{}".format(f.kind, value.lower())
|
|
353
|
+
placeholder = by_value.get(key)
|
|
354
|
+
if placeholder is None:
|
|
355
|
+
word = _placeholder_word(f)
|
|
356
|
+
counters[word] = counters.get(word, 0) + 1
|
|
357
|
+
placeholder = "[{}_{}]".format(word, counters[word])
|
|
358
|
+
by_value[key] = placeholder
|
|
359
|
+
replacements[placeholder] = value
|
|
360
|
+
out.append(text[at : f.start] + placeholder)
|
|
361
|
+
at = f.end
|
|
362
|
+
out.append(text[at:])
|
|
363
|
+
return Redaction(text="".join(out), replacements=replacements)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def restore(text: str, replacements: Mapping[str, str]) -> str:
|
|
367
|
+
"""Put the original values back into text a model wrote using the placeholders."""
|
|
368
|
+
keys = sorted(replacements, key=len, reverse=True)
|
|
369
|
+
if not keys:
|
|
370
|
+
return text
|
|
371
|
+
pattern = re.compile("|".join(re.escape(k) for k in keys))
|
|
372
|
+
return pattern.sub(lambda m: replacements.get(m.group(0), m.group(0)), text)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ── Deciding ───────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass
|
|
379
|
+
class ScreenTarget:
|
|
380
|
+
"""What the destination model is approved to receive: its AI register record."""
|
|
381
|
+
|
|
382
|
+
name: str
|
|
383
|
+
approved_classification: Optional[str]
|
|
384
|
+
personal_data: bool
|
|
385
|
+
active: bool = True
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@dataclass
|
|
389
|
+
class ScreenAssessment:
|
|
390
|
+
verdict: str
|
|
391
|
+
classification: str
|
|
392
|
+
personal_data: bool
|
|
393
|
+
categories: List[str]
|
|
394
|
+
offending: List[str]
|
|
395
|
+
reasons: List[str]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
DEFAULT_SCREEN_POLICY: Dict[str, object] = {
|
|
399
|
+
"secrets": "deny",
|
|
400
|
+
"beyondApproval": "redact",
|
|
401
|
+
"notRedactable": "ask",
|
|
402
|
+
"unregistered": {"approvedClassification": "internal", "personalData": False},
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
_VERDICT_RANK = {"allow": 0, "redact": 1, "ask": 2, "deny": 3}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _stricter(a: str, b: str) -> str:
|
|
409
|
+
return b if _VERDICT_RANK[b] > _VERDICT_RANK[a] else a
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _rank(c: str) -> int:
|
|
413
|
+
return SCREEN_CLASSIFICATIONS.index(c)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def describe_counts(counts: Mapping[str, int]) -> str:
|
|
417
|
+
"""``2 email addresses and 1 IBAN``."""
|
|
418
|
+
parts = []
|
|
419
|
+
for k in DATA_KIND_KEYS:
|
|
420
|
+
n = counts.get(k) or 0
|
|
421
|
+
if n <= 0:
|
|
422
|
+
continue
|
|
423
|
+
label = str(DATA_KINDS[k]["label"])
|
|
424
|
+
s = "" if n == 1 else "s"
|
|
425
|
+
if k == "email":
|
|
426
|
+
parts.append("{} email address{}".format(n, "" if n == 1 else "es"))
|
|
427
|
+
elif k == "special_category":
|
|
428
|
+
parts.append("{} mention{} of special-category data".format(n, s))
|
|
429
|
+
elif k == "secret":
|
|
430
|
+
parts.append("{} secret{} or credential{}".format(n, s, s))
|
|
431
|
+
elif k == "term":
|
|
432
|
+
parts.append("{} confidential term{}".format(n, s))
|
|
433
|
+
elif k == "person_name":
|
|
434
|
+
parts.append("{} {}".format(n, "person's name" if n == 1 else "people's names"))
|
|
435
|
+
elif re.match(r"^[A-Z]{2,}", label):
|
|
436
|
+
parts.append("{} {}{}".format(n, label, s))
|
|
437
|
+
else:
|
|
438
|
+
parts.append("{} {}{}".format(n, label.lower(), s))
|
|
439
|
+
if len(parts) <= 1:
|
|
440
|
+
return parts[0] if parts else "nothing"
|
|
441
|
+
return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def assess_counts(
|
|
445
|
+
counts: Mapping[str, int],
|
|
446
|
+
target: Optional[ScreenTarget],
|
|
447
|
+
policy: Optional[Mapping[str, object]] = None,
|
|
448
|
+
term_classification: str = "confidential",
|
|
449
|
+
) -> ScreenAssessment:
|
|
450
|
+
"""Decide from counts alone, the same way the server does."""
|
|
451
|
+
p = dict(DEFAULT_SCREEN_POLICY)
|
|
452
|
+
p.update(policy or {})
|
|
453
|
+
present = [k for k in DATA_KIND_KEYS if (counts.get(k) or 0) > 0]
|
|
454
|
+
categories = [c for c in SCREEN_CATEGORIES if any(DATA_KINDS[k]["category"] == c for k in present)]
|
|
455
|
+
|
|
456
|
+
def class_of(k: str) -> str:
|
|
457
|
+
return term_classification if k == "term" else _CATEGORY_CLASSIFICATION[str(DATA_KINDS[k]["category"])]
|
|
458
|
+
|
|
459
|
+
classification = "public"
|
|
460
|
+
for k in present:
|
|
461
|
+
if _rank(class_of(k)) > _rank(classification):
|
|
462
|
+
classification = class_of(k)
|
|
463
|
+
personal = any(c in _PERSONAL_CATEGORIES for c in categories)
|
|
464
|
+
|
|
465
|
+
if target is not None and not target.active:
|
|
466
|
+
return ScreenAssessment(
|
|
467
|
+
"deny",
|
|
468
|
+
classification,
|
|
469
|
+
personal,
|
|
470
|
+
categories,
|
|
471
|
+
present,
|
|
472
|
+
["{} is not an active system in the AI register, so nothing should be sent to it.".format(target.name)],
|
|
473
|
+
)
|
|
474
|
+
reasons: List[str] = []
|
|
475
|
+
offending: List[str] = []
|
|
476
|
+
verdict = "allow"
|
|
477
|
+
if not present:
|
|
478
|
+
return ScreenAssessment(verdict, classification, personal, categories, offending, reasons)
|
|
479
|
+
|
|
480
|
+
unregistered = p["unregistered"]
|
|
481
|
+
assert isinstance(unregistered, Mapping)
|
|
482
|
+
name = target.name if target is not None else "an AI system that is not in the register"
|
|
483
|
+
approved = (
|
|
484
|
+
(target.approved_classification or str(unregistered["approvedClassification"]))
|
|
485
|
+
if target is not None
|
|
486
|
+
else str(unregistered["approvedClassification"])
|
|
487
|
+
)
|
|
488
|
+
personal_approved = target.personal_data if target is not None else bool(unregistered["personalData"])
|
|
489
|
+
|
|
490
|
+
secrets = counts.get("secret") or 0
|
|
491
|
+
if secrets > 0:
|
|
492
|
+
verdict = _stricter(verdict, str(p["secrets"]))
|
|
493
|
+
offending.append("secret")
|
|
494
|
+
reasons.append("It contains {}, which should never be sent to a model.".format(describe_counts({"secret": secrets})))
|
|
495
|
+
|
|
496
|
+
for k in present:
|
|
497
|
+
if k == "secret":
|
|
498
|
+
continue
|
|
499
|
+
too_sensitive = _rank(class_of(k)) > _rank(approved)
|
|
500
|
+
personal_not_approved = DATA_KINDS[k]["category"] != "confidential" and not personal_approved
|
|
501
|
+
if not too_sensitive and not personal_not_approved:
|
|
502
|
+
continue
|
|
503
|
+
action = str(p["beyondApproval"] if DATA_KINDS[k]["redactable"] else p["notRedactable"])
|
|
504
|
+
verdict = _stricter(verdict, action)
|
|
505
|
+
offending.append(k)
|
|
506
|
+
why = (
|
|
507
|
+
"which is not approved to receive personal data"
|
|
508
|
+
if personal_not_approved
|
|
509
|
+
else "which is approved for {} data at most".format(approved)
|
|
510
|
+
)
|
|
511
|
+
reasons.append("It contains {}, and goes to {}, {}.".format(describe_counts({k: counts[k]}), name, why))
|
|
512
|
+
|
|
513
|
+
return ScreenAssessment(verdict, classification, personal, categories, offending, reasons)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def redact_for(text: str, findings: Sequence[ScreenFinding], verdict: str, offending: Sequence[str]) -> Optional[Redaction]:
|
|
517
|
+
"""The text with only the findings behind a verdict replaced. ``None`` for a deny."""
|
|
518
|
+
if verdict == "deny":
|
|
519
|
+
return None
|
|
520
|
+
wanted = set(offending)
|
|
521
|
+
return redact(text, [f for f in findings if f.kind in wanted])
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def term_classification_of(findings: Sequence[ScreenFinding]) -> str:
|
|
525
|
+
"""The strictest classification among the terms found."""
|
|
526
|
+
return "restricted" if any(f.kind == "term" and f.classification == "restricted" for f in findings) else "confidential"
|
|
File without changes
|