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,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