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,632 @@
|
|
|
1
|
+
"""Policy compilation: schema validation plus semantic checks, producing an immutable ``Policy``.
|
|
2
|
+
|
|
3
|
+
Semantic checks (all errors are reported together, with safe messages):
|
|
4
|
+
|
|
5
|
+
* categories/detectors referenced anywhere must exist (built-in or declared ``custom_categories``);
|
|
6
|
+
* action parameters must validate against the action's typed parameter model;
|
|
7
|
+
* credentials can never be ``allow``-ed (nor generalized/date-shifted);
|
|
8
|
+
* ``allow`` of a high-risk category toward a possibly untrusted destination needs
|
|
9
|
+
``acknowledge_risk: true`` (reported as a warning);
|
|
10
|
+
* ``hard``/``guardrail`` rules cannot be ``allow``; guardrails must name environment and
|
|
11
|
+
target environment or destination;
|
|
12
|
+
* equally ranked rules that may overlap and disagree are rejected as ``policy_conflict``;
|
|
13
|
+
* custom/field regexes must compile and pass the regex-safety heuristics;
|
|
14
|
+
* replacement constants must not look like real identifiers or secrets;
|
|
15
|
+
* untrusted pinned destinations and transfer approvals require ``strict`` and the rescan gate;
|
|
16
|
+
untrusted pinned destinations also require ``scan_json_keys``;
|
|
17
|
+
* credential detectors cannot be disabled in strict/untrusted/transfer policies;
|
|
18
|
+
* ``rescan.fail_on_categories`` cannot be empty (``credential.secret`` is always gating).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import fnmatch
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from pydantic import ValidationError
|
|
31
|
+
|
|
32
|
+
from ciphergate.constants import (
|
|
33
|
+
BUILTIN_CATEGORIES,
|
|
34
|
+
CREDENTIAL_CATEGORY,
|
|
35
|
+
CREDENTIAL_DETECTORS,
|
|
36
|
+
CREDENTIAL_PERMITTED_ACTIONS,
|
|
37
|
+
HIGH_RISK_CATEGORIES,
|
|
38
|
+
HIGH_RISK_FAMILIES,
|
|
39
|
+
STACK_TRACE_DETECTOR,
|
|
40
|
+
UNTRUSTED_DESTINATIONS,
|
|
41
|
+
Action,
|
|
42
|
+
Destination,
|
|
43
|
+
InputFormat,
|
|
44
|
+
)
|
|
45
|
+
from ciphergate.detectors.field_names import normalize_field_name
|
|
46
|
+
from ciphergate.detectors.regex_safety import regex_problems
|
|
47
|
+
from ciphergate.detectors.registry import BUILTIN_DETECTOR_IDS, unsafe_replacement_reason
|
|
48
|
+
from ciphergate.errors import PolicyValidationError, ValidationIssue
|
|
49
|
+
from ciphergate.models.context import EffectiveContext
|
|
50
|
+
from ciphergate.models.finding import Finding
|
|
51
|
+
from ciphergate.models.policy import (
|
|
52
|
+
CATEGORY_RE,
|
|
53
|
+
CATEGORY_SELECTOR_RE,
|
|
54
|
+
PARAM_MODELS,
|
|
55
|
+
ActionParams,
|
|
56
|
+
DateShiftParams,
|
|
57
|
+
PolicyDocument,
|
|
58
|
+
PseudonymizeParams,
|
|
59
|
+
ReplaceParams,
|
|
60
|
+
Rule,
|
|
61
|
+
)
|
|
62
|
+
from ciphergate.policy.loader import parse_policy_yaml, policy_digest, read_policy_bytes
|
|
63
|
+
from ciphergate.policy.precedence import (
|
|
64
|
+
LEVEL_GUARDRAIL,
|
|
65
|
+
LEVEL_HARD_RULE,
|
|
66
|
+
category_selector_matches,
|
|
67
|
+
matches_may_overlap,
|
|
68
|
+
rule_level,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
MATCH_NO = 0
|
|
72
|
+
MATCH_YES = 1
|
|
73
|
+
MATCH_CONFIDENCE_ONLY = 2
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class CompiledRule:
|
|
78
|
+
rule: Rule
|
|
79
|
+
index: int
|
|
80
|
+
level: int
|
|
81
|
+
params: ActionParams
|
|
82
|
+
field_names: frozenset[str] | None
|
|
83
|
+
field_regex: re.Pattern[str] | None
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def id(self) -> str:
|
|
87
|
+
return self.rule.id
|
|
88
|
+
|
|
89
|
+
def evaluate(self, finding: Finding, ctx: EffectiveContext, fmt: InputFormat) -> int:
|
|
90
|
+
m = self.rule.match
|
|
91
|
+
if m.categories is not None and not any(
|
|
92
|
+
category_selector_matches(s, finding.category) for s in m.categories
|
|
93
|
+
):
|
|
94
|
+
return MATCH_NO
|
|
95
|
+
if m.detectors is not None and finding.detector not in m.detectors:
|
|
96
|
+
return MATCH_NO
|
|
97
|
+
if m.formats is not None and fmt not in m.formats:
|
|
98
|
+
return MATCH_NO
|
|
99
|
+
if self.field_names is not None and (
|
|
100
|
+
not finding.field_name
|
|
101
|
+
or normalize_field_name(finding.field_name) not in self.field_names
|
|
102
|
+
):
|
|
103
|
+
return MATCH_NO
|
|
104
|
+
path = finding.location.field_path
|
|
105
|
+
if m.field_paths is not None and not any(
|
|
106
|
+
fnmatch.fnmatchcase(path, p) for p in m.field_paths
|
|
107
|
+
):
|
|
108
|
+
return MATCH_NO
|
|
109
|
+
if self.field_regex is not None and not self.field_regex.search(path):
|
|
110
|
+
return MATCH_NO
|
|
111
|
+
for selector, value in (
|
|
112
|
+
(m.environments, ctx.environment),
|
|
113
|
+
(m.target_environments, ctx.target_environment),
|
|
114
|
+
(m.destinations, ctx.destination),
|
|
115
|
+
(m.domain_profiles, ctx.domain_profile),
|
|
116
|
+
(m.use_cases, ctx.use_case),
|
|
117
|
+
):
|
|
118
|
+
if selector is not None and (value is None or value not in selector):
|
|
119
|
+
return MATCH_NO
|
|
120
|
+
if m.min_confidence is not None and finding.confidence < m.min_confidence:
|
|
121
|
+
return MATCH_CONFIDENCE_ONLY
|
|
122
|
+
if m.max_confidence is not None and finding.confidence > m.max_confidence:
|
|
123
|
+
return MATCH_NO
|
|
124
|
+
return MATCH_YES
|
|
125
|
+
|
|
126
|
+
def selectors(self) -> tuple[str, ...]:
|
|
127
|
+
return tuple(name for name, value in self.rule.match if value is not None)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class CompiledDefault:
|
|
132
|
+
selector: str
|
|
133
|
+
action: Action
|
|
134
|
+
params: ActionParams
|
|
135
|
+
reason: str | None
|
|
136
|
+
specificity: int # 0 exact, 1 prefix glob, 2 "*"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class Policy:
|
|
141
|
+
"""A validated, compiled policy. Immutable; safe to share across runs."""
|
|
142
|
+
|
|
143
|
+
document: PolicyDocument
|
|
144
|
+
digest: str
|
|
145
|
+
source_name: str | None
|
|
146
|
+
rules: tuple[CompiledRule, ...]
|
|
147
|
+
defaults: tuple[CompiledDefault, ...]
|
|
148
|
+
categories: frozenset[str]
|
|
149
|
+
warnings: tuple[str, ...]
|
|
150
|
+
required_keys: frozenset[str]
|
|
151
|
+
replacement_values: frozenset[str] = field(default_factory=frozenset)
|
|
152
|
+
# Built-in high-risk categories plus custom categories in a high-risk family.
|
|
153
|
+
high_risk: frozenset[str] = HIGH_RISK_CATEGORIES
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def name(self) -> str:
|
|
157
|
+
return self.document.name
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def version(self) -> int:
|
|
161
|
+
return self.document.policy_version
|
|
162
|
+
|
|
163
|
+
def default_for(self, category: str) -> CompiledDefault | None:
|
|
164
|
+
for default in self.defaults:
|
|
165
|
+
if category_selector_matches(default.selector, category):
|
|
166
|
+
return default
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def rescan_fail_categories(self, *, untrusted: bool = False) -> frozenset[str]:
|
|
170
|
+
"""Categories whose unexpected residuals fail the rescan.
|
|
171
|
+
|
|
172
|
+
``credential.secret`` is always included; toward an untrusted (or missing) destination
|
|
173
|
+
every high-risk category is included regardless of configuration.
|
|
174
|
+
"""
|
|
175
|
+
configured = self.document.settings.rescan.fail_on_categories
|
|
176
|
+
base = frozenset(configured) if configured is not None else self.high_risk
|
|
177
|
+
base |= {CREDENTIAL_CATEGORY}
|
|
178
|
+
return base | self.high_risk if untrusted else base
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class ValidationResult:
|
|
183
|
+
valid: bool
|
|
184
|
+
errors: tuple[ValidationIssue, ...]
|
|
185
|
+
warnings: tuple[str, ...]
|
|
186
|
+
policy: Policy | None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _loc(parts: tuple[int | str, ...]) -> str:
|
|
190
|
+
out = ""
|
|
191
|
+
for p in parts:
|
|
192
|
+
out += f"[{p}]" if isinstance(p, int) else (f".{p}" if out else str(p))
|
|
193
|
+
return out or "policy"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _pydantic_issues(exc: ValidationError) -> list[ValidationIssue]:
|
|
197
|
+
issues = []
|
|
198
|
+
for err in exc.errors(include_url=False, include_input=False, include_context=False):
|
|
199
|
+
msg = str(err.get("msg", "invalid value"))
|
|
200
|
+
msg = msg.removeprefix("Value error, ")
|
|
201
|
+
issues.append(ValidationIssue(_loc(tuple(err.get("loc", ()))), msg, str(err.get("type"))))
|
|
202
|
+
return issues
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_params(
|
|
206
|
+
action: Action, params: dict[str, Any], where: str, issues: list[ValidationIssue]
|
|
207
|
+
) -> ActionParams | None:
|
|
208
|
+
model = PARAM_MODELS[action]
|
|
209
|
+
try:
|
|
210
|
+
return model.model_validate(params) # type: ignore[return-value]
|
|
211
|
+
except ValidationError as exc:
|
|
212
|
+
for issue in _pydantic_issues(exc):
|
|
213
|
+
issues.append(
|
|
214
|
+
ValidationIssue(f"{where}.params.{issue.location}", issue.message, issue.code)
|
|
215
|
+
)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _effective_destinations(doc: PolicyDocument, rule_dests: list[Destination] | None) -> set[str]:
|
|
220
|
+
if rule_dests is not None:
|
|
221
|
+
return {str(d) for d in rule_dests}
|
|
222
|
+
pinned = doc.context.destination
|
|
223
|
+
if pinned is not None and "destination" not in doc.context.allow_cli_override:
|
|
224
|
+
return {str(pinned)}
|
|
225
|
+
return {str(d) for d in Destination}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _selector_can_cover(selectors: list[str] | None, categories: frozenset[str]) -> set[str]:
|
|
229
|
+
if selectors is None:
|
|
230
|
+
return set(categories)
|
|
231
|
+
return {c for c in categories if any(category_selector_matches(s, c) for s in selectors)}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def compile_policy(raw: dict[str, Any], *, digest: str, source_name: str | None = None) -> Policy:
|
|
235
|
+
try:
|
|
236
|
+
doc = PolicyDocument.model_validate(raw)
|
|
237
|
+
except ValidationError as exc:
|
|
238
|
+
schema_issues = _pydantic_issues(exc)
|
|
239
|
+
raise PolicyValidationError(
|
|
240
|
+
f"policy failed schema validation ({len(schema_issues)} issue(s))",
|
|
241
|
+
issues=schema_issues,
|
|
242
|
+
) from None
|
|
243
|
+
|
|
244
|
+
issues: list[ValidationIssue] = []
|
|
245
|
+
warnings: list[str] = []
|
|
246
|
+
|
|
247
|
+
# --- categories
|
|
248
|
+
for i, cat in enumerate(doc.custom_categories):
|
|
249
|
+
if not CATEGORY_RE.match(cat):
|
|
250
|
+
issues.append(ValidationIssue(f"custom_categories[{i}]", "invalid category id format"))
|
|
251
|
+
elif cat in BUILTIN_CATEGORIES:
|
|
252
|
+
issues.append(
|
|
253
|
+
ValidationIssue(f"custom_categories[{i}]", "duplicates a built-in category")
|
|
254
|
+
)
|
|
255
|
+
categories = frozenset(BUILTIN_CATEGORIES | set(doc.custom_categories))
|
|
256
|
+
high_risk = HIGH_RISK_CATEGORIES | {
|
|
257
|
+
c for c in doc.custom_categories if c.split(".", 1)[0] in HIGH_RISK_FAMILIES
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
def check_category(cat: str, where: str) -> None:
|
|
261
|
+
if cat not in categories:
|
|
262
|
+
issues.append(
|
|
263
|
+
ValidationIssue(
|
|
264
|
+
where, "unknown category (declare it in custom_categories)", "unknown_category"
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def check_selector(sel: str, where: str) -> None:
|
|
269
|
+
if not CATEGORY_SELECTOR_RE.match(sel):
|
|
270
|
+
issues.append(ValidationIssue(where, "invalid category selector", "invalid_selector"))
|
|
271
|
+
elif not sel.endswith("*") and sel not in categories:
|
|
272
|
+
check_category(sel, where)
|
|
273
|
+
elif sel.endswith(".*") and not any(category_selector_matches(sel, c) for c in categories):
|
|
274
|
+
issues.append(
|
|
275
|
+
ValidationIssue(where, "selector matches no known category", "unknown_category")
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# --- detectors
|
|
279
|
+
detector_ids = set(BUILTIN_DETECTOR_IDS) | {"policy_classification", STACK_TRACE_DETECTOR}
|
|
280
|
+
if doc.detectors.presidio.enabled:
|
|
281
|
+
detector_ids.add("presidio_ner")
|
|
282
|
+
for entity, cat in doc.detectors.presidio.entities.items():
|
|
283
|
+
check_category(cat, f"detectors.presidio.entities.{entity}")
|
|
284
|
+
warnings.append(
|
|
285
|
+
"detectors.presidio is enabled: runs need the optional 'ner' extra and the spaCy "
|
|
286
|
+
f"model '{doc.detectors.presidio.model}' installed locally (never downloaded); NER "
|
|
287
|
+
"is incomplete and can be slow"
|
|
288
|
+
)
|
|
289
|
+
seen_custom: set[str] = set()
|
|
290
|
+
for i, det in enumerate(doc.detectors.custom):
|
|
291
|
+
where = f"detectors.custom[{i}]"
|
|
292
|
+
if det.id in seen_custom:
|
|
293
|
+
issues.append(ValidationIssue(where, "duplicate custom detector id", "duplicate_id"))
|
|
294
|
+
seen_custom.add(det.id)
|
|
295
|
+
detector_ids.add(f"custom:{det.id}")
|
|
296
|
+
check_category(det.category, f"{where}.category")
|
|
297
|
+
for problem in regex_problems(det.pattern, strict=doc.detectors.reject_risky_regex):
|
|
298
|
+
issues.append(ValidationIssue(f"{where}.pattern", problem, "unsafe_regex"))
|
|
299
|
+
for i, name in enumerate(doc.detectors.disabled):
|
|
300
|
+
if name not in BUILTIN_DETECTOR_IDS:
|
|
301
|
+
issues.append(
|
|
302
|
+
ValidationIssue(
|
|
303
|
+
f"detectors.disabled[{i}]", "unknown built-in detector", "unknown_detector"
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
pinned_untrusted = (
|
|
307
|
+
doc.context.destination in UNTRUSTED_DESTINATIONS
|
|
308
|
+
or doc.context.target_environment in ("external", "unknown")
|
|
309
|
+
)
|
|
310
|
+
disabled_credential = CREDENTIAL_DETECTORS & set(doc.detectors.disabled)
|
|
311
|
+
if disabled_credential and (doc.settings.strict or pinned_untrusted or doc.transfers):
|
|
312
|
+
issues.append(
|
|
313
|
+
ValidationIssue(
|
|
314
|
+
"detectors.disabled",
|
|
315
|
+
"secret_pattern/field_name cannot be disabled in strict, untrusted-destination "
|
|
316
|
+
"or transfer-approving policies",
|
|
317
|
+
"credential_detection_required",
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
elif disabled_credential:
|
|
321
|
+
warnings.append("credential/field-name detection is disabled; secrets may go undetected")
|
|
322
|
+
|
|
323
|
+
# --- classifications
|
|
324
|
+
seen_cls: set[str] = set()
|
|
325
|
+
for i, cls_ in enumerate(doc.classifications):
|
|
326
|
+
if cls_.id in seen_cls:
|
|
327
|
+
issues.append(
|
|
328
|
+
ValidationIssue(f"classifications[{i}].id", "duplicate id", "duplicate_id")
|
|
329
|
+
)
|
|
330
|
+
seen_cls.add(cls_.id)
|
|
331
|
+
check_category(cls_.category, f"classifications[{i}].category")
|
|
332
|
+
|
|
333
|
+
# --- rules
|
|
334
|
+
compiled: list[CompiledRule] = []
|
|
335
|
+
required_keys: set[str] = set()
|
|
336
|
+
replacement_values: set[str] = set()
|
|
337
|
+
seen_rules: set[str] = set()
|
|
338
|
+
for i, rule in enumerate(doc.rules):
|
|
339
|
+
where = f"rules[{i}]"
|
|
340
|
+
if rule.id in seen_rules:
|
|
341
|
+
issues.append(ValidationIssue(f"{where}.id", "duplicate rule id", "duplicate_id"))
|
|
342
|
+
seen_rules.add(rule.id)
|
|
343
|
+
m = rule.match
|
|
344
|
+
for j, sel in enumerate(m.categories or []):
|
|
345
|
+
check_selector(sel, f"{where}.match.categories[{j}]")
|
|
346
|
+
for j, det_id in enumerate(m.detectors or []):
|
|
347
|
+
if det_id not in detector_ids:
|
|
348
|
+
issues.append(
|
|
349
|
+
ValidationIssue(
|
|
350
|
+
f"{where}.match.detectors[{j}]", "unknown detector", "unknown_detector"
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
field_regex = None
|
|
354
|
+
if m.field_regex is not None:
|
|
355
|
+
problems = regex_problems(m.field_regex, strict=True)
|
|
356
|
+
if problems:
|
|
357
|
+
for problem in problems:
|
|
358
|
+
issues.append(
|
|
359
|
+
ValidationIssue(f"{where}.match.field_regex", problem, "unsafe_regex")
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
field_regex = re.compile(m.field_regex)
|
|
363
|
+
params = _parse_params(rule.action, rule.params, where, issues)
|
|
364
|
+
level = rule_level(rule)
|
|
365
|
+
if rule.hard and rule.action == Action.ALLOW:
|
|
366
|
+
issues.append(ValidationIssue(where, "hard rules cannot allow", "unsafe_allow"))
|
|
367
|
+
if rule.guardrail:
|
|
368
|
+
if rule.action == Action.ALLOW:
|
|
369
|
+
issues.append(
|
|
370
|
+
ValidationIssue(where, "guardrail rules cannot allow", "unsafe_allow")
|
|
371
|
+
)
|
|
372
|
+
if m.environments is None or (m.target_environments is None and m.destinations is None):
|
|
373
|
+
issues.append(
|
|
374
|
+
ValidationIssue(
|
|
375
|
+
where,
|
|
376
|
+
"guardrail rules must select environments and target_environments "
|
|
377
|
+
"or destinations",
|
|
378
|
+
"invalid_guardrail",
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
if rule.hard and rule.guardrail:
|
|
382
|
+
issues.append(ValidationIssue(where, "a rule cannot be both hard and guardrail"))
|
|
383
|
+
|
|
384
|
+
covered = _selector_can_cover(m.categories, categories)
|
|
385
|
+
if (
|
|
386
|
+
CREDENTIAL_CATEGORY in covered
|
|
387
|
+
and m.categories is not None
|
|
388
|
+
and (rule.action not in CREDENTIAL_PERMITTED_ACTIONS)
|
|
389
|
+
):
|
|
390
|
+
issues.append(
|
|
391
|
+
ValidationIssue(
|
|
392
|
+
where,
|
|
393
|
+
f"action '{rule.action}' is never permitted for credential.secret",
|
|
394
|
+
"credential_unsafe_action",
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
if rule.action == Action.ALLOW:
|
|
398
|
+
dests = _effective_destinations(doc, m.destinations)
|
|
399
|
+
untrusted = dests & {str(d) for d in UNTRUSTED_DESTINATIONS}
|
|
400
|
+
risky = covered & high_risk
|
|
401
|
+
if untrusted and risky and not rule.acknowledge_risk:
|
|
402
|
+
issues.append(
|
|
403
|
+
ValidationIssue(
|
|
404
|
+
where,
|
|
405
|
+
"allow may apply high-risk categories to an untrusted destination; "
|
|
406
|
+
"restrict "
|
|
407
|
+
"'categories'/'destinations' or set acknowledge_risk: true",
|
|
408
|
+
"high_risk_allow_unacknowledged",
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
elif untrusted and risky:
|
|
412
|
+
warnings.append(
|
|
413
|
+
f"rule '{rule.id}' allows high-risk categories toward a possibly untrusted "
|
|
414
|
+
"destination (acknowledge_risk: true)"
|
|
415
|
+
)
|
|
416
|
+
elif rule.acknowledge_risk:
|
|
417
|
+
warnings.append(
|
|
418
|
+
f"rule '{rule.id}': acknowledge_risk has no effect on non-allow actions"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if isinstance(params, PseudonymizeParams | DateShiftParams):
|
|
422
|
+
required_keys.add(params.key_env)
|
|
423
|
+
if isinstance(params, ReplaceParams) and isinstance(params.value, str):
|
|
424
|
+
reason = unsafe_replacement_reason(params.value)
|
|
425
|
+
if reason:
|
|
426
|
+
issues.append(
|
|
427
|
+
ValidationIssue(f"{where}.params.value", reason, "unsafe_replacement")
|
|
428
|
+
)
|
|
429
|
+
replacement_values.add(params.value)
|
|
430
|
+
if isinstance(params, ReplaceParams) and params.value is not None:
|
|
431
|
+
replacement_values.add(str(params.value))
|
|
432
|
+
if params is not None:
|
|
433
|
+
compiled.append(
|
|
434
|
+
CompiledRule(
|
|
435
|
+
rule=rule,
|
|
436
|
+
index=i,
|
|
437
|
+
level=level,
|
|
438
|
+
params=params,
|
|
439
|
+
field_names=(
|
|
440
|
+
frozenset(normalize_field_name(n) for n in m.field_names)
|
|
441
|
+
if m.field_names is not None
|
|
442
|
+
else None
|
|
443
|
+
),
|
|
444
|
+
field_regex=field_regex,
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# --- conflicts between equally ranked rules
|
|
449
|
+
for a_idx, a in enumerate(compiled):
|
|
450
|
+
for b in compiled[a_idx + 1 :]:
|
|
451
|
+
if a.level != b.level or a.rule.priority != b.rule.priority:
|
|
452
|
+
continue
|
|
453
|
+
if a.rule.action == b.rule.action and a.params == b.params:
|
|
454
|
+
continue
|
|
455
|
+
if a.level in (LEVEL_HARD_RULE, LEVEL_GUARDRAIL) and (
|
|
456
|
+
a.rule.action == b.rule.action == Action.BLOCK
|
|
457
|
+
):
|
|
458
|
+
continue
|
|
459
|
+
if matches_may_overlap(a.rule.match, b.rule.match):
|
|
460
|
+
issues.append(
|
|
461
|
+
ValidationIssue(
|
|
462
|
+
f"rules[{a.index}],rules[{b.index}]",
|
|
463
|
+
f"rules '{a.id}' and '{b.id}' have the same precedence level and priority, "
|
|
464
|
+
"may match the same finding, and choose different actions; "
|
|
465
|
+
"change a priority or narrow a selector",
|
|
466
|
+
"policy_conflict",
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# --- defaults
|
|
471
|
+
defaults: list[CompiledDefault] = []
|
|
472
|
+
for sel, spec in doc.defaults.items():
|
|
473
|
+
where = f"defaults.{sel}"
|
|
474
|
+
check_selector(sel, where)
|
|
475
|
+
params = _parse_params(spec.action, spec.params, where, issues)
|
|
476
|
+
covered = (
|
|
477
|
+
_selector_can_cover([sel], categories) if CATEGORY_SELECTOR_RE.match(sel) else set()
|
|
478
|
+
)
|
|
479
|
+
if spec.action == Action.ALLOW and covered & high_risk:
|
|
480
|
+
issues.append(
|
|
481
|
+
ValidationIssue(
|
|
482
|
+
where,
|
|
483
|
+
"defaults cannot allow high-risk categories; use an explicit scoped rule",
|
|
484
|
+
"unsafe_allow",
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
if CREDENTIAL_CATEGORY in covered and spec.action not in CREDENTIAL_PERMITTED_ACTIONS:
|
|
488
|
+
issues.append(
|
|
489
|
+
ValidationIssue(
|
|
490
|
+
where,
|
|
491
|
+
f"action '{spec.action}' is never permitted for credential.secret",
|
|
492
|
+
"credential_unsafe_action",
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
if isinstance(params, PseudonymizeParams | DateShiftParams):
|
|
496
|
+
required_keys.add(params.key_env)
|
|
497
|
+
if isinstance(params, ReplaceParams) and params.value is not None:
|
|
498
|
+
if isinstance(params.value, str):
|
|
499
|
+
reason = unsafe_replacement_reason(params.value)
|
|
500
|
+
if reason:
|
|
501
|
+
issues.append(
|
|
502
|
+
ValidationIssue(f"{where}.params.value", reason, "unsafe_replacement")
|
|
503
|
+
)
|
|
504
|
+
replacement_values.add(str(params.value))
|
|
505
|
+
if params is not None:
|
|
506
|
+
specificity = 2 if sel == "*" else (1 if sel.endswith(".*") else 0)
|
|
507
|
+
defaults.append(CompiledDefault(sel, spec.action, params, spec.reason, specificity))
|
|
508
|
+
defaults.sort(key=lambda d: (d.specificity, -len(d.selector)))
|
|
509
|
+
|
|
510
|
+
# --- rescan / strictness / transfers
|
|
511
|
+
fail_on = doc.settings.rescan.fail_on_categories
|
|
512
|
+
for i, cat in enumerate(fail_on or []):
|
|
513
|
+
check_category(cat, f"settings.rescan.fail_on_categories[{i}]")
|
|
514
|
+
if fail_on is not None and not fail_on:
|
|
515
|
+
issues.append(
|
|
516
|
+
ValidationIssue(
|
|
517
|
+
"settings.rescan.fail_on_categories",
|
|
518
|
+
"must not be empty (omit it to use the high-risk default)",
|
|
519
|
+
"invalid_value",
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
elif fail_on is not None and not high_risk <= set(fail_on):
|
|
523
|
+
warnings.append(
|
|
524
|
+
"settings.rescan.fail_on_categories omits high-risk categories; credential.secret "
|
|
525
|
+
"is always gating and untrusted destinations still gate on every high-risk category"
|
|
526
|
+
)
|
|
527
|
+
if not doc.requirements.destination:
|
|
528
|
+
warnings.append(
|
|
529
|
+
"requirements.destination is false: runs without a destination are treated as "
|
|
530
|
+
"untrusted (strict policy and rescan gate required)"
|
|
531
|
+
)
|
|
532
|
+
if doc.settings.scan_json_keys is False and pinned_untrusted:
|
|
533
|
+
issues.append(
|
|
534
|
+
ValidationIssue(
|
|
535
|
+
"settings.scan_json_keys",
|
|
536
|
+
"policies pinned to an external/untrusted destination must scan JSON keys",
|
|
537
|
+
"key_scan_required",
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
if pinned_untrusted:
|
|
541
|
+
if not doc.settings.strict:
|
|
542
|
+
issues.append(
|
|
543
|
+
ValidationIssue(
|
|
544
|
+
"settings.strict",
|
|
545
|
+
"policies pinned to an external/untrusted destination must be strict",
|
|
546
|
+
"strict_required",
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
if not doc.settings.rescan.enabled:
|
|
550
|
+
issues.append(
|
|
551
|
+
ValidationIssue(
|
|
552
|
+
"settings.rescan.enabled",
|
|
553
|
+
"policies pinned to an external/untrusted destination "
|
|
554
|
+
"must enable the rescan gate",
|
|
555
|
+
"rescan_required",
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
seen_transfers: set[str] = set()
|
|
559
|
+
for i, transfer in enumerate(doc.transfers):
|
|
560
|
+
if transfer.id in seen_transfers:
|
|
561
|
+
issues.append(ValidationIssue(f"transfers[{i}].id", "duplicate id", "duplicate_id"))
|
|
562
|
+
seen_transfers.add(transfer.id)
|
|
563
|
+
if transfer.approval.approved_on > dt.date.today():
|
|
564
|
+
issues.append(
|
|
565
|
+
ValidationIssue(
|
|
566
|
+
f"transfers[{i}].approval.approved_on",
|
|
567
|
+
"approval date is in the future",
|
|
568
|
+
"invalid_value",
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
warnings.append(
|
|
572
|
+
f"transfer approval '{transfer.id}' lifts the built-in production-to-non-production "
|
|
573
|
+
"block for the listed context; confirm it is still reviewed and current"
|
|
574
|
+
)
|
|
575
|
+
if doc.transfers and (not doc.settings.strict or not doc.settings.rescan.enabled):
|
|
576
|
+
issues.append(
|
|
577
|
+
ValidationIssue(
|
|
578
|
+
"transfers",
|
|
579
|
+
"transfer approvals require settings.strict and settings.rescan.enabled",
|
|
580
|
+
"transfer_requires_strict",
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
if not doc.settings.strict:
|
|
584
|
+
warnings.append("settings.strict is false: unknown context will not force review")
|
|
585
|
+
if doc.context.allow_cli_override:
|
|
586
|
+
warnings.append(
|
|
587
|
+
"context.allow_cli_override permits CLI values to replace pinned context: "
|
|
588
|
+
+ ", ".join(doc.context.allow_cli_override)
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
if issues:
|
|
592
|
+
raise PolicyValidationError(
|
|
593
|
+
f"policy failed semantic validation ({len(issues)} issue(s))", issues=issues
|
|
594
|
+
)
|
|
595
|
+
return Policy(
|
|
596
|
+
document=doc,
|
|
597
|
+
digest=digest,
|
|
598
|
+
source_name=source_name,
|
|
599
|
+
rules=tuple(compiled),
|
|
600
|
+
defaults=tuple(defaults),
|
|
601
|
+
categories=categories,
|
|
602
|
+
warnings=tuple(warnings),
|
|
603
|
+
required_keys=frozenset(required_keys),
|
|
604
|
+
replacement_values=frozenset(replacement_values),
|
|
605
|
+
high_risk=frozenset(high_risk),
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def load_policy(path: str | Path) -> Policy:
|
|
610
|
+
"""Load, schema-validate and compile a policy file. Raises ``PolicyValidationError``."""
|
|
611
|
+
data = read_policy_bytes(path)
|
|
612
|
+
raw = parse_policy_yaml(data)
|
|
613
|
+
return compile_policy(raw, digest=policy_digest(data), source_name=Path(path).name)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def load_policy_text(text: str, *, source_name: str | None = None) -> Policy:
|
|
617
|
+
data = text.encode("utf-8")
|
|
618
|
+
return compile_policy(
|
|
619
|
+
parse_policy_yaml(data), digest=policy_digest(data), source_name=source_name
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def validate_policy(source: str | Path | Policy) -> ValidationResult:
|
|
624
|
+
"""Validate without raising. Never reads any input dataset."""
|
|
625
|
+
if isinstance(source, Policy):
|
|
626
|
+
return ValidationResult(True, (), source.warnings, source)
|
|
627
|
+
try:
|
|
628
|
+
policy = load_policy(source)
|
|
629
|
+
except PolicyValidationError as exc:
|
|
630
|
+
issues = exc.issues or [ValidationIssue("policy", exc.message, exc.code)]
|
|
631
|
+
return ValidationResult(False, tuple(issues), (), None)
|
|
632
|
+
return ValidationResult(True, (), policy.warnings, policy)
|
ciphergate/py.typed
ADDED
|
File without changes
|