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 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)