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
ciphergate/config.py ADDED
@@ -0,0 +1,50 @@
1
+ """Operational runtime limits. Data-handling decisions live in the policy, never here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field, replace
6
+ from typing import Literal
7
+
8
+ PathDisplay = Literal["basename", "redacted", "full"]
9
+
10
+ MIB = 1024 * 1024
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class RuntimeSettings:
15
+ """Resource limits and presentation options (units: bytes unless stated).
16
+
17
+ Every limit fails the run when exceeded; input is never silently truncated.
18
+ """
19
+
20
+ max_input_bytes: int = 100 * MIB
21
+ max_json_document_bytes: int = 32 * MIB
22
+ max_record_bytes: int = 1 * MIB
23
+ max_json_depth: int = 64
24
+ max_findings: int = 200_000
25
+ max_report_items: int = 1_000
26
+ max_batch_files: int = 1_000
27
+ encoding: str = "utf-8"
28
+ csv_delimiter: str = ","
29
+ path_display: PathDisplay = "basename"
30
+ # Stored for completeness of the run report; never changes decisions.
31
+ extra: dict[str, str] = field(default_factory=dict)
32
+
33
+ def __post_init__(self) -> None:
34
+ for name in (
35
+ "max_input_bytes",
36
+ "max_json_document_bytes",
37
+ "max_record_bytes",
38
+ "max_json_depth",
39
+ "max_findings",
40
+ "max_report_items",
41
+ "max_batch_files",
42
+ ):
43
+ if getattr(self, name) <= 0:
44
+ raise ValueError(f"{name} must be a positive integer")
45
+ if len(self.csv_delimiter) != 1 or self.csv_delimiter in '\r\n"':
46
+ raise ValueError("csv_delimiter must be a single character other than quote or newline")
47
+
48
+ def with_overrides(self, **changes: object) -> RuntimeSettings:
49
+ clean = {k: v for k, v in changes.items() if v is not None}
50
+ return replace(self, **clean) # type: ignore[arg-type]
@@ -0,0 +1,274 @@
1
+ """Stable identifiers shared across CipherGate: context values, categories, actions, codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum, StrEnum
6
+
7
+
8
+ class Environment(StrEnum):
9
+ """Where the source data originated (policy context only; never verified)."""
10
+
11
+ DEV = "dev"
12
+ STG = "stg"
13
+ PRD = "prd"
14
+ UNKNOWN = "unknown"
15
+
16
+
17
+ class TargetEnvironment(StrEnum):
18
+ """Where the output will be used, when that is an application environment."""
19
+
20
+ DEV = "dev"
21
+ STG = "stg"
22
+ PRD = "prd"
23
+ EXTERNAL = "external"
24
+ UNKNOWN = "unknown"
25
+
26
+
27
+ class Destination(StrEnum):
28
+ """The actual intended recipient/channel of the output."""
29
+
30
+ LOCAL_DEV = "local_dev"
31
+ APPROVED_INTERNAL = "approved_internal"
32
+ STAGING_TEST = "staging_test"
33
+ PRODUCTION_MONITORING = "production_monitoring"
34
+ EXTERNAL_VENDOR = "external_vendor"
35
+ EXTERNAL_AI = "external_ai"
36
+ PUBLIC_REPOSITORY = "public_repository"
37
+ UNKNOWN = "unknown"
38
+
39
+
40
+ # Destinations that receive data outside the organisation's control, or whose trust is unknown.
41
+ UNTRUSTED_DESTINATIONS: frozenset[Destination] = frozenset(
42
+ {
43
+ Destination.EXTERNAL_VENDOR,
44
+ Destination.EXTERNAL_AI,
45
+ Destination.PUBLIC_REPOSITORY,
46
+ Destination.UNKNOWN,
47
+ }
48
+ )
49
+ NON_PRODUCTION_DESTINATIONS: frozenset[Destination] = frozenset(
50
+ {Destination.LOCAL_DEV, Destination.STAGING_TEST}
51
+ )
52
+
53
+
54
+ class DomainProfile(StrEnum):
55
+ DEVELOPER_LOGS = "developer_logs"
56
+ TEST_DATASET = "test_dataset"
57
+ HEALTHCARE = "healthcare"
58
+ FINANCIAL = "financial"
59
+ GENERAL = "general"
60
+
61
+
62
+ class UseCase(StrEnum):
63
+ DEBUGGING = "debugging"
64
+ ANALYTICS = "analytics"
65
+ TRAINING = "training"
66
+ EXTERNAL_LLM = "external_llm"
67
+ SUPPORT_BUNDLE = "support_bundle"
68
+ TEST_FIXTURE = "test_fixture"
69
+ MONITORING = "monitoring"
70
+ INCIDENT_RESPONSE = "incident_response"
71
+
72
+
73
+ class InputFormat(StrEnum):
74
+ CSV = "csv"
75
+ JSON = "json"
76
+ JSONL = "jsonl"
77
+ TEXT = "text"
78
+ LOGFMT = "logfmt"
79
+ JSONLOG = "jsonlog"
80
+ ACCESSLOG = "accesslog"
81
+ SYSLOG = "syslog"
82
+ STACKTRACE = "stacktrace"
83
+
84
+
85
+ # Line-oriented formats: each line is scanned as text and rewritten span by span.
86
+ LINE_FORMATS = frozenset(
87
+ {
88
+ InputFormat.TEXT,
89
+ InputFormat.LOGFMT,
90
+ InputFormat.JSONLOG,
91
+ InputFormat.ACCESSLOG,
92
+ InputFormat.SYSLOG,
93
+ InputFormat.STACKTRACE,
94
+ }
95
+ )
96
+
97
+ # Findings for stack-trace frames and source lines (--format stacktrace); not a DetectorSet member.
98
+ STACK_TRACE_DETECTOR = "stack_trace"
99
+
100
+
101
+ class Action(StrEnum):
102
+ ALLOW = "allow"
103
+ REDACT = "redact"
104
+ REPLACE = "replace"
105
+ PSEUDONYMIZE = "pseudonymize"
106
+ GENERALIZE = "generalize"
107
+ DATE_SHIFT = "date_shift"
108
+ DROP_FIELD = "drop_field"
109
+ DROP_RECORD = "drop_record"
110
+ BLOCK = "block"
111
+ REVIEW = "review"
112
+
113
+
114
+ # Higher rank = more protective. Used to combine several decisions on the same value.
115
+ ACTION_RANK: dict[Action, int] = {
116
+ Action.BLOCK: 100,
117
+ Action.REVIEW: 90,
118
+ Action.DROP_RECORD: 80,
119
+ Action.DROP_FIELD: 70,
120
+ Action.REDACT: 60,
121
+ Action.REPLACE: 50,
122
+ Action.PSEUDONYMIZE: 40,
123
+ Action.GENERALIZE: 30,
124
+ Action.DATE_SHIFT: 20,
125
+ Action.ALLOW: 0,
126
+ }
127
+
128
+ # Actions that change a value in place (as opposed to removing it or stopping the run).
129
+ VALUE_TRANSFORM_ACTIONS: frozenset[Action] = frozenset(
130
+ {Action.REDACT, Action.REPLACE, Action.PSEUDONYMIZE, Action.GENERALIZE, Action.DATE_SHIFT}
131
+ )
132
+
133
+ CREDENTIAL_CATEGORY = "credential.secret"
134
+ UNKNOWN_CANDIDATE_CATEGORY = "unknown.sensitive_candidate"
135
+
136
+ BUILTIN_CATEGORIES: frozenset[str] = frozenset(
137
+ {
138
+ "credential.secret",
139
+ "pii.email",
140
+ "pii.phone",
141
+ "pii.person_name",
142
+ "pii.government_id",
143
+ "pii.payment_card",
144
+ "pii.bank_account",
145
+ "pii.address",
146
+ "pii.date_of_birth",
147
+ "pii.precise_location",
148
+ "pii.ip_address",
149
+ "pii.device_identifier",
150
+ "pii.customer_identifier",
151
+ "health.phi",
152
+ "financial.sensitive",
153
+ "employment.sensitive",
154
+ "network.telemetry",
155
+ "proprietary.source",
156
+ "security.internal",
157
+ "location.coarse",
158
+ "identifier.generic",
159
+ "unknown.sensitive_candidate",
160
+ "date.generic",
161
+ }
162
+ )
163
+
164
+ # Categories for which an `allow` toward an untrusted destination requires explicit acknowledgement,
165
+ # and which the residual rescan gate fails on by default.
166
+ HIGH_RISK_CATEGORIES: frozenset[str] = frozenset(
167
+ {
168
+ "credential.secret",
169
+ "pii.email",
170
+ "pii.phone",
171
+ "pii.person_name",
172
+ "pii.government_id",
173
+ "pii.payment_card",
174
+ "pii.bank_account",
175
+ "pii.address",
176
+ "pii.date_of_birth",
177
+ "pii.precise_location",
178
+ "pii.customer_identifier",
179
+ "health.phi",
180
+ "financial.sensitive",
181
+ "employment.sensitive",
182
+ "unknown.sensitive_candidate",
183
+ }
184
+ )
185
+
186
+ # Custom categories in these families are treated as high-risk too (e.g. ``pii.member_number``).
187
+ HIGH_RISK_FAMILIES: frozenset[str] = frozenset(
188
+ {"credential", "pii", "health", "financial", "employment", "unknown"}
189
+ )
190
+
191
+ # Detectors that must stay enabled for strict or untrusted-destination runs (credential coverage).
192
+ CREDENTIAL_DETECTORS: frozenset[str] = frozenset({"secret_pattern", "field_name"})
193
+
194
+ # Actions permitted for credential findings. Everything else is a hard-constraint violation.
195
+ CREDENTIAL_PERMITTED_ACTIONS: frozenset[Action] = frozenset(
196
+ {
197
+ Action.BLOCK,
198
+ Action.REVIEW,
199
+ Action.REDACT,
200
+ Action.REPLACE,
201
+ Action.PSEUDONYMIZE,
202
+ Action.DROP_FIELD,
203
+ Action.DROP_RECORD,
204
+ }
205
+ )
206
+
207
+
208
+ class ReasonCode(StrEnum):
209
+ """Stable, machine-readable reason codes. They never contain values."""
210
+
211
+ CREDENTIAL_DETECTED_HARD_BLOCK = "credential_detected_hard_block"
212
+ EXPLICIT_HARD_BLOCK = "explicit_hard_block"
213
+ ENVIRONMENT_GUARDRAIL = "environment_guardrail"
214
+ EXPLICIT_POLICY_MATCH = "explicit_policy_match"
215
+ PROFILE_DEFAULT = "profile_default"
216
+ FIELD_NAME_SENSITIVE = "field_name_sensitive"
217
+ VALUE_PATTERN_MATCH = "value_pattern_match"
218
+ CONFIDENCE_BELOW_RULE_THRESHOLD = "confidence_below_rule_threshold"
219
+ NO_APPLICABLE_RULE_REQUIRES_REVIEW = "no_applicable_rule_requires_review"
220
+ NO_APPLICABLE_RULE_BLOCKS = "no_applicable_rule_blocks"
221
+ HIGH_RISK_ALLOW_REQUIRES_ACKNOWLEDGEMENT = "high_risk_allow_requires_acknowledgement"
222
+ UNSUPPORTED_TRANSFORM = "unsupported_transform"
223
+ DESTINATION_REQUIRED_BUT_MISSING = "destination_required_but_missing"
224
+ ENVIRONMENT_REQUIRED_BUT_MISSING = "environment_required_but_missing"
225
+ TARGET_ENVIRONMENT_REQUIRED_BUT_MISSING = "target_environment_required_but_missing"
226
+ ENVIRONMENT_CONTEXT_CONFLICT = "environment_context_conflict"
227
+ UNSUPPORTED_ENVIRONMENT = "unsupported_environment"
228
+ UNKNOWN_CONTEXT_REQUIRES_REVIEW = "unknown_context_requires_review"
229
+ PRD_TO_NONPRODUCTION_BLOCKED = "prd_to_nonproduction_transfer_blocked"
230
+ PRD_TO_NONPRODUCTION_APPROVED = "prd_to_nonproduction_transfer_approved"
231
+ STRICT_POLICY_REQUIRED = "strict_policy_required_for_destination"
232
+ RESCAN_REQUIRED = "rescan_required_for_destination"
233
+ CREDENTIAL_DETECTION_REQUIRED = "credential_detection_required"
234
+ KEY_SCAN_REQUIRED = "json_key_scan_required"
235
+ POLICY_CONFLICT = "policy_conflict"
236
+ KEY_MISSING = "key_missing"
237
+ KEY_INVALID = "key_invalid"
238
+ PARSE_ERROR = "parse_error"
239
+ SIZE_LIMIT_EXCEEDED = "size_limit_exceeded"
240
+ RESIDUAL_FINDING = "residual_finding"
241
+
242
+
243
+ class ExitCode(IntEnum):
244
+ OK = 0
245
+ BLOCKED = 1
246
+ USAGE = 2
247
+ POLICY_INVALID = 3
248
+ INPUT_ERROR = 4
249
+ OUTPUT_ERROR = 5
250
+ KEY_OR_TRANSFORM_CONFIG = 6
251
+ RESCAN_FAILED = 7
252
+ INTERNAL = 8
253
+ INTERRUPTED = 130
254
+
255
+
256
+ def confidence_band(confidence: float) -> str:
257
+ """Named band for a confidence estimate. Bands are engineering estimates, not probabilities."""
258
+ if confidence >= 0.8:
259
+ return "high"
260
+ if confidence >= 0.5:
261
+ return "medium"
262
+ return "low"
263
+
264
+
265
+ REPORT_SCHEMA = "ciphergate.report/v1"
266
+ PSEUDONYM_VERSION = "pz1"
267
+ DEFAULT_KEY_ENV = "CIPHERGATE_HMAC_KEY"
268
+ MIN_KEY_BYTES = 32
269
+
270
+ DISCLAIMER = (
271
+ "A successful run means the configured processing completed under the selected policy. "
272
+ "It does not prove that the output contains no sensitive information; "
273
+ "detectors can miss values."
274
+ )
@@ -0,0 +1,5 @@
1
+ """Deterministic local detectors. Detectors find candidates; they never decide actions."""
2
+
3
+ from ciphergate.detectors.registry import BUILTIN_DETECTOR_IDS, DetectorSet
4
+
5
+ __all__ = ["BUILTIN_DETECTOR_IDS", "DetectorSet"]
@@ -0,0 +1,53 @@
1
+ """Detector interface and shared helpers.
2
+
3
+ Detectors only produce candidate findings (category, evidence, confidence, location). They never
4
+ decide what happens to a value; that is the policy engine's job.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Protocol
11
+
12
+ from ciphergate.models.finding import Finding, ScanItem, value_type_name
13
+
14
+ # Output shapes CipherGate itself produces. Detectors skip them so a sanitized file is not
15
+ # re-flagged by its own markers/pseudonyms.
16
+ MARKER_TOKEN_RE = re.compile(r"\[[A-Z][A-Z0-9_]{0,31}(?::[a-z0-9_.]{1,48})?\]")
17
+ PSEUDONYM_TOKEN_RE = re.compile(r"(?:[a-z][a-z0-9_]{0,31})?pz1_[a-z2-7]{32}")
18
+
19
+
20
+ def is_ciphergate_token(text: str) -> bool:
21
+ return bool(MARKER_TOKEN_RE.fullmatch(text) or PSEUDONYM_TOKEN_RE.fullmatch(text))
22
+
23
+
24
+ class Detector(Protocol):
25
+ id: str
26
+
27
+ def detect(self, item: ScanItem) -> list[Finding]: ...
28
+
29
+
30
+ def make_finding(
31
+ item: ScanItem,
32
+ *,
33
+ category: str,
34
+ detector: str,
35
+ confidence: float,
36
+ evidence: tuple[str, ...],
37
+ span: tuple[int, int] | None = None,
38
+ ) -> Finding:
39
+ return Finding(
40
+ category=category,
41
+ detector=detector,
42
+ confidence=round(max(0.0, min(1.0, confidence)), 3),
43
+ evidence=evidence,
44
+ location=item.location(span),
45
+ field_name=item.field_name,
46
+ value_type=value_type_name(item.value),
47
+ )
48
+
49
+
50
+ def preceding_context(text: str, start: int, keywords: tuple[str, ...], window: int = 24) -> bool:
51
+ """True if a keyword occurs in the ``window`` chars before ``start`` (case-insensitive)."""
52
+ snippet = text[max(0, start - window) : start].lower()
53
+ return any(k in snippet for k in keywords)
@@ -0,0 +1,37 @@
1
+ """User-configured regex detectors (validated by ``regex_safety`` at policy load time)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ciphergate.detectors.base import is_ciphergate_token, make_finding
8
+ from ciphergate.models.finding import Finding, ScanItem
9
+ from ciphergate.models.policy import CustomDetector
10
+
11
+
12
+ class CustomRegexDetector:
13
+ def __init__(self, spec: CustomDetector) -> None:
14
+ self.id = f"custom:{spec.id}"
15
+ self.category = spec.category
16
+ self.confidence = spec.confidence
17
+ self._regex = re.compile(spec.pattern, re.IGNORECASE if spec.ignore_case else 0)
18
+
19
+ def detect(self, item: ScanItem) -> list[Finding]:
20
+ text = item.text
21
+ if not text:
22
+ return []
23
+ out: list[Finding] = []
24
+ for m in self._regex.finditer(text):
25
+ if m.end() == m.start() or is_ciphergate_token(m.group(0)):
26
+ continue
27
+ out.append(
28
+ make_finding(
29
+ item,
30
+ category=self.category,
31
+ detector=self.id,
32
+ confidence=self.confidence,
33
+ evidence=("pattern_match", "custom_regex"),
34
+ span=(m.start(), m.end()),
35
+ )
36
+ )
37
+ return out
@@ -0,0 +1,92 @@
1
+ """Date detector. Dates are classified as date of birth only with field or context evidence.
2
+
3
+ Supported shapes: ISO ``YYYY-MM-DD`` (optionally with time and offset), ``YYYY/MM/DD``,
4
+ ``DD/MM/YYYY`` or ``MM/DD/YYYY`` (flagged ``ambiguous_locale``) and ``DD.MM.YYYY``. Without DOB
5
+ evidence, a date is only reported (as ``date.generic``) when ``dates_emit_generic`` is enabled.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import re
12
+
13
+ from ciphergate.detectors.base import make_finding, preceding_context
14
+ from ciphergate.detectors.field_names import classify_field_name
15
+ from ciphergate.models.finding import Finding, ScanItem
16
+
17
+ _DATE_RE = re.compile(
18
+ r"(?<![\d/.-])"
19
+ r"(?:"
20
+ r"(?P<iso>(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})"
21
+ r"(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?)"
22
+ r"|(?P<ymd>(?P<y2>\d{4})/(?P<m2>\d{2})/(?P<d2>\d{2}))"
23
+ r"|(?P<amb>(?P<a1>\d{1,2})[/.](?P<a2>\d{1,2})[/.](?P<y3>\d{4}))"
24
+ r")"
25
+ r"(?![\d/-])"
26
+ )
27
+ _DOB_KEYWORDS = ("dob", "born", "birth", "d.o.b")
28
+
29
+
30
+ def _valid(year: int, month: int, day: int) -> bool:
31
+ try:
32
+ dt.date(year, month, day)
33
+ except ValueError:
34
+ return False
35
+ return True
36
+
37
+
38
+ class DateDetector:
39
+ id = "date_pattern"
40
+
41
+ def __init__(self, emit_generic: bool = False) -> None:
42
+ self.emit_generic = emit_generic
43
+
44
+ def detect(self, item: ScanItem) -> list[Finding]:
45
+ text = item.text
46
+ if not text or not isinstance(item.value, str) or sum(c.isdigit() for c in text) < 6:
47
+ return []
48
+ field_term = classify_field_name(item.field_name) if item.field_name else None
49
+ field_is_dob = field_term is not None and field_term.category == "pii.date_of_birth"
50
+ out: list[Finding] = []
51
+ for m in _DATE_RE.finditer(text):
52
+ evidence = ["pattern_match"]
53
+ if m.group("iso"):
54
+ ok = _valid(int(m.group("y")), int(m.group("m")), int(m.group("d")))
55
+ elif m.group("ymd"):
56
+ ok = _valid(int(m.group("y2")), int(m.group("m2")), int(m.group("d2")))
57
+ else:
58
+ a, b, y = int(m.group("a1")), int(m.group("a2")), int(m.group("y3"))
59
+ ok = _valid(y, b, a) or _valid(y, a, b)
60
+ if _valid(y, b, a) and _valid(y, a, b) and a != b:
61
+ evidence.append("ambiguous_locale")
62
+ if not ok:
63
+ continue
64
+ span = (m.start(), m.end())
65
+ if field_is_dob:
66
+ evidence.append("field_name_context")
67
+ elif preceding_context(text, m.start(), _DOB_KEYWORDS):
68
+ evidence.append("context_keyword")
69
+ else:
70
+ if self.emit_generic:
71
+ out.append(
72
+ make_finding(
73
+ item,
74
+ category="date.generic",
75
+ detector=self.id,
76
+ confidence=0.6,
77
+ evidence=tuple(evidence),
78
+ span=span,
79
+ )
80
+ )
81
+ continue
82
+ out.append(
83
+ make_finding(
84
+ item,
85
+ category="pii.date_of_birth",
86
+ detector=self.id,
87
+ confidence=0.85,
88
+ evidence=tuple(evidence),
89
+ span=span,
90
+ )
91
+ )
92
+ return out
@@ -0,0 +1,56 @@
1
+ """Email-like address detector (ASCII local parts and domains; bounded regex)."""
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
+ _EMAIL_RE = re.compile(
11
+ r"(?<![A-Za-z0-9._%+-])"
12
+ r"([A-Za-z0-9._%+-]{1,64})@((?:[A-Za-z0-9-]{1,63}\.){1,8}[A-Za-z]{2,63})"
13
+ r"(?![A-Za-z0-9-])"
14
+ )
15
+
16
+ RESERVED_EMAIL_DOMAINS = ("example.com", "example.org", "example.net")
17
+ RESERVED_EMAIL_TLDS = (".invalid", ".example", ".test", ".localhost")
18
+
19
+
20
+ def is_reserved_email_domain(domain: str) -> bool:
21
+ d = domain.lower()
22
+ return (
23
+ d in RESERVED_EMAIL_DOMAINS
24
+ or any(d.endswith(t) or d == t[1:] for t in RESERVED_EMAIL_TLDS)
25
+ or any(d.endswith("." + r) for r in RESERVED_EMAIL_DOMAINS)
26
+ )
27
+
28
+
29
+ class EmailDetector:
30
+ id = "email_regex"
31
+
32
+ def detect(self, item: ScanItem) -> list[Finding]:
33
+ text = item.text
34
+ if not text or "@" not in text:
35
+ return []
36
+ out: list[Finding] = []
37
+ for m in _EMAIL_RE.finditer(text):
38
+ local, domain = m.group(1), m.group(2)
39
+ if local.startswith(".") or local.endswith(".") or ".." in local or ".." in domain:
40
+ continue
41
+ if any(label.startswith("-") or label.endswith("-") for label in domain.split(".")):
42
+ continue
43
+ evidence = ["pattern_match"]
44
+ if is_reserved_email_domain(domain):
45
+ evidence.append("reserved_domain")
46
+ out.append(
47
+ make_finding(
48
+ item,
49
+ category="pii.email",
50
+ detector=self.id,
51
+ confidence=0.9,
52
+ evidence=tuple(evidence),
53
+ span=(m.start(), m.end()),
54
+ )
55
+ )
56
+ return out