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
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]
|
ciphergate/constants.py
ADDED
|
@@ -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,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
|