ciphergate-cli 0.7.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.
Files changed (74) hide show
  1. ciphergate/__init__.py +95 -0
  2. ciphergate/__main__.py +6 -0
  3. ciphergate/cli.py +832 -0
  4. ciphergate/config.py +50 -0
  5. ciphergate/constants.py +274 -0
  6. ciphergate/detectors/__init__.py +5 -0
  7. ciphergate/detectors/base.py +53 -0
  8. ciphergate/detectors/custom_regex.py +37 -0
  9. ciphergate/detectors/dates.py +92 -0
  10. ciphergate/detectors/email.py +56 -0
  11. ciphergate/detectors/field_names.py +220 -0
  12. ciphergate/detectors/ip_address.py +85 -0
  13. ciphergate/detectors/network.py +42 -0
  14. ciphergate/detectors/payment_card.py +77 -0
  15. ciphergate/detectors/phone.py +67 -0
  16. ciphergate/detectors/presidio_ner.py +112 -0
  17. ciphergate/detectors/regex_safety.py +40 -0
  18. ciphergate/detectors/registry.py +194 -0
  19. ciphergate/detectors/secrets.py +130 -0
  20. ciphergate/errors.py +119 -0
  21. ciphergate/models/__init__.py +1 -0
  22. ciphergate/models/context.py +69 -0
  23. ciphergate/models/finding.py +108 -0
  24. ciphergate/models/policy.py +402 -0
  25. ciphergate/models/report.py +115 -0
  26. ciphergate/pipeline/__init__.py +1 -0
  27. ciphergate/pipeline/atomic_output.py +143 -0
  28. ciphergate/pipeline/batch.py +533 -0
  29. ciphergate/pipeline/inspector.py +282 -0
  30. ciphergate/pipeline/paths.py +98 -0
  31. ciphergate/pipeline/planner.py +192 -0
  32. ciphergate/pipeline/rescan.py +120 -0
  33. ciphergate/pipeline/sanitizer.py +392 -0
  34. ciphergate/pipeline/stdio.py +173 -0
  35. ciphergate/pipeline/streams.py +152 -0
  36. ciphergate/policy/__init__.py +26 -0
  37. ciphergate/policy/context.py +203 -0
  38. ciphergate/policy/engine.py +194 -0
  39. ciphergate/policy/loader.py +116 -0
  40. ciphergate/policy/precedence.py +149 -0
  41. ciphergate/policy/validator.py +632 -0
  42. ciphergate/py.typed +0 -0
  43. ciphergate/readers/__init__.py +113 -0
  44. ciphergate/readers/accesslog_reader.py +244 -0
  45. ciphergate/readers/base.py +131 -0
  46. ciphergate/readers/csv_reader.py +241 -0
  47. ciphergate/readers/json_reader.py +184 -0
  48. ciphergate/readers/jsonl_reader.py +97 -0
  49. ciphergate/readers/jsonlog_reader.py +178 -0
  50. ciphergate/readers/line_structure.py +125 -0
  51. ciphergate/readers/logfmt_reader.py +100 -0
  52. ciphergate/readers/stacktrace_reader.py +103 -0
  53. ciphergate/readers/syslog_reader.py +309 -0
  54. ciphergate/readers/text_reader.py +59 -0
  55. ciphergate/reporting/__init__.py +1 -0
  56. ciphergate/reporting/audit.py +85 -0
  57. ciphergate/reporting/safe_text.py +71 -0
  58. ciphergate/reporting/serializer.py +44 -0
  59. ciphergate/reporting/terminal.py +147 -0
  60. ciphergate/templates/starter_policy.yml +77 -0
  61. ciphergate/transforms/__init__.py +6 -0
  62. ciphergate/transforms/date_shift.py +79 -0
  63. ciphergate/transforms/generalize.py +107 -0
  64. ciphergate/transforms/keys.py +177 -0
  65. ciphergate/transforms/pseudonymize.py +113 -0
  66. ciphergate/transforms/redact.py +9 -0
  67. ciphergate/transforms/registry.py +201 -0
  68. ciphergate/transforms/replace.py +16 -0
  69. ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
  70. ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
  71. ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
  72. ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
  73. ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
  74. ciphergate_cli-0.7.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,220 @@
1
+ """Field-name heuristics and policy-declared field classifications.
2
+
3
+ Matching is token-based on a normalized name (camelCase/kebab-case/spaces -> snake_case, lowercase).
4
+ A term matches when the normalized name equals it, or — for suffix-capable terms — ends with
5
+ ``_<term>``. The longest matching term wins, so ``server_ip_address`` is an IP address rather
6
+ than a postal address. Names like ``token_count``, ``key_name`` or ``monkey`` deliberately do not
7
+ match: this is a heuristic, not a universal dictionary.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import re
14
+ from dataclasses import dataclass
15
+
16
+ from ciphergate.detectors.base import is_ciphergate_token, make_finding
17
+ from ciphergate.models.finding import Finding, ScanItem
18
+ from ciphergate.models.policy import Classification
19
+
20
+ _CAMEL_1 = re.compile(r"([a-z0-9])([A-Z])")
21
+ _CAMEL_2 = re.compile(r"([A-Z]+)([A-Z][a-z])")
22
+ _NON_ALNUM = re.compile(r"[^A-Za-z0-9]+")
23
+
24
+
25
+ def normalize_field_name(name: str) -> str:
26
+ s = _CAMEL_2.sub(r"\1_\2", name)
27
+ s = _CAMEL_1.sub(r"\1_\2", s)
28
+ return _NON_ALNUM.sub("_", s).strip("_").lower()
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class _Term:
33
+ term: str
34
+ category: str
35
+ confidence: float
36
+ suffix: bool
37
+
38
+
39
+ def _terms(category: str, confidence: float, suffix: str = "", exact: str = "") -> list[_Term]:
40
+ out = [_Term(t, category, confidence, True) for t in suffix.split()]
41
+ out += [_Term(t, category, confidence, False) for t in exact.split()]
42
+ return out
43
+
44
+
45
+ _TERMS: list[_Term] = [
46
+ *_terms(
47
+ "credential.secret",
48
+ 0.85,
49
+ suffix="password passwd passphrase pwd secret api_key apikey access_token refresh_token "
50
+ "auth_token id_token bearer_token session_token private_key client_secret secret_key "
51
+ "access_key authorization credentials connection_string conn_str x_api_key",
52
+ ),
53
+ *_terms("credential.secret", 0.6, exact="token"),
54
+ *_terms(
55
+ "pii.email", 0.8, suffix="email e_mail email_address mail_address", exact="emails mail"
56
+ ),
57
+ *_terms(
58
+ "pii.phone",
59
+ 0.75,
60
+ suffix="phone phone_number mobile mobile_number telephone contact_number fax cell_phone",
61
+ exact="tel msisdn phones",
62
+ ),
63
+ *_terms(
64
+ "pii.person_name",
65
+ 0.7,
66
+ suffix="first_name last_name full_name given_name family_name surname middle_name "
67
+ "patient_name employee_name customer_name contact_name maiden_name",
68
+ ),
69
+ *_terms("pii.person_name", 0.45, exact="name"),
70
+ *_terms("pii.date_of_birth", 0.85, suffix="dob date_of_birth birth_date birthdate birthday"),
71
+ *_terms(
72
+ "pii.payment_card",
73
+ 0.85,
74
+ suffix="card_number cc_number credit_card credit_card_number cvv cvc card_cvv",
75
+ exact="pan",
76
+ ),
77
+ *_terms(
78
+ "pii.bank_account",
79
+ 0.8,
80
+ suffix="bank_account bank_account_number account_number iban routing_number sort_code",
81
+ ),
82
+ *_terms(
83
+ "pii.government_id",
84
+ 0.85,
85
+ suffix="ssn social_security_number national_id tax_id passport_number drivers_license "
86
+ "national_insurance_number",
87
+ exact="passport nino",
88
+ ),
89
+ *_terms(
90
+ "health.phi",
91
+ 0.75,
92
+ suffix="diagnosis medication medications medical_record medical_record_number patient_id "
93
+ "icd_code procedure_code allergies prescription clinical_notes",
94
+ exact="mrn patient condition claim claim_id",
95
+ ),
96
+ *_terms(
97
+ "pii.precise_location",
98
+ 0.75,
99
+ suffix="latitude longitude gps coordinates geo_location geolocation",
100
+ exact="lat lon lng",
101
+ ),
102
+ *_terms(
103
+ "pii.address",
104
+ 0.7,
105
+ suffix="address street street_address home_address mailing_address postal_address",
106
+ exact="postal_code zip zip_code postcode",
107
+ ),
108
+ *_terms(
109
+ "pii.ip_address",
110
+ 0.8,
111
+ suffix="ip ip_address client_ip remote_addr ipv4 ipv6 source_ip x_forwarded_for",
112
+ ),
113
+ *_terms(
114
+ "pii.device_identifier",
115
+ 0.7,
116
+ suffix="device_id mac_address imei advertising_id idfa serial_number device_token",
117
+ ),
118
+ *_terms(
119
+ "pii.customer_identifier",
120
+ 0.7,
121
+ suffix="user_id customer_id account_id member_id subscriber_id username user_name "
122
+ "remote_user",
123
+ exact="login",
124
+ ),
125
+ *_terms(
126
+ "employment.sensitive",
127
+ 0.7,
128
+ suffix="employee_id salary performance_rating hr_case_id",
129
+ ),
130
+ *_terms(
131
+ "financial.sensitive",
132
+ 0.6,
133
+ suffix="balance account_balance credit_score transaction_amount income",
134
+ ),
135
+ *_terms(
136
+ "network.telemetry",
137
+ 0.6,
138
+ suffix="trace_id request_id session_id span_id correlation_id hostname",
139
+ exact="host",
140
+ ),
141
+ ]
142
+
143
+
144
+ def classify_field_name(name: str) -> _Term | None:
145
+ normalized = normalize_field_name(name)
146
+ if not normalized:
147
+ return None
148
+ best: _Term | None = None
149
+ for term in _TERMS:
150
+ hit = normalized == term.term or (term.suffix and normalized.endswith("_" + term.term))
151
+ if hit and (best is None or len(term.term) > len(best.term)):
152
+ best = term
153
+ return best
154
+
155
+
156
+ def _has_protectable_value(item: ScanItem) -> bool:
157
+ if item.is_empty:
158
+ return False
159
+ return not (isinstance(item.value, str) and is_ciphergate_token(item.value))
160
+
161
+
162
+ class FieldNameDetector:
163
+ id = "field_name"
164
+
165
+ def detect(self, item: ScanItem) -> list[Finding]:
166
+ if item.in_key or not item.field_name or not _has_protectable_value(item):
167
+ return []
168
+ term = classify_field_name(item.field_name)
169
+ if term is None:
170
+ return []
171
+ evidence = (
172
+ ("field_name_match",)
173
+ if not term.suffix or normalize_field_name(item.field_name) == term.term
174
+ else ("field_name_match", "field_name_suffix")
175
+ )
176
+ return [
177
+ make_finding(
178
+ item,
179
+ category=term.category,
180
+ detector=self.id,
181
+ confidence=term.confidence,
182
+ evidence=evidence,
183
+ )
184
+ ]
185
+
186
+
187
+ class PolicyClassificationDetector:
188
+ """Emits findings for fields the policy explicitly classifies (e.g. ``admission_date``)."""
189
+
190
+ id = "policy_classification"
191
+
192
+ def __init__(self, classifications: list[Classification]) -> None:
193
+ self._rules = [
194
+ (
195
+ c,
196
+ frozenset(normalize_field_name(n) for n in c.field_names),
197
+ tuple(c.field_paths),
198
+ )
199
+ for c in classifications
200
+ ]
201
+
202
+ def detect(self, item: ScanItem) -> list[Finding]:
203
+ if item.in_key or not self._rules or not _has_protectable_value(item):
204
+ return []
205
+ normalized = normalize_field_name(item.field_name) if item.field_name else ""
206
+ out: list[Finding] = []
207
+ for rule, names, paths in self._rules:
208
+ by_name = bool(normalized) and normalized in names
209
+ by_path = any(fnmatch.fnmatchcase(item.field_path, p) for p in paths)
210
+ if by_name or by_path:
211
+ out.append(
212
+ make_finding(
213
+ item,
214
+ category=rule.category,
215
+ detector=self.id,
216
+ confidence=rule.confidence,
217
+ evidence=("policy_classification", f"classification:{rule.id}"),
218
+ )
219
+ )
220
+ return out
@@ -0,0 +1,85 @@
1
+ """IPv4/IPv6 detector. Candidates come from bounded regexes; validity comes from ``ipaddress``.
2
+
3
+ Global addresses are reported as ``pii.ip_address``. Private, loopback, link-local and other
4
+ non-global addresses are reported as ``network.telemetry`` (they can reveal internal topology).
5
+ Version strings such as ``1.2.3.4`` are indistinguishable from IPv4 addresses
6
+ (known false positive).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ipaddress
12
+ import re
13
+
14
+ from ciphergate.detectors.base import make_finding
15
+ from ciphergate.models.finding import Finding, ScanItem
16
+
17
+ _V4_RE = re.compile(r"(?<![\d.])((?:\d{1,3}\.){3}\d{1,3})(?![\d.]|\.\d)")
18
+ _V6_RE = re.compile(
19
+ r"(?<![:\w.])\[?((?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}(?:%[0-9A-Za-z]{1,16})?)\]?(?![:\w])"
20
+ )
21
+
22
+ DOCUMENTATION_V4 = (
23
+ ipaddress.ip_network("192.0.2.0/24"),
24
+ ipaddress.ip_network("198.51.100.0/24"),
25
+ ipaddress.ip_network("203.0.113.0/24"),
26
+ )
27
+ DOCUMENTATION_V6 = ipaddress.ip_network("2001:db8::/32")
28
+
29
+
30
+ def is_documentation_address(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
31
+ if isinstance(addr, ipaddress.IPv4Address):
32
+ return any(addr in n for n in DOCUMENTATION_V4)
33
+ return addr in DOCUMENTATION_V6
34
+
35
+
36
+ class IPAddressDetector:
37
+ id = "ip_address"
38
+
39
+ def detect(self, item: ScanItem) -> list[Finding]:
40
+ text = item.text
41
+ if not text:
42
+ return []
43
+ out: list[Finding] = []
44
+ if "." in text:
45
+ for m in _V4_RE.finditer(text):
46
+ try:
47
+ addr4 = ipaddress.IPv4Address(m.group(1))
48
+ except ValueError:
49
+ continue
50
+ out.append(self._finding(item, addr4, (m.start(1), m.end(1)), "ipv4"))
51
+ if text.count(":") >= 2:
52
+ for m in _V6_RE.finditer(text):
53
+ raw = m.group(1)
54
+ if not any(c in "0123456789abcdefABCDEF" for c in raw.replace(":", "")):
55
+ continue
56
+ try:
57
+ addr6 = ipaddress.IPv6Address(raw.split("%", 1)[0])
58
+ except ValueError:
59
+ continue
60
+ out.append(self._finding(item, addr6, (m.start(1), m.end(1)), "ipv6"))
61
+ return out
62
+
63
+ def _finding(
64
+ self,
65
+ item: ScanItem,
66
+ addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
67
+ span: tuple[int, int],
68
+ family: str,
69
+ ) -> Finding:
70
+ evidence = ["pattern_match", "stdlib_validated", family]
71
+ if is_documentation_address(addr):
72
+ evidence.append("documentation_range")
73
+ if addr.is_global:
74
+ category, confidence = "pii.ip_address", 0.85
75
+ else:
76
+ category, confidence = "network.telemetry", 0.8
77
+ evidence.append("non_global_address")
78
+ return make_finding(
79
+ item,
80
+ category=category,
81
+ detector=self.id,
82
+ confidence=confidence,
83
+ evidence=tuple(evidence),
84
+ span=span,
85
+ )
@@ -0,0 +1,42 @@
1
+ """Internal hostname detector (configurable suffixes such as ``.internal`` / ``.corp``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ciphergate.detectors.base import make_finding
8
+ from ciphergate.models.finding import Finding, ScanItem
9
+
10
+
11
+ class InternalHostDetector:
12
+ id = "internal_host"
13
+
14
+ def __init__(self, suffixes: list[str]) -> None:
15
+ alternatives = "|".join(
16
+ re.escape(s.lstrip(".")) for s in sorted(suffixes, key=len, reverse=True)
17
+ )
18
+ label = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
19
+ self._regex = (
20
+ re.compile(
21
+ rf"(?<![\w.-])((?:{label}\.){{1,10}}(?:{alternatives})|localhost)(?![\w-]|\.\w)",
22
+ re.IGNORECASE,
23
+ )
24
+ if alternatives
25
+ else re.compile(r"(?<![\w.-])(localhost)(?![\w-]|\.\w)", re.IGNORECASE)
26
+ )
27
+
28
+ def detect(self, item: ScanItem) -> list[Finding]:
29
+ text = item.text
30
+ if not text or not isinstance(item.value, str):
31
+ return []
32
+ return [
33
+ make_finding(
34
+ item,
35
+ category="network.telemetry",
36
+ detector=self.id,
37
+ confidence=0.75,
38
+ evidence=("pattern_match", "internal_host_suffix"),
39
+ span=(m.start(1), m.end(1)),
40
+ )
41
+ for m in self._regex.finditer(text)
42
+ ]
@@ -0,0 +1,77 @@
1
+ """Payment-card-like detector: 13-19 digits, Luhn-valid, scored by issuer prefix and context.
2
+
3
+ A Luhn-valid digit run without a known issuer prefix or context keyword is low confidence:
4
+ long numeric identifiers pass Luhn roughly 10% of the time by chance.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from ciphergate.detectors.base import make_finding, preceding_context
12
+ from ciphergate.models.finding import Finding, ScanItem
13
+
14
+ _CARD_RE = re.compile(r"(?<![0-9A-Za-z_])(\d(?:[ -]?\d){12,18})(?![0-9A-Za-z_])")
15
+ _KEYWORDS = ("card", "visa", "mastercard", "amex", "pan", "cc", "credit", "debit", "payment")
16
+
17
+
18
+ def luhn_valid(digits: str) -> bool:
19
+ total = 0
20
+ for i, ch in enumerate(reversed(digits)):
21
+ d = ord(ch) - 48
22
+ if i % 2 == 1:
23
+ d *= 2
24
+ if d > 9:
25
+ d -= 9
26
+ total += d
27
+ return total % 10 == 0
28
+
29
+
30
+ def known_issuer_prefix(digits: str) -> bool:
31
+ if digits[0] == "4" and len(digits) in (13, 16, 19):
32
+ return True
33
+ two, four = int(digits[:2]), int(digits[:4])
34
+ if 51 <= two <= 55 or 2221 <= four <= 2720:
35
+ return len(digits) == 16
36
+ if two in (34, 37):
37
+ return len(digits) == 15
38
+ if digits.startswith("6011") or two == 65:
39
+ return len(digits) in (16, 19)
40
+ return False
41
+
42
+
43
+ class PaymentCardDetector:
44
+ id = "luhn_card"
45
+
46
+ def detect(self, item: ScanItem) -> list[Finding]:
47
+ text = item.text
48
+ if not text or sum(c.isdigit() for c in text) < 13:
49
+ return []
50
+ out: list[Finding] = []
51
+ for m in _CARD_RE.finditer(text):
52
+ raw = m.group(1)
53
+ digits = raw.replace(" ", "").replace("-", "")
54
+ if not 13 <= len(digits) <= 19 or len(set(digits)) == 1 or not luhn_valid(digits):
55
+ continue
56
+ seps = {c for c in raw if c in " -"}
57
+ if len(seps) > 1:
58
+ continue
59
+ evidence = ["pattern_match", "checksum_valid"]
60
+ confidence = 0.4
61
+ if known_issuer_prefix(digits):
62
+ confidence = 0.8
63
+ evidence.append("issuer_prefix")
64
+ if preceding_context(text, m.start(), _KEYWORDS, window=32):
65
+ confidence += 0.15
66
+ evidence.append("context_keyword")
67
+ out.append(
68
+ make_finding(
69
+ item,
70
+ category="pii.payment_card",
71
+ detector=self.id,
72
+ confidence=min(confidence, 0.95),
73
+ evidence=tuple(evidence),
74
+ span=(m.start(1), m.end(1)),
75
+ )
76
+ )
77
+ return out
@@ -0,0 +1,67 @@
1
+ """Phone-like number detector with conservative, context-dependent confidence.
2
+
3
+ Locale ambiguity is high: this detector does not validate numbering plans. A leading ``+`` with
4
+ 7-15 digits (E.164 length) or a common separator layout gives medium/high confidence; a bare digit
5
+ run is low confidence unless a context keyword (``phone``, ``tel``, ...) precedes it. Spans that
6
+ look like dates, IP addresses or payment cards are suppressed by the registry.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from ciphergate.detectors.base import make_finding, preceding_context
14
+ from ciphergate.models.finding import Finding, ScanItem
15
+
16
+ _PHONE_RE = re.compile(
17
+ r"(?<![\w+./:-])"
18
+ r"(\+?\(?\d{1,4}\)?(?:[ .-]?\(?\d{1,5}\)?){1,6})"
19
+ r"(?:\s?(?:x|ext\.?)\s?\d{1,6})?"
20
+ r"(?![\w/:-]|\.\d)"
21
+ )
22
+ _DATE_LIKE = re.compile(r"^\d{4}[-./]\d{1,2}[-./]\d{1,2}$|^\d{1,2}[-./]\d{1,2}[-./]\d{2,4}$")
23
+ _SEPARATED = re.compile(r"^\(?\d{2,4}\)?[ .-]\d{2,5}[ .-]\d{2,5}(?:[ .-]\d{2,5})?$")
24
+ _KEYWORDS = ("phone", "tel", "mobile", "cell", "call", "fax", "sms", "whatsapp", "contact")
25
+
26
+
27
+ class PhoneDetector:
28
+ id = "phone_pattern"
29
+
30
+ def detect(self, item: ScanItem) -> list[Finding]:
31
+ text = item.text
32
+ if not text or sum(c.isdigit() for c in text) < 7:
33
+ return []
34
+ out: list[Finding] = []
35
+ for m in _PHONE_RE.finditer(text):
36
+ candidate = m.group(0)
37
+ core = m.group(1)
38
+ digits = re.sub(r"\D", "", core)
39
+ if not 7 <= len(digits) <= 15 or len(set(digits)) == 1:
40
+ continue
41
+ if _DATE_LIKE.match(core.strip()):
42
+ continue
43
+ evidence = ["pattern_match"]
44
+ if core.startswith("+"):
45
+ confidence = 0.8 if len(digits) >= 8 else 0.55
46
+ evidence.append("international_prefix")
47
+ elif _SEPARATED.match(core.strip()):
48
+ confidence = 0.6
49
+ evidence.append("separator_layout")
50
+ else:
51
+ confidence = 0.3
52
+ if preceding_context(text, m.start(), _KEYWORDS):
53
+ confidence += 0.2
54
+ evidence.append("context_keyword")
55
+ if "x" in candidate.lower() or "ext" in candidate.lower():
56
+ evidence.append("extension")
57
+ out.append(
58
+ make_finding(
59
+ item,
60
+ category="pii.phone",
61
+ detector=self.id,
62
+ confidence=min(confidence, 0.95),
63
+ evidence=tuple(evidence),
64
+ span=(m.start(), m.end()),
65
+ )
66
+ )
67
+ return out
@@ -0,0 +1,112 @@
1
+ """Optional local NER detector backed by Microsoft Presidio (``pip install 'ciphergate-cli[ner]'``).
2
+
3
+ Off by default; enabled only by ``detectors.presidio.enabled: true`` in the policy. Safety:
4
+
5
+ * Never downloads anything. Presidio's spaCy engine downloads a missing model on first use, so
6
+ the configured spaCy model package must already be importable; otherwise the run fails with
7
+ ``ner_model_missing`` before Presidio is imported or constructed. (CipherGate does not import
8
+ network libraries at all, so it cannot police third-party code beyond this check.)
9
+ * Missing package or model fails the run (exit 6); it never silently runs without NER.
10
+ * Like every detector it only reports candidates (entity type, score, span). It cannot allow a
11
+ value: decisions stay with the policy engine, so explicit block/review rules still win.
12
+ * Presidio errors are replaced by a fixed message; its exception text is never shown.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import importlib
19
+ import importlib.util
20
+ from typing import Any
21
+
22
+ from ciphergate.detectors.base import is_ciphergate_token, make_finding
23
+ from ciphergate.errors import DetectionError, DetectorUnavailableError
24
+ from ciphergate.models.finding import Finding, ScanItem
25
+ from ciphergate.models.policy import PresidioSettings
26
+
27
+ DETECTOR_ID = "presidio_ner"
28
+ PACKAGE = "presidio_analyzer"
29
+
30
+
31
+ def _installed(module: str) -> bool:
32
+ try:
33
+ return importlib.util.find_spec(module) is not None
34
+ except (ImportError, ValueError):
35
+ return False
36
+
37
+
38
+ @functools.lru_cache(maxsize=4)
39
+ def load_analyzer(language: str, model: str) -> Any:
40
+ """Build (once per process) a Presidio AnalyzerEngine on an already-installed spaCy model."""
41
+ if not _installed(PACKAGE):
42
+ raise DetectorUnavailableError(
43
+ "detectors.presidio is enabled but the optional NER extra is not installed "
44
+ "(pip install 'ciphergate-cli[ner]')",
45
+ code="ner_not_installed",
46
+ )
47
+ if not _installed(model):
48
+ raise DetectorUnavailableError(
49
+ "detectors.presidio is enabled but its spaCy model is not installed; CipherGate "
50
+ "never downloads models, install it yourself (see docs/detector-coverage.md)",
51
+ code="ner_model_missing",
52
+ )
53
+ try:
54
+ analyzer_mod = importlib.import_module(PACKAGE)
55
+ nlp_mod = importlib.import_module(f"{PACKAGE}.nlp_engine")
56
+ provider = nlp_mod.NlpEngineProvider(
57
+ nlp_configuration={
58
+ "nlp_engine_name": "spacy",
59
+ "models": [{"lang_code": language, "model_name": model}],
60
+ }
61
+ )
62
+ return analyzer_mod.AnalyzerEngine(
63
+ nlp_engine=provider.create_engine(), supported_languages=[language]
64
+ )
65
+ except DetectorUnavailableError:
66
+ raise
67
+ except Exception: # noqa: BLE001 - third-party messages may contain anything; replace them
68
+ raise DetectorUnavailableError(
69
+ "the optional NER detector could not be initialised", code="ner_init_failed"
70
+ ) from None
71
+
72
+
73
+ class PresidioDetector:
74
+ id = DETECTOR_ID
75
+
76
+ def __init__(self, settings: PresidioSettings, analyzer: Any | None = None) -> None:
77
+ self.settings = settings
78
+ self.entities = dict(settings.entities)
79
+ self.analyzer = analyzer or load_analyzer(settings.language, settings.model)
80
+
81
+ def detect(self, item: ScanItem) -> list[Finding]:
82
+ text = item.text
83
+ if not text or len(text) < 2 or not any(c.isalpha() for c in text):
84
+ return []
85
+ try:
86
+ results = self.analyzer.analyze(
87
+ text=text,
88
+ language=self.settings.language,
89
+ entities=list(self.entities),
90
+ score_threshold=self.settings.min_score,
91
+ )
92
+ except Exception: # noqa: BLE001 - never forward third-party messages (may echo input)
93
+ raise DetectionError("the optional NER detector failed", code="ner_failed") from None
94
+ out: list[Finding] = []
95
+ for result in results:
96
+ category = self.entities.get(str(result.entity_type))
97
+ start, end = int(result.start), int(result.end)
98
+ if category is None or not 0 <= start < end <= len(text):
99
+ continue
100
+ if is_ciphergate_token(text[start:end].strip("\"'")):
101
+ continue
102
+ out.append(
103
+ make_finding(
104
+ item,
105
+ category=category,
106
+ detector=self.id,
107
+ confidence=float(result.score),
108
+ evidence=("ner_entity", f"entity:{str(result.entity_type).lower()}"),
109
+ span=(start, end),
110
+ )
111
+ )
112
+ return out
@@ -0,0 +1,40 @@
1
+ """Heuristic checks for user-supplied regular expressions.
2
+
3
+ Python's ``re`` engine backtracks, so a pathological pattern can take exponential time. These checks
4
+ reject common risky constructs (nested quantifiers, backreferences, empty matches,
5
+ excessive length).
6
+ They are heuristics, NOT a proof of regex safety; inputs are additionally bounded by record limits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ MAX_PATTERN_LENGTH = 512
14
+
15
+ _NESTED_QUANTIFIER = re.compile(r"\((?:[^()\\]|\\.)*[*+}](?:[^()\\]|\\.)*\)\s*(?:[*+]|\{\d*,)")
16
+ _BACKREFERENCE = re.compile(r"\\[1-9]|\(\?P=")
17
+ _UNBOUNDED_WILDCARD_RUN = re.compile(r"(?:\.\*|\.\+)(?:[^()]*)(?:\.\*|\.\+)")
18
+
19
+
20
+ def regex_problems(pattern: str, *, strict: bool = True) -> list[str]:
21
+ """Return a list of safe problem descriptions (never echoes the pattern)."""
22
+ problems: list[str] = []
23
+ if len(pattern) > MAX_PATTERN_LENGTH:
24
+ problems.append(f"pattern longer than {MAX_PATTERN_LENGTH} characters")
25
+ return problems
26
+ try:
27
+ compiled = re.compile(pattern)
28
+ except re.error:
29
+ problems.append("pattern does not compile")
30
+ return problems
31
+ if compiled.search("") is not None:
32
+ problems.append("pattern can match the empty string")
33
+ if strict:
34
+ if _NESTED_QUANTIFIER.search(pattern):
35
+ problems.append("nested quantifier (risk of catastrophic backtracking)")
36
+ if _BACKREFERENCE.search(pattern):
37
+ problems.append("backreferences are not allowed")
38
+ if _UNBOUNDED_WILDCARD_RUN.search(pattern):
39
+ problems.append("multiple unbounded wildcards (risk of excessive backtracking)")
40
+ return problems