maskerade 0.1.0__py3-none-any.whl
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.
- maskerade/__init__.py +53 -0
- maskerade/_extras.py +238 -0
- maskerade/allowlist/__init__.py +274 -0
- maskerade/allowlist/allowlist.yaml +3359 -0
- maskerade/allowlist/allowlist_overrides.yaml +39 -0
- maskerade/allowlist/build.py +734 -0
- maskerade/cli.py +494 -0
- maskerade/config.py +43 -0
- maskerade/default_config.yaml +842 -0
- maskerade/masker.py +610 -0
- maskerade/pipeline.py +1040 -0
- maskerade/proxy/__init__.py +23 -0
- maskerade/proxy/fields.py +1276 -0
- maskerade/proxy/note.py +96 -0
- maskerade/proxy/rules.py +391 -0
- maskerade/proxy/server.py +1110 -0
- maskerade/proxy/store.py +730 -0
- maskerade/proxy/stream.py +398 -0
- maskerade/proxy/system_note.txt +29 -0
- maskerade/stages/__init__.py +0 -0
- maskerade/stages/allowlist/allowlist.yaml +3032 -0
- maskerade/stages/allowlist/allowlist_overrides.yaml +39 -0
- maskerade/stages/checksum.py +377 -0
- maskerade/stages/custom.py +280 -0
- maskerade/stages/form.py +1450 -0
- maskerade/stages/gliner.py +755 -0
- maskerade/stages/id.py +424 -0
- maskerade/stages/keyval.py +1962 -0
- maskerade/stages/org_evidence.py +906 -0
- maskerade/stages/regex.py +379 -0
- maskerade/validator.py +176 -0
- maskerade/warm.py +241 -0
- maskerade/wizard.py +976 -0
- maskerade-0.1.0.dist-info/METADATA +229 -0
- maskerade-0.1.0.dist-info/RECORD +39 -0
- maskerade-0.1.0.dist-info/WHEEL +5 -0
- maskerade-0.1.0.dist-info/entry_points.txt +2 -0
- maskerade-0.1.0.dist-info/licenses/LICENSE +202 -0
- maskerade-0.1.0.dist-info/top_level.txt +1 -0
maskerade/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""maskerade — local anonymisation of pentest prompts and HTTP traffic.
|
|
2
|
+
|
|
3
|
+
Masks sensitive values out of text before it is sent to a third-party AI, and
|
|
4
|
+
restores them in the reply. Everything runs locally; nothing is sent anywhere
|
|
5
|
+
by this package.
|
|
6
|
+
|
|
7
|
+
from maskerade import Pipeline
|
|
8
|
+
|
|
9
|
+
p = Pipeline() # the default tier: "fast"
|
|
10
|
+
r = p.run(text)
|
|
11
|
+
r["masked_text"], r["mask_map"], r["entities"], r["validation"]
|
|
12
|
+
p.unmask(reply, r["mask_map"])
|
|
13
|
+
|
|
14
|
+
Two tiers:
|
|
15
|
+
fast custom + regex + form + keyval + checksum + id
|
|
16
|
+
pure Python, three dependencies, tens of milliseconds.
|
|
17
|
+
THE DEFAULT, and what `pip install maskerade` can run.
|
|
18
|
+
full the above plus gliner (+ org_evidence). This is C2, the configuration
|
|
19
|
+
every number in the decision record was measured on, and it needs
|
|
20
|
+
`pip install maskerade[ner]` — plus a one-off model download,
|
|
21
|
+
`python -m maskerade.warm --full`.
|
|
22
|
+
|
|
23
|
+
Both tiers need the BPE encoder, because `form` is in both:
|
|
24
|
+
|
|
25
|
+
python -m maskerade.warm # ~1.7 MB, once, with network
|
|
26
|
+
|
|
27
|
+
That is not optional. form fails closed without it, and fail-closed means the
|
|
28
|
+
proxy does not start.
|
|
29
|
+
|
|
30
|
+
WHICH TIER AM I RUNNING?
|
|
31
|
+
------------------------
|
|
32
|
+
maskerade --version names the default tier and the extras
|
|
33
|
+
p.mode_string() names the stages actually live
|
|
34
|
+
|
|
35
|
+
The published numbers — gold 0.919 recall, corpus 0.955 — were measured with
|
|
36
|
+
tier "full" and maskerade[ner]. THE DEFAULT INSTALL IS A DIFFERENT DETECTOR
|
|
37
|
+
SET AND ITS RECALL HAS NOT BEEN MEASURED. Anywhere those numbers appear, they
|
|
38
|
+
describe `maskerade[ner]` at tier full and nothing else.
|
|
39
|
+
|
|
40
|
+
The tier boundary itself is still not derived from measurement — see the
|
|
41
|
+
Week 1 laptop run. Until it is, "fast" and "full" are names for stage sets,
|
|
42
|
+
not a policy.
|
|
43
|
+
"""
|
|
44
|
+
from .pipeline import (
|
|
45
|
+
Pipeline, DetectorError, TIER_FAST, TIER_FULL, TIERS, STAGE_KEYS,
|
|
46
|
+
DEFAULT_TIER,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"Pipeline", "DetectorError",
|
|
51
|
+
"TIER_FAST", "TIER_FULL", "TIERS", "STAGE_KEYS", "DEFAULT_TIER",
|
|
52
|
+
]
|
|
53
|
+
__version__ = "0.1.0"
|
maskerade/_extras.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Which optional extras are installed — answered WITHOUT importing them.
|
|
3
|
+
|
|
4
|
+
A bug report has to say which install it came from. `pip install maskerade`
|
|
5
|
+
and `pip install maskerade[ner]` are the same version string, the same
|
|
6
|
+
commands and two different detector sets, so "0.1.0.dev0" on its own does not
|
|
7
|
+
identify the thing that ran.
|
|
8
|
+
|
|
9
|
+
IT MUST NOT IMPORT WHAT IT REPORTS ON
|
|
10
|
+
-------------------------------------
|
|
11
|
+
importlib.util.find_spec() locates a module without executing it, so probing
|
|
12
|
+
for torch costs a path search rather than the measured ~88 s of instrumented
|
|
13
|
+
import time that `import gliner2` triggers once torch is present. That is not
|
|
14
|
+
an optimisation here, it is a correctness requirement: bench/no_torch_check.py
|
|
15
|
+
asserts that nothing heavy appears in sys.modules under the default tier, and
|
|
16
|
+
this module is reachable from `maskerade --version` and from the proxy's
|
|
17
|
+
startup path. A plain `import torch` inside a try/except would make the gate
|
|
18
|
+
fail and would make the light install pay for a question about itself.
|
|
19
|
+
|
|
20
|
+
find_spec can do more than return None — it raises ModuleNotFoundError when a
|
|
21
|
+
PARENT package is missing, and ValueError on a half-initialised module — so
|
|
22
|
+
every call is wrapped. An extra reported as absent when it is present is a
|
|
23
|
+
cosmetic error in a version banner; an exception there would take out the
|
|
24
|
+
command.
|
|
25
|
+
"""
|
|
26
|
+
from importlib import util as _util
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# extra name -> the modules that must all be importable for it to be usable.
|
|
30
|
+
#
|
|
31
|
+
# gliner2 alone is not enough and that is the whole reason this mapping is not
|
|
32
|
+
# just the extra's name: `pip install gliner2` without [local] installs a
|
|
33
|
+
# 356 kB wheel with no torch and no transformers, which resolves here, imports
|
|
34
|
+
# there, and fails at model load. The three names below are what
|
|
35
|
+
# gliner2[local] actually brings (read from its wheel metadata, 2.1.0).
|
|
36
|
+
EXTRA_MODULES = {
|
|
37
|
+
"ner": ("gliner2", "torch", "transformers"),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def module_present(name):
|
|
42
|
+
"""True when `name` can be located on the path. Never imports it."""
|
|
43
|
+
try:
|
|
44
|
+
return _util.find_spec(name) is not None
|
|
45
|
+
except (ImportError, ValueError):
|
|
46
|
+
# ModuleNotFoundError on a missing parent package, ValueError on a
|
|
47
|
+
# module that is in sys.modules with a None __spec__.
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def module_version(name):
|
|
52
|
+
"""The installed version of a distribution, or None. Never imports it.
|
|
53
|
+
|
|
54
|
+
importlib.metadata reads the .dist-info on disk, so asking for torch's
|
|
55
|
+
version costs a directory scan rather than the seconds an import costs.
|
|
56
|
+
|
|
57
|
+
This exists because "ner: installed" is not enough to reproduce a run.
|
|
58
|
+
gliner2 is pinned, torch and transformers are not, and all three decide
|
|
59
|
+
what the model produces — so the version banner names the trio and a bug
|
|
60
|
+
report stops being a guess.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
from importlib import metadata as _md
|
|
64
|
+
except ImportError: # pragma: no cover - py<3.8
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
return _md.version(name)
|
|
68
|
+
except Exception:
|
|
69
|
+
# PackageNotFoundError, and anything a malformed dist-info raises. A
|
|
70
|
+
# version we cannot read is reported as unknown, never as absent:
|
|
71
|
+
# the module may be perfectly importable from a source checkout.
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _requirements(dist="maskerade"):
|
|
76
|
+
"""(requirements, sources) for `dist`, preferring .dist-info.
|
|
77
|
+
|
|
78
|
+
WHY THIS IS NOT JUST importlib.metadata.requires(). Running from the
|
|
79
|
+
repository root — which is where shipcheck and `maskerade --version` are
|
|
80
|
+
run — puts the CWD on sys.path, and a `maskerade.egg-info` left there by
|
|
81
|
+
`pip install -e .` is then discovered as a distribution. It is a
|
|
82
|
+
SEPARATE, OLDER copy of the metadata: measured 2026-10-17, an egg-info
|
|
83
|
+
written before the pin landed reported `gliner2[local]` while the
|
|
84
|
+
installed .dist-info beside the package correctly reported
|
|
85
|
+
`gliner2[local]==2.0.0`.
|
|
86
|
+
|
|
87
|
+
So the probe that exists to avoid a second, drifting copy of the pin was
|
|
88
|
+
reading a second, drifting copy of the pin. .dist-info wins, and every
|
|
89
|
+
source found is returned so a caller can say when they disagree.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
from importlib import metadata as _md
|
|
93
|
+
except ImportError: # pragma: no cover - py<3.8
|
|
94
|
+
return [], []
|
|
95
|
+
want = dist.lower().replace("_", "-")
|
|
96
|
+
found = []
|
|
97
|
+
try:
|
|
98
|
+
for d in _md.distributions():
|
|
99
|
+
try:
|
|
100
|
+
name = (d.metadata["Name"] or "")
|
|
101
|
+
except Exception:
|
|
102
|
+
continue
|
|
103
|
+
if name.lower().replace("_", "-") != want:
|
|
104
|
+
continue
|
|
105
|
+
path = str(getattr(d, "_path", "") or "")
|
|
106
|
+
# 0 sorts first: a real install beats a build artifact.
|
|
107
|
+
rank = 0 if path.endswith(".dist-info") else 1
|
|
108
|
+
try:
|
|
109
|
+
reqs = list(d.requires or [])
|
|
110
|
+
except Exception:
|
|
111
|
+
reqs = []
|
|
112
|
+
found.append((rank, path, reqs))
|
|
113
|
+
except Exception:
|
|
114
|
+
found = []
|
|
115
|
+
if not found:
|
|
116
|
+
try:
|
|
117
|
+
return list(_md.requires(dist) or []), []
|
|
118
|
+
except Exception:
|
|
119
|
+
return [], []
|
|
120
|
+
found.sort(key=lambda t: t[0])
|
|
121
|
+
return found[0][2], [(p, r) for _rank, p, r in found]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def metadata_conflict(module="gliner2", extra="ner", dist="maskerade"):
|
|
125
|
+
"""Distinct requirement strings for `module` across metadata copies.
|
|
126
|
+
|
|
127
|
+
More than one entry means a stale egg-info (or a second install) is
|
|
128
|
+
stating a different constraint from the one in force. Returns a list of
|
|
129
|
+
(path, requirement) or [] when everything agrees.
|
|
130
|
+
"""
|
|
131
|
+
_reqs, sources = _requirements(dist)
|
|
132
|
+
if len(sources) < 2:
|
|
133
|
+
return []
|
|
134
|
+
seen = {}
|
|
135
|
+
for path, reqs in sources:
|
|
136
|
+
found = None
|
|
137
|
+
for req in reqs:
|
|
138
|
+
head = req.split(";")[0].strip()
|
|
139
|
+
name = head.split("[")[0].split("=")[0].split("<")[0] \
|
|
140
|
+
.split(">")[0].split("!")[0].split("~")[0].strip()
|
|
141
|
+
if name.lower().replace("_", "-") != module.lower().replace("_", "-"):
|
|
142
|
+
continue
|
|
143
|
+
if extra and ("extra" not in req or extra not in req):
|
|
144
|
+
continue
|
|
145
|
+
found = head
|
|
146
|
+
break
|
|
147
|
+
seen[path] = found
|
|
148
|
+
if len(set(seen.values())) < 2:
|
|
149
|
+
return []
|
|
150
|
+
return sorted(seen.items())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def declared_requirement(module, extra=None, dist="maskerade"):
|
|
154
|
+
"""What THIS package asks for, read from its own installed metadata.
|
|
155
|
+
|
|
156
|
+
The pin lives in pyproject.toml and nowhere else. Restating it as a
|
|
157
|
+
constant here would create a second copy that drifts silently, and a
|
|
158
|
+
drifting copy of a pin is worse than no copy, because it would report a
|
|
159
|
+
constraint the installer never applied. Reading it back from the
|
|
160
|
+
installed metadata means this can only ever report what is actually in
|
|
161
|
+
force.
|
|
162
|
+
|
|
163
|
+
Returns the requirement string (e.g. "gliner2[local]==2.0.0") or None.
|
|
164
|
+
"""
|
|
165
|
+
reqs, _sources = _requirements(dist)
|
|
166
|
+
for req in reqs:
|
|
167
|
+
head = req.split(";")[0].strip()
|
|
168
|
+
name = head.split("[")[0].split("=")[0].split("<")[0] \
|
|
169
|
+
.split(">")[0].split("!")[0].split("~")[0].strip()
|
|
170
|
+
if name.lower().replace("_", "-") != module.lower().replace("_", "-"):
|
|
171
|
+
continue
|
|
172
|
+
if extra and ("extra" not in req or extra not in req):
|
|
173
|
+
continue
|
|
174
|
+
return head
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def pin_mismatch():
|
|
179
|
+
"""(requirement, installed) when an EXACT pin is not what is installed.
|
|
180
|
+
|
|
181
|
+
The case this catches: the pin is raised, or an older environment is
|
|
182
|
+
reused, and the frozen snapshots no longer describe the library doing
|
|
183
|
+
the detecting. Only `==` is checked — a range is a deliberate statement
|
|
184
|
+
that several versions are acceptable, so a version inside it is not a
|
|
185
|
+
mismatch to report.
|
|
186
|
+
"""
|
|
187
|
+
req = declared_requirement("gliner2", extra="ner")
|
|
188
|
+
if not req or "==" not in req:
|
|
189
|
+
return None
|
|
190
|
+
wanted = req.split("==", 1)[1].strip()
|
|
191
|
+
got = module_version("gliner2")
|
|
192
|
+
if got is None or got == wanted:
|
|
193
|
+
return None
|
|
194
|
+
return (req, got)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def extras_status():
|
|
198
|
+
"""{extra: {"installed": bool, "present": [...], "missing": [...]}}"""
|
|
199
|
+
out = {}
|
|
200
|
+
for extra, modules in EXTRA_MODULES.items():
|
|
201
|
+
present = [m for m in modules if module_present(m)]
|
|
202
|
+
missing = [m for m in modules if m not in present]
|
|
203
|
+
out[extra] = {
|
|
204
|
+
"installed": not missing,
|
|
205
|
+
"present": present,
|
|
206
|
+
"missing": missing,
|
|
207
|
+
}
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def summary():
|
|
212
|
+
"""One line naming the extras, for a version banner.
|
|
213
|
+
|
|
214
|
+
A PARTIAL extra is called out rather than rounded down to "absent": the
|
|
215
|
+
half-installed state is the one that produces a confusing bug report,
|
|
216
|
+
because the tier a caller asks for decides whether it is fatal.
|
|
217
|
+
"""
|
|
218
|
+
bits = []
|
|
219
|
+
for extra, st in sorted(extras_status().items()):
|
|
220
|
+
if st["installed"]:
|
|
221
|
+
# Name the versions. All three decide what the model emits, and
|
|
222
|
+
# only one of them is pinned.
|
|
223
|
+
seen = []
|
|
224
|
+
for m in EXTRA_MODULES[extra]:
|
|
225
|
+
v = module_version(m)
|
|
226
|
+
seen.append("%s %s" % (m, v if v else "?"))
|
|
227
|
+
bits.append("%s: installed (%s)" % (extra, ", ".join(seen)))
|
|
228
|
+
elif st["present"]:
|
|
229
|
+
bits.append("%s: PARTIAL (missing %s)"
|
|
230
|
+
% (extra, ", ".join(st["missing"])))
|
|
231
|
+
else:
|
|
232
|
+
bits.append("%s: not installed" % extra)
|
|
233
|
+
return "; ".join(bits) if bits else "no optional extras defined"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def ner_available():
|
|
237
|
+
"""True when the full tier could actually run on this install."""
|
|
238
|
+
return extras_status()["ner"]["installed"]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Technical-vocabulary allow-list — post-detection suppression.
|
|
2
|
+
|
|
3
|
+
Pipeline component. Pipeline.__init__ loads it ONCE (the agent loop calls
|
|
4
|
+
run() several times per tool call, so a per-run load would re-read 10k terms
|
|
5
|
+
each time) and run() applies it after every detection stage, before span
|
|
6
|
+
reconciliation and masking. Toggled by stages.allowlist; the run(whitelist=)
|
|
7
|
+
parameter still overrides it per call for the benchmark.
|
|
8
|
+
|
|
9
|
+
Every *.yaml in this directory is merged at load time, so an optional,
|
|
10
|
+
separately-licensed file such as ``allowlist_wappalyzer.yaml`` is picked up
|
|
11
|
+
automatically when present and simply absent otherwise. A missing directory or
|
|
12
|
+
missing pyyaml yields an empty AllowList, which is falsy (``__len__``) and
|
|
13
|
+
therefore disables suppression on its own — it never raises.
|
|
14
|
+
|
|
15
|
+
What it does
|
|
16
|
+
------------
|
|
17
|
+
Drops detected entities whose whole text is universal technical vocabulary —
|
|
18
|
+
technology/product names, protocol parameter names, standard-format acronyms —
|
|
19
|
+
that identifies nobody. This is the industry-standard mechanism for the
|
|
20
|
+
"technical vocabulary flagged as sensitive" problem (Presidio allow_list, Google
|
|
21
|
+
DLP exclusion dictionaries, Datadog redaction-excluded-identifiers, gitleaks
|
|
22
|
+
allowlists): a sourced suppression pass applied after detection, not a change to
|
|
23
|
+
the detectors.
|
|
24
|
+
|
|
25
|
+
Matching contract (the safety property)
|
|
26
|
+
---------------------------------------
|
|
27
|
+
* whole-token, case-insensitive: an entity is suppressed only when its ENTIRE
|
|
28
|
+
text equals an allow-list term (after casefold + surrounding-quote/space
|
|
29
|
+
trim). Substrings never match, so allow-listing "oracle" cannot un-mask
|
|
30
|
+
"oracle-client-db-prod-4471" — that string is not equal to "oracle".
|
|
31
|
+
|
|
32
|
+
SCOPE OF THAT SENTENCE, corrected 2026-10-14. It is true of THIS module
|
|
33
|
+
and it is not true of the pipeline as a whole. `maskerade/stages/id.py`
|
|
34
|
+
reads the same term list and tests a candidate's PREFIX against it, so
|
|
35
|
+
`oracle-client-db-prod-4471` IS vetoed there — by the id stage, not by
|
|
36
|
+
this filter. Measured; owner decision to keep, with the reasoning and
|
|
37
|
+
the accepted cost recorded at the `_load_allowlist` comment in that file.
|
|
38
|
+
Anyone relying on the whole-token property must read it as a property of
|
|
39
|
+
this filter's own suppression pass, not as a guarantee that an
|
|
40
|
+
allow-listed term can never cause a longer string to go unmasked.
|
|
41
|
+
* custom-stage entities are never suppressed: explicit pentester intent wins,
|
|
42
|
+
so a client literally named "Oracle" can be forced masked via a custom rule.
|
|
43
|
+
(At the benchmark's injection point only NER-slot entities are seen, so this
|
|
44
|
+
holds by construction, but the guard is explicit for safety.)
|
|
45
|
+
|
|
46
|
+
Residual risk: a client legitimately named after a technology (company called
|
|
47
|
+
"Oracle") whose name is detected as a bare "Oracle" will not be masked by the
|
|
48
|
+
probabilistic detectors once suppressed here. Mitigation: a custom rule, or
|
|
49
|
+
listing the term under `exclude:` in the YAML. This is the accepted trade of any
|
|
50
|
+
allow-list and is why matching is whole-token and custom-exempt.
|
|
51
|
+
Beyond the vocabulary allow-list, this module also applies a few TYPE-AWARE
|
|
52
|
+
POLICY FILTERS (config-driven, in allowlist_overrides.yaml) that drop whole
|
|
53
|
+
classes of detection the pipeline should not mask:
|
|
54
|
+
* email_requires_at — an EMAIL_ADDRESS detection with no '@' is never a real
|
|
55
|
+
address (fixes a field/word merely containing "email" being masked);
|
|
56
|
+
* drop_types — entity types out of scope for the engagement (e.g. FINANCIAL_DATA);
|
|
57
|
+
* drop_single_token_types — one-word place names (bare city/region/timezone),
|
|
58
|
+
which are public geography rather than client PII.
|
|
59
|
+
"""
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
62
|
+
|
|
63
|
+
_DEFAULT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "allowlist.yaml")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AllowList:
|
|
67
|
+
"""A loaded allow-list with a whole-token matcher + type-aware policy filters."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, terms_by_group=None, exclude=None, policy=None):
|
|
70
|
+
# term (casefolded) -> group name, for per-source attribution
|
|
71
|
+
self._term_group = {}
|
|
72
|
+
# separator-collapsed term -> group, for space/case/separator-insensitive
|
|
73
|
+
# matching of multi-part TECH names ("amazon s3" == "AmazonS3",
|
|
74
|
+
# "content-type" == "ContentType"). Only built for terms that actually
|
|
75
|
+
# contain a separator and whose collapsed form is >= 5 chars, so short
|
|
76
|
+
# tokens can't collide; single tokens are already handled by exact match.
|
|
77
|
+
self._collapsed = {}
|
|
78
|
+
for group, terms in (terms_by_group or {}).items():
|
|
79
|
+
for t in terms:
|
|
80
|
+
key = self._norm(t)
|
|
81
|
+
if key:
|
|
82
|
+
self._term_group.setdefault(key, group)
|
|
83
|
+
ck = self._collapse(key)
|
|
84
|
+
if ck and len(ck) >= 5 and ck != key:
|
|
85
|
+
self._collapsed.setdefault(ck, group)
|
|
86
|
+
# `exclude` lets a user force-keep a term that would otherwise be
|
|
87
|
+
# suppressed (e.g. their client really is called "Oracle").
|
|
88
|
+
for t in (exclude or []):
|
|
89
|
+
k = self._norm(t)
|
|
90
|
+
self._term_group.pop(k, None)
|
|
91
|
+
self._collapsed.pop(self._collapse(k), None)
|
|
92
|
+
self.policy = policy or {}
|
|
93
|
+
|
|
94
|
+
# ── normalisation: casefold + strip surrounding quotes/space ──
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _norm(text):
|
|
97
|
+
if not text:
|
|
98
|
+
return ""
|
|
99
|
+
s = text.strip()
|
|
100
|
+
while len(s) >= 2 and s[0] in "\"'`" and s[0] == s[-1]:
|
|
101
|
+
s = s[1:-1].strip()
|
|
102
|
+
return s.casefold()
|
|
103
|
+
|
|
104
|
+
# collapse spaces / underscores / hyphens / dots for separator-insensitive
|
|
105
|
+
# matching of multi-part technology names.
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _collapse(norm_text):
|
|
108
|
+
return re.sub(r"[ _.\-]", "", norm_text)
|
|
109
|
+
|
|
110
|
+
def __len__(self):
|
|
111
|
+
return len(self._term_group)
|
|
112
|
+
|
|
113
|
+
def group_of(self, entity_text):
|
|
114
|
+
"""Return the allow-list group that matches this text, or None."""
|
|
115
|
+
n = self._norm(entity_text)
|
|
116
|
+
g = self._term_group.get(n)
|
|
117
|
+
if g is not None:
|
|
118
|
+
return g
|
|
119
|
+
# fall back to separator-collapsed match (multi-part tech names)
|
|
120
|
+
ck = self._collapse(n)
|
|
121
|
+
if len(ck) >= 5:
|
|
122
|
+
return self._collapsed.get(ck)
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def _policy_reason(self, e):
|
|
126
|
+
"""Return a policy label if this entity should be dropped by a type-aware
|
|
127
|
+
policy rule, else None. Applied only to non-custom, non-vocab entities.
|
|
128
|
+
"""
|
|
129
|
+
p = self.policy
|
|
130
|
+
if not p:
|
|
131
|
+
return None
|
|
132
|
+
etype = (e.get("entity_type") or "").upper()
|
|
133
|
+
text = e.get("text", "") or ""
|
|
134
|
+
|
|
135
|
+
# 1. A real email always contains '@'. An EMAIL_ADDRESS detection without
|
|
136
|
+
# one is a false positive (a field/word merely containing "email").
|
|
137
|
+
# The '@' may be URL-encoded: the email pattern in
|
|
138
|
+
# default_config.yaml deliberately matches %40 and %2540, because
|
|
139
|
+
# addresses cross the wire encoded. Until 2026-10-21 this check
|
|
140
|
+
# tested for a literal '@' only and silently dropped every such
|
|
141
|
+
# match, so `jan.novak%40acmebank.cz` was sent whole.
|
|
142
|
+
if p.get("email_requires_at") and etype in ("EMAIL_ADDRESS", "EMAIL"):
|
|
143
|
+
if not any(at in text for at in ("@", "%40", "%2540")):
|
|
144
|
+
return "policy:email_no_at"
|
|
145
|
+
|
|
146
|
+
# 2. Entity types dropped entirely (out of scope for the engagement).
|
|
147
|
+
if etype in {t.upper() for t in (p.get("drop_types") or [])}:
|
|
148
|
+
return f"policy:drop_type:{etype}"
|
|
149
|
+
|
|
150
|
+
# 3. One-word place names are public geography, not client PII. A single
|
|
151
|
+
# token = no internal whitespace (multi-word street addresses stay).
|
|
152
|
+
if etype in {t.upper() for t in (p.get("drop_single_token_types") or [])}:
|
|
153
|
+
if text.strip() and not re.search(r"\s", text.strip()):
|
|
154
|
+
return f"policy:single_token:{etype}"
|
|
155
|
+
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def filter(self, entities, text=None):
|
|
159
|
+
"""Split entities into (kept, suppressed).
|
|
160
|
+
|
|
161
|
+
Suppressed items are annotated with `_allowlist_group` (a vocabulary
|
|
162
|
+
group name, or a `policy:...` label). Custom-stage entities are always
|
|
163
|
+
kept — explicit pentester intent overrides both vocab and policy.
|
|
164
|
+
|
|
165
|
+
`text` (the source the entities were detected in) enables the span-aware
|
|
166
|
+
guard: a vocabulary or single-word-location match is NOT suppressed when
|
|
167
|
+
the entity is a fragment of a larger identifier in the text (e.g. the
|
|
168
|
+
region in `...cluster-x.eu-central-1.rds.amazonaws.com`). The guard can
|
|
169
|
+
only ever KEEP more, so it is strictly recall-safe.
|
|
170
|
+
|
|
171
|
+
CALLER CONTRACT: `text` must be the exact string the entities' start/end
|
|
172
|
+
offsets index into. Entities from a different coordinate frame (the
|
|
173
|
+
regex stage detects against pre-regex-masking text) must be passed in a
|
|
174
|
+
SEPARATE call with text=None — offsets that are merely in range but
|
|
175
|
+
point at the wrong characters make the guard decide confidently and
|
|
176
|
+
wrongly. See Pipeline.run(), which splits the list for this reason.
|
|
177
|
+
"""
|
|
178
|
+
kept, suppressed = [], []
|
|
179
|
+
for e in entities:
|
|
180
|
+
if e.get("stage") == "custom":
|
|
181
|
+
kept.append(e)
|
|
182
|
+
continue
|
|
183
|
+
group = self.group_of(e.get("text", ""))
|
|
184
|
+
is_vocab = group is not None
|
|
185
|
+
reason = group if is_vocab else self._policy_reason(e)
|
|
186
|
+
if reason is not None:
|
|
187
|
+
# The embedding guard applies to vocabulary matches and to the
|
|
188
|
+
# single-word-location policy (tokens that could be a fragment of
|
|
189
|
+
# a sensitive composite). It does NOT apply to email_no_at or
|
|
190
|
+
# drop_type, which are type policies unrelated to embedding.
|
|
191
|
+
guardable = is_vocab or reason.startswith("policy:single_token")
|
|
192
|
+
if guardable and text is not None and self._is_embedded(e, text):
|
|
193
|
+
reason = None
|
|
194
|
+
if reason is None:
|
|
195
|
+
kept.append(e)
|
|
196
|
+
else:
|
|
197
|
+
item = dict(e)
|
|
198
|
+
item["_allowlist_group"] = reason
|
|
199
|
+
suppressed.append(item)
|
|
200
|
+
return kept, suppressed
|
|
201
|
+
|
|
202
|
+
# identifier continuation chars — a run of these around an entity means the
|
|
203
|
+
# entity is a sub-token of a larger identifier (hostname, ARN, resource id).
|
|
204
|
+
_ID_CHARS = frozenset(
|
|
205
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _is_embedded(self, e, text):
|
|
209
|
+
"""True if the entity is a fragment of a larger identifier in `text`.
|
|
210
|
+
|
|
211
|
+
Expands outward from the entity span over identifier characters; if the
|
|
212
|
+
surrounding maximal run (minus edge punctuation) is longer than the
|
|
213
|
+
entity itself, the entity is embedded (e.g. a region inside a hostname).
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
s = int(e["start"]); en = int(e["end"])
|
|
217
|
+
except (KeyError, TypeError, ValueError):
|
|
218
|
+
return False
|
|
219
|
+
if s < 0 or en > len(text) or s >= en:
|
|
220
|
+
return False
|
|
221
|
+
i = s
|
|
222
|
+
while i > 0 and text[i - 1] in self._ID_CHARS:
|
|
223
|
+
i -= 1
|
|
224
|
+
j = en
|
|
225
|
+
while j < len(text) and text[j] in self._ID_CHARS:
|
|
226
|
+
j += 1
|
|
227
|
+
run = text[i:j].strip("._-")
|
|
228
|
+
return len(run) > (en - s)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def load_allowlist(path=None):
|
|
232
|
+
"""Load the allow-list. Returns an AllowList (empty if unavailable).
|
|
233
|
+
|
|
234
|
+
If `path` is a file, load just that file. If `path` is a directory (or None,
|
|
235
|
+
meaning this package's own directory), load and MERGE every ``*.yaml`` in it
|
|
236
|
+
— so an optional, separately-licensed file such as ``allowlist_wappalyzer.yaml``
|
|
237
|
+
is picked up automatically when present and simply absent otherwise.
|
|
238
|
+
|
|
239
|
+
Never raises: a missing file/dir or missing pyyaml yields an empty (no-op)
|
|
240
|
+
allow-list so the benchmark still runs.
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
import yaml
|
|
244
|
+
except ImportError:
|
|
245
|
+
print(" [ALLOWLIST] pyyaml not installed — suppression disabled")
|
|
246
|
+
return AllowList()
|
|
247
|
+
|
|
248
|
+
if path is None:
|
|
249
|
+
path = os.path.dirname(os.path.abspath(__file__))
|
|
250
|
+
|
|
251
|
+
if os.path.isdir(path):
|
|
252
|
+
import glob
|
|
253
|
+
files = sorted(glob.glob(os.path.join(path, "*.yaml")))
|
|
254
|
+
elif os.path.exists(path):
|
|
255
|
+
files = [path]
|
|
256
|
+
else:
|
|
257
|
+
print(f" [ALLOWLIST] {path} not found — suppression disabled "
|
|
258
|
+
f"(run tests/allowlist/build.py)")
|
|
259
|
+
return AllowList()
|
|
260
|
+
|
|
261
|
+
terms_by_group = {}
|
|
262
|
+
exclude = []
|
|
263
|
+
policy = {}
|
|
264
|
+
for fp in files:
|
|
265
|
+
with open(fp, encoding="utf-8") as f:
|
|
266
|
+
doc = yaml.safe_load(f) or {}
|
|
267
|
+
for name, g in (doc.get("groups") or {}).items():
|
|
268
|
+
terms_by_group.setdefault(name, [])
|
|
269
|
+
terms_by_group[name].extend(g.get("terms") or [])
|
|
270
|
+
exclude.extend(doc.get("exclude") or [])
|
|
271
|
+
pol = doc.get("policy")
|
|
272
|
+
if isinstance(pol, dict):
|
|
273
|
+
policy.update(pol)
|
|
274
|
+
return AllowList(terms_by_group, exclude=exclude, policy=policy)
|