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