toolgovern-cli 0.1.1__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.
- toolgovern/__init__.py +222 -0
- toolgovern/approval/__init__.py +25 -0
- toolgovern/approval/pending_registry.py +379 -0
- toolgovern/classifier/__init__.py +19 -0
- toolgovern/classifier/credential_access.py +180 -0
- toolgovern/classifier/cross_agent_inheritance.py +216 -0
- toolgovern/classifier/filesystem_scope.py +202 -0
- toolgovern/classifier/index.py +80 -0
- toolgovern/classifier/information_flow.py +154 -0
- toolgovern/classifier/network_egress.py +289 -0
- toolgovern/classifier/shell_risk.py +357 -0
- toolgovern/classifier/util.py +259 -0
- toolgovern/cli.py +298 -0
- toolgovern/mcp_trust/__init__.py +342 -0
- toolgovern/middleware/__init__.py +30 -0
- toolgovern/middleware/idempotency_cache.py +117 -0
- toolgovern/middleware/on_tool_call.py +538 -0
- toolgovern/policy/__init__.py +10 -0
- toolgovern/policy/load_policy.py +41 -0
- toolgovern/policy/validate_policy.py +106 -0
- toolgovern/py.typed +0 -0
- toolgovern/scoping/__init__.py +13 -0
- toolgovern/scoping/inheritance_enforcer.py +145 -0
- toolgovern/scoping/scope_declaration.py +84 -0
- toolgovern/shared/__init__.py +0 -0
- toolgovern/shared/paths.py +266 -0
- toolgovern/trace/__init__.py +33 -0
- toolgovern/trace/canonical_json.py +27 -0
- toolgovern/trace/trace_reader.py +206 -0
- toolgovern/trace/trace_writer.py +247 -0
- toolgovern/types.py +247 -0
- toolgovern_cli-0.1.1.dist-info/METADATA +337 -0
- toolgovern_cli-0.1.1.dist-info/RECORD +36 -0
- toolgovern_cli-0.1.1.dist-info/WHEEL +4 -0
- toolgovern_cli-0.1.1.dist-info/entry_points.txt +2 -0
- toolgovern_cli-0.1.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""TG08 -- Information-Flow Control (confidentiality-label propagation).
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/classifier/information-flow.ts``.
|
|
4
|
+
|
|
5
|
+
A categorically different question from TG01-TG05: those all ask "should this call happen" (is
|
|
6
|
+
this argument dangerous, is this path/host/credential in scope). This asks "can this *data* flow
|
|
7
|
+
here" -- does a call read from a source the caller has labeled confidential-or-higher, and
|
|
8
|
+
write/send that data to a destination whose declared trust tier is lower (or not declared at
|
|
9
|
+
all)? That is the same governance question Microsoft Agent Framework's FIDES answers with a full
|
|
10
|
+
confidentiality-label lattice tracked across an MCP gateway boundary -- this module is
|
|
11
|
+
deliberately NOT that. It is the smallest real primitive that lets a genuine label-propagation
|
|
12
|
+
check exist at all: one flat, closed label order (``ConfidentialityLabel`` in ``../types.py``), a
|
|
13
|
+
caller-declared source/sink labeling (``IfcPolicy``), and a single per-call rule.
|
|
14
|
+
|
|
15
|
+
What this explicitly does NOT do, disclosed rather than hidden:
|
|
16
|
+
|
|
17
|
+
- No automatic label inference. toolgovern cannot know that ``args["table"] == "customers.ssn"``
|
|
18
|
+
is confidential, or that ``args["webhook"]`` points at an untrusted third party -- those are
|
|
19
|
+
business facts only the integrator has. ``IfcPolicy.sources`` / ``IfcPolicy.sink_trust`` are a
|
|
20
|
+
real, if minimal, labeling API the caller must declare; this rule only evaluates what was
|
|
21
|
+
declared.
|
|
22
|
+
- No cross-call / session-level taint tracking. Each call is evaluated in isolation, exactly like
|
|
23
|
+
every other TG0x rule -- if confidential data is read in one call and only handed to an
|
|
24
|
+
untrusted sink two calls later, this rule does not see that.
|
|
25
|
+
- No reader/principal-scoped lattice. ``ConfidentialityLabel`` is one flat total order, not a set
|
|
26
|
+
of readers or a join/meet lattice over multiple simultaneous principals.
|
|
27
|
+
- No result-value inspection. This evaluates the call's declared source/sink arguments, not the
|
|
28
|
+
actual data a tool call returns.
|
|
29
|
+
|
|
30
|
+
Fail-closed posture (the one property this module does commit to): a call whose source is
|
|
31
|
+
labeled confidential-or-higher and whose destination's trust tier is NOT declared in
|
|
32
|
+
``IfcPolicy.sink_trust`` never silently allows -- it requires human approval. Only a destination
|
|
33
|
+
explicitly declared trustworthy enough (rank >= the source's label) is allowed through
|
|
34
|
+
unchallenged; a destination explicitly declared LOWER-trust is denied outright.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from typing import Any, Callable, List, Mapping, Optional, Sequence
|
|
41
|
+
|
|
42
|
+
from ..types import ConfidentialityLabel, RuleContext, RuleMatch
|
|
43
|
+
|
|
44
|
+
_CATEGORY = "TG08"
|
|
45
|
+
|
|
46
|
+
# Total order for ConfidentialityLabel, least to most sensitive.
|
|
47
|
+
_LABEL_ORDER: Sequence[str] = ("public", "internal", "confidential", "restricted")
|
|
48
|
+
|
|
49
|
+
# Argument key names a caller may use to name the source/sink of a labeled data flow.
|
|
50
|
+
# Deliberately separate, explicit key sets from TG02/TG04's path/credential-style keys and TG03's
|
|
51
|
+
# host/url-style keys -- reusing those would mean silently *inferring* that any path/host/
|
|
52
|
+
# credential argument is "the source" or "the sink" of a two-sided flow, which is exactly the
|
|
53
|
+
# automatic inference this module disclaims. A caller opts into TG08 by naming the source and
|
|
54
|
+
# sink explicitly under one of these keys.
|
|
55
|
+
_SOURCE_KEYS = ["source", "sourceId", "from", "readFrom"]
|
|
56
|
+
_SINK_KEYS = ["sink", "sinkId", "to", "destination", "sendTo", "forwardTo"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class _Rule:
|
|
61
|
+
id: str
|
|
62
|
+
category: str
|
|
63
|
+
description: str
|
|
64
|
+
_evaluate: Callable[[RuleContext], Optional[RuleMatch]]
|
|
65
|
+
|
|
66
|
+
def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
|
|
67
|
+
return self._evaluate(ctx)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _label_rank(label: str) -> int:
|
|
71
|
+
return _LABEL_ORDER.index(label)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _first_string(args: Mapping[str, Any], keys: Sequence[str]) -> Optional[str]:
|
|
75
|
+
for key in keys:
|
|
76
|
+
value = args.get(key)
|
|
77
|
+
if isinstance(value, str) and len(value) > 0:
|
|
78
|
+
return value
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _lookup_label(identifier: str, labels: Mapping[str, str]) -> Optional[str]:
|
|
83
|
+
"""Whether ``identifier`` matches an entry in a declared IfcPolicy label map -- exact match,
|
|
84
|
+
a trailing path-segment match, or a substring match. Mirrors ``is_credential_granted()``'s
|
|
85
|
+
matching style in ``util.py``. Exact match is checked across the whole map first so a
|
|
86
|
+
longer, more specific declared key is never shadowed by an unrelated shorter key that
|
|
87
|
+
merely happens to be a substring of it."""
|
|
88
|
+
lower = identifier.lower()
|
|
89
|
+
for key, label in labels.items():
|
|
90
|
+
if key.lower() == lower:
|
|
91
|
+
return label
|
|
92
|
+
for key, label in labels.items():
|
|
93
|
+
k = key.lower()
|
|
94
|
+
if k and (lower.endswith(f"/{k}") or k in lower):
|
|
95
|
+
return label
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
|
|
100
|
+
return RuleMatch(
|
|
101
|
+
rule_id=rule_id,
|
|
102
|
+
category=_CATEGORY, # type: ignore[arg-type]
|
|
103
|
+
decision=decision, # type: ignore[arg-type]
|
|
104
|
+
reason=reason,
|
|
105
|
+
matched_argument=matched_argument,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _confidential_source_to_untrusted_sink_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
110
|
+
ifc = ctx.scope.ifc
|
|
111
|
+
if ifc is None:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
source = _first_string(ctx.args, _SOURCE_KEYS)
|
|
115
|
+
if not source:
|
|
116
|
+
return None
|
|
117
|
+
source_label = _lookup_label(source, ifc.sources)
|
|
118
|
+
if not source_label or source_label == "public":
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
sink = _first_string(ctx.args, _SINK_KEYS)
|
|
122
|
+
if not sink:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
sink_trust = _lookup_label(sink, ifc.sink_trust)
|
|
126
|
+
if sink_trust is None:
|
|
127
|
+
return _match(
|
|
128
|
+
"TG08-confidential-source-to-untrusted-sink",
|
|
129
|
+
"require-approval",
|
|
130
|
+
f'Call reads from "{source}" labeled "{source_label}" and sends to "{sink}", whose '
|
|
131
|
+
"trust tier is not declared in the IFC policy. Failing closed pending human review.",
|
|
132
|
+
sink,
|
|
133
|
+
)
|
|
134
|
+
if _label_rank(sink_trust) < _label_rank(source_label):
|
|
135
|
+
return _match(
|
|
136
|
+
"TG08-confidential-source-to-untrusted-sink",
|
|
137
|
+
"deny",
|
|
138
|
+
f'Call reads from "{source}" labeled "{source_label}" but destination "{sink}" is '
|
|
139
|
+
f'only trusted for "{sink_trust}" data.',
|
|
140
|
+
sink,
|
|
141
|
+
)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
information_flow_rules: List[_Rule] = [
|
|
146
|
+
_Rule(
|
|
147
|
+
"TG08-confidential-source-to-untrusted-sink",
|
|
148
|
+
_CATEGORY,
|
|
149
|
+
"A call reads from a source labeled confidential-or-higher and writes/sends to a "
|
|
150
|
+
"destination whose declared trust tier is lower than the source's label, or whose "
|
|
151
|
+
"trust tier was never declared at all (fails closed to require-approval, not allow).",
|
|
152
|
+
_confidential_source_to_untrusted_sink_evaluate,
|
|
153
|
+
),
|
|
154
|
+
]
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""TG03 -- Undeclared Network Egress.
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/classifier/network-egress.ts``.
|
|
4
|
+
|
|
5
|
+
Fires when a call reaches a host not present in the caller's declared network scope
|
|
6
|
+
(``scope.network``: ``False`` for no access, ``True`` for unrestricted, or an explicit host
|
|
7
|
+
allowlist).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import socket
|
|
14
|
+
import threading
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from ..shared.paths import host_matches_allowed, is_ip_literal, is_private_or_metadata_target
|
|
19
|
+
from ..types import RuleContext, RuleMatch
|
|
20
|
+
from .util import extract_candidate_host, extract_command, extract_host
|
|
21
|
+
|
|
22
|
+
_CATEGORY = "TG03"
|
|
23
|
+
|
|
24
|
+
_KNOWN_RELAY_DOMAINS = [
|
|
25
|
+
"pastebin.com",
|
|
26
|
+
"pastebin-mirror.io",
|
|
27
|
+
"transfer.sh",
|
|
28
|
+
"ngrok.io",
|
|
29
|
+
"ngrok-free.app",
|
|
30
|
+
"requestbin.com",
|
|
31
|
+
"webhook.site",
|
|
32
|
+
"file.io",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class _Rule:
|
|
38
|
+
id: str
|
|
39
|
+
category: str
|
|
40
|
+
description: str
|
|
41
|
+
_evaluate: Callable[[RuleContext], Optional[RuleMatch]]
|
|
42
|
+
|
|
43
|
+
def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
|
|
44
|
+
return self._evaluate(ctx)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
|
|
48
|
+
return RuleMatch(
|
|
49
|
+
rule_id=rule_id,
|
|
50
|
+
category=_CATEGORY, # type: ignore[arg-type]
|
|
51
|
+
decision=decision, # type: ignore[arg-type]
|
|
52
|
+
reason=reason,
|
|
53
|
+
matched_argument=matched_argument,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_host_in_scope(host: str, network) -> bool:
|
|
58
|
+
if network is True:
|
|
59
|
+
return True
|
|
60
|
+
if network is False:
|
|
61
|
+
return False
|
|
62
|
+
return any(host_matches_allowed(host, allowed) for allowed in network)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _network_disabled_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
66
|
+
if ctx.scope.network is not False:
|
|
67
|
+
return None
|
|
68
|
+
host = extract_candidate_host(ctx.args)
|
|
69
|
+
if not host:
|
|
70
|
+
return None
|
|
71
|
+
return _match(
|
|
72
|
+
"TG03-network-disabled",
|
|
73
|
+
"deny",
|
|
74
|
+
f'Network call to "{host}" attempted with network scope disabled.',
|
|
75
|
+
host,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _host_not_in_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
80
|
+
if ctx.scope.network is False or ctx.scope.network is True:
|
|
81
|
+
return None
|
|
82
|
+
host = extract_candidate_host(ctx.args)
|
|
83
|
+
if not host:
|
|
84
|
+
return None
|
|
85
|
+
if _is_host_in_scope(host, ctx.scope.network):
|
|
86
|
+
return None
|
|
87
|
+
return _match(
|
|
88
|
+
"TG03-host-not-in-scope", "deny", f'Host "{host}" is not in the declared network allowlist.', host
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _raw_ip_literal_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
93
|
+
host = extract_candidate_host(ctx.args)
|
|
94
|
+
if not host or not is_ip_literal(host):
|
|
95
|
+
return None
|
|
96
|
+
# An explicit, exact allowlist entry for this host is honored even if it is private/
|
|
97
|
+
# metadata -- that's a deliberate, specific operator decision (they named this exact
|
|
98
|
+
# address), unlike the blanket scope.network is True grant checked below, which
|
|
99
|
+
# incidentally covers everything including metadata endpoints without the operator ever
|
|
100
|
+
# having considered this specific address.
|
|
101
|
+
if isinstance(ctx.scope.network, (list, tuple)) and host in ctx.scope.network:
|
|
102
|
+
return None
|
|
103
|
+
if is_private_or_metadata_target(host):
|
|
104
|
+
# Loopback, RFC1918/unique-local, link-local, and cloud-metadata targets are denied
|
|
105
|
+
# outright for any scope that did not explicitly name this exact host above -- this
|
|
106
|
+
# check must run before the scope.network is True early-return below, not after it:
|
|
107
|
+
# an agent with unrestricted (but not host-specific) network access must still never
|
|
108
|
+
# be able to reach an internal network or cloud-metadata endpoint via this rule.
|
|
109
|
+
# That's the entire point of "never approvable" -- it cannot be conditional on a
|
|
110
|
+
# blanket network=True grant.
|
|
111
|
+
return _match(
|
|
112
|
+
"TG03-raw-ip-literal",
|
|
113
|
+
"deny",
|
|
114
|
+
f'Connection to loopback/private/cloud-metadata IP literal "{host}" is never approvable.',
|
|
115
|
+
host,
|
|
116
|
+
)
|
|
117
|
+
if ctx.scope.network is True:
|
|
118
|
+
return None
|
|
119
|
+
return _match("TG03-raw-ip-literal", "require-approval", f'Connection to raw IP literal "{host}".', host)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_PORT_PATTERN = re.compile(r":(\d{2,5})\b")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _non_standard_port_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
126
|
+
raw = extract_host(ctx.args) or extract_command(ctx.args) or ""
|
|
127
|
+
port_match = _PORT_PATTERN.search(raw)
|
|
128
|
+
if not port_match:
|
|
129
|
+
return None
|
|
130
|
+
port = int(port_match.group(1))
|
|
131
|
+
if port in (80, 443):
|
|
132
|
+
return None
|
|
133
|
+
host = extract_candidate_host(ctx.args)
|
|
134
|
+
if not host:
|
|
135
|
+
return None
|
|
136
|
+
if ctx.scope.network is True:
|
|
137
|
+
return None
|
|
138
|
+
if isinstance(ctx.scope.network, (list, tuple)) and host in ctx.scope.network:
|
|
139
|
+
return None
|
|
140
|
+
return _match(
|
|
141
|
+
"TG03-non-standard-port",
|
|
142
|
+
"require-approval",
|
|
143
|
+
f'Connection to "{host}" on non-standard port {port}.',
|
|
144
|
+
f"{host}:{port}",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _dns_exfil_pattern_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
149
|
+
host = extract_candidate_host(ctx.args)
|
|
150
|
+
if not host:
|
|
151
|
+
return None
|
|
152
|
+
first_label = host.split(".")[0] if host else ""
|
|
153
|
+
if len(first_label) < 40:
|
|
154
|
+
return None
|
|
155
|
+
return _match(
|
|
156
|
+
"TG03-dns-exfil-pattern", "require-approval", f'Unusually long subdomain label on "{host}".', host
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _known_paste_relay_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
161
|
+
host = extract_candidate_host(ctx.args)
|
|
162
|
+
if not host:
|
|
163
|
+
return None
|
|
164
|
+
hit = next(
|
|
165
|
+
(domain for domain in _KNOWN_RELAY_DOMAINS if host == domain or host.endswith(f".{domain}")),
|
|
166
|
+
None,
|
|
167
|
+
)
|
|
168
|
+
if not hit:
|
|
169
|
+
return None
|
|
170
|
+
if isinstance(ctx.scope.network, (list, tuple)) and hit in ctx.scope.network:
|
|
171
|
+
return None
|
|
172
|
+
return _match(
|
|
173
|
+
"TG03-known-paste-relay", "deny", f'Host "{host}" matches known paste/relay service "{hit}".', host
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_DNS_LOOKUP_TIMEOUT_SECONDS = 3.0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolve_host_addresses(host: str) -> List[str]:
|
|
181
|
+
"""Resolves every address a hostname maps to via the OS resolver (``socket.getaddrinfo``,
|
|
182
|
+
which also honors ``/etc/hosts`` -- so an operator-added ``127.0.0.1 internal-alias`` entry
|
|
183
|
+
is caught exactly like a real DNS A/AAAA record would be), racing it against a hard timeout
|
|
184
|
+
in a worker thread so a hung/unresponsive resolver cannot stall the call indefinitely.
|
|
185
|
+
|
|
186
|
+
``socket.getaddrinfo()`` has no built-in timeout of its own and cannot be cancelled once
|
|
187
|
+
started, so this uses the same thread + ``join(timeout)`` idiom
|
|
188
|
+
``middleware/on_tool_call.py``'s ``_resolve_approval`` already uses for approval timeouts,
|
|
189
|
+
rather than inventing a second timeout mechanism.
|
|
190
|
+
|
|
191
|
+
Raises on failure or timeout -- never returns a value implying "safe, nothing found" for
|
|
192
|
+
those cases. Callers must treat an exception as "unknown, fail closed."
|
|
193
|
+
"""
|
|
194
|
+
result_box: Dict[str, Any] = {}
|
|
195
|
+
|
|
196
|
+
def _run() -> None:
|
|
197
|
+
try:
|
|
198
|
+
result_box["infos"] = socket.getaddrinfo(host, None)
|
|
199
|
+
except Exception as error: # noqa: BLE001 -- re-raised on the calling thread below
|
|
200
|
+
result_box["error"] = error
|
|
201
|
+
|
|
202
|
+
thread = threading.Thread(target=_run, daemon=True)
|
|
203
|
+
thread.start()
|
|
204
|
+
thread.join(_DNS_LOOKUP_TIMEOUT_SECONDS)
|
|
205
|
+
if thread.is_alive():
|
|
206
|
+
raise TimeoutError(
|
|
207
|
+
f'DNS lookup for "{host}" timed out after {_DNS_LOOKUP_TIMEOUT_SECONDS}s'
|
|
208
|
+
)
|
|
209
|
+
if "error" in result_box:
|
|
210
|
+
raise result_box["error"] # type: ignore[misc]
|
|
211
|
+
infos = result_box.get("infos", [])
|
|
212
|
+
return [info[4][0] for info in infos]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _dns_resolves_private_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
216
|
+
host = extract_candidate_host(ctx.args)
|
|
217
|
+
# A raw IP literal is already fully handled by TG03-raw-ip-literal; resolving it would be a
|
|
218
|
+
# no-op (or, for a bare decimal-encoded literal, actively wrong) -- this rule only concerns
|
|
219
|
+
# itself with actual hostnames.
|
|
220
|
+
if not host or is_ip_literal(host):
|
|
221
|
+
return None
|
|
222
|
+
# Mirrors TG03-raw-ip-literal's own carve-out: an explicit, exact allowlist entry for this
|
|
223
|
+
# hostname is a deliberate, specific operator decision, honored even if it turns out to
|
|
224
|
+
# resolve to a private address.
|
|
225
|
+
if isinstance(ctx.scope.network, (list, tuple)) and host in ctx.scope.network:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
addresses = _resolve_host_addresses(host)
|
|
230
|
+
except Exception as error:
|
|
231
|
+
# Fail CLOSED on a DNS-resolution failure (NXDOMAIN, timeout, resolver error, whatever the
|
|
232
|
+
# cause) -- an unresolvable host is never treated as "safe to allow." require-approval
|
|
233
|
+
# (not an automatic allow) matches TG03-raw-ip-literal's own use of require-approval for
|
|
234
|
+
# anything not affirmatively known to be safe.
|
|
235
|
+
return _match(
|
|
236
|
+
"TG03-dns-resolves-private",
|
|
237
|
+
"require-approval",
|
|
238
|
+
f'DNS resolution for host "{host}" failed ({error}); failing closed rather than '
|
|
239
|
+
"assuming an unresolvable host is safe to reach.",
|
|
240
|
+
host,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if not addresses:
|
|
244
|
+
# Some resolvers return an empty record set rather than raising for an unknown name --
|
|
245
|
+
# treated identically to a resolution failure above, for the same fail-closed reason.
|
|
246
|
+
return _match(
|
|
247
|
+
"TG03-dns-resolves-private",
|
|
248
|
+
"require-approval",
|
|
249
|
+
f'DNS resolution for host "{host}" returned no addresses; failing closed rather than '
|
|
250
|
+
"assuming an unresolvable host is safe to reach.",
|
|
251
|
+
host,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
private_address = next((a for a in addresses if is_private_or_metadata_target(a)), None)
|
|
255
|
+
if not private_address:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
return _match(
|
|
259
|
+
"TG03-dns-resolves-private",
|
|
260
|
+
"deny",
|
|
261
|
+
f'Host "{host}" resolves via DNS to loopback/private/cloud-metadata address '
|
|
262
|
+
f'"{private_address}" -- denied even though the call argument is a hostname, not a raw '
|
|
263
|
+
"IP literal. Residual limitation, disclosed rather than hidden: this is a "
|
|
264
|
+
"resolve-then-check at classification time, not a connection-time guarantee -- it "
|
|
265
|
+
"narrows but does not eliminate DNS-rebinding TOCTOU, since an attacker who controls "
|
|
266
|
+
"this name's DNS answer can still swap it to a private/internal address after this "
|
|
267
|
+
"check runs and before the tool's own HTTP client actually connects. True TOCTOU-proof "
|
|
268
|
+
"protection would require the tool's own HTTP client to connect to this exact "
|
|
269
|
+
"resolved+validated address (DNS pinning), which a pre-execution argument gate like "
|
|
270
|
+
"govern_tool() cannot enforce -- see docs/security-model.md.",
|
|
271
|
+
host,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
network_egress_rules: List[_Rule] = [
|
|
276
|
+
_Rule("TG03-network-disabled", _CATEGORY, "Any network egress attempted while the agent has no network scope at all.", _network_disabled_evaluate),
|
|
277
|
+
_Rule("TG03-host-not-in-scope", _CATEGORY, "The target host is not present in the declared network allowlist.", _host_not_in_scope_evaluate),
|
|
278
|
+
_Rule("TG03-raw-ip-literal", _CATEGORY, "Connection to a raw IP literal, bypassing a domain-based allowlist.", _raw_ip_literal_evaluate),
|
|
279
|
+
_Rule("TG03-non-standard-port", _CATEGORY, "Connection to a non-standard port on a host outside the allowlist.", _non_standard_port_evaluate),
|
|
280
|
+
_Rule("TG03-dns-exfil-pattern", _CATEGORY, "Suspiciously long, high-entropy subdomain label -- a common DNS-exfil shape.", _dns_exfil_pattern_evaluate),
|
|
281
|
+
_Rule("TG03-known-paste-relay", _CATEGORY, "Target host matches a known paste/relay/tunnel service commonly used for exfil.", _known_paste_relay_evaluate),
|
|
282
|
+
_Rule(
|
|
283
|
+
"TG03-dns-resolves-private",
|
|
284
|
+
_CATEGORY,
|
|
285
|
+
"A hostname argument that resolves via DNS to a loopback/RFC1918/link-local/cloud-metadata "
|
|
286
|
+
"address, even though the argument itself is a domain name, not a raw IP literal.",
|
|
287
|
+
_dns_resolves_private_evaluate,
|
|
288
|
+
),
|
|
289
|
+
]
|