ciphergate-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. ciphergate/__init__.py +95 -0
  2. ciphergate/__main__.py +6 -0
  3. ciphergate/cli.py +832 -0
  4. ciphergate/config.py +50 -0
  5. ciphergate/constants.py +274 -0
  6. ciphergate/detectors/__init__.py +5 -0
  7. ciphergate/detectors/base.py +53 -0
  8. ciphergate/detectors/custom_regex.py +37 -0
  9. ciphergate/detectors/dates.py +92 -0
  10. ciphergate/detectors/email.py +56 -0
  11. ciphergate/detectors/field_names.py +220 -0
  12. ciphergate/detectors/ip_address.py +85 -0
  13. ciphergate/detectors/network.py +42 -0
  14. ciphergate/detectors/payment_card.py +77 -0
  15. ciphergate/detectors/phone.py +67 -0
  16. ciphergate/detectors/presidio_ner.py +112 -0
  17. ciphergate/detectors/regex_safety.py +40 -0
  18. ciphergate/detectors/registry.py +194 -0
  19. ciphergate/detectors/secrets.py +130 -0
  20. ciphergate/errors.py +119 -0
  21. ciphergate/models/__init__.py +1 -0
  22. ciphergate/models/context.py +69 -0
  23. ciphergate/models/finding.py +108 -0
  24. ciphergate/models/policy.py +402 -0
  25. ciphergate/models/report.py +115 -0
  26. ciphergate/pipeline/__init__.py +1 -0
  27. ciphergate/pipeline/atomic_output.py +143 -0
  28. ciphergate/pipeline/batch.py +533 -0
  29. ciphergate/pipeline/inspector.py +282 -0
  30. ciphergate/pipeline/paths.py +98 -0
  31. ciphergate/pipeline/planner.py +192 -0
  32. ciphergate/pipeline/rescan.py +120 -0
  33. ciphergate/pipeline/sanitizer.py +392 -0
  34. ciphergate/pipeline/stdio.py +173 -0
  35. ciphergate/pipeline/streams.py +152 -0
  36. ciphergate/policy/__init__.py +26 -0
  37. ciphergate/policy/context.py +203 -0
  38. ciphergate/policy/engine.py +194 -0
  39. ciphergate/policy/loader.py +116 -0
  40. ciphergate/policy/precedence.py +149 -0
  41. ciphergate/policy/validator.py +632 -0
  42. ciphergate/py.typed +0 -0
  43. ciphergate/readers/__init__.py +113 -0
  44. ciphergate/readers/accesslog_reader.py +244 -0
  45. ciphergate/readers/base.py +131 -0
  46. ciphergate/readers/csv_reader.py +241 -0
  47. ciphergate/readers/json_reader.py +184 -0
  48. ciphergate/readers/jsonl_reader.py +97 -0
  49. ciphergate/readers/jsonlog_reader.py +178 -0
  50. ciphergate/readers/line_structure.py +125 -0
  51. ciphergate/readers/logfmt_reader.py +100 -0
  52. ciphergate/readers/stacktrace_reader.py +103 -0
  53. ciphergate/readers/syslog_reader.py +309 -0
  54. ciphergate/readers/text_reader.py +59 -0
  55. ciphergate/reporting/__init__.py +1 -0
  56. ciphergate/reporting/audit.py +85 -0
  57. ciphergate/reporting/safe_text.py +71 -0
  58. ciphergate/reporting/serializer.py +44 -0
  59. ciphergate/reporting/terminal.py +147 -0
  60. ciphergate/templates/starter_policy.yml +77 -0
  61. ciphergate/transforms/__init__.py +6 -0
  62. ciphergate/transforms/date_shift.py +79 -0
  63. ciphergate/transforms/generalize.py +107 -0
  64. ciphergate/transforms/keys.py +177 -0
  65. ciphergate/transforms/pseudonymize.py +113 -0
  66. ciphergate/transforms/redact.py +9 -0
  67. ciphergate/transforms/registry.py +201 -0
  68. ciphergate/transforms/replace.py +16 -0
  69. ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
  70. ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
  71. ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
  72. ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
  73. ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
  74. ciphergate_cli-0.7.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,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
+ )