endpointsweep 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.
- endpointsweep/__init__.py +13 -0
- endpointsweep/checks/__init__.py +28 -0
- endpointsweep/checks/caps.py +277 -0
- endpointsweep/checks/fleet.py +160 -0
- endpointsweep/checks/hygiene.py +230 -0
- endpointsweep/checks/injection.py +152 -0
- endpointsweep/checks/registry.py +213 -0
- endpointsweep/checks/supplychain.py +227 -0
- endpointsweep/cli.py +391 -0
- endpointsweep/collectors/__init__.py +6 -0
- endpointsweep/collectors/endpointsweep_collect.bash +619 -0
- endpointsweep/collectors/endpointsweep_collect.ps1 +654 -0
- endpointsweep/collectors/endpointsweep_collect.sh +686 -0
- endpointsweep/data/known_packages.json +66 -0
- endpointsweep/matchers.py +285 -0
- endpointsweep/merge.py +266 -0
- endpointsweep/parsers/__init__.py +110 -0
- endpointsweep/parsers/base.py +227 -0
- endpointsweep/parsers/claude_desktop.py +38 -0
- endpointsweep/parsers/cursor.py +26 -0
- endpointsweep/parsers/generic_mcp.py +31 -0
- endpointsweep/parsers/vscode.py +71 -0
- endpointsweep/report/__init__.py +17 -0
- endpointsweep/report/html.py +179 -0
- endpointsweep/report/markdown.py +400 -0
- endpointsweep/report/sarif.py +183 -0
- endpointsweep/schema.py +451 -0
- endpointsweep/scoring.py +120 -0
- endpointsweep-0.1.0.dist-info/METADATA +365 -0
- endpointsweep-0.1.0.dist-info/RECORD +34 -0
- endpointsweep-0.1.0.dist-info/WHEEL +5 -0
- endpointsweep-0.1.0.dist-info/entry_points.txt +3 -0
- endpointsweep-0.1.0.dist-info/licenses/LICENSE +21 -0
- endpointsweep-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""EndpointSweep — fleet-scale MCP and agentic-AI discovery and audit.
|
|
2
|
+
|
|
3
|
+
Collectors (POSIX sh / bash / PowerShell) inventory endpoints under real
|
|
4
|
+
EDR/MDM constraints; this Python package parses, audits and aggregates their
|
|
5
|
+
output centrally.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
# Bumped when the on-wire collector JSON contract changes incompatibly.
|
|
11
|
+
COLLECTOR_SCHEMA_VERSION = "1"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__", "COLLECTOR_SCHEMA_VERSION"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Check catalog. Importing this package populates the registry.
|
|
2
|
+
|
|
3
|
+
Families: ``caps`` (ES-1xx), ``injection`` (ES-2xx), ``supplychain`` (ES-3xx),
|
|
4
|
+
``hygiene`` (ES-4xx), ``fleet`` (ES-9xx).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from . import caps, fleet, hygiene, injection, supplychain # noqa: F401 (registration side effect)
|
|
10
|
+
from .registry import (
|
|
11
|
+
REGISTRY,
|
|
12
|
+
Check,
|
|
13
|
+
CheckContext,
|
|
14
|
+
CheckMeta,
|
|
15
|
+
all_checks,
|
|
16
|
+
run_fleet_checks,
|
|
17
|
+
run_host_checks,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"REGISTRY",
|
|
22
|
+
"Check",
|
|
23
|
+
"CheckContext",
|
|
24
|
+
"CheckMeta",
|
|
25
|
+
"all_checks",
|
|
26
|
+
"run_fleet_checks",
|
|
27
|
+
"run_host_checks",
|
|
28
|
+
]
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""ES-1xx — capability and scope checks for declared MCP servers (vector 4).
|
|
2
|
+
|
|
3
|
+
What can this server actually touch on the endpoint? These are the checks that
|
|
4
|
+
turn "an MCP server is installed" into "an MCP server that can run shell
|
|
5
|
+
commands as root over the whole filesystem is installed".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
from ..matchers import (
|
|
13
|
+
EGRESS_SCOPE_ARGS,
|
|
14
|
+
EXEC_SERVER_HINTS,
|
|
15
|
+
EXEC_TOOL_NAMES,
|
|
16
|
+
FILESYSTEM_HINTS,
|
|
17
|
+
NETWORK_HINTS,
|
|
18
|
+
basename,
|
|
19
|
+
inline_code_flags,
|
|
20
|
+
is_exec_interpreter,
|
|
21
|
+
is_writable_path,
|
|
22
|
+
matches_any,
|
|
23
|
+
mode_is_world_writable,
|
|
24
|
+
root_scope_args,
|
|
25
|
+
)
|
|
26
|
+
from ..schema import Finding, HostInventory, MCPServer, Severity, Vector
|
|
27
|
+
from .registry import CheckContext, CheckMeta, check
|
|
28
|
+
|
|
29
|
+
_LOOPBACK = ("localhost", "127.0.0.1", "::1", "0.0.0.0")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_ONE_STEP_LOWER = {
|
|
33
|
+
Severity.CRITICAL: Severity.HIGH,
|
|
34
|
+
Severity.HIGH: Severity.MEDIUM,
|
|
35
|
+
Severity.MEDIUM: Severity.LOW,
|
|
36
|
+
Severity.LOW: Severity.INFO,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def downgrade(severity: Severity) -> Severity:
|
|
41
|
+
return _ONE_STEP_LOWER.get(severity, severity)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def severity_for(meta: CheckMeta, server: MCPServer) -> Severity:
|
|
45
|
+
"""A disabled server is latent, not live: report it one step lower."""
|
|
46
|
+
return meta.severity if not server.disabled else downgrade(meta.severity)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _suffix(server: MCPServer) -> str:
|
|
50
|
+
return " (currently disabled in config)" if server.disabled else ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@check(
|
|
54
|
+
"ES-101",
|
|
55
|
+
"MCP server has local command-execution capability",
|
|
56
|
+
Severity.HIGH,
|
|
57
|
+
Vector.MCP_SERVERS,
|
|
58
|
+
rationale=(
|
|
59
|
+
"A server that can spawn a shell turns any successful prompt injection into "
|
|
60
|
+
"local code execution in the user's security context."
|
|
61
|
+
),
|
|
62
|
+
fix=(
|
|
63
|
+
"Remove the server, or replace it with a task-specific server. If execution is "
|
|
64
|
+
"genuinely required, pin it to an allow-list of commands and require per-call "
|
|
65
|
+
"approval (never autoApprove)."
|
|
66
|
+
),
|
|
67
|
+
owasp=("LLM06: Excessive Agency", "LLM05: Improper Output Handling"),
|
|
68
|
+
atlas=("AML.T0053: LLM Plugin Compromise", "AML.T0011: User Execution"),
|
|
69
|
+
)
|
|
70
|
+
def exec_capable_server(inv: HostInventory, ctx: CheckContext) -> Iterable[Finding]:
|
|
71
|
+
meta = exec_capable_server.meta
|
|
72
|
+
for server in inv.mcp_servers:
|
|
73
|
+
reasons: list[str] = []
|
|
74
|
+
if is_exec_interpreter(server.command):
|
|
75
|
+
reasons.append(f"command is the interpreter {basename(server.command)!r}")
|
|
76
|
+
flags = inline_code_flags(server.command, server.args)
|
|
77
|
+
if flags:
|
|
78
|
+
reasons.append(f"runtime invoked with inline-code flag {flags[0]!r}")
|
|
79
|
+
hint = matches_any(server.invocation, EXEC_SERVER_HINTS)
|
|
80
|
+
if hint:
|
|
81
|
+
reasons.append(f"package name matches known exec server {hint!r}")
|
|
82
|
+
exec_tools = [t.name for t in server.tools if t.name.lower() in EXEC_TOOL_NAMES]
|
|
83
|
+
if exec_tools:
|
|
84
|
+
reasons.append(f"advertises execution tools: {', '.join(sorted(exec_tools))}")
|
|
85
|
+
if not reasons:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Auto-approval is what removes the human from the loop: either the
|
|
89
|
+
# execution tool is listed explicitly, or the client approves everything.
|
|
90
|
+
if "*" in server.auto_approved_tools:
|
|
91
|
+
auto = sorted(server.auto_approved_tools)
|
|
92
|
+
else:
|
|
93
|
+
auto = sorted(set(server.auto_approved_tools) & set(exec_tools))
|
|
94
|
+
severity = Severity.CRITICAL if auto else severity_for(meta, server)
|
|
95
|
+
detail = "; ".join(reasons) + _suffix(server)
|
|
96
|
+
if auto:
|
|
97
|
+
detail += f"; execution is auto-approved ({', '.join(auto)}) — no human in the loop"
|
|
98
|
+
yield meta.finding(
|
|
99
|
+
host=inv.hostname,
|
|
100
|
+
subject=f"{server.client}:{server.name}",
|
|
101
|
+
detail=detail,
|
|
102
|
+
severity=severity,
|
|
103
|
+
evidence={
|
|
104
|
+
"command": server.command,
|
|
105
|
+
"args": server.args,
|
|
106
|
+
"auto_approved": auto,
|
|
107
|
+
"user": server.user,
|
|
108
|
+
},
|
|
109
|
+
location=server.config_path,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@check(
|
|
114
|
+
"ES-102",
|
|
115
|
+
"Filesystem MCP server rooted at a filesystem or home directory",
|
|
116
|
+
Severity.HIGH,
|
|
117
|
+
Vector.MCP_SERVERS,
|
|
118
|
+
rationale=(
|
|
119
|
+
"A filesystem server scoped to /, ~ or a wildcard can read SSH keys, browser "
|
|
120
|
+
"profiles, cloud credentials and source trees — the whole endpoint is in scope "
|
|
121
|
+
"of any injected instruction."
|
|
122
|
+
),
|
|
123
|
+
fix="Re-scope the server to the specific project directories it needs, never a home or root path.",
|
|
124
|
+
owasp=("LLM06: Excessive Agency", "LLM02: Sensitive Information Disclosure"),
|
|
125
|
+
atlas=("AML.T0053: LLM Plugin Compromise", "AML.T0025: Exfiltration via Cyber Means"),
|
|
126
|
+
)
|
|
127
|
+
def filesystem_root_scope(inv: HostInventory, ctx: CheckContext) -> Iterable[Finding]:
|
|
128
|
+
meta = filesystem_root_scope.meta
|
|
129
|
+
for server in inv.mcp_servers:
|
|
130
|
+
risky = root_scope_args(server.args)
|
|
131
|
+
if not risky:
|
|
132
|
+
continue
|
|
133
|
+
fs_hint = matches_any(server.invocation, FILESYSTEM_HINTS)
|
|
134
|
+
fs_tools = [t.name for t in server.tools if "file" in t.name.lower() or "read" in t.name.lower()]
|
|
135
|
+
if not fs_hint and not fs_tools and not is_exec_interpreter(server.command):
|
|
136
|
+
# A root-ish path argument to a non-filesystem server is weaker
|
|
137
|
+
# evidence; still worth a look, one severity step down.
|
|
138
|
+
severity = downgrade(Severity.MEDIUM) if server.disabled else Severity.MEDIUM
|
|
139
|
+
why = "root-scoped path argument on a non-filesystem server"
|
|
140
|
+
else:
|
|
141
|
+
severity = severity_for(meta, server)
|
|
142
|
+
why = f"filesystem-capable server ({fs_hint or 'advertised file tools'})"
|
|
143
|
+
yield meta.finding(
|
|
144
|
+
host=inv.hostname,
|
|
145
|
+
subject=f"{server.client}:{server.name}",
|
|
146
|
+
detail=f"{why} granted scope: {', '.join(risky)}{_suffix(server)}",
|
|
147
|
+
severity=severity,
|
|
148
|
+
evidence={"scopes": risky, "command": server.command, "args": server.args},
|
|
149
|
+
location=server.config_path,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@check(
|
|
154
|
+
"ES-103",
|
|
155
|
+
"Network-egress MCP server with unrestricted host scope",
|
|
156
|
+
Severity.MEDIUM,
|
|
157
|
+
Vector.MCP_SERVERS,
|
|
158
|
+
rationale=(
|
|
159
|
+
"A fetch/browser server with no host allow-list is a ready-made exfiltration "
|
|
160
|
+
"channel: injected content can name the destination."
|
|
161
|
+
),
|
|
162
|
+
fix=(
|
|
163
|
+
"Constrain the server to an allow-list of hosts/domains, or route it through an "
|
|
164
|
+
"egress proxy that enforces one. If the endpoint URL itself carries the credential, "
|
|
165
|
+
"rotate it and move to a header- or broker-supplied token."
|
|
166
|
+
),
|
|
167
|
+
owasp=("LLM02: Sensitive Information Disclosure", "LLM06: Excessive Agency"),
|
|
168
|
+
atlas=("AML.T0025: Exfiltration via Cyber Means",),
|
|
169
|
+
)
|
|
170
|
+
def unrestricted_egress(inv: HostInventory, ctx: CheckContext) -> Iterable[Finding]:
|
|
171
|
+
meta = unrestricted_egress.meta
|
|
172
|
+
for server in inv.mcp_servers:
|
|
173
|
+
hint = matches_any(server.invocation, NETWORK_HINTS)
|
|
174
|
+
remote = server.url and not any(h in server.url.lower() for h in _LOOPBACK)
|
|
175
|
+
if not hint and not remote:
|
|
176
|
+
continue
|
|
177
|
+
if any(a.split("=")[0] in EGRESS_SCOPE_ARGS for a in server.args):
|
|
178
|
+
continue
|
|
179
|
+
severity = severity_for(meta, server)
|
|
180
|
+
if remote:
|
|
181
|
+
detail = f"remote {server.transport.value} transport to external endpoint {server.url}"
|
|
182
|
+
if server.url_carries_secret:
|
|
183
|
+
# The endpoint URL is the credential: anyone who reads the
|
|
184
|
+
# config — or a backup of it — holds the session.
|
|
185
|
+
detail += "; endpoint URL embeds a credential-shaped segment (redacted here)"
|
|
186
|
+
severity = Severity.HIGH
|
|
187
|
+
else:
|
|
188
|
+
detail = f"egress-capable server ({hint}) with no host allow-list argument"
|
|
189
|
+
yield meta.finding(
|
|
190
|
+
host=inv.hostname,
|
|
191
|
+
subject=f"{server.client}:{server.name}",
|
|
192
|
+
detail=detail + _suffix(server),
|
|
193
|
+
severity=severity,
|
|
194
|
+
evidence={
|
|
195
|
+
"command": server.command,
|
|
196
|
+
"args": server.args,
|
|
197
|
+
"url": server.url,
|
|
198
|
+
"url_carries_secret": server.url_carries_secret,
|
|
199
|
+
},
|
|
200
|
+
location=server.config_path,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@check(
|
|
205
|
+
"ES-104",
|
|
206
|
+
"MCP server binary runs from a user- or world-writable path",
|
|
207
|
+
Severity.HIGH,
|
|
208
|
+
Vector.MCP_SERVERS,
|
|
209
|
+
rationale=(
|
|
210
|
+
"Anything that can write to /tmp, %TEMP% or a world-writable file can replace "
|
|
211
|
+
"the server binary and inherit every permission the agent has granted it."
|
|
212
|
+
),
|
|
213
|
+
fix="Move the server under a managed, root-owned install path and tighten permissions to 0755 or stricter.",
|
|
214
|
+
owasp=("LLM03: Supply Chain",),
|
|
215
|
+
atlas=("AML.T0010: ML Supply Chain Compromise", "AML.T0011: User Execution"),
|
|
216
|
+
)
|
|
217
|
+
def writable_server_path(inv: HostInventory, ctx: CheckContext) -> Iterable[Finding]:
|
|
218
|
+
meta = writable_server_path.meta
|
|
219
|
+
for server in inv.mcp_servers:
|
|
220
|
+
candidates = [server.command_path or server.command, *server.args[:2]]
|
|
221
|
+
hits = [c for c in candidates if is_writable_path(c)]
|
|
222
|
+
world_writable = mode_is_world_writable(server.command_mode)
|
|
223
|
+
if not hits and not world_writable:
|
|
224
|
+
continue
|
|
225
|
+
if world_writable:
|
|
226
|
+
detail = f"server binary {server.command_path} has mode {server.command_mode} (world-writable)"
|
|
227
|
+
else:
|
|
228
|
+
detail = f"server launches from writable staging path: {hits[0]}"
|
|
229
|
+
yield meta.finding(
|
|
230
|
+
host=inv.hostname,
|
|
231
|
+
subject=f"{server.client}:{server.name}",
|
|
232
|
+
detail=detail + _suffix(server),
|
|
233
|
+
severity=severity_for(meta, server),
|
|
234
|
+
evidence={
|
|
235
|
+
"command": server.command,
|
|
236
|
+
"command_path": server.command_path,
|
|
237
|
+
"command_mode": server.command_mode,
|
|
238
|
+
"writable_paths": hits,
|
|
239
|
+
},
|
|
240
|
+
location=server.config_path,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@check(
|
|
245
|
+
"ES-105",
|
|
246
|
+
"MCP server declared in an elevated (root/SYSTEM) scope",
|
|
247
|
+
Severity.MEDIUM,
|
|
248
|
+
Vector.MCP_SERVERS,
|
|
249
|
+
rationale=(
|
|
250
|
+
"A server declared system-wide or launched with elevation runs outside the "
|
|
251
|
+
"user's blast radius: its tools act with administrative rights."
|
|
252
|
+
),
|
|
253
|
+
fix="Move the declaration into the user scope and drop elevation; no MCP server should need root to answer a model.",
|
|
254
|
+
owasp=("LLM06: Excessive Agency",),
|
|
255
|
+
atlas=("AML.T0053: LLM Plugin Compromise", "AML.T0012: Valid Accounts"),
|
|
256
|
+
)
|
|
257
|
+
def elevated_server(inv: HostInventory, ctx: CheckContext) -> Iterable[Finding]:
|
|
258
|
+
meta = elevated_server.meta
|
|
259
|
+
escalators = {"sudo", "doas", "runas", "runas.exe", "gsudo", "pkexec"}
|
|
260
|
+
for server in inv.mcp_servers:
|
|
261
|
+
reasons = []
|
|
262
|
+
if basename(server.command) in escalators:
|
|
263
|
+
reasons.append(f"launched via {basename(server.command)}")
|
|
264
|
+
if server.elevated_scope:
|
|
265
|
+
reasons.append("declared in a root/SYSTEM-owned config scope")
|
|
266
|
+
if server.user in ("root", "SYSTEM") and server.user:
|
|
267
|
+
reasons.append(f"config owned by {server.user}")
|
|
268
|
+
if not reasons:
|
|
269
|
+
continue
|
|
270
|
+
yield meta.finding(
|
|
271
|
+
host=inv.hostname,
|
|
272
|
+
subject=f"{server.client}:{server.name}",
|
|
273
|
+
detail="; ".join(reasons) + _suffix(server),
|
|
274
|
+
severity=severity_for(meta, server),
|
|
275
|
+
evidence={"command": server.command, "user": server.user, "config": server.config_path},
|
|
276
|
+
location=server.config_path,
|
|
277
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""ES-9xx — fleet-level checks.
|
|
2
|
+
|
|
3
|
+
These are the checks that only exist once you have N hosts in one model. No
|
|
4
|
+
single-host scanner can answer "is this server the same server everywhere?" or
|
|
5
|
+
"how much of the fleet actually runs this?" — that is the whole point of the
|
|
6
|
+
collect-and-aggregate split (brief §2.1).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from ..matchers import normalize_invocation, package_from_invocation, split_spec
|
|
15
|
+
from ..schema import Finding, FleetModel, Severity, Vector
|
|
16
|
+
from .registry import CheckContext, fleet_check
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _server_index(fleet: FleetModel) -> dict[str, dict[str, set[str]]]:
|
|
20
|
+
"""identity -> normalised invocation -> hosts running it."""
|
|
21
|
+
index: dict[str, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
|
|
22
|
+
for report in fleet.hosts:
|
|
23
|
+
host = report.inventory.hostname
|
|
24
|
+
for server in report.inventory.mcp_servers:
|
|
25
|
+
invocation = normalize_invocation(server.command, server.args)
|
|
26
|
+
index[server.identity][invocation].add(host)
|
|
27
|
+
return index
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_managed(ctx: CheckContext, identity: str, invocations: Iterable[str]) -> bool:
|
|
31
|
+
managed = {m.lower() for m in ctx.managed_software}
|
|
32
|
+
if not managed:
|
|
33
|
+
return False
|
|
34
|
+
if identity in managed:
|
|
35
|
+
return True
|
|
36
|
+
for invocation in invocations:
|
|
37
|
+
parts = invocation.split()
|
|
38
|
+
if not parts:
|
|
39
|
+
continue
|
|
40
|
+
pkg = package_from_invocation(parts[0], parts[1:])
|
|
41
|
+
name, _ = split_spec(pkg)
|
|
42
|
+
if name and name.lower() in managed:
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@fleet_check(
|
|
48
|
+
"ES-901",
|
|
49
|
+
"Same MCP server name resolves to different commands across the fleet",
|
|
50
|
+
Severity.HIGH,
|
|
51
|
+
Vector.MCP_SERVERS,
|
|
52
|
+
rationale=(
|
|
53
|
+
"One name, several implementations: either the fleet is running unmanaged "
|
|
54
|
+
"variants of a server, or one host's copy has been swapped. A per-machine "
|
|
55
|
+
"scanner cannot see this — every host looks internally consistent."
|
|
56
|
+
),
|
|
57
|
+
fix=(
|
|
58
|
+
"Diff the variants. Standardise on one pinned invocation distributed by the "
|
|
59
|
+
"software-management stack, and investigate any host whose variant is unique."
|
|
60
|
+
),
|
|
61
|
+
owasp=("LLM03: Supply Chain", "LLM04: Data and Model Poisoning"),
|
|
62
|
+
atlas=("AML.T0010: ML Supply Chain Compromise", "AML.T0018: Manipulate AI Model"),
|
|
63
|
+
)
|
|
64
|
+
def inconsistent_server_provenance(fleet: FleetModel, ctx: CheckContext) -> Iterable[Finding]:
|
|
65
|
+
meta = inconsistent_server_provenance.meta
|
|
66
|
+
for identity, variants in sorted(_server_index(fleet).items()):
|
|
67
|
+
if len(variants) < 2:
|
|
68
|
+
continue
|
|
69
|
+
all_hosts = sorted({h for hosts in variants.values() for h in hosts})
|
|
70
|
+
if len(all_hosts) < 2:
|
|
71
|
+
continue
|
|
72
|
+
ranked = sorted(variants.items(), key=lambda kv: (-len(kv[1]), kv[0]))
|
|
73
|
+
outliers = [inv for inv, hosts in ranked[1:] if len(hosts) == 1]
|
|
74
|
+
severity = Severity.HIGH if outliers else Severity.MEDIUM
|
|
75
|
+
yield meta.finding(
|
|
76
|
+
subject=identity,
|
|
77
|
+
detail=(
|
|
78
|
+
f"server {identity!r} runs {len(variants)} distinct invocations across "
|
|
79
|
+
f"{len(all_hosts)} host(s)"
|
|
80
|
+
+ (f"; {len(outliers)} appear on exactly one host" if outliers else "")
|
|
81
|
+
),
|
|
82
|
+
severity=severity,
|
|
83
|
+
hosts=all_hosts,
|
|
84
|
+
evidence={
|
|
85
|
+
"variants": [
|
|
86
|
+
{"invocation": inv, "host_count": len(hosts), "hosts": sorted(hosts)}
|
|
87
|
+
for inv, hosts in ranked
|
|
88
|
+
],
|
|
89
|
+
"singleton_variants": outliers,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@fleet_check(
|
|
95
|
+
"ES-902",
|
|
96
|
+
"Long-tail MCP server installed on a small fraction of the fleet",
|
|
97
|
+
Severity.MEDIUM,
|
|
98
|
+
Vector.MCP_SERVERS,
|
|
99
|
+
rationale=(
|
|
100
|
+
"Managed software is common; shadow installs are rare. A server on a handful "
|
|
101
|
+
"of hosts and on no approved-software list is the shape of an individually "
|
|
102
|
+
"installed, unreviewed integration."
|
|
103
|
+
),
|
|
104
|
+
fix="Confirm with the owning user, then either add the server to the managed baseline or remove it.",
|
|
105
|
+
owasp=("LLM03: Supply Chain",),
|
|
106
|
+
atlas=("AML.T0010: ML Supply Chain Compromise",),
|
|
107
|
+
)
|
|
108
|
+
def long_tail_server(fleet: FleetModel, ctx: CheckContext) -> Iterable[Finding]:
|
|
109
|
+
meta = long_tail_server.meta
|
|
110
|
+
total = fleet.host_count
|
|
111
|
+
if total < ctx.min_fleet_for_prevalence:
|
|
112
|
+
return
|
|
113
|
+
for identity, variants in sorted(_server_index(fleet).items()):
|
|
114
|
+
hosts = sorted({h for hs in variants.values() for h in hs})
|
|
115
|
+
prevalence = len(hosts) / total
|
|
116
|
+
if prevalence >= ctx.rare_threshold:
|
|
117
|
+
continue
|
|
118
|
+
if _is_managed(ctx, identity, variants.keys()):
|
|
119
|
+
continue
|
|
120
|
+
yield meta.finding(
|
|
121
|
+
subject=identity,
|
|
122
|
+
detail=(
|
|
123
|
+
f"present on {len(hosts)}/{total} hosts ({prevalence:.1%}, below the "
|
|
124
|
+
f"{ctx.rare_threshold:.0%} long-tail threshold) and not on the managed-software list"
|
|
125
|
+
),
|
|
126
|
+
hosts=hosts,
|
|
127
|
+
evidence={
|
|
128
|
+
"host_count": len(hosts),
|
|
129
|
+
"fleet_size": total,
|
|
130
|
+
"prevalence": round(prevalence, 4),
|
|
131
|
+
"invocations": sorted(variants.keys()),
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@fleet_check(
|
|
137
|
+
"ES-903",
|
|
138
|
+
"Fleet shadow-AI census",
|
|
139
|
+
Severity.INFO,
|
|
140
|
+
Vector.AGENTIC_AI,
|
|
141
|
+
rationale="The inventory answer to the question that prompted the scan: what is actually out there.",
|
|
142
|
+
fix="No action. Use the census tables to set the approved-software baseline that ES-902 measures against.",
|
|
143
|
+
)
|
|
144
|
+
def fleet_census(fleet: FleetModel, ctx: CheckContext) -> Iterable[Finding]:
|
|
145
|
+
meta = fleet_census.meta
|
|
146
|
+
census = fleet.census or {}
|
|
147
|
+
if not census:
|
|
148
|
+
return
|
|
149
|
+
totals = census.get("totals", {})
|
|
150
|
+
yield meta.finding(
|
|
151
|
+
subject="fleet-census",
|
|
152
|
+
detail=(
|
|
153
|
+
f"{fleet.host_count} host(s): {totals.get('mcp_servers', 0)} MCP server "
|
|
154
|
+
f"declarations, {totals.get('agentic_tools', 0)} agentic CLI installs, "
|
|
155
|
+
f"{totals.get('ide_extensions', 0)} IDE AI extensions, "
|
|
156
|
+
f"{totals.get('model_runtimes', 0)} local model runtimes"
|
|
157
|
+
),
|
|
158
|
+
hosts=[h.inventory.hostname for h in fleet.hosts],
|
|
159
|
+
evidence={"totals": totals, "top_servers": census.get("top_servers", [])[:10]},
|
|
160
|
+
)
|