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,152 @@
1
+ """Binary-stream variants of the single-file operations (Python API counterpart of CLI ``-``).
2
+
3
+ The pipeline reads its input twice and checks it did not change, so a stream is first spooled
4
+ into a private workspace (``StdioWorkspace``: owner-only on POSIX), capped at
5
+ ``max_input_bytes`` while reading. ``sanitize_stream`` sanitizes into the same workspace and
6
+ copies to ``output`` only after every check passed; a blocked, review or failed run writes
7
+ nothing to it. The workspace is removed on success, failure and interruption.
8
+
9
+ Streams must be binary (``open(..., "rb")``, ``io.BytesIO``); the format is always required
10
+ because there is no file name to infer it from. Reports show ``<stream>`` as the path.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ from pathlib import Path
17
+ from typing import IO
18
+
19
+ from ciphergate.config import RuntimeSettings
20
+ from ciphergate.constants import InputFormat
21
+ from ciphergate.errors import OutputWriteError, UsageError
22
+ from ciphergate.models.context import ContextInput
23
+ from ciphergate.models.report import RunReport
24
+ from ciphergate.pipeline.sanitizer import (
25
+ SanitizeOptions,
26
+ inspect_file,
27
+ plan_file,
28
+ rescan_file,
29
+ sanitize_file,
30
+ )
31
+ from ciphergate.pipeline.stdio import StdioWorkspace
32
+ from ciphergate.policy.validator import Policy
33
+ from ciphergate.transforms.keys import KeyProvider
34
+
35
+ STREAM_DISPLAY = "<stream>"
36
+ _CHUNK = 1024 * 1024
37
+
38
+
39
+ def _require_format(fmt: InputFormat | None) -> InputFormat:
40
+ if fmt is None:
41
+ raise UsageError("a stream needs an explicit format", code="format_required")
42
+ return InputFormat(fmt)
43
+
44
+
45
+ def _spool(
46
+ ws: StdioWorkspace, stream: IO[bytes], fmt: InputFormat, runtime: RuntimeSettings
47
+ ) -> Path:
48
+ return ws.spool_input(stream, fmt, runtime.max_input_bytes, label="input stream")
49
+
50
+
51
+ def inspect_stream(
52
+ stream: IO[bytes],
53
+ policy: Policy,
54
+ context: ContextInput | None = None,
55
+ *,
56
+ fmt: InputFormat,
57
+ runtime: RuntimeSettings | None = None,
58
+ keys: KeyProvider | None = None,
59
+ ) -> RunReport:
60
+ """Detect and evaluate data read from a binary stream; writes no data anywhere."""
61
+ fmt, runtime = _require_format(fmt), runtime or RuntimeSettings()
62
+ with StdioWorkspace() as ws:
63
+ report = inspect_file(_spool(ws, stream, fmt, runtime), policy, context,
64
+ runtime=runtime, fmt=fmt, keys=keys) # fmt: skip
65
+ report.input["path"] = STREAM_DISPLAY
66
+ return report
67
+
68
+
69
+ def plan_stream(
70
+ stream: IO[bytes],
71
+ policy: Policy,
72
+ context: ContextInput | None = None,
73
+ *,
74
+ fmt: InputFormat,
75
+ runtime: RuntimeSettings | None = None,
76
+ keys: KeyProvider | None = None,
77
+ ) -> RunReport:
78
+ """Preview the decisions ``sanitize_stream`` would make; writes no data anywhere."""
79
+ fmt, runtime = _require_format(fmt), runtime or RuntimeSettings()
80
+ with StdioWorkspace() as ws:
81
+ report = plan_file(_spool(ws, stream, fmt, runtime), policy, context,
82
+ runtime=runtime, fmt=fmt, keys=keys) # fmt: skip
83
+ report.input["path"] = STREAM_DISPLAY
84
+ return report
85
+
86
+
87
+ def rescan_stream(
88
+ stream: IO[bytes],
89
+ policy: Policy,
90
+ context: ContextInput | None = None,
91
+ *,
92
+ fmt: InputFormat,
93
+ strict: bool | None = None,
94
+ runtime: RuntimeSettings | None = None,
95
+ ) -> RunReport:
96
+ """Report residual findings in (sanitized) data read from a binary stream. Read-only."""
97
+ fmt, runtime = _require_format(fmt), runtime or RuntimeSettings()
98
+ with StdioWorkspace() as ws:
99
+ report = rescan_file(_spool(ws, stream, fmt, runtime), policy, context,
100
+ strict=strict, runtime=runtime, fmt=fmt) # fmt: skip
101
+ report.input["path"] = STREAM_DISPLAY
102
+ return report
103
+
104
+
105
+ def sanitize_stream(
106
+ stream: IO[bytes],
107
+ output: IO[bytes],
108
+ policy: Policy,
109
+ context: ContextInput | None = None,
110
+ options: SanitizeOptions | None = None,
111
+ ) -> RunReport:
112
+ """Sanitize a binary stream into ``output``; ``output`` is written only if the run succeeded.
113
+
114
+ ``options.format`` is required. Without ``options.apply`` this is a plan and nothing is
115
+ written. If copying to ``output`` fails part-way, ``OutputWriteError`` says so explicitly:
116
+ ``output`` may then hold an incomplete (but sanitized) prefix.
117
+ """
118
+ options = options or SanitizeOptions()
119
+ fmt = _require_format(options.format)
120
+ runtime = options.runtime or RuntimeSettings()
121
+ run_options = SanitizeOptions(
122
+ apply=options.apply, force=False, keys=options.keys, format=fmt, runtime=runtime
123
+ )
124
+ with StdioWorkspace() as ws:
125
+ staged = ws.output_path()
126
+ report = sanitize_file(_spool(ws, stream, fmt, runtime), staged, policy, context,
127
+ run_options) # fmt: skip
128
+ report.input["path"] = STREAM_DISPLAY
129
+ report.output["path"] = STREAM_DISPLAY
130
+ if report.output.get("written"):
131
+ _copy(staged, output)
132
+ return report
133
+
134
+
135
+ def _copy(path: Path, output: IO[bytes]) -> None:
136
+ try:
137
+ with path.open("rb") as fh:
138
+ shutil.copyfileobj(fh, output, _CHUNK)
139
+ output.flush()
140
+ except KeyboardInterrupt:
141
+ raise OutputWriteError(
142
+ "interrupted while writing the output stream; it may be incomplete",
143
+ code="stream_interrupted",
144
+ ) from None
145
+ except TypeError:
146
+ raise UsageError(
147
+ "output must be a binary stream (open it with 'wb')", code="stream_not_binary"
148
+ ) from None
149
+ except OSError:
150
+ raise OutputWriteError(
151
+ "failed to write the output stream; it may be incomplete", code="stream_write_failed"
152
+ ) from None
@@ -0,0 +1,26 @@
1
+ """Policy loading, validation, context resolution and the decision engine."""
2
+
3
+ from ciphergate.policy.context import GateIssue, evaluate_gates, resolve_context
4
+ from ciphergate.policy.engine import Decision, PolicyEngine
5
+ from ciphergate.policy.validator import (
6
+ Policy,
7
+ ValidationResult,
8
+ compile_policy,
9
+ load_policy,
10
+ load_policy_text,
11
+ validate_policy,
12
+ )
13
+
14
+ __all__ = [
15
+ "Decision",
16
+ "GateIssue",
17
+ "Policy",
18
+ "PolicyEngine",
19
+ "ValidationResult",
20
+ "compile_policy",
21
+ "evaluate_gates",
22
+ "load_policy",
23
+ "load_policy_text",
24
+ "resolve_context",
25
+ "validate_policy",
26
+ ]
@@ -0,0 +1,203 @@
1
+ """Context resolution (policy pins vs. caller input) and run-level environment guardrails.
2
+
3
+ Environment values are *declared* context. CipherGate cannot verify that data really came from the
4
+ stated environment; CI pipelines should source these values from controlled configuration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Literal
11
+
12
+ from ciphergate.constants import (
13
+ CREDENTIAL_DETECTORS,
14
+ NON_PRODUCTION_DESTINATIONS,
15
+ Destination,
16
+ Environment,
17
+ InputFormat,
18
+ ReasonCode,
19
+ TargetEnvironment,
20
+ )
21
+ from ciphergate.errors import ContextError
22
+ from ciphergate.models.context import (
23
+ CONTEXT_FIELDS,
24
+ ContextInput,
25
+ ContextSource,
26
+ EffectiveContext,
27
+ )
28
+ from ciphergate.policy.validator import Policy
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class GateIssue:
33
+ reason_code: ReasonCode
34
+ severity: Literal["block", "review"]
35
+ message: str
36
+ field: str | None = None
37
+
38
+ def to_dict(self) -> dict[str, str | None]:
39
+ return {
40
+ "reason_code": str(self.reason_code),
41
+ "severity": self.severity,
42
+ "field": self.field,
43
+ "message": self.message,
44
+ }
45
+
46
+
47
+ def resolve_context(policy: Policy, given: ContextInput | None = None) -> EffectiveContext:
48
+ """Combine policy-pinned context with caller input; conflicts fail unless overridable."""
49
+ given = given or ContextInput()
50
+ pins = policy.document.context
51
+ values: dict[str, object] = {}
52
+ sources: dict[str, ContextSource] = {}
53
+ for name in CONTEXT_FIELDS:
54
+ pinned = getattr(pins, name)
55
+ supplied = getattr(given, name)
56
+ if pinned is not None and supplied is not None and pinned != supplied:
57
+ if name not in pins.allow_cli_override:
58
+ raise ContextError(
59
+ f"'{name}' supplied by the caller conflicts with the value pinned by the "
60
+ "policy; "
61
+ "use a policy for that context or add it to context.allow_cli_override",
62
+ code=str(ReasonCode.ENVIRONMENT_CONTEXT_CONFLICT),
63
+ )
64
+ values[name], sources[name] = supplied, "cli_override"
65
+ elif pinned is not None:
66
+ values[name], sources[name] = pinned, "policy"
67
+ elif supplied is not None:
68
+ values[name], sources[name] = supplied, "cli"
69
+ else:
70
+ values[name], sources[name] = None, "unset"
71
+ return EffectiveContext(sources=sources, **values) # type: ignore[arg-type]
72
+
73
+
74
+ def _approval_matches(policy: Policy, ctx: EffectiveContext) -> str | None:
75
+ """An approval must name every context dimension the run supplies (no implicit widening)."""
76
+ for transfer in policy.document.transfers:
77
+ if ctx.environment not in transfer.from_environments:
78
+ continue
79
+ if ctx.target_environment is not None and (
80
+ ctx.target_environment not in transfer.to_target_environments
81
+ ):
82
+ continue
83
+ if ctx.destination is not None and ctx.destination not in transfer.to_destinations:
84
+ continue
85
+ return transfer.id
86
+ return None
87
+
88
+
89
+ def evaluate_gates(
90
+ policy: Policy, ctx: EffectiveContext, fmt: InputFormat | None = None
91
+ ) -> tuple[list[GateIssue], list[str]]:
92
+ """Run-level checks that no rule can override. Returns (issues, approvals_used).
93
+
94
+ A missing destination counts as untrusted: it never relaxes the strict/rescan requirements.
95
+ """
96
+ doc = policy.document
97
+ issues: list[GateIssue] = []
98
+ approvals: list[str] = []
99
+ req = doc.requirements
100
+
101
+ if req.environment and ctx.environment is None:
102
+ issues.append(
103
+ GateIssue(
104
+ ReasonCode.ENVIRONMENT_REQUIRED_BUT_MISSING,
105
+ "block",
106
+ "source environment is required (--environment)",
107
+ "environment",
108
+ )
109
+ )
110
+ if req.target_environment and ctx.target_environment is None:
111
+ issues.append(
112
+ GateIssue(
113
+ ReasonCode.TARGET_ENVIRONMENT_REQUIRED_BUT_MISSING,
114
+ "block",
115
+ "target environment is required (--target-environment)",
116
+ "target_environment",
117
+ )
118
+ )
119
+ if req.destination and ctx.destination is None:
120
+ issues.append(
121
+ GateIssue(
122
+ ReasonCode.DESTINATION_REQUIRED_BUT_MISSING,
123
+ "block",
124
+ "destination is required (--destination)",
125
+ "destination",
126
+ )
127
+ )
128
+
129
+ if doc.settings.strict:
130
+ for name, unknown in (
131
+ ("environment", Environment.UNKNOWN),
132
+ ("target_environment", TargetEnvironment.UNKNOWN),
133
+ ("destination", Destination.UNKNOWN),
134
+ ):
135
+ if getattr(ctx, name) == unknown:
136
+ issues.append(
137
+ GateIssue(
138
+ ReasonCode.UNKNOWN_CONTEXT_REQUIRES_REVIEW,
139
+ "review",
140
+ f"{name} is 'unknown'; strict policies require known "
141
+ "context before release",
142
+ name,
143
+ )
144
+ )
145
+
146
+ prd_to_nonprod = ctx.environment == Environment.PRD and (
147
+ ctx.target_environment in (TargetEnvironment.DEV, TargetEnvironment.STG)
148
+ or ctx.destination in NON_PRODUCTION_DESTINATIONS
149
+ )
150
+ if prd_to_nonprod:
151
+ approval = _approval_matches(policy, ctx)
152
+ if approval is None:
153
+ issues.append(
154
+ GateIssue(
155
+ ReasonCode.PRD_TO_NONPRODUCTION_BLOCKED,
156
+ "block",
157
+ "production-origin data headed to a non-production target is blocked "
158
+ "unless the "
159
+ "policy contains a reviewed 'transfers' approval for this context",
160
+ )
161
+ )
162
+ else:
163
+ approvals.append(approval)
164
+
165
+ untrusted = ctx.is_untrusted or ctx.target_environment == TargetEnvironment.PRD
166
+ if untrusted and not doc.settings.strict:
167
+ issues.append(
168
+ GateIssue(
169
+ ReasonCode.STRICT_POLICY_REQUIRED,
170
+ "block",
171
+ "external, unknown, missing or production targets require a strict policy",
172
+ )
173
+ )
174
+ if untrusted and not doc.settings.rescan.enabled:
175
+ issues.append(
176
+ GateIssue(
177
+ ReasonCode.RESCAN_REQUIRED,
178
+ "block",
179
+ "external, unknown, missing or production targets require the rescan gate",
180
+ )
181
+ )
182
+ if untrusted and CREDENTIAL_DETECTORS & set(doc.detectors.disabled):
183
+ issues.append(
184
+ GateIssue(
185
+ ReasonCode.CREDENTIAL_DETECTION_REQUIRED,
186
+ "block",
187
+ "secret_pattern/field_name detectors must stay enabled for this target",
188
+ )
189
+ )
190
+ if (
191
+ ctx.is_untrusted
192
+ and fmt in (InputFormat.JSON, InputFormat.JSONL)
193
+ and not doc.settings.scan_json_keys
194
+ ):
195
+ issues.append(
196
+ GateIssue(
197
+ ReasonCode.KEY_SCAN_REQUIRED,
198
+ "block",
199
+ "JSON keys are not scanned (settings.scan_json_keys: false); keys can carry "
200
+ "data and must be scanned for an untrusted or missing destination",
201
+ )
202
+ )
203
+ return issues, approvals
@@ -0,0 +1,194 @@
1
+ """The policy engine: (finding, context, format) -> Decision. Deterministic and side-effect free.
2
+
3
+ See ``ciphergate.policy.precedence`` for the precedence levels. After a rule/default/fallback is
4
+ selected, engine hard constraints are applied; they cannot be overridden by any rule:
5
+
6
+ * credentials are never allowed (nor generalized/date-shifted) — forced to ``block``;
7
+ * an ``allow`` of a high-risk category toward an untrusted or missing destination (or an
8
+ external/unknown target environment) without
9
+ ``acknowledge_risk`` becomes ``review``;
10
+ * transforms that cannot apply to the location (e.g. ``drop_field`` on a text line, value
11
+ transforms on JSON keys/CSV header cells) become ``review`` with ``unsupported_transform``, or
12
+ ``block`` when the finding is a credential.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from ciphergate.constants import (
21
+ CREDENTIAL_CATEGORY,
22
+ CREDENTIAL_PERMITTED_ACTIONS,
23
+ LINE_FORMATS,
24
+ VALUE_TRANSFORM_ACTIONS,
25
+ Action,
26
+ InputFormat,
27
+ ReasonCode,
28
+ )
29
+ from ciphergate.models.context import EffectiveContext
30
+ from ciphergate.models.finding import Finding
31
+ from ciphergate.models.policy import ActionParams, EmptyParams
32
+ from ciphergate.policy.precedence import (
33
+ LEVEL_DEFAULT,
34
+ LEVEL_FALLBACK,
35
+ LEVEL_GUARDRAIL,
36
+ LEVEL_HARD_CONSTRAINT,
37
+ LEVEL_HARD_RULE,
38
+ LEVEL_NAMES,
39
+ )
40
+ from ciphergate.policy.validator import (
41
+ MATCH_CONFIDENCE_ONLY,
42
+ MATCH_YES,
43
+ CompiledRule,
44
+ Policy,
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Decision:
50
+ action: Action
51
+ reason_code: ReasonCode
52
+ level: int
53
+ rule_id: str | None = None
54
+ rule_reason: str | None = None
55
+ params: ActionParams = field(default_factory=EmptyParams)
56
+ matched_on: tuple[str, ...] = ()
57
+ overridden_rule_id: str | None = None
58
+
59
+ @property
60
+ def requires_review(self) -> bool:
61
+ return self.action == Action.REVIEW
62
+
63
+ @property
64
+ def level_name(self) -> str:
65
+ return LEVEL_NAMES[self.level]
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ data: dict[str, Any] = {
69
+ "action": str(self.action),
70
+ "reason_code": str(self.reason_code),
71
+ "precedence": self.level_name,
72
+ "rule_id": self.rule_id,
73
+ }
74
+ if self.rule_reason:
75
+ data["rule_reason"] = self.rule_reason
76
+ if self.matched_on:
77
+ data["matched_on"] = list(self.matched_on)
78
+ if self.overridden_rule_id:
79
+ data["overridden_rule_id"] = self.overridden_rule_id
80
+ data["requires_review"] = self.requires_review
81
+ return data
82
+
83
+
84
+ class PolicyEngine:
85
+ def __init__(self, policy: Policy) -> None:
86
+ self.policy = policy
87
+
88
+ def decide(self, finding: Finding, ctx: EffectiveContext, fmt: InputFormat) -> Decision:
89
+ decision = self._select(finding, ctx, fmt)
90
+ return self._apply_hard_constraints(decision, finding, ctx, fmt)
91
+
92
+ def _select(self, finding: Finding, ctx: EffectiveContext, fmt: InputFormat) -> Decision:
93
+ matched: list[CompiledRule] = []
94
+ confidence_miss = False
95
+ for rule in self.policy.rules:
96
+ result = rule.evaluate(finding, ctx, fmt)
97
+ if result == MATCH_YES:
98
+ matched.append(rule)
99
+ elif result == MATCH_CONFIDENCE_ONLY:
100
+ confidence_miss = True
101
+ if matched:
102
+ level = min(r.level for r in matched)
103
+ at_level = [r for r in matched if r.level == level]
104
+ priority = max(r.rule.priority for r in at_level)
105
+ top = [r for r in at_level if r.rule.priority == priority]
106
+ if len({(r.rule.action, r.params.model_dump_json()) for r in top}) > 1:
107
+ # Unreachable for validated policies; fail closed rather than pick by order.
108
+ return Decision(
109
+ Action.BLOCK,
110
+ ReasonCode.POLICY_CONFLICT,
111
+ LEVEL_HARD_CONSTRAINT,
112
+ rule_id=",".join(sorted(r.id for r in top)),
113
+ )
114
+ chosen = top[0]
115
+ if level == LEVEL_HARD_RULE and chosen.rule.action == Action.BLOCK:
116
+ reason = (
117
+ ReasonCode.CREDENTIAL_DETECTED_HARD_BLOCK
118
+ if finding.category == CREDENTIAL_CATEGORY
119
+ else ReasonCode.EXPLICIT_HARD_BLOCK
120
+ )
121
+ elif level == LEVEL_GUARDRAIL:
122
+ reason = ReasonCode.ENVIRONMENT_GUARDRAIL
123
+ else:
124
+ reason = ReasonCode.EXPLICIT_POLICY_MATCH
125
+ return Decision(
126
+ action=chosen.rule.action,
127
+ reason_code=reason,
128
+ level=level,
129
+ rule_id=chosen.id,
130
+ rule_reason=chosen.rule.reason,
131
+ params=chosen.params,
132
+ matched_on=chosen.selectors(),
133
+ )
134
+ default = self.policy.default_for(finding.category)
135
+ if default is not None:
136
+ return Decision(
137
+ action=default.action,
138
+ reason_code=ReasonCode.PROFILE_DEFAULT,
139
+ level=LEVEL_DEFAULT,
140
+ rule_id=f"defaults:{default.selector}",
141
+ rule_reason=default.reason,
142
+ params=default.params,
143
+ )
144
+ behavior = Action(self.policy.document.settings.unknown_sensitive_behavior)
145
+ if confidence_miss:
146
+ reason = ReasonCode.CONFIDENCE_BELOW_RULE_THRESHOLD
147
+ elif behavior == Action.BLOCK:
148
+ reason = ReasonCode.NO_APPLICABLE_RULE_BLOCKS
149
+ else:
150
+ reason = ReasonCode.NO_APPLICABLE_RULE_REQUIRES_REVIEW
151
+ return Decision(action=behavior, reason_code=reason, level=LEVEL_FALLBACK)
152
+
153
+ def _apply_hard_constraints(
154
+ self, decision: Decision, finding: Finding, ctx: EffectiveContext, fmt: InputFormat
155
+ ) -> Decision:
156
+ if finding.category == CREDENTIAL_CATEGORY and (
157
+ decision.action not in CREDENTIAL_PERMITTED_ACTIONS
158
+ ):
159
+ return Decision(
160
+ Action.BLOCK,
161
+ ReasonCode.CREDENTIAL_DETECTED_HARD_BLOCK,
162
+ LEVEL_HARD_CONSTRAINT,
163
+ overridden_rule_id=decision.rule_id,
164
+ )
165
+ if decision.action == Action.ALLOW and finding.category in self.policy.high_risk:
166
+ untrusted = ctx.is_untrusted
167
+ rule = next((r for r in self.policy.rules if r.id == decision.rule_id), None)
168
+ acknowledged = rule is not None and rule.rule.acknowledge_risk
169
+ if untrusted and not acknowledged:
170
+ return Decision(
171
+ Action.REVIEW,
172
+ ReasonCode.HIGH_RISK_ALLOW_REQUIRES_ACKNOWLEDGEMENT,
173
+ LEVEL_HARD_CONSTRAINT,
174
+ overridden_rule_id=decision.rule_id,
175
+ )
176
+ unsupported = (decision.action == Action.DROP_FIELD and fmt in LINE_FORMATS) or (
177
+ finding.location.in_key and decision.action in VALUE_TRANSFORM_ACTIONS
178
+ )
179
+ if unsupported and finding.category == CREDENTIAL_CATEGORY:
180
+ # A credential that cannot be removed where it is must not wait for review.
181
+ return Decision(
182
+ Action.BLOCK,
183
+ ReasonCode.CREDENTIAL_DETECTED_HARD_BLOCK,
184
+ LEVEL_HARD_CONSTRAINT,
185
+ overridden_rule_id=decision.rule_id,
186
+ )
187
+ if unsupported:
188
+ return Decision(
189
+ Action.REVIEW,
190
+ ReasonCode.UNSUPPORTED_TRANSFORM,
191
+ LEVEL_HARD_CONSTRAINT,
192
+ overridden_rule_id=decision.rule_id,
193
+ )
194
+ return decision
@@ -0,0 +1,116 @@
1
+ """Safe YAML loading for policies.
2
+
3
+ * Uses ``yaml.SafeLoader`` (no arbitrary tags, no object construction).
4
+ * Rejects duplicate mapping keys (PyYAML silently keeps the last one by default).
5
+ * Rejects anchors/aliases, which enable "billion laughs" expansion and make policies harder
6
+ to review.
7
+ * Enforces a size limit before parsing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+ from yaml.events import AliasEvent
18
+ from yaml.nodes import MappingNode, Node
19
+
20
+ from ciphergate.errors import PolicyValidationError, ValidationIssue
21
+
22
+ MAX_POLICY_BYTES = 1024 * 1024
23
+
24
+
25
+ class _StrictSafeLoader(yaml.SafeLoader):
26
+ def compose_node(self, parent: Node | None, index: int) -> Node | None:
27
+ if self.check_event(AliasEvent):
28
+ event = self.peek_event() # type: ignore[no-untyped-call]
29
+ line = event.start_mark.line + 1 if event.start_mark else 0
30
+ raise PolicyValidationError(
31
+ "YAML anchors/aliases are not allowed in policies",
32
+ issues=[ValidationIssue(f"line {line}", "alias not allowed", "yaml_alias")],
33
+ code="policy_yaml_alias",
34
+ )
35
+ node: Node | None = super().compose_node(parent, index)
36
+ return node
37
+
38
+ def construct_mapping(self, node: MappingNode, deep: bool = False) -> dict[Any, Any]:
39
+ seen: set[Any] = set()
40
+ for key_node, _ in node.value:
41
+ key = self.construct_object(key_node, deep=deep)
42
+ if not isinstance(key, str | int | bool | float) and key is not None:
43
+ raise PolicyValidationError(
44
+ "policy mapping keys must be scalars",
45
+ issues=[_issue_at(key_node, "non-scalar key", "yaml_key")],
46
+ )
47
+ if key in seen:
48
+ raise PolicyValidationError(
49
+ "duplicate key in policy YAML",
50
+ issues=[_issue_at(key_node, f"duplicate key '{key}'", "yaml_duplicate_key")],
51
+ code="policy_duplicate_key",
52
+ )
53
+ seen.add(key)
54
+ return super().construct_mapping(node, deep=deep)
55
+
56
+
57
+ def _issue_at(node: Node, message: str, code: str) -> ValidationIssue:
58
+ line = node.start_mark.line + 1 if node.start_mark else 0
59
+ return ValidationIssue(f"line {line}", message, code)
60
+
61
+
62
+ def read_policy_bytes(path: str | Path) -> bytes:
63
+ p = Path(path)
64
+ try:
65
+ if not p.is_file():
66
+ raise PolicyValidationError(
67
+ "policy file not found or not a regular file", code="policy_not_found"
68
+ )
69
+ size = p.stat().st_size
70
+ if size > MAX_POLICY_BYTES:
71
+ raise PolicyValidationError(
72
+ f"policy file exceeds {MAX_POLICY_BYTES} bytes", code="policy_too_large"
73
+ )
74
+ return p.read_bytes()
75
+ except OSError as exc:
76
+ raise PolicyValidationError(
77
+ "policy file could not be read", code="policy_unreadable"
78
+ ) from exc
79
+
80
+
81
+ def parse_policy_yaml(data: bytes) -> dict[str, Any]:
82
+ """Parse YAML bytes into a plain mapping, with safe, value-free error messages."""
83
+ if len(data) > MAX_POLICY_BYTES:
84
+ raise PolicyValidationError("policy exceeds size limit", code="policy_too_large")
85
+ try:
86
+ text = data.decode("utf-8-sig")
87
+ except UnicodeDecodeError as exc:
88
+ raise PolicyValidationError(
89
+ "policy file is not valid UTF-8", code="policy_encoding"
90
+ ) from exc
91
+ try:
92
+ loaded = yaml.load(text, Loader=_StrictSafeLoader) # noqa: S506 - strict SafeLoader subclass
93
+ except PolicyValidationError:
94
+ raise
95
+ except yaml.MarkedYAMLError as exc:
96
+ mark = exc.problem_mark
97
+ where = f"line {mark.line + 1}, column {mark.column + 1}" if mark else "unknown position"
98
+ raise PolicyValidationError(
99
+ "policy YAML syntax error",
100
+ issues=[ValidationIssue(where, "YAML syntax error", "yaml_syntax")],
101
+ code="policy_yaml_syntax",
102
+ ) from exc
103
+ except yaml.YAMLError as exc:
104
+ raise PolicyValidationError(
105
+ "policy YAML could not be parsed", code="policy_yaml_syntax"
106
+ ) from exc
107
+ if not isinstance(loaded, dict):
108
+ raise PolicyValidationError(
109
+ "policy document must be a YAML mapping", code="policy_not_mapping"
110
+ )
111
+ return loaded
112
+
113
+
114
+ def policy_digest(data: bytes) -> str:
115
+ """Digest of the policy file contents. Policies reference keys by env-var name only."""
116
+ return "sha256:" + hashlib.sha256(data).hexdigest()