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,194 @@
|
|
|
1
|
+
"""Builds the per-run detector set from policy settings (no global mutable registry)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ciphergate.detectors.base import Detector, make_finding
|
|
8
|
+
from ciphergate.detectors.custom_regex import CustomRegexDetector
|
|
9
|
+
from ciphergate.detectors.dates import DateDetector
|
|
10
|
+
from ciphergate.detectors.email import EmailDetector, is_reserved_email_domain
|
|
11
|
+
from ciphergate.detectors.field_names import FieldNameDetector, PolicyClassificationDetector
|
|
12
|
+
from ciphergate.detectors.ip_address import IPAddressDetector
|
|
13
|
+
from ciphergate.detectors.network import InternalHostDetector
|
|
14
|
+
from ciphergate.detectors.payment_card import PaymentCardDetector
|
|
15
|
+
from ciphergate.detectors.phone import PhoneDetector
|
|
16
|
+
from ciphergate.detectors.secrets import PEM_BEGIN_RE, PEM_END_RE, SecretDetector
|
|
17
|
+
from ciphergate.models.finding import Finding, ScanItem
|
|
18
|
+
from ciphergate.models.policy import DetectorSettings, PolicyDocument
|
|
19
|
+
|
|
20
|
+
BUILTIN_DETECTOR_IDS: tuple[str, ...] = (
|
|
21
|
+
"field_name",
|
|
22
|
+
"secret_pattern",
|
|
23
|
+
"luhn_card",
|
|
24
|
+
"ip_address",
|
|
25
|
+
"date_pattern",
|
|
26
|
+
"email_regex",
|
|
27
|
+
"internal_host",
|
|
28
|
+
"phone_pattern",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Phone matching is the least specific pattern; drop phone spans that overlap these detectors.
|
|
32
|
+
_PHONE_SUPPRESSORS = frozenset({"luhn_card", "ip_address", "date_pattern", "secret_pattern"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
FIELD_CONTEXT_DETECTORS = frozenset({"field_name", "policy_classification"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class TextBlockState:
|
|
40
|
+
"""Cross-line state for text/log input (multi-line PEM private key blocks)."""
|
|
41
|
+
|
|
42
|
+
in_private_key_block: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _overlaps(a: tuple[int, int], b: tuple[int, int]) -> bool:
|
|
46
|
+
return a[0] < b[1] and b[0] < a[1]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DetectorSet:
|
|
50
|
+
def __init__(self, settings: DetectorSettings, document: PolicyDocument | None = None) -> None:
|
|
51
|
+
disabled = set(settings.disabled)
|
|
52
|
+
detectors: list[Detector] = []
|
|
53
|
+
factories: dict[str, Detector] = {
|
|
54
|
+
"field_name": FieldNameDetector(),
|
|
55
|
+
"secret_pattern": SecretDetector(),
|
|
56
|
+
"luhn_card": PaymentCardDetector(),
|
|
57
|
+
"ip_address": IPAddressDetector(),
|
|
58
|
+
"date_pattern": DateDetector(emit_generic=settings.dates_emit_generic),
|
|
59
|
+
"email_regex": EmailDetector(),
|
|
60
|
+
"internal_host": InternalHostDetector(settings.internal_host_suffixes),
|
|
61
|
+
"phone_pattern": PhoneDetector(),
|
|
62
|
+
}
|
|
63
|
+
for detector_id in BUILTIN_DETECTOR_IDS:
|
|
64
|
+
if detector_id not in disabled:
|
|
65
|
+
detectors.append(factories[detector_id])
|
|
66
|
+
if document is not None and document.classifications:
|
|
67
|
+
detectors.append(PolicyClassificationDetector(document.classifications))
|
|
68
|
+
detectors.extend(CustomRegexDetector(spec) for spec in settings.custom)
|
|
69
|
+
if settings.presidio.enabled:
|
|
70
|
+
# Imported lazily: the base install never imports the optional NER code path.
|
|
71
|
+
from ciphergate.detectors.presidio_ner import PresidioDetector # noqa: PLC0415
|
|
72
|
+
|
|
73
|
+
detectors.append(PresidioDetector(settings.presidio))
|
|
74
|
+
self.detectors = tuple(detectors)
|
|
75
|
+
self.ids = tuple(d.id for d in detectors)
|
|
76
|
+
|
|
77
|
+
def scan(self, item: ScanItem) -> list[Finding]:
|
|
78
|
+
findings: list[Finding] = []
|
|
79
|
+
for detector in self.detectors:
|
|
80
|
+
findings.extend(detector.detect(item))
|
|
81
|
+
return self._post_process(findings)
|
|
82
|
+
|
|
83
|
+
def scan_field_context(self, item: ScanItem) -> list[Finding]:
|
|
84
|
+
"""Only the detectors that judge a value by its field name/path (logfmt keys)."""
|
|
85
|
+
findings: list[Finding] = []
|
|
86
|
+
for detector in self.detectors:
|
|
87
|
+
if detector.id in FIELD_CONTEXT_DETECTORS:
|
|
88
|
+
findings.extend(detector.detect(item))
|
|
89
|
+
return findings
|
|
90
|
+
|
|
91
|
+
def scan_text_line(self, item: ScanItem, state: TextBlockState) -> list[Finding]:
|
|
92
|
+
"""Scan one text/log line, tracking multi-line private key blocks across lines."""
|
|
93
|
+
text = item.text or ""
|
|
94
|
+
findings = self.scan(item)
|
|
95
|
+
if "secret_pattern" not in self.ids:
|
|
96
|
+
return findings
|
|
97
|
+
if state.in_private_key_block:
|
|
98
|
+
findings = [f for f in findings if f.category != "credential.secret"]
|
|
99
|
+
findings.append(
|
|
100
|
+
make_finding(
|
|
101
|
+
item,
|
|
102
|
+
category="credential.secret",
|
|
103
|
+
detector="secret_pattern",
|
|
104
|
+
confidence=0.99,
|
|
105
|
+
evidence=("pattern_match", "family:private_key_block"),
|
|
106
|
+
span=(0, len(text)) if text else None,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
if PEM_END_RE.search(text):
|
|
110
|
+
state.in_private_key_block = False
|
|
111
|
+
return self._post_process(findings)
|
|
112
|
+
begins = list(PEM_BEGIN_RE.finditer(text))
|
|
113
|
+
begin = begins[-1] if begins else None
|
|
114
|
+
if begin is not None and not PEM_END_RE.search(text, begin.end()):
|
|
115
|
+
state.in_private_key_block = True
|
|
116
|
+
return findings
|
|
117
|
+
|
|
118
|
+
def post_process(self, findings: list[Finding]) -> list[Finding]:
|
|
119
|
+
"""Phone suppression and de-duplication (used for combined logfmt findings)."""
|
|
120
|
+
return self._post_process(findings)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def dedupe(findings: list[Finding]) -> list[Finding]:
|
|
124
|
+
"""Drop exact duplicates (same category, detector and span), keeping order."""
|
|
125
|
+
out: list[Finding] = []
|
|
126
|
+
seen: set[tuple[str, str, tuple[int, int] | None]] = set()
|
|
127
|
+
for f in findings:
|
|
128
|
+
key = (f.category, f.detector, f.location.span)
|
|
129
|
+
if key not in seen:
|
|
130
|
+
seen.add(key)
|
|
131
|
+
out.append(f)
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _post_process(findings: list[Finding]) -> list[Finding]:
|
|
136
|
+
if not findings:
|
|
137
|
+
return findings
|
|
138
|
+
strong = [
|
|
139
|
+
f.location.span
|
|
140
|
+
for f in findings
|
|
141
|
+
if f.detector in _PHONE_SUPPRESSORS and f.location.span is not None
|
|
142
|
+
] + [
|
|
143
|
+
f.location.span
|
|
144
|
+
for f in findings
|
|
145
|
+
if f.category == "pii.email"
|
|
146
|
+
and f.location.span is not None
|
|
147
|
+
# Field-context findings cover a whole value; they must not hide a phone inside it.
|
|
148
|
+
and f.detector not in FIELD_CONTEXT_DETECTORS
|
|
149
|
+
]
|
|
150
|
+
out: list[Finding] = []
|
|
151
|
+
seen: set[tuple[str, str, tuple[int, int] | None]] = set()
|
|
152
|
+
for f in findings:
|
|
153
|
+
span = f.location.span
|
|
154
|
+
if (
|
|
155
|
+
f.detector == "phone_pattern"
|
|
156
|
+
and span is not None
|
|
157
|
+
and any(_overlaps(span, s) for s in strong)
|
|
158
|
+
):
|
|
159
|
+
continue
|
|
160
|
+
key = (f.category, f.detector, span)
|
|
161
|
+
if key in seen:
|
|
162
|
+
continue
|
|
163
|
+
seen.add(key)
|
|
164
|
+
out.append(f)
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def unsafe_replacement_reason(value: str) -> str | None:
|
|
169
|
+
"""Why a configured replacement constant looks like a real identifier or secret, if it does."""
|
|
170
|
+
probe = ScanItem(record_index=0, line=None, field_path="", field_name=None, value=value)
|
|
171
|
+
secrets = SecretDetector().detect(probe)
|
|
172
|
+
if secrets:
|
|
173
|
+
return f"looks like a credential ({secrets[0].evidence[-1]})"
|
|
174
|
+
if PaymentCardDetector().detect(probe):
|
|
175
|
+
return "looks like a payment card number"
|
|
176
|
+
for f in EmailDetector().detect(probe):
|
|
177
|
+
if "reserved_domain" not in f.evidence:
|
|
178
|
+
return "email replacements must use a reserved domain such as example.invalid"
|
|
179
|
+
for f in IPAddressDetector().detect(probe):
|
|
180
|
+
if "documentation_range" not in f.evidence:
|
|
181
|
+
return "IP replacements must use a documentation range (192.0.2.0/24, 2001:db8::/32)"
|
|
182
|
+
for f in PhoneDetector().detect(probe):
|
|
183
|
+
if f.confidence >= 0.5:
|
|
184
|
+
return "looks like a real phone number; use an obviously synthetic value"
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
__all__ = [
|
|
189
|
+
"BUILTIN_DETECTOR_IDS",
|
|
190
|
+
"DetectorSet",
|
|
191
|
+
"TextBlockState",
|
|
192
|
+
"is_reserved_email_domain",
|
|
193
|
+
"unsafe_replacement_reason",
|
|
194
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Credential and secret detector.
|
|
2
|
+
|
|
3
|
+
Pattern families: PEM private keys (including multi-line blocks inside one value), bearer/basic
|
|
4
|
+
authorization values, common token formats (AWS access key IDs, GitHub, Slack, Stripe-style,
|
|
5
|
+
Google API keys, JWTs), URL userinfo passwords, connection-string passwords and assignment-style
|
|
6
|
+
``password=...`` / ``api_key: ...`` values. Spans cover the secret part only, so surrounding debug
|
|
7
|
+
context can be kept. Pattern detection has false negatives (e.g. base64-wrapped, split or
|
|
8
|
+
custom-format secrets); it is never proof that content is credential-free.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from ciphergate.detectors.base import MARKER_TOKEN_RE, PSEUDONYM_TOKEN_RE, make_finding
|
|
17
|
+
from ciphergate.models.finding import Finding, ScanItem
|
|
18
|
+
|
|
19
|
+
PEM_BEGIN_RE = re.compile(r"-----BEGIN (?:[A-Z0-9]{1,16} ){0,3}PRIVATE KEY(?: BLOCK)?-----")
|
|
20
|
+
PEM_END_RE = re.compile(r"-----END (?:[A-Z0-9]{1,16} ){0,3}PRIVATE KEY(?: BLOCK)?-----")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class _Pattern:
|
|
25
|
+
name: str
|
|
26
|
+
regex: re.Pattern[str]
|
|
27
|
+
group: int
|
|
28
|
+
confidence: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_PATTERNS: tuple[_Pattern, ...] = (
|
|
32
|
+
_Pattern("bearer_token", re.compile(r"(?i)\bbearer\s+([A-Za-z0-9\-._~+/]{8,2048}=*)"), 1, 0.9),
|
|
33
|
+
_Pattern(
|
|
34
|
+
"basic_auth", re.compile(r"(?i)\bbasic\s+([A-Za-z0-9+/]{8,1024}={0,2})(?![\w])"), 1, 0.8
|
|
35
|
+
),
|
|
36
|
+
_Pattern("aws_access_key_id", re.compile(r"\b((?:AKIA|ASIA)[A-Z0-9]{16})\b"), 1, 0.95),
|
|
37
|
+
_Pattern("github_token", re.compile(r"\b(gh[pousr]_[A-Za-z0-9]{36,255})\b"), 1, 0.95),
|
|
38
|
+
_Pattern("github_pat", re.compile(r"\b(github_pat_[A-Za-z0-9_]{22,255})\b"), 1, 0.95),
|
|
39
|
+
_Pattern("slack_token", re.compile(r"\b(xox[abprs]-[A-Za-z0-9-]{10,200})"), 1, 0.95),
|
|
40
|
+
_Pattern(
|
|
41
|
+
"stripe_style_key", re.compile(r"\b([sr]k_(?:live|test)_[A-Za-z0-9]{16,128})\b"), 1, 0.9
|
|
42
|
+
),
|
|
43
|
+
_Pattern("google_api_key", re.compile(r"\b(AIza[0-9A-Za-z\-_]{35})(?![\w-])"), 1, 0.9),
|
|
44
|
+
_Pattern(
|
|
45
|
+
"jwt",
|
|
46
|
+
re.compile(
|
|
47
|
+
r"\b(eyJ[A-Za-z0-9_-]{5,2048}\.eyJ[A-Za-z0-9_-]{5,4096}\.[A-Za-z0-9_-]{5,2048})"
|
|
48
|
+
),
|
|
49
|
+
1,
|
|
50
|
+
0.85,
|
|
51
|
+
),
|
|
52
|
+
_Pattern(
|
|
53
|
+
"url_userinfo_password",
|
|
54
|
+
re.compile(r"\b[a-zA-Z][a-zA-Z0-9+.-]{1,20}://[^\s:/@]{1,128}:([^\s@/]{1,256})@"),
|
|
55
|
+
1,
|
|
56
|
+
0.9,
|
|
57
|
+
),
|
|
58
|
+
_Pattern(
|
|
59
|
+
"connection_string_password",
|
|
60
|
+
re.compile(r"(?i)(?<![a-z0-9_])(?:password|pwd)\s*=\s*([^;\s\"']{1,256})"),
|
|
61
|
+
1,
|
|
62
|
+
0.85,
|
|
63
|
+
),
|
|
64
|
+
_Pattern(
|
|
65
|
+
"assignment",
|
|
66
|
+
re.compile(
|
|
67
|
+
r"(?i)(?<![a-z0-9])"
|
|
68
|
+
r"(?:password|passwd|passphrase|secret|api[_-]?key|apikey|access[_-]?token|"
|
|
69
|
+
r"refresh[_-]?token|auth[_-]?token|client[_-]?secret|secret[_-]?key|private[_-]?key|"
|
|
70
|
+
r"access[_-]?key|token|credentials?)"
|
|
71
|
+
r"(?:\\?[\"'])?\s{0,3}[:=]\s{0,3}(?:\\?[\"'])?"
|
|
72
|
+
r"([^\s\"'\\,;&}]{1,512})"
|
|
73
|
+
),
|
|
74
|
+
1,
|
|
75
|
+
0.8,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
_PLACEHOLDER_VALUES = frozenset(
|
|
80
|
+
{"", "null", "none", "nil", "undefined", "true", "false", "redacted", "***", "******", "xxx"}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_placeholder(value: str) -> bool:
|
|
85
|
+
v = value.strip().lower()
|
|
86
|
+
return (
|
|
87
|
+
v in _PLACEHOLDER_VALUES
|
|
88
|
+
or bool(MARKER_TOKEN_RE.fullmatch(value))
|
|
89
|
+
or bool(PSEUDONYM_TOKEN_RE.fullmatch(value))
|
|
90
|
+
or set(v) <= {"*"}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SecretDetector:
|
|
95
|
+
id = "secret_pattern"
|
|
96
|
+
|
|
97
|
+
def detect(self, item: ScanItem) -> list[Finding]:
|
|
98
|
+
text = item.text
|
|
99
|
+
if not text or len(text) < 6:
|
|
100
|
+
return []
|
|
101
|
+
out: list[Finding] = []
|
|
102
|
+
covered: list[tuple[int, int]] = []
|
|
103
|
+
for begin in PEM_BEGIN_RE.finditer(text):
|
|
104
|
+
closing = PEM_END_RE.search(text, begin.end())
|
|
105
|
+
span = (begin.start(), closing.end() if closing else len(text))
|
|
106
|
+
covered.append(span)
|
|
107
|
+
out.append(self._finding(item, span, "private_key_header", 0.99))
|
|
108
|
+
for pattern in _PATTERNS:
|
|
109
|
+
for m in pattern.regex.finditer(text):
|
|
110
|
+
start, end = m.start(pattern.group), m.end(pattern.group)
|
|
111
|
+
value = m.group(pattern.group)
|
|
112
|
+
if _is_placeholder(value):
|
|
113
|
+
continue
|
|
114
|
+
if any(s <= start and end <= e for s, e in covered):
|
|
115
|
+
continue
|
|
116
|
+
covered.append((start, end))
|
|
117
|
+
out.append(self._finding(item, (start, end), pattern.name, pattern.confidence))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def _finding(
|
|
121
|
+
self, item: ScanItem, span: tuple[int, int], family: str, confidence: float
|
|
122
|
+
) -> Finding:
|
|
123
|
+
return make_finding(
|
|
124
|
+
item,
|
|
125
|
+
category="credential.secret",
|
|
126
|
+
detector=self.id,
|
|
127
|
+
confidence=confidence,
|
|
128
|
+
evidence=("pattern_match", f"family:{family}"),
|
|
129
|
+
span=span,
|
|
130
|
+
)
|
ciphergate/errors.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Exception hierarchy with stable error codes and safe messages.
|
|
2
|
+
|
|
3
|
+
Messages are always authored by CipherGate and must never contain input values, matched text,
|
|
4
|
+
key material, or third-party parser excerpts. Callers chain the original exception with
|
|
5
|
+
``raise ... from exc`` only when it is needed for debugging; the CLI never renders chained messages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from ciphergate.constants import ExitCode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CipherGateError(Exception):
|
|
16
|
+
"""Base class. ``code`` is a stable identifier; ``message`` is safe to display."""
|
|
17
|
+
|
|
18
|
+
default_code = "ciphergate_error"
|
|
19
|
+
exit_code: ExitCode = ExitCode.INTERNAL
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, code: str | None = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.code = code or self.default_code
|
|
24
|
+
self.message = message
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
return f"{self.code}: {self.message}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UsageError(CipherGateError):
|
|
31
|
+
default_code = "usage_error"
|
|
32
|
+
exit_code = ExitCode.USAGE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class ValidationIssue:
|
|
37
|
+
"""A single policy validation problem: a location in the policy plus a safe message."""
|
|
38
|
+
|
|
39
|
+
location: str
|
|
40
|
+
message: str
|
|
41
|
+
code: str = "invalid_value"
|
|
42
|
+
|
|
43
|
+
def render(self) -> str:
|
|
44
|
+
return f"{self.location}: {self.message} [{self.code}]"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PolicyValidationError(CipherGateError):
|
|
48
|
+
default_code = "policy_invalid"
|
|
49
|
+
exit_code = ExitCode.POLICY_INVALID
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self, message: str, *, issues: list[ValidationIssue] | None = None, code: str | None = None
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(message, code=code)
|
|
55
|
+
self.issues = list(issues or [])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ContextError(CipherGateError):
|
|
59
|
+
"""Required context is missing, unsupported, or conflicts with a policy-pinned value."""
|
|
60
|
+
|
|
61
|
+
default_code = "context_error"
|
|
62
|
+
exit_code = ExitCode.BLOCKED
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InputFormatError(CipherGateError):
|
|
66
|
+
default_code = "input_format_error"
|
|
67
|
+
exit_code = ExitCode.INPUT_ERROR
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InputLimitError(CipherGateError):
|
|
71
|
+
default_code = "size_limit_exceeded"
|
|
72
|
+
exit_code = ExitCode.INPUT_ERROR
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DetectionError(CipherGateError):
|
|
76
|
+
default_code = "detection_error"
|
|
77
|
+
exit_code = ExitCode.INPUT_ERROR
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DetectorUnavailableError(CipherGateError):
|
|
81
|
+
"""An optional detector is enabled by the policy but cannot be used (never skipped)."""
|
|
82
|
+
|
|
83
|
+
default_code = "detector_unavailable"
|
|
84
|
+
exit_code = ExitCode.KEY_OR_TRANSFORM_CONFIG
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class UnsafePathError(CipherGateError):
|
|
88
|
+
default_code = "unsafe_path"
|
|
89
|
+
exit_code = ExitCode.OUTPUT_ERROR
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OutputWriteError(CipherGateError):
|
|
93
|
+
default_code = "output_write_error"
|
|
94
|
+
exit_code = ExitCode.OUTPUT_ERROR
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class MissingKeyError(CipherGateError):
|
|
98
|
+
default_code = "key_missing"
|
|
99
|
+
exit_code = ExitCode.KEY_OR_TRANSFORM_CONFIG
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TransformError(CipherGateError):
|
|
103
|
+
default_code = "transform_error"
|
|
104
|
+
exit_code = ExitCode.KEY_OR_TRANSFORM_CONFIG
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PolicyBlockedError(CipherGateError):
|
|
108
|
+
default_code = "policy_blocked"
|
|
109
|
+
exit_code = ExitCode.BLOCKED
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class RescanFailedError(CipherGateError):
|
|
113
|
+
default_code = "rescan_failed"
|
|
114
|
+
exit_code = ExitCode.RESCAN_FAILED
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class InternalError(CipherGateError):
|
|
118
|
+
default_code = "internal_error"
|
|
119
|
+
exit_code = ExitCode.INTERNAL
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typed data models: findings, context, policy schema, reports."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Run context: source environment, target environment, destination, profile, use case."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from ciphergate.constants import (
|
|
9
|
+
UNTRUSTED_DESTINATIONS,
|
|
10
|
+
Destination,
|
|
11
|
+
DomainProfile,
|
|
12
|
+
Environment,
|
|
13
|
+
TargetEnvironment,
|
|
14
|
+
UseCase,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
ContextField = Literal[
|
|
18
|
+
"environment", "target_environment", "destination", "domain_profile", "use_case"
|
|
19
|
+
]
|
|
20
|
+
CONTEXT_FIELDS: tuple[ContextField, ...] = (
|
|
21
|
+
"environment",
|
|
22
|
+
"target_environment",
|
|
23
|
+
"destination",
|
|
24
|
+
"domain_profile",
|
|
25
|
+
"use_case",
|
|
26
|
+
)
|
|
27
|
+
ContextSource = Literal["policy", "cli", "cli_override", "unset"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ContextInput:
|
|
32
|
+
"""Context supplied by the caller (CLI flags or API). ``None`` means not supplied."""
|
|
33
|
+
|
|
34
|
+
environment: Environment | None = None
|
|
35
|
+
target_environment: TargetEnvironment | None = None
|
|
36
|
+
destination: Destination | None = None
|
|
37
|
+
domain_profile: DomainProfile | None = None
|
|
38
|
+
use_case: UseCase | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class EffectiveContext:
|
|
43
|
+
"""Context after combining policy pins with caller input. ``None`` means missing."""
|
|
44
|
+
|
|
45
|
+
environment: Environment | None = None
|
|
46
|
+
target_environment: TargetEnvironment | None = None
|
|
47
|
+
destination: Destination | None = None
|
|
48
|
+
domain_profile: DomainProfile | None = None
|
|
49
|
+
use_case: UseCase | None = None
|
|
50
|
+
sources: dict[str, ContextSource] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_untrusted(self) -> bool:
|
|
54
|
+
"""True unless the recipient is known and internal. A missing destination is untrusted."""
|
|
55
|
+
return (
|
|
56
|
+
self.destination is None
|
|
57
|
+
or self.destination in UNTRUSTED_DESTINATIONS
|
|
58
|
+
or self.target_environment in (TargetEnvironment.EXTERNAL, TargetEnvironment.UNKNOWN)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def get(self, name: ContextField) -> str | None:
|
|
62
|
+
value = getattr(self, name)
|
|
63
|
+
return None if value is None else str(value)
|
|
64
|
+
|
|
65
|
+
def to_report(self) -> dict[str, dict[str, str | None]]:
|
|
66
|
+
return {
|
|
67
|
+
name: {"value": self.get(name), "source": self.sources.get(name, "unset")}
|
|
68
|
+
for name in CONTEXT_FIELDS
|
|
69
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Detection results. A ``Finding`` never carries the matched value, only where and why."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ciphergate.constants import confidence_band
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Location:
|
|
13
|
+
"""Structural locator. ``span`` is a character offset range inside the scanned value/line."""
|
|
14
|
+
|
|
15
|
+
record_index: int
|
|
16
|
+
line: int | None
|
|
17
|
+
field_path: str
|
|
18
|
+
column_index: int | None = None
|
|
19
|
+
span: tuple[int, int] | None = None
|
|
20
|
+
in_key: bool = False
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, Any]:
|
|
23
|
+
data: dict[str, Any] = {"record": self.record_index, "line": self.line}
|
|
24
|
+
if self.field_path:
|
|
25
|
+
data["field_path"] = self.field_path
|
|
26
|
+
if self.column_index is not None:
|
|
27
|
+
data["column"] = self.column_index
|
|
28
|
+
if self.span is not None:
|
|
29
|
+
data["span"] = [self.span[0], self.span[1]]
|
|
30
|
+
if self.in_key:
|
|
31
|
+
data["in_key"] = True
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Finding:
|
|
37
|
+
category: str
|
|
38
|
+
detector: str
|
|
39
|
+
confidence: float
|
|
40
|
+
evidence: tuple[str, ...]
|
|
41
|
+
location: Location
|
|
42
|
+
field_name: str | None
|
|
43
|
+
value_type: str
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def band(self) -> str:
|
|
47
|
+
return confidence_band(self.confidence)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def whole_value(self) -> bool:
|
|
51
|
+
return self.location.span is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def value_type_name(value: Any) -> str:
|
|
55
|
+
"""Basic JSON-ish type name. Never the value itself."""
|
|
56
|
+
if value is None:
|
|
57
|
+
return "null"
|
|
58
|
+
if isinstance(value, bool):
|
|
59
|
+
return "boolean"
|
|
60
|
+
if isinstance(value, int):
|
|
61
|
+
return "integer"
|
|
62
|
+
if isinstance(value, float):
|
|
63
|
+
return "float"
|
|
64
|
+
if isinstance(value, str):
|
|
65
|
+
return "string"
|
|
66
|
+
if isinstance(value, list):
|
|
67
|
+
return "array"
|
|
68
|
+
if isinstance(value, dict):
|
|
69
|
+
return "object"
|
|
70
|
+
return "other"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class ScanItem:
|
|
75
|
+
"""One scalar value (or JSON key) handed to detectors. Held in memory only during a scan."""
|
|
76
|
+
|
|
77
|
+
record_index: int
|
|
78
|
+
line: int | None
|
|
79
|
+
field_path: str
|
|
80
|
+
field_name: str | None
|
|
81
|
+
value: Any
|
|
82
|
+
column_index: int | None = None
|
|
83
|
+
in_key: bool = False
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def text(self) -> str | None:
|
|
87
|
+
"""Text form used by content detectors. Booleans and nulls carry no scannable content."""
|
|
88
|
+
if self.value is None or isinstance(self.value, bool):
|
|
89
|
+
return None
|
|
90
|
+
if isinstance(self.value, str):
|
|
91
|
+
return self.value
|
|
92
|
+
if isinstance(self.value, int | float):
|
|
93
|
+
return repr(self.value) if isinstance(self.value, float) else str(self.value)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def is_empty(self) -> bool:
|
|
98
|
+
return self.value is None or self.value == ""
|
|
99
|
+
|
|
100
|
+
def location(self, span: tuple[int, int] | None = None) -> Location:
|
|
101
|
+
return Location(
|
|
102
|
+
record_index=self.record_index,
|
|
103
|
+
line=self.line,
|
|
104
|
+
field_path=self.field_path,
|
|
105
|
+
column_index=self.column_index,
|
|
106
|
+
span=span,
|
|
107
|
+
in_key=self.in_key,
|
|
108
|
+
)
|