toolprint 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.
- toolprint/__init__.py +10 -0
- toolprint/auth.py +219 -0
- toolprint/baseline.py +241 -0
- toolprint/bundle.py +194 -0
- toolprint/canonical.py +278 -0
- toolprint/cli.py +563 -0
- toolprint/connect.py +147 -0
- toolprint/context.py +142 -0
- toolprint/demo.py +182 -0
- toolprint/discover.py +318 -0
- toolprint/drift.py +246 -0
- toolprint/effects.py +215 -0
- toolprint/findings/__init__.py +0 -0
- toolprint/findings/engine.py +376 -0
- toolprint/findings/library.py +161 -0
- toolprint/lexical.py +213 -0
- toolprint/model.py +196 -0
- toolprint/protocol.py +228 -0
- toolprint/py.typed +0 -0
- toolprint/registry.py +188 -0
- toolprint/render/__init__.py +0 -0
- toolprint/render/findings_json.py +147 -0
- toolprint/render/html.py +53 -0
- toolprint/render/jsonout.py +101 -0
- toolprint/render/sarif.py +127 -0
- toolprint/render/text.py +301 -0
- toolprint/tokens.py +98 -0
- toolprint/transport.py +316 -0
- toolprint/vendor/__init__.py +0 -0
- toolprint/vendor/mcp_collect.py +399 -0
- toolprint/viewer/report.html +627 -0
- toolprint-0.1.0.dist-info/METADATA +208 -0
- toolprint-0.1.0.dist-info/RECORD +37 -0
- toolprint-0.1.0.dist-info/WHEEL +5 -0
- toolprint-0.1.0.dist-info/entry_points.txt +2 -0
- toolprint-0.1.0.dist-info/licenses/LICENSE +202 -0
- toolprint-0.1.0.dist-info/top_level.txt +1 -0
toolprint/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""toolprint - what your agent's tool surface costs, and what it can do.
|
|
2
|
+
|
|
3
|
+
Footprint: the tokens tool definitions consume on every request. Fingerprint:
|
|
4
|
+
what those definitions are, and whether they changed without review.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# The public name is spelled here and in cli.py's argparse prog only, which is
|
|
8
|
+
# what made the rename from the mcpdrift placeholder a two-line change.
|
|
9
|
+
TOOL_NAME = "toolprint"
|
|
10
|
+
__version__ = "0.1.0"
|
toolprint/auth.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Auth posture classification and literal-credential detection.
|
|
2
|
+
|
|
3
|
+
Client configs support variable expansion (`${VAR}`, `${VAR:-default}`), so a
|
|
4
|
+
header of `Bearer ${API_KEY}` is hygienic while the same header holding the token
|
|
5
|
+
itself is a plaintext credential sitting in a dotfile. Telling those apart is a
|
|
6
|
+
string test, which means we get it for free in --no-connect mode. It becomes
|
|
7
|
+
finding HYG-002 in M2.
|
|
8
|
+
|
|
9
|
+
Everything here reports *locations*, never values. A detector that quotes the
|
|
10
|
+
secret it found has recreated the problem it was looking for.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import List, Optional, Tuple
|
|
17
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
18
|
+
|
|
19
|
+
from .model import (
|
|
20
|
+
AUTH_ENV_VAR,
|
|
21
|
+
AUTH_HELPER_COMMAND,
|
|
22
|
+
AUTH_LITERAL_SECRET,
|
|
23
|
+
AUTH_NONE,
|
|
24
|
+
AUTH_OAUTH,
|
|
25
|
+
SecretLocation,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# ${VAR}, ${VAR:-default} and bare $VAR expansions.
|
|
29
|
+
ENV_REF = re.compile(r"\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*")
|
|
30
|
+
|
|
31
|
+
# Auth scheme words that legitimately sit next to a reference and are not secrets.
|
|
32
|
+
SCHEME_WORDS = re.compile(r"\b(bearer|basic|token|apikey|api_key|key)\b", re.I)
|
|
33
|
+
|
|
34
|
+
# Field names that are credential-carrying by convention.
|
|
35
|
+
CREDENTIAL_HEADERS = {
|
|
36
|
+
"authorization",
|
|
37
|
+
"proxy-authorization",
|
|
38
|
+
"cookie",
|
|
39
|
+
"x-api-key",
|
|
40
|
+
"api-key",
|
|
41
|
+
"apikey",
|
|
42
|
+
"x-auth-token",
|
|
43
|
+
"x-access-token",
|
|
44
|
+
"x-goog-api-key",
|
|
45
|
+
"token",
|
|
46
|
+
"secret",
|
|
47
|
+
}
|
|
48
|
+
CREDENTIAL_WORDS = frozenset({
|
|
49
|
+
"TOKEN", "KEY", "APIKEY", "SECRET", "PASSWORD", "PASSWD", "PASS",
|
|
50
|
+
"CREDENTIAL", "CREDENTIALS", "AUTH", "PAT", "SESSION", "BEARER", "SIGNATURE",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
# Split on separators and camelCase, so CONTEXT7_API_KEY and apiKey both yield a
|
|
54
|
+
# "KEY" token while X-Monkey-Id does not. A substring test would match "MONKEY".
|
|
55
|
+
_NAME_TOKENS = re.compile(r"[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])")
|
|
56
|
+
|
|
57
|
+
# High-precision issuer prefixes. These identify a credential regardless of what
|
|
58
|
+
# the surrounding field is called, which catches secrets in oddly-named fields.
|
|
59
|
+
SECRET_PREFIXES = (
|
|
60
|
+
"sk-ant-",
|
|
61
|
+
"sk-",
|
|
62
|
+
"ghp_",
|
|
63
|
+
"gho_",
|
|
64
|
+
"ghu_",
|
|
65
|
+
"ghs_",
|
|
66
|
+
"github_pat_",
|
|
67
|
+
"glpat-",
|
|
68
|
+
"xoxb-",
|
|
69
|
+
"xoxp-",
|
|
70
|
+
"xoxa-",
|
|
71
|
+
"AKIA",
|
|
72
|
+
"ASIA",
|
|
73
|
+
"AIza",
|
|
74
|
+
"npm_",
|
|
75
|
+
"dop_v1_",
|
|
76
|
+
"hf_",
|
|
77
|
+
"pk_live_",
|
|
78
|
+
"rk_live_",
|
|
79
|
+
"eyJ", # JWT
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# A credential-shaped residue this long is a token, not a scheme word or a path.
|
|
83
|
+
MIN_SECRET_RESIDUE = 12
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _residue(value: str) -> str:
|
|
87
|
+
"""What is left of a value once expansions and scheme words are removed."""
|
|
88
|
+
without_refs = ENV_REF.sub("", value)
|
|
89
|
+
without_scheme = SCHEME_WORDS.sub("", without_refs)
|
|
90
|
+
return re.sub(r"[^A-Za-z0-9]", "", without_scheme)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def has_env_reference(value: str) -> bool:
|
|
94
|
+
return bool(ENV_REF.search(value))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Auth schemes a token may legitimately sit behind: "Authorization: Bearer ghp_...".
|
|
98
|
+
SCHEME_PREFIX = re.compile(r"^(?:bearer|basic|token|apikey)\s+", re.I)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _has_issuer_prefix(value: str) -> bool:
|
|
102
|
+
"""True if the value *starts* with a known credential prefix.
|
|
103
|
+
|
|
104
|
+
Anchored deliberately. An unanchored substring test matches "sk-" inside
|
|
105
|
+
ordinary strings such as "task-runner" or "disk-cache", and it also misfires
|
|
106
|
+
on vendor keys that merely embed a known prefix (Context7's "ctx7sk-..." is
|
|
107
|
+
a real credential, but it is caught by the credential-named-field rule below
|
|
108
|
+
with an accurate reason rather than by a bogus prefix match).
|
|
109
|
+
"""
|
|
110
|
+
candidate = SCHEME_PREFIX.sub("", value.strip())
|
|
111
|
+
return any(candidate.startswith(prefix) for prefix in SECRET_PREFIXES)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def looks_like_secret(value: object, credential_field: bool) -> Optional[str]:
|
|
115
|
+
"""Return a reason string if this value looks like a literal credential."""
|
|
116
|
+
if not isinstance(value, str) or not value.strip():
|
|
117
|
+
return None
|
|
118
|
+
stripped = value.strip()
|
|
119
|
+
|
|
120
|
+
# A prefix in front of a ${VAR} reference is hygienic, not a secret. Slack
|
|
121
|
+
# tokens are configured as "xoxb-${SLACK_TOKEN}", and flagging that as a
|
|
122
|
+
# plaintext credential is a false accusation about a config that is doing
|
|
123
|
+
# exactly the right thing. Checked before the prefix rule, which would
|
|
124
|
+
# otherwise match on "xoxb-" alone.
|
|
125
|
+
if has_env_reference(stripped) and len(_residue(stripped)) < MIN_SECRET_RESIDUE:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
if _has_issuer_prefix(stripped):
|
|
129
|
+
return "value begins with a recognised credential prefix"
|
|
130
|
+
if credential_field and len(_residue(stripped)) >= MIN_SECRET_RESIDUE:
|
|
131
|
+
return "credential-named field holds a literal value, not a ${VAR} reference"
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_credential_name(name: str) -> bool:
|
|
136
|
+
"""True if a header or env var name conventionally carries a credential.
|
|
137
|
+
|
|
138
|
+
Token-based rather than substring-based: CONTEXT7_API_KEY and apiKey are
|
|
139
|
+
credentials, X-Monkey-Id and CACHE_DIR are not.
|
|
140
|
+
"""
|
|
141
|
+
if name.lower() in CREDENTIAL_HEADERS:
|
|
142
|
+
return True
|
|
143
|
+
tokens = {t.upper() for t in _NAME_TOKENS.split(name) if t}
|
|
144
|
+
return bool(tokens & CREDENTIAL_WORDS)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def scan_for_secrets(
|
|
148
|
+
headers: Optional[dict],
|
|
149
|
+
env: Optional[dict],
|
|
150
|
+
args: Optional[list],
|
|
151
|
+
url: Optional[str],
|
|
152
|
+
) -> List[SecretLocation]:
|
|
153
|
+
"""Locate literal credentials. Records field paths only, never values."""
|
|
154
|
+
found: List[SecretLocation] = []
|
|
155
|
+
|
|
156
|
+
for name, value in (headers or {}).items():
|
|
157
|
+
reason = looks_like_secret(value, is_credential_name(name))
|
|
158
|
+
if reason:
|
|
159
|
+
found.append(SecretLocation("headers.{}".format(name), reason))
|
|
160
|
+
|
|
161
|
+
for name, value in (env or {}).items():
|
|
162
|
+
reason = looks_like_secret(value, is_credential_name(name))
|
|
163
|
+
if reason:
|
|
164
|
+
found.append(SecretLocation("env.{}".format(name), reason))
|
|
165
|
+
|
|
166
|
+
for index, value in enumerate(args or []):
|
|
167
|
+
if not isinstance(value, str):
|
|
168
|
+
continue
|
|
169
|
+
# Both "--token=VALUE" and a bare token positional.
|
|
170
|
+
flag_match = re.match(r"--?([A-Za-z0-9_-]*(?:key|token|secret|password|auth)[A-Za-z0-9_-]*)=(.+)$", value, re.I)
|
|
171
|
+
if flag_match:
|
|
172
|
+
reason = looks_like_secret(flag_match.group(2), True)
|
|
173
|
+
else:
|
|
174
|
+
reason = looks_like_secret(value, False)
|
|
175
|
+
if reason:
|
|
176
|
+
found.append(SecretLocation("args[{}]".format(index), reason))
|
|
177
|
+
|
|
178
|
+
if url:
|
|
179
|
+
try:
|
|
180
|
+
query = urlsplit(url).query
|
|
181
|
+
except ValueError:
|
|
182
|
+
query = ""
|
|
183
|
+
for name, value in parse_qsl(query, keep_blank_values=True):
|
|
184
|
+
credential_field = is_credential_name(name)
|
|
185
|
+
reason = looks_like_secret(value, credential_field)
|
|
186
|
+
if reason:
|
|
187
|
+
found.append(
|
|
188
|
+
SecretLocation("url.query.{}".format(name), reason)
|
|
189
|
+
)
|
|
190
|
+
return found
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def classify(
|
|
194
|
+
secret_locations: List[SecretLocation],
|
|
195
|
+
headers: Optional[dict],
|
|
196
|
+
env: Optional[dict],
|
|
197
|
+
headers_helper: Optional[str],
|
|
198
|
+
has_oauth: bool,
|
|
199
|
+
) -> Tuple[str, List[str]]:
|
|
200
|
+
"""Return (auth_method, notes). Worst posture wins; see model.py for order."""
|
|
201
|
+
notes: List[str] = []
|
|
202
|
+
|
|
203
|
+
if secret_locations:
|
|
204
|
+
return AUTH_LITERAL_SECRET, notes
|
|
205
|
+
if headers_helper:
|
|
206
|
+
notes.append("auth headers are minted by an external command; posture is not statically determinable")
|
|
207
|
+
return AUTH_HELPER_COMMAND, notes
|
|
208
|
+
if has_oauth:
|
|
209
|
+
return AUTH_OAUTH, notes
|
|
210
|
+
|
|
211
|
+
referenced = [n for n, v in (headers or {}).items() if isinstance(v, str) and has_env_reference(v)]
|
|
212
|
+
credential_env = [n for n in (env or {}) if is_credential_name(n)]
|
|
213
|
+
if referenced or credential_env:
|
|
214
|
+
return AUTH_ENV_VAR, notes
|
|
215
|
+
|
|
216
|
+
if headers:
|
|
217
|
+
# Headers present but none credential-shaped: informational, not auth.
|
|
218
|
+
notes.append("headers present but none carry a credential")
|
|
219
|
+
return AUTH_NONE, notes
|
toolprint/baseline.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""The approved baseline: what the tool surface looked like when someone reviewed it.
|
|
2
|
+
|
|
3
|
+
A baseline records component hashes plus just enough schema shape to say *how* a
|
|
4
|
+
schema changed - whether a parameter was removed, a type narrowed, or a required
|
|
5
|
+
field added - because "the schema hash changed" is not actionable on its own.
|
|
6
|
+
|
|
7
|
+
Identity is `name@transport:endpoint`, not the config key. Moving a server from
|
|
8
|
+
user scope to project scope is not tool drift and must not read as any, whereas
|
|
9
|
+
the same name pointing at a different endpoint is a different server and should.
|
|
10
|
+
|
|
11
|
+
False-positive management is not optional here. `approve` records who accepted
|
|
12
|
+
what and when; `exceptions` carry a reason and an expiry so a standing exception
|
|
13
|
+
cannot become permanent by neglect. Without both, this recreates certificate
|
|
14
|
+
warning fatigue and users will --fail-on none it into irrelevance.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime
|
|
20
|
+
import json
|
|
21
|
+
from collections import OrderedDict
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
from . import __version__, canonical, effects, lexical
|
|
26
|
+
from .model import Inventory, Server
|
|
27
|
+
|
|
28
|
+
BASELINE_VERSION = 1
|
|
29
|
+
DEFAULT_PATH = ".toolprint-baseline.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def server_identity(server: Server) -> str:
|
|
33
|
+
endpoint = server.url_host or server.command_basename or "?"
|
|
34
|
+
return "{}@{}:{}".format(server.name, server.transport, endpoint)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _schema_shape(schema: Any) -> Dict[str, Any]:
|
|
38
|
+
"""Enough of a schema to classify a later change, and no more.
|
|
39
|
+
|
|
40
|
+
Storing the whole schema would make the baseline a second copy of the tool
|
|
41
|
+
surface; storing only a hash would make every change indistinguishable.
|
|
42
|
+
"""
|
|
43
|
+
resolved, _ = canonical.canonicalise(schema, resolve=True)
|
|
44
|
+
if not isinstance(resolved, dict):
|
|
45
|
+
return {"required": [], "properties": {}}
|
|
46
|
+
properties = resolved.get("properties")
|
|
47
|
+
shape: Dict[str, List[str]] = {}
|
|
48
|
+
if isinstance(properties, dict):
|
|
49
|
+
for name in sorted(properties):
|
|
50
|
+
value = properties[name]
|
|
51
|
+
declared = value.get("type") if isinstance(value, dict) else None
|
|
52
|
+
if isinstance(declared, str):
|
|
53
|
+
shape[name] = [declared]
|
|
54
|
+
elif isinstance(declared, list):
|
|
55
|
+
shape[name] = sorted(str(t) for t in declared)
|
|
56
|
+
else:
|
|
57
|
+
shape[name] = []
|
|
58
|
+
required = resolved.get("required")
|
|
59
|
+
return {
|
|
60
|
+
"required": sorted(str(r) for r in required) if isinstance(required, list) else [],
|
|
61
|
+
"properties": shape,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def tool_record(tool: Dict[str, Any]) -> Dict[str, Any]:
|
|
66
|
+
hashes = canonical.hash_tool(tool)
|
|
67
|
+
classified = effects.classify(tool)
|
|
68
|
+
record = OrderedDict(sorted(hashes.items()))
|
|
69
|
+
record["effect"] = classified["effect"]
|
|
70
|
+
record["declared_ceiling"] = classified["declared_ceiling"]
|
|
71
|
+
record["annotations"] = {
|
|
72
|
+
key: tool.get("annotations", {}).get(key)
|
|
73
|
+
for key in ("readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint")
|
|
74
|
+
if isinstance(tool.get("annotations"), dict)
|
|
75
|
+
and key in tool.get("annotations", {})
|
|
76
|
+
}
|
|
77
|
+
record["schema_shape"] = _schema_shape(tool.get("inputSchema"))
|
|
78
|
+
return record
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def snapshot(inventory: Inventory) -> Dict[str, Any]:
|
|
82
|
+
"""Current state, in the same shape a baseline stores. One entry per server."""
|
|
83
|
+
servers: Dict[str, Any] = {}
|
|
84
|
+
for server in sorted(inventory.servers, key=lambda s: s.key):
|
|
85
|
+
if server.fetch_status != "ok" or not server.tools:
|
|
86
|
+
continue
|
|
87
|
+
identity = server_identity(server)
|
|
88
|
+
if identity in servers:
|
|
89
|
+
continue # the same running server registered twice
|
|
90
|
+
tools = OrderedDict()
|
|
91
|
+
for tool in sorted(server.tools, key=lambda t: str(t.get("name"))):
|
|
92
|
+
name = tool.get("name")
|
|
93
|
+
if isinstance(name, str):
|
|
94
|
+
tools[name] = tool_record(tool)
|
|
95
|
+
record = OrderedDict(sorted(canonical.hash_server(server.tools).items()))
|
|
96
|
+
record["transport"] = server.transport
|
|
97
|
+
record["auth_method"] = server.auth_method
|
|
98
|
+
record["tools"] = tools
|
|
99
|
+
servers[identity] = record
|
|
100
|
+
return OrderedDict(sorted(servers.items()))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def now() -> str:
|
|
104
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build(inventory: Inventory, approved_by: Optional[str] = None,
|
|
108
|
+
note: Optional[str] = None) -> Dict[str, Any]:
|
|
109
|
+
stamp = now()
|
|
110
|
+
return OrderedDict([
|
|
111
|
+
("baseline_version", BASELINE_VERSION),
|
|
112
|
+
("generator", "toolprint/{}".format(__version__)),
|
|
113
|
+
# Bumping the effect-class verb lists changes classifications, which would
|
|
114
|
+
# otherwise be indistinguishable from real drift. A bump is a re-approval
|
|
115
|
+
# event, not a finding.
|
|
116
|
+
("heuristics_version", effects.HEURISTICS_VERSION),
|
|
117
|
+
("created_at", stamp),
|
|
118
|
+
("approved_at", stamp),
|
|
119
|
+
("approved_by", approved_by),
|
|
120
|
+
("note", note),
|
|
121
|
+
("exceptions", []),
|
|
122
|
+
("servers", _stamp_first_observed(snapshot(inventory), stamp)),
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _stamp_first_observed(servers: Dict[str, Any], stamp: str) -> Dict[str, Any]:
|
|
127
|
+
"""Record when each server entered the watch.
|
|
128
|
+
|
|
129
|
+
Without this, a server added in week six looks like it had six quiet weeks.
|
|
130
|
+
Any rate computed across the whole file would then be wrong in the direction
|
|
131
|
+
that flatters the result, which is the worst direction for it to be wrong in.
|
|
132
|
+
"""
|
|
133
|
+
for record in servers.values():
|
|
134
|
+
record.setdefault("first_observed", stamp)
|
|
135
|
+
return servers
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def adopt_new(document: Dict[str, Any], current: Dict[str, Any],
|
|
139
|
+
stamp: Optional[str] = None) -> List[str]:
|
|
140
|
+
"""Add servers that are being watched but were never baselined.
|
|
141
|
+
|
|
142
|
+
Adding a server to the watchlist is an intentional act, not drift, so it
|
|
143
|
+
produces no change to approve - which meant it never entered the baseline and
|
|
144
|
+
was compared against nothing, indefinitely.
|
|
145
|
+
"""
|
|
146
|
+
stamp = stamp or now()
|
|
147
|
+
stored = document.setdefault("servers", {})
|
|
148
|
+
added = []
|
|
149
|
+
for identity in sorted(current):
|
|
150
|
+
if identity not in stored:
|
|
151
|
+
record = json.loads(json.dumps(current[identity]))
|
|
152
|
+
record["first_observed"] = stamp
|
|
153
|
+
stored[identity] = record
|
|
154
|
+
added.append(identity)
|
|
155
|
+
return added
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def dropped(document: Dict[str, Any], current: Dict[str, Any]) -> List[str]:
|
|
159
|
+
"""Baselined servers no longer being watched or no longer reachable."""
|
|
160
|
+
return sorted(set(document.get("servers") or {}) - set(current))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load(path: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
164
|
+
"""Return (baseline, error). A missing or unreadable baseline is exit code 3."""
|
|
165
|
+
file = Path(path)
|
|
166
|
+
if not file.is_file():
|
|
167
|
+
return None, "no baseline at {}".format(path)
|
|
168
|
+
try:
|
|
169
|
+
document = json.loads(file.read_text(encoding="utf-8"))
|
|
170
|
+
except (OSError, ValueError) as exc:
|
|
171
|
+
return None, "baseline at {} is unreadable: {}".format(path, exc)
|
|
172
|
+
if not isinstance(document, dict) or "servers" not in document:
|
|
173
|
+
return None, "baseline at {} is not a toolprint baseline".format(path)
|
|
174
|
+
if document.get("baseline_version") != BASELINE_VERSION:
|
|
175
|
+
return None, "baseline schema version {}, this build writes {}".format(
|
|
176
|
+
document.get("baseline_version"), BASELINE_VERSION)
|
|
177
|
+
return document, None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def save(path: str, document: Dict[str, Any]) -> None:
|
|
181
|
+
Path(path).write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --------------------------------------------------------------------------
|
|
185
|
+
# Exceptions
|
|
186
|
+
# --------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def active_exceptions(document: Dict[str, Any], today: Optional[str] = None
|
|
189
|
+
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
190
|
+
"""Split exceptions into (active, expired). Expiry is mandatory by design."""
|
|
191
|
+
today = today or datetime.date.today().isoformat()
|
|
192
|
+
active, expired = [], []
|
|
193
|
+
for item in document.get("exceptions") or []:
|
|
194
|
+
if not isinstance(item, dict):
|
|
195
|
+
continue
|
|
196
|
+
expires = str(item.get("expires") or "")
|
|
197
|
+
(expired if expires and expires < today else active).append(item)
|
|
198
|
+
return active, expired
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def is_excepted(exceptions: Sequence[Dict[str, Any]], server: str, tool: Optional[str],
|
|
202
|
+
rule: str) -> Optional[Dict[str, Any]]:
|
|
203
|
+
for item in exceptions:
|
|
204
|
+
if item.get("rule") not in (rule, "*"):
|
|
205
|
+
continue
|
|
206
|
+
if item.get("server") not in (server, "*"):
|
|
207
|
+
continue
|
|
208
|
+
if item.get("tool") not in (tool, "*", None):
|
|
209
|
+
continue
|
|
210
|
+
return item
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------
|
|
215
|
+
# First-baseline safety checks
|
|
216
|
+
# --------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def first_baseline_objections(inventory: Inventory) -> List[str]:
|
|
219
|
+
"""Reasons to refuse writing a clean baseline over a suspicious state.
|
|
220
|
+
|
|
221
|
+
Trust-on-first-use means a baseline blesses whatever is there. Writing one
|
|
222
|
+
over a description that already contains a bidi override, or a server that
|
|
223
|
+
already names another server's tools, records the attack as approved and
|
|
224
|
+
guarantees it is never reported again.
|
|
225
|
+
"""
|
|
226
|
+
objections: List[str] = []
|
|
227
|
+
by_server: Dict[str, List[Dict[str, Any]]] = {}
|
|
228
|
+
for server in inventory.servers:
|
|
229
|
+
if server.fetch_status == "ok" and server.tools:
|
|
230
|
+
by_server.setdefault(server_identity(server), []).extend(server.tools)
|
|
231
|
+
|
|
232
|
+
for identity in sorted(by_server):
|
|
233
|
+
for tool in by_server[identity]:
|
|
234
|
+
for hit in lexical.inspect_tool(tool):
|
|
235
|
+
objections.append("{} / {}: {} in {} ({})".format(
|
|
236
|
+
identity, tool.get("name"), hit["kind"], hit["field"], hit["detail"]))
|
|
237
|
+
|
|
238
|
+
for hit in lexical.shadowing(by_server):
|
|
239
|
+
objections.append("{} / {}: description names {!r}, owned by {}".format(
|
|
240
|
+
hit["server"], hit["tool"], hit["references"], hit["owned_by"]))
|
|
241
|
+
return objections
|
toolprint/bundle.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Bundle export: a redacted description of an MCP deployment, for assessment.
|
|
2
|
+
|
|
3
|
+
The customer runs a collection, reads the file, and sends it. Everything here
|
|
4
|
+
serves that review step, which means the governing question is not "what would be
|
|
5
|
+
useful to have" but "what can a sceptical security engineer approve in five
|
|
6
|
+
minutes".
|
|
7
|
+
|
|
8
|
+
Redaction is an allowlist, in one visible constant. A denylist - strip anything
|
|
9
|
+
that looks like a secret - fails open and cannot be verified by reading; an
|
|
10
|
+
allowlist fails closed and a reviewer can check it in thirty seconds. If a field
|
|
11
|
+
is not named in EMITTED_FIELDS it never reaches the bundle, including fields
|
|
12
|
+
added to the model later.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import platform
|
|
21
|
+
import sys
|
|
22
|
+
import textwrap
|
|
23
|
+
from collections import OrderedDict
|
|
24
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from .model import Inventory, Server
|
|
28
|
+
|
|
29
|
+
BUNDLE_VERSION = 1
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Everything this export will emit. Nothing else leaves the machine.
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
EMITTED_FIELDS = {
|
|
35
|
+
"server": ["name", "source_client", "scope", "transport", "command_basename",
|
|
36
|
+
"auth_method", "auth_env_names", "auth_header_names", "url_host",
|
|
37
|
+
"fetch_status", "protocol_version", "protocol_era"],
|
|
38
|
+
"tool": ["name", "title", "description", "inputSchema", "outputSchema",
|
|
39
|
+
"annotations", "token_estimate", "token_method"],
|
|
40
|
+
# No "usage" entry: neither this exporter nor mcp_collect.py collects call
|
|
41
|
+
# counts yet, and an allowlist naming a field nothing emits misleads the
|
|
42
|
+
# person reading it. It comes back when --usage lands.
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
DATA_POLICY = (
|
|
46
|
+
"This bundle contains: MCP server names, which client they were configured in, "
|
|
47
|
+
"transport type, the basename of any command (for example 'npx'), the hostname "
|
|
48
|
+
"of any remote server, the NAMES of environment variables and HTTP headers used "
|
|
49
|
+
"for authentication, and the full tool definitions each server advertises - tool "
|
|
50
|
+
"names, descriptions and JSON schemas.\n"
|
|
51
|
+
"It does NOT contain: environment variable values, HTTP header values, API keys, "
|
|
52
|
+
"tokens or passwords of any kind; full command lines or arguments; absolute file "
|
|
53
|
+
"paths; URL paths or query strings; usernames or hostnames of your machine; or "
|
|
54
|
+
"the contents of any file, request or response.\n"
|
|
55
|
+
"Tool definitions are included in full because they are the substance of the "
|
|
56
|
+
"assessment, and because they are already what the language model sees on every "
|
|
57
|
+
"request."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _digest(salt: str, value: str) -> str:
|
|
62
|
+
"""Stable, salted, non-reversible identifier.
|
|
63
|
+
|
|
64
|
+
HMAC rather than a plain hash: server and tool names are drawn from a small
|
|
65
|
+
public vocabulary, so an unsalted hash of 'github' is recovered instantly by
|
|
66
|
+
dictionary. The customer keeps the salt, so they can re-identify their own
|
|
67
|
+
bundle and nobody else can.
|
|
68
|
+
"""
|
|
69
|
+
return hmac.new(salt.encode("utf-8"), value.encode("utf-8"),
|
|
70
|
+
hashlib.sha256).hexdigest()[:12]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Anonymiser:
|
|
74
|
+
"""Replaces identifiers consistently, so the bundle stays analysable."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, salt: Optional[str]):
|
|
77
|
+
self.salt = salt
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def active(self) -> bool:
|
|
81
|
+
return bool(self.salt)
|
|
82
|
+
|
|
83
|
+
def name(self, value: Optional[str], prefix: str) -> Optional[str]:
|
|
84
|
+
if not value or not self.salt:
|
|
85
|
+
return value
|
|
86
|
+
return "{}-{}".format(prefix, _digest(self.salt, value))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _tool(tool: Dict[str, Any], tokens: Dict[str, int], method: Optional[str]) -> Dict[str, Any]:
|
|
90
|
+
name = tool.get("name")
|
|
91
|
+
out: "OrderedDict[str, Any]" = OrderedDict()
|
|
92
|
+
for field in EMITTED_FIELDS["tool"]:
|
|
93
|
+
if field == "token_estimate":
|
|
94
|
+
out[field] = tokens.get(name) if isinstance(name, str) else None
|
|
95
|
+
elif field == "token_method":
|
|
96
|
+
out[field] = method
|
|
97
|
+
elif field in tool:
|
|
98
|
+
out[field] = tool[field]
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _server(server: Server, anon: Anonymiser) -> Dict[str, Any]:
|
|
103
|
+
# Built field by field from the allowlist, so a new attribute on Server
|
|
104
|
+
# cannot silently start appearing in bundles.
|
|
105
|
+
values = {
|
|
106
|
+
"name": anon.name(server.name, "server") if anon.active else server.name,
|
|
107
|
+
"source_client": server.client,
|
|
108
|
+
"scope": server.scope,
|
|
109
|
+
"transport": server.transport,
|
|
110
|
+
"command_basename": server.command_basename,
|
|
111
|
+
"auth_method": server.auth_method,
|
|
112
|
+
"auth_env_names": server.env_names,
|
|
113
|
+
"auth_header_names": server.header_names,
|
|
114
|
+
"url_host": anon.name(server.url_host, "host") if anon.active else server.url_host,
|
|
115
|
+
"fetch_status": server.fetch_status,
|
|
116
|
+
"protocol_version": server.protocol_version,
|
|
117
|
+
"protocol_era": server.protocol_era,
|
|
118
|
+
}
|
|
119
|
+
out: "OrderedDict[str, Any]" = OrderedDict(
|
|
120
|
+
(field, values[field]) for field in EMITTED_FIELDS["server"])
|
|
121
|
+
out["tools"] = [_tool(t, server.tool_tokens, server.token_method) for t in server.tools]
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build(
|
|
126
|
+
inventory: Inventory,
|
|
127
|
+
collected_at: str,
|
|
128
|
+
mode: str,
|
|
129
|
+
salt: Optional[str] = None,
|
|
130
|
+
usage: Optional[Sequence[Dict[str, Any]]] = None,
|
|
131
|
+
kit_sha256: Optional[str] = None,
|
|
132
|
+
) -> Dict[str, Any]:
|
|
133
|
+
anon = Anonymiser(salt)
|
|
134
|
+
servers = sorted(inventory.servers, key=lambda s: s.key)
|
|
135
|
+
return OrderedDict([
|
|
136
|
+
("bundle_version", BUNDLE_VERSION),
|
|
137
|
+
("kit_version", __version__),
|
|
138
|
+
("kit_sha256", kit_sha256),
|
|
139
|
+
("collected_at", collected_at),
|
|
140
|
+
("mode", mode),
|
|
141
|
+
("anonymized", anon.active),
|
|
142
|
+
("data_policy", DATA_POLICY),
|
|
143
|
+
("platform", OrderedDict([
|
|
144
|
+
("os", sys.platform),
|
|
145
|
+
("python", platform.python_version()),
|
|
146
|
+
])),
|
|
147
|
+
("clients_found", inventory.clients_found),
|
|
148
|
+
("servers", [_server(s, anon) for s in servers]),
|
|
149
|
+
("usage", list(usage or [])),
|
|
150
|
+
# Failures are data. "Three servers were unreachable" is a finding.
|
|
151
|
+
("collection_errors", [
|
|
152
|
+
OrderedDict([("kind", e.kind), ("client", e.client),
|
|
153
|
+
("detail", e.detail)])
|
|
154
|
+
for e in inventory.errors
|
|
155
|
+
]),
|
|
156
|
+
])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def summarise(document: Dict[str, Any], path: str, size: int) -> str:
|
|
160
|
+
"""What the user sees after a bundle is written. Ends with the review order."""
|
|
161
|
+
servers = document.get("servers", [])
|
|
162
|
+
tools = sum(len(s.get("tools", [])) for s in servers)
|
|
163
|
+
failed = [s for s in servers if s.get("fetch_status") not in ("ok", "not_attempted")]
|
|
164
|
+
lines = [
|
|
165
|
+
"",
|
|
166
|
+
"Bundle written: {} ({:,} bytes)".format(path, size),
|
|
167
|
+
" {} server(s), {} tool definition(s) captured".format(len(servers), tools),
|
|
168
|
+
" mode: {}{}".format(document.get("mode"),
|
|
169
|
+
", anonymized" if document.get("anonymized") else ""),
|
|
170
|
+
]
|
|
171
|
+
if failed:
|
|
172
|
+
lines.append(" {} server(s) could not be reached (recorded in the bundle)".format(
|
|
173
|
+
len(failed)))
|
|
174
|
+
errors = document.get("collection_errors") or []
|
|
175
|
+
if errors:
|
|
176
|
+
lines.append(" {} config file(s) could not be parsed (recorded in the bundle)".format(
|
|
177
|
+
len(errors)))
|
|
178
|
+
lines += ["", "DATA POLICY", ""]
|
|
179
|
+
for paragraph in document.get("data_policy", "").split("\n"):
|
|
180
|
+
lines.append(textwrap.fill(paragraph, 78, initial_indent=" ",
|
|
181
|
+
subsequent_indent=" "))
|
|
182
|
+
lines += [
|
|
183
|
+
"",
|
|
184
|
+
"Review this file before sending it. It contains exactly what is listed",
|
|
185
|
+
"above and nothing else.",
|
|
186
|
+
"",
|
|
187
|
+
]
|
|
188
|
+
return "\n".join(lines)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def find_secrets(document: Dict[str, Any], needles: Sequence[str]) -> List[str]:
|
|
192
|
+
"""Test helper: does any known secret appear anywhere in the bundle?"""
|
|
193
|
+
blob = json.dumps(document)
|
|
194
|
+
return sorted({n for n in needles if n and n in blob})
|