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 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})