interbolt 0.1.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.
- interbolt/__init__.py +55 -0
- interbolt/cli/__init__.py +43 -0
- interbolt/constants.py +51 -0
- interbolt/enforcement/__init__.py +302 -0
- interbolt/errors.py +70 -0
- interbolt/models/__init__.py +1 -0
- interbolt/models/core.py +162 -0
- interbolt/models/protocols.py +56 -0
- interbolt/policy/__init__.py +61 -0
- interbolt/policy/engine.py +205 -0
- interbolt/policy/schema.json +131 -0
- interbolt/policy/schema.py +172 -0
- interbolt/reporting/__init__.py +57 -0
- interbolt/runtime/__init__.py +259 -0
- interbolt/runtime/guard.py +176 -0
- interbolt/taint/__init__.py +355 -0
- interbolt/utils/__init__.py +43 -0
- interbolt-0.1.0.dist-info/METADATA +104 -0
- interbolt-0.1.0.dist-info/RECORD +22 -0
- interbolt-0.1.0.dist-info/WHEEL +4 -0
- interbolt-0.1.0.dist-info/entry_points.txt +2 -0
- interbolt-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from interbolt.models.core import Decision, Event, Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class Reporter(Protocol):
|
|
11
|
+
"""The reporting seam. `enforcement` emits through this, never a concrete type."""
|
|
12
|
+
|
|
13
|
+
def export(self, event: Event | Finding) -> None:
|
|
14
|
+
"""Emit a decision event or audit finding.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
event: The record to emit.
|
|
18
|
+
|
|
19
|
+
A reporter's `export` must never block or delay a decision; that is the
|
|
20
|
+
reporter author's responsibility, not a guarantee the engine provides.
|
|
21
|
+
"""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class ApprovalResolver(Protocol):
|
|
27
|
+
"""Resolves a `require_approval` decision to allow or deny.
|
|
28
|
+
|
|
29
|
+
Invoked synchronously at a sync call site and asynchronously (awaited) at an
|
|
30
|
+
async call site; a sync call site cannot use a resolver that only returns an
|
|
31
|
+
awaitable.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __call__(self, decision: Decision) -> bool | Awaitable[bool]:
|
|
35
|
+
"""Decide whether to allow the call that produced `decision`.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
decision: The decision that requires approval.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
`True` to allow the call, `False` to deny it. May return an
|
|
42
|
+
awaitable when called from an async call site.
|
|
43
|
+
"""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def auto_deny(decision: Decision) -> bool:
|
|
48
|
+
"""The default `ApprovalResolver`: deny every approval request.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
decision: The decision that requires approval.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Always `False`.
|
|
55
|
+
"""
|
|
56
|
+
return False
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from interbolt.models.core import TrustLevel
|
|
4
|
+
from interbolt.policy.engine import CompiledSink, compile_policy
|
|
5
|
+
from interbolt.policy.schema import (
|
|
6
|
+
PolicyDocument,
|
|
7
|
+
load_policy_document,
|
|
8
|
+
validate_policy,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Policy:
|
|
13
|
+
"""A loaded, compiled policy: the validated document plus its compiled sinks.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
document: The validated policy document.
|
|
17
|
+
compiled_sinks: Every sink's compiled, ready-to-evaluate rule list.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self, document: PolicyDocument, compiled_sinks: dict[str, CompiledSink]
|
|
22
|
+
) -> None:
|
|
23
|
+
self.document = document
|
|
24
|
+
self.compiled_sinks = compiled_sinks
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def sources_table(self) -> dict[str, TrustLevel]:
|
|
28
|
+
"""The declared source-to-trust mapping, for trust resolution at the sink."""
|
|
29
|
+
return {
|
|
30
|
+
declaration.name: declaration.trust for declaration in self.document.sources
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_file(cls, path: str) -> Policy:
|
|
35
|
+
"""Load, validate, and compile a policy file in one call.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
path: Filesystem path to the policy YAML file.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A `Policy` ready to pass to `configure()`.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
PolicyEvaluationError: If the file is missing, malformed, or
|
|
45
|
+
fails schema or CEL compilation.
|
|
46
|
+
"""
|
|
47
|
+
document = load_policy_document(path)
|
|
48
|
+
return cls(document=document, compiled_sinks=compile_policy(document))
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def validate(cls, path: str) -> list[str]:
|
|
52
|
+
"""Statically analyze a policy file without loading or compiling it for use.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
path: Filesystem path to the policy YAML file.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A list of human-readable problem descriptions; empty if valid.
|
|
59
|
+
Never raises.
|
|
60
|
+
"""
|
|
61
|
+
return validate_policy(path)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import celpy
|
|
9
|
+
from celpy import celtypes
|
|
10
|
+
from celpy.adapter import json_to_cel
|
|
11
|
+
|
|
12
|
+
from interbolt.models.core import Action, Label, TrustLevel
|
|
13
|
+
from interbolt.policy.schema import PolicyDocument
|
|
14
|
+
|
|
15
|
+
_ANY_MACRO_PATTERN = re.compile(r"\.any\(")
|
|
16
|
+
_ENV = celpy.Environment()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _rewrite_any_to_exists(source: str) -> str:
|
|
20
|
+
"""Rewrite the policy DSL's `.any(` to CEL's real `exists` macro.
|
|
21
|
+
|
|
22
|
+
CEL's macro set is exactly `{map, filter, all, exists, exists_one, reduce,
|
|
23
|
+
min}`; there is no `any`. `exists` ("at least one element satisfies the
|
|
24
|
+
predicate") is semantically identical to Python's `any`, so this is a safe
|
|
25
|
+
textual rewrite, done once at compile time, never per evaluation.
|
|
26
|
+
"""
|
|
27
|
+
return _ANY_MACRO_PATTERN.sub(".exists(", source)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compile_cel_expression(source: str) -> celpy.Runner:
|
|
31
|
+
"""Compile one CEL `when` expression into a reusable, evaluate-many program.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
source: The CEL expression text, as written in the policy YAML.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A compiled celpy program, ready for repeated `evaluate()` calls.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
celpy.CELParseError: If the expression is not valid CEL.
|
|
41
|
+
"""
|
|
42
|
+
return _ENV.program(_ENV.compile(_rewrite_any_to_exists(source)))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class CompiledRule:
|
|
47
|
+
"""One compiled rule. `program is None` marks the unconditional catch-all."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
action: Action
|
|
51
|
+
program: celpy.Runner | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class CompiledSink:
|
|
56
|
+
"""A sink's ordered, compiled rule list."""
|
|
57
|
+
|
|
58
|
+
rules: tuple[CompiledRule, ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compile_policy(document: PolicyDocument) -> dict[str, CompiledSink]:
|
|
62
|
+
"""Compile every sink's rule list once, at policy load time.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
document: The validated policy document.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A mapping of dotted sink key to its compiled rule list.
|
|
69
|
+
"""
|
|
70
|
+
compiled: dict[str, CompiledSink] = {}
|
|
71
|
+
for sink_key, rules in document.sinks.items():
|
|
72
|
+
compiled_rules = tuple(
|
|
73
|
+
CompiledRule(
|
|
74
|
+
name=rule.name,
|
|
75
|
+
action=rule.action,
|
|
76
|
+
program=compile_cel_expression(rule.when)
|
|
77
|
+
if rule.when is not None
|
|
78
|
+
else None,
|
|
79
|
+
)
|
|
80
|
+
for rule in rules
|
|
81
|
+
)
|
|
82
|
+
compiled[sink_key] = CompiledSink(rules=compiled_rules)
|
|
83
|
+
return compiled
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_label_trust(
|
|
87
|
+
label: Label, sources_table: Mapping[str, TrustLevel]
|
|
88
|
+
) -> TrustLevel:
|
|
89
|
+
"""Resolve a label's trust from every name in its lineage, untrusted-wins.
|
|
90
|
+
|
|
91
|
+
Trust is never stored on a `Label`; this is the one place it is computed,
|
|
92
|
+
at the sink, from the policy's `sources` table. A name not in the table
|
|
93
|
+
defaults to untrusted (default-deny).
|
|
94
|
+
"""
|
|
95
|
+
for name in label.lineage:
|
|
96
|
+
if sources_table.get(name, TrustLevel.UNTRUSTED) is TrustLevel.UNTRUSTED:
|
|
97
|
+
return TrustLevel.UNTRUSTED
|
|
98
|
+
return TrustLevel.TRUSTED
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _convert_args(args: Mapping[str, Any]) -> celtypes.MapType:
|
|
102
|
+
converted: dict[celtypes.StringType, Any] = {}
|
|
103
|
+
for key, value in args.items():
|
|
104
|
+
try:
|
|
105
|
+
converted[celtypes.StringType(key)] = json_to_cel(value)
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
# Not representable in CEL (e.g. an arbitrary object); simply
|
|
108
|
+
# unavailable to `when` predicates, same as a missing key.
|
|
109
|
+
continue
|
|
110
|
+
return celtypes.MapType(converted)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_context(
|
|
114
|
+
*,
|
|
115
|
+
tool: str,
|
|
116
|
+
args: Mapping[str, Any],
|
|
117
|
+
labels: tuple[Label, ...],
|
|
118
|
+
trifecta: frozenset[str],
|
|
119
|
+
sources_table: Mapping[str, TrustLevel],
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Build the CEL evaluation context for one `check()` call.
|
|
122
|
+
|
|
123
|
+
`args` must already be stripped of taint carriers (plain `str`/`bytes`/
|
|
124
|
+
containers) by the caller; this function has no knowledge of `Tainted`,
|
|
125
|
+
`TaintedBytes`, or `LabeledValue` (`policy/` never imports `taint/`).
|
|
126
|
+
|
|
127
|
+
`taint` stays a plain CEL list so `taint.any(...)`/`taint.all(...)` keep
|
|
128
|
+
working as macros over it. The two aggregate convenience values move to
|
|
129
|
+
top-level siblings, `sources` and `max_trust`, rather than `taint.sources`/
|
|
130
|
+
`taint.max_trust`: CEL cannot make one context variable both a list (for
|
|
131
|
+
the macros) and a map (for dotted field access) at once.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
tool: The dotted qualified tool name.
|
|
135
|
+
args: The call's bound arguments, taint carriers already stripped.
|
|
136
|
+
labels: Every label collected from the call's original arguments.
|
|
137
|
+
trifecta: The trifecta legs satisfied by this call.
|
|
138
|
+
sources_table: The policy's declared source-to-trust mapping.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
A context mapping ready for `celpy.Runner.evaluate(...)`.
|
|
142
|
+
"""
|
|
143
|
+
taint_list = celtypes.ListType(
|
|
144
|
+
[
|
|
145
|
+
celtypes.MapType(
|
|
146
|
+
{
|
|
147
|
+
celtypes.StringType("source"): celtypes.StringType(label.source),
|
|
148
|
+
celtypes.StringType("trust"): celtypes.StringType(
|
|
149
|
+
resolve_label_trust(label, sources_table).value
|
|
150
|
+
),
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
for label in labels
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
all_sources: dict[str, None] = {}
|
|
158
|
+
for label in labels:
|
|
159
|
+
for name in label.lineage:
|
|
160
|
+
all_sources.setdefault(name, None)
|
|
161
|
+
|
|
162
|
+
max_trust = (
|
|
163
|
+
TrustLevel.UNTRUSTED
|
|
164
|
+
if any(
|
|
165
|
+
resolve_label_trust(label, sources_table) is TrustLevel.UNTRUSTED
|
|
166
|
+
for label in labels
|
|
167
|
+
)
|
|
168
|
+
else TrustLevel.TRUSTED
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
"tool": celtypes.StringType(tool),
|
|
173
|
+
"args": _convert_args(args),
|
|
174
|
+
"taint": taint_list,
|
|
175
|
+
"sources": celtypes.ListType(
|
|
176
|
+
[celtypes.StringType(name) for name in all_sources]
|
|
177
|
+
),
|
|
178
|
+
"max_trust": celtypes.StringType(max_trust.value),
|
|
179
|
+
"trifecta": celtypes.ListType([celtypes.StringType(leg) for leg in trifecta]),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def evaluate_sink(
|
|
184
|
+
compiled_sink: CompiledSink, context: Mapping[str, Any], *, default_action: Action
|
|
185
|
+
) -> tuple[str | None, Action]:
|
|
186
|
+
"""Evaluate a sink's compiled rules, first match wins.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
compiled_sink: The sink's compiled rule list.
|
|
190
|
+
context: The CEL context built by `build_context`.
|
|
191
|
+
default_action: The action to fall through to if no rule matches.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The matched rule's name (or `None` for the default) and its action.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
celpy.evaluation.CELEvalError: If a rule's `when` references a missing
|
|
198
|
+
argument, a `None` value, or otherwise fails to evaluate.
|
|
199
|
+
"""
|
|
200
|
+
for rule in compiled_sink.rules:
|
|
201
|
+
if rule.program is None:
|
|
202
|
+
return rule.name, rule.action
|
|
203
|
+
if bool(rule.program.evaluate(context)):
|
|
204
|
+
return rule.name, rule.action
|
|
205
|
+
return None, default_action
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$defs": {
|
|
3
|
+
"Action": {
|
|
4
|
+
"description": "The three possible policy decisions.",
|
|
5
|
+
"enum": [
|
|
6
|
+
"allow",
|
|
7
|
+
"block",
|
|
8
|
+
"require_approval"
|
|
9
|
+
],
|
|
10
|
+
"title": "Action",
|
|
11
|
+
"type": "string"
|
|
12
|
+
},
|
|
13
|
+
"Defaults": {
|
|
14
|
+
"description": "Default-deny posture for sources and sinks not otherwise declared.",
|
|
15
|
+
"properties": {
|
|
16
|
+
"source_trust": {
|
|
17
|
+
"$ref": "#/$defs/TrustLevel",
|
|
18
|
+
"default": "untrusted"
|
|
19
|
+
},
|
|
20
|
+
"sink_action": {
|
|
21
|
+
"$ref": "#/$defs/Action",
|
|
22
|
+
"default": "require_approval"
|
|
23
|
+
},
|
|
24
|
+
"fail_mode": {
|
|
25
|
+
"default": "enforce",
|
|
26
|
+
"enum": [
|
|
27
|
+
"enforce",
|
|
28
|
+
"monitor",
|
|
29
|
+
"dry_run"
|
|
30
|
+
],
|
|
31
|
+
"title": "Fail Mode",
|
|
32
|
+
"type": "string"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"title": "Defaults",
|
|
36
|
+
"type": "object"
|
|
37
|
+
},
|
|
38
|
+
"SinkRule": {
|
|
39
|
+
"description": "One ordered rule within a sink's rule list. First match wins.",
|
|
40
|
+
"properties": {
|
|
41
|
+
"name": {
|
|
42
|
+
"title": "Name",
|
|
43
|
+
"type": "string"
|
|
44
|
+
},
|
|
45
|
+
"when": {
|
|
46
|
+
"anyOf": [
|
|
47
|
+
{
|
|
48
|
+
"type": "string"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"type": "null"
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"default": null,
|
|
55
|
+
"title": "When"
|
|
56
|
+
},
|
|
57
|
+
"action": {
|
|
58
|
+
"$ref": "#/$defs/Action"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"required": [
|
|
62
|
+
"name",
|
|
63
|
+
"action"
|
|
64
|
+
],
|
|
65
|
+
"title": "SinkRule",
|
|
66
|
+
"type": "object"
|
|
67
|
+
},
|
|
68
|
+
"SourceDeclaration": {
|
|
69
|
+
"description": "A declared ingress source and the trust level it resolves to.",
|
|
70
|
+
"properties": {
|
|
71
|
+
"name": {
|
|
72
|
+
"title": "Name",
|
|
73
|
+
"type": "string"
|
|
74
|
+
},
|
|
75
|
+
"trust": {
|
|
76
|
+
"$ref": "#/$defs/TrustLevel"
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"required": [
|
|
80
|
+
"name",
|
|
81
|
+
"trust"
|
|
82
|
+
],
|
|
83
|
+
"title": "SourceDeclaration",
|
|
84
|
+
"type": "object"
|
|
85
|
+
},
|
|
86
|
+
"TrustLevel": {
|
|
87
|
+
"description": "The result of resolving a source name against a policy's sources table.",
|
|
88
|
+
"enum": [
|
|
89
|
+
"trusted",
|
|
90
|
+
"untrusted"
|
|
91
|
+
],
|
|
92
|
+
"title": "TrustLevel",
|
|
93
|
+
"type": "string"
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"description": "The validated shape of a policy YAML file.",
|
|
97
|
+
"properties": {
|
|
98
|
+
"version": {
|
|
99
|
+
"title": "Version",
|
|
100
|
+
"type": "string"
|
|
101
|
+
},
|
|
102
|
+
"defaults": {
|
|
103
|
+
"$ref": "#/$defs/Defaults"
|
|
104
|
+
},
|
|
105
|
+
"sources": {
|
|
106
|
+
"default": [],
|
|
107
|
+
"items": {
|
|
108
|
+
"$ref": "#/$defs/SourceDeclaration"
|
|
109
|
+
},
|
|
110
|
+
"title": "Sources",
|
|
111
|
+
"type": "array"
|
|
112
|
+
},
|
|
113
|
+
"sinks": {
|
|
114
|
+
"additionalProperties": {
|
|
115
|
+
"items": {
|
|
116
|
+
"$ref": "#/$defs/SinkRule"
|
|
117
|
+
},
|
|
118
|
+
"type": "array"
|
|
119
|
+
},
|
|
120
|
+
"title": "Sinks",
|
|
121
|
+
"type": "object"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"required": [
|
|
125
|
+
"version",
|
|
126
|
+
"defaults",
|
|
127
|
+
"sinks"
|
|
128
|
+
],
|
|
129
|
+
"title": "PolicyDocument",
|
|
130
|
+
"type": "object"
|
|
131
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
|
|
7
|
+
|
|
8
|
+
from interbolt.constants import TRIFECTA_COMPUTABLE_LEGS
|
|
9
|
+
from interbolt.errors import InterboltConfigError, PolicyEvaluationError
|
|
10
|
+
from interbolt.models.core import Action, Mode, TrustLevel, validate_qualified_name_part
|
|
11
|
+
|
|
12
|
+
_TRIFECTA_LEG_PATTERN = re.compile(r"trifecta\.contains\(\s*[\"']([^\"']+)[\"']\s*\)")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SourceDeclaration(BaseModel):
|
|
16
|
+
"""A declared ingress source and the trust level it resolves to."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(frozen=True)
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
trust: TrustLevel
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Defaults(BaseModel):
|
|
25
|
+
"""Default-deny posture for sources and sinks not otherwise declared."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(frozen=True)
|
|
28
|
+
|
|
29
|
+
source_trust: TrustLevel = TrustLevel.UNTRUSTED
|
|
30
|
+
sink_action: Action = Action.REQUIRE_APPROVAL
|
|
31
|
+
fail_mode: Mode = Mode.ENFORCE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SinkRule(BaseModel):
|
|
35
|
+
"""One ordered rule within a sink's rule list. First match wins."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(frozen=True)
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
when: str | None = None
|
|
41
|
+
action: Action
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _split_sink_key(key: str) -> tuple[str, str]:
|
|
45
|
+
namespace, separator, tool = key.rpartition(".")
|
|
46
|
+
if not separator:
|
|
47
|
+
raise InterboltConfigError(
|
|
48
|
+
f"sink key {key!r} must be a dotted 'namespace.tool' name"
|
|
49
|
+
)
|
|
50
|
+
validate_qualified_name_part(namespace, part="namespace")
|
|
51
|
+
validate_qualified_name_part(tool, part="tool")
|
|
52
|
+
return namespace, tool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PolicyDocument(BaseModel):
|
|
56
|
+
"""The validated shape of a policy YAML file."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(frozen=True)
|
|
59
|
+
|
|
60
|
+
version: str
|
|
61
|
+
defaults: Defaults
|
|
62
|
+
sources: tuple[SourceDeclaration, ...] = ()
|
|
63
|
+
sinks: dict[str, tuple[SinkRule, ...]]
|
|
64
|
+
|
|
65
|
+
@field_validator("sinks")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _validate_sink_keys(
|
|
68
|
+
cls, value: dict[str, tuple[SinkRule, ...]]
|
|
69
|
+
) -> dict[str, tuple[SinkRule, ...]]:
|
|
70
|
+
for key in value:
|
|
71
|
+
_split_sink_key(key)
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_policy_document(path: str) -> PolicyDocument:
|
|
76
|
+
"""Load and validate a policy YAML file against `PolicyDocument`.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path: Filesystem path to the policy YAML file.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The validated `PolicyDocument`.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
PolicyEvaluationError: If the file cannot be read, is not valid YAML,
|
|
86
|
+
or does not conform to the policy schema.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
with open(path, encoding="utf-8") as handle:
|
|
90
|
+
data = yaml.safe_load(handle)
|
|
91
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
92
|
+
raise PolicyEvaluationError(
|
|
93
|
+
f"failed to read policy file {path!r}: {exc}"
|
|
94
|
+
) from exc
|
|
95
|
+
try:
|
|
96
|
+
return PolicyDocument.model_validate(data)
|
|
97
|
+
except ValidationError as exc:
|
|
98
|
+
raise PolicyEvaluationError(f"policy file {path!r} is invalid: {exc}") from exc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_policy(path: str) -> list[str]:
|
|
102
|
+
"""Statically analyze a policy file: schema, CEL compilation, dead rules.
|
|
103
|
+
|
|
104
|
+
Never executes an agent and never observes live taint; this is the
|
|
105
|
+
dynamic counterpart's opposite number (`dry_run` + the audit flag, which
|
|
106
|
+
are in-process instruments). Does not attempt to resolve whether every
|
|
107
|
+
source name referenced inside a `when` expression is declared, since that
|
|
108
|
+
would require walking the CEL AST for string-literal comparisons against
|
|
109
|
+
`t.source`; out of scope for v1's static check.
|
|
110
|
+
|
|
111
|
+
Rejects any `when` expression referencing a trifecta leg outside the
|
|
112
|
+
v1-computable set (`{"from_untrusted"}`), since
|
|
113
|
+
`trifecta.contains("reaches_external")` silently evaluates to `false` at
|
|
114
|
+
runtime rather than failing: a rule built on it never fires, with no
|
|
115
|
+
signal unless caught here.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
path: Filesystem path to the policy YAML file.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
A list of human-readable problem descriptions. Empty if the policy is
|
|
122
|
+
valid. Never raises.
|
|
123
|
+
"""
|
|
124
|
+
from interbolt.policy.engine import compile_cel_expression
|
|
125
|
+
|
|
126
|
+
problems: list[str] = []
|
|
127
|
+
try:
|
|
128
|
+
with open(path, encoding="utf-8") as handle:
|
|
129
|
+
data = yaml.safe_load(handle)
|
|
130
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
131
|
+
return [f"failed to read policy file {path!r}: {exc}"]
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
document = PolicyDocument.model_validate(data)
|
|
135
|
+
except ValidationError as exc:
|
|
136
|
+
for error in exc.errors():
|
|
137
|
+
location = ".".join(str(part) for part in error["loc"])
|
|
138
|
+
problems.append(f"{location}: {error['msg']}")
|
|
139
|
+
return problems
|
|
140
|
+
|
|
141
|
+
for sink_key, rules in document.sinks.items():
|
|
142
|
+
catch_all_seen = False
|
|
143
|
+
for rule in rules:
|
|
144
|
+
if catch_all_seen:
|
|
145
|
+
problems.append(
|
|
146
|
+
f"sink {sink_key!r}: rule {rule.name!r} is unreachable, "
|
|
147
|
+
"placed after an unconditional catch-all rule"
|
|
148
|
+
)
|
|
149
|
+
if rule.when is None:
|
|
150
|
+
if catch_all_seen:
|
|
151
|
+
problems.append(
|
|
152
|
+
f"sink {sink_key!r}: more than one unconditional catch-all rule"
|
|
153
|
+
)
|
|
154
|
+
catch_all_seen = True
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
compile_cel_expression(rule.when)
|
|
158
|
+
except Exception as exc: # noqa: BLE001 -- surfacing any compile failure
|
|
159
|
+
problems.append(
|
|
160
|
+
f"sink {sink_key!r}: rule {rule.name!r} "
|
|
161
|
+
f"has an invalid CEL expression: {exc}"
|
|
162
|
+
)
|
|
163
|
+
for leg in _TRIFECTA_LEG_PATTERN.findall(rule.when):
|
|
164
|
+
if leg not in TRIFECTA_COMPUTABLE_LEGS:
|
|
165
|
+
problems.append(
|
|
166
|
+
f"sink {sink_key!r}: rule {rule.name!r} references "
|
|
167
|
+
f"trifecta leg {leg!r}, which is not computed in v1 "
|
|
168
|
+
f"(trifecta.contains({leg!r}) always evaluates false); "
|
|
169
|
+
f"computable legs are {sorted(TRIFECTA_COMPUTABLE_LEGS)}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return problems
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from interbolt.models.core import Decision, Event, Finding
|
|
4
|
+
from interbolt.utils import get_logger
|
|
5
|
+
|
|
6
|
+
_logger = get_logger("reporting")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NullReporter:
|
|
10
|
+
"""The default reporter: a no-op. Keeps the library fully local by default."""
|
|
11
|
+
|
|
12
|
+
def export(self, event: Event | Finding) -> None:
|
|
13
|
+
"""Discard the record.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
event: The record to discard.
|
|
17
|
+
"""
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InMemoryReporter:
|
|
22
|
+
"""Captures every exported record in memory; the testing/audit assertion surface."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.events: list[Event] = []
|
|
26
|
+
self.decisions: list[Decision] = []
|
|
27
|
+
self.findings: list[Finding] = []
|
|
28
|
+
|
|
29
|
+
def export(self, event: Event | Finding) -> None:
|
|
30
|
+
"""Capture the record.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
event: The record to capture.
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(event, Event):
|
|
36
|
+
self.events.append(event)
|
|
37
|
+
self.decisions.append(event.decision)
|
|
38
|
+
else:
|
|
39
|
+
self.findings.append(event)
|
|
40
|
+
|
|
41
|
+
def clear(self) -> None:
|
|
42
|
+
"""Discard every captured record."""
|
|
43
|
+
self.events.clear()
|
|
44
|
+
self.decisions.clear()
|
|
45
|
+
self.findings.clear()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LoggingReporter:
|
|
49
|
+
"""Emits every record via the library logger, at DEBUG."""
|
|
50
|
+
|
|
51
|
+
def export(self, event: Event | Finding) -> None:
|
|
52
|
+
"""Log the record.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
event: The record to log.
|
|
56
|
+
"""
|
|
57
|
+
_logger.debug("export: %r", event)
|