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.
- ciphergate/__init__.py +95 -0
- ciphergate/__main__.py +6 -0
- ciphergate/cli.py +832 -0
- ciphergate/config.py +50 -0
- ciphergate/constants.py +274 -0
- ciphergate/detectors/__init__.py +5 -0
- ciphergate/detectors/base.py +53 -0
- ciphergate/detectors/custom_regex.py +37 -0
- ciphergate/detectors/dates.py +92 -0
- ciphergate/detectors/email.py +56 -0
- ciphergate/detectors/field_names.py +220 -0
- ciphergate/detectors/ip_address.py +85 -0
- ciphergate/detectors/network.py +42 -0
- ciphergate/detectors/payment_card.py +77 -0
- ciphergate/detectors/phone.py +67 -0
- ciphergate/detectors/presidio_ner.py +112 -0
- ciphergate/detectors/regex_safety.py +40 -0
- ciphergate/detectors/registry.py +194 -0
- ciphergate/detectors/secrets.py +130 -0
- ciphergate/errors.py +119 -0
- ciphergate/models/__init__.py +1 -0
- ciphergate/models/context.py +69 -0
- ciphergate/models/finding.py +108 -0
- ciphergate/models/policy.py +402 -0
- ciphergate/models/report.py +115 -0
- ciphergate/pipeline/__init__.py +1 -0
- ciphergate/pipeline/atomic_output.py +143 -0
- ciphergate/pipeline/batch.py +533 -0
- ciphergate/pipeline/inspector.py +282 -0
- ciphergate/pipeline/paths.py +98 -0
- ciphergate/pipeline/planner.py +192 -0
- ciphergate/pipeline/rescan.py +120 -0
- ciphergate/pipeline/sanitizer.py +392 -0
- ciphergate/pipeline/stdio.py +173 -0
- ciphergate/pipeline/streams.py +152 -0
- ciphergate/policy/__init__.py +26 -0
- ciphergate/policy/context.py +203 -0
- ciphergate/policy/engine.py +194 -0
- ciphergate/policy/loader.py +116 -0
- ciphergate/policy/precedence.py +149 -0
- ciphergate/policy/validator.py +632 -0
- ciphergate/py.typed +0 -0
- ciphergate/readers/__init__.py +113 -0
- ciphergate/readers/accesslog_reader.py +244 -0
- ciphergate/readers/base.py +131 -0
- ciphergate/readers/csv_reader.py +241 -0
- ciphergate/readers/json_reader.py +184 -0
- ciphergate/readers/jsonl_reader.py +97 -0
- ciphergate/readers/jsonlog_reader.py +178 -0
- ciphergate/readers/line_structure.py +125 -0
- ciphergate/readers/logfmt_reader.py +100 -0
- ciphergate/readers/stacktrace_reader.py +103 -0
- ciphergate/readers/syslog_reader.py +309 -0
- ciphergate/readers/text_reader.py +59 -0
- ciphergate/reporting/__init__.py +1 -0
- ciphergate/reporting/audit.py +85 -0
- ciphergate/reporting/safe_text.py +71 -0
- ciphergate/reporting/serializer.py +44 -0
- ciphergate/reporting/terminal.py +147 -0
- ciphergate/templates/starter_policy.yml +77 -0
- ciphergate/transforms/__init__.py +6 -0
- ciphergate/transforms/date_shift.py +79 -0
- ciphergate/transforms/generalize.py +107 -0
- ciphergate/transforms/keys.py +177 -0
- ciphergate/transforms/pseudonymize.py +113 -0
- ciphergate/transforms/redact.py +9 -0
- ciphergate/transforms/registry.py +201 -0
- ciphergate/transforms/replace.py +16 -0
- ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
- ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
- ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
- ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
- ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
- 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
|