splitagent 0.0.3__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.
- splitagent/__init__.py +8 -0
- splitagent/__main__.py +6 -0
- splitagent/agents/__init__.py +10 -0
- splitagent/agents/base.py +477 -0
- splitagent/agents/blue.py +57 -0
- splitagent/agents/chat.py +60 -0
- splitagent/agents/prompts.py +462 -0
- splitagent/agents/red.py +75 -0
- splitagent/cli.py +701 -0
- splitagent/config.py +697 -0
- splitagent/core/__init__.py +19 -0
- splitagent/core/bus.py +62 -0
- splitagent/core/context.py +587 -0
- splitagent/core/context_manager.py +381 -0
- splitagent/core/engine.py +424 -0
- splitagent/core/models.py +310 -0
- splitagent/core/proc.py +73 -0
- splitagent/core/sandbox.py +184 -0
- splitagent/core/toolbox.py +520 -0
- splitagent/core/workspace.py +420 -0
- splitagent/desktop/__init__.py +7 -0
- splitagent/desktop/api.py +525 -0
- splitagent/desktop/app.py +1131 -0
- splitagent/desktop/web/app.js +3067 -0
- splitagent/desktop/web/assets/Inter.ttf +0 -0
- splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- splitagent/desktop/web/index.html +760 -0
- splitagent/desktop/web/styles.css +1612 -0
- splitagent/errors.py +27 -0
- splitagent/llm/__init__.py +8 -0
- splitagent/llm/client.py +488 -0
- splitagent/llm/types.py +172 -0
- splitagent/report/__init__.py +9 -0
- splitagent/report/cvss.py +93 -0
- splitagent/report/generator.py +733 -0
- splitagent/tools/__init__.py +8 -0
- splitagent/tools/base.py +135 -0
- splitagent/tools/defense.py +475 -0
- splitagent/tools/exploit.py +318 -0
- splitagent/tools/http_pool.py +109 -0
- splitagent/tools/knowledge.py +376 -0
- splitagent/tools/recon.py +182 -0
- splitagent/tools/registry.py +62 -0
- splitagent/tools/validate.py +908 -0
- splitagent/tools/web.py +386 -0
- splitagent/tools/workspace_tools.py +411 -0
- splitagent/ui/__init__.py +5 -0
- splitagent/ui/app.py +389 -0
- splitagent/ui/stream.py +234 -0
- splitagent/ui/theme.py +72 -0
- splitagent-0.0.3.dist-info/METADATA +987 -0
- splitagent-0.0.3.dist-info/RECORD +56 -0
- splitagent-0.0.3.dist-info/WHEEL +5 -0
- splitagent-0.0.3.dist-info/entry_points.txt +2 -0
- splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
- splitagent-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Shared tools: the agents' memory of findings, mitigations and context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from splitagent.report.cvss import parse_vector
|
|
8
|
+
from splitagent.tools.base import Tool, ToolContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _record_finding(
|
|
12
|
+
ctx: ToolContext,
|
|
13
|
+
title: str,
|
|
14
|
+
description: str,
|
|
15
|
+
severity: str = "",
|
|
16
|
+
category: str = "general",
|
|
17
|
+
cvss_vector: str = "",
|
|
18
|
+
target: str = "",
|
|
19
|
+
endpoint: str = "",
|
|
20
|
+
evidence: str = "",
|
|
21
|
+
recommendation: str = "",
|
|
22
|
+
cwe: str = "",
|
|
23
|
+
owasp: str = "",
|
|
24
|
+
impact: str = "",
|
|
25
|
+
reproduction: str = "",
|
|
26
|
+
confidence: str = "medium",
|
|
27
|
+
references: list[str] | None = None,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
score = None
|
|
30
|
+
if cvss_vector:
|
|
31
|
+
score = parse_vector(cvss_vector)
|
|
32
|
+
payload = {
|
|
33
|
+
"title": title,
|
|
34
|
+
"description": description,
|
|
35
|
+
"severity": severity,
|
|
36
|
+
"category": category,
|
|
37
|
+
"cvss_vector": cvss_vector,
|
|
38
|
+
"cvss_score": score,
|
|
39
|
+
"target": target or ctx.context.state.target,
|
|
40
|
+
"endpoint": endpoint,
|
|
41
|
+
"evidence": evidence,
|
|
42
|
+
"recommendation": recommendation,
|
|
43
|
+
"cwe": cwe,
|
|
44
|
+
"owasp": owasp,
|
|
45
|
+
"impact": impact,
|
|
46
|
+
"reproduction": reproduction,
|
|
47
|
+
"confidence": confidence,
|
|
48
|
+
"references": references or [],
|
|
49
|
+
"round": ctx.round,
|
|
50
|
+
}
|
|
51
|
+
finding = await ctx.context.add_finding(payload, agent="red")
|
|
52
|
+
return {
|
|
53
|
+
"recorded": True,
|
|
54
|
+
"id": finding.id,
|
|
55
|
+
"severity": finding.severity,
|
|
56
|
+
"cvss_score": finding.cvss_score,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _record_mitigation(
|
|
61
|
+
ctx: ToolContext,
|
|
62
|
+
finding_id: str,
|
|
63
|
+
title: str,
|
|
64
|
+
kind: str = "config",
|
|
65
|
+
description: str = "",
|
|
66
|
+
content: str = "",
|
|
67
|
+
rationale: str = "",
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
mitigation = await ctx.context.add_mitigation(
|
|
70
|
+
{
|
|
71
|
+
"finding_id": finding_id,
|
|
72
|
+
"title": title,
|
|
73
|
+
"kind": kind,
|
|
74
|
+
"description": description,
|
|
75
|
+
"content": content,
|
|
76
|
+
"rationale": rationale,
|
|
77
|
+
"round": ctx.round,
|
|
78
|
+
},
|
|
79
|
+
agent="blue",
|
|
80
|
+
)
|
|
81
|
+
return {"recorded": True, "id": mitigation.id, "finding_id": finding_id}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _list_findings(ctx: ToolContext) -> dict[str, Any]:
|
|
85
|
+
findings = [
|
|
86
|
+
{
|
|
87
|
+
"id": f.id,
|
|
88
|
+
"title": f.title,
|
|
89
|
+
"severity": f.severity,
|
|
90
|
+
"cvss_score": f.cvss_score,
|
|
91
|
+
"status": f.status,
|
|
92
|
+
"endpoint": f.endpoint,
|
|
93
|
+
}
|
|
94
|
+
for f in ctx.context.state.findings
|
|
95
|
+
]
|
|
96
|
+
return {"count": len(findings), "findings": findings}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_finding(ctx: ToolContext, finding_id: str) -> dict[str, Any]:
|
|
100
|
+
finding = ctx.context.state.get_finding(finding_id)
|
|
101
|
+
if finding is None:
|
|
102
|
+
return {"error": f"finding '{finding_id}' not found"}
|
|
103
|
+
return finding.to_dict()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _write_todos(ctx: ToolContext, todos: list[dict[str, Any]]) -> dict[str, Any]:
|
|
107
|
+
await ctx.context.set_todos(todos)
|
|
108
|
+
remaining = [item for item in ctx.context.state.todos if item["status"] != "completed"]
|
|
109
|
+
return {
|
|
110
|
+
"updated": True,
|
|
111
|
+
"total": len(ctx.context.state.todos),
|
|
112
|
+
"remaining": len(remaining),
|
|
113
|
+
"todos": ctx.context.state.todos,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_context(ctx: ToolContext) -> dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"session": ctx.context.state.id,
|
|
120
|
+
"target": ctx.context.state.target,
|
|
121
|
+
"scope": ctx.context.state.scope,
|
|
122
|
+
"findings": ctx.context.findings_digest(),
|
|
123
|
+
"mitigations": ctx.context.mitigations_digest(),
|
|
124
|
+
"todos": ctx.context.todo_summary(),
|
|
125
|
+
"round": ctx.round,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _propose_engagement(
|
|
130
|
+
ctx: ToolContext,
|
|
131
|
+
objective: str,
|
|
132
|
+
scope: list[str] | None = None,
|
|
133
|
+
phases: list[str] | None = None,
|
|
134
|
+
out_of_scope: list[str] | None = None,
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
"""Record a proposed engagement plan and surface it for operator approval.
|
|
137
|
+
|
|
138
|
+
The copilot calls this once it understands the engagement. Nothing is
|
|
139
|
+
scanned until the operator approves the plan in the UI.
|
|
140
|
+
"""
|
|
141
|
+
plan = {
|
|
142
|
+
"objective": objective,
|
|
143
|
+
"scope": list(scope or []),
|
|
144
|
+
"phases": list(phases or []),
|
|
145
|
+
"out_of_scope": list(out_of_scope or []),
|
|
146
|
+
}
|
|
147
|
+
ctx.context.state.notes.append("PROPOSED ENGAGEMENT: " + objective)
|
|
148
|
+
from splitagent.core.bus import Event
|
|
149
|
+
|
|
150
|
+
await ctx.context.bus.emit(
|
|
151
|
+
Event(
|
|
152
|
+
type="chat.plan",
|
|
153
|
+
agent="assistant",
|
|
154
|
+
data={"plan": plan, "awaiting_approval": True},
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return {
|
|
158
|
+
"proposed": True,
|
|
159
|
+
"plan": plan,
|
|
160
|
+
"next": (
|
|
161
|
+
"Plan proposed. Stop and ask the operator to confirm before running any scan or probe."
|
|
162
|
+
),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def knowledge_tools(ctx: ToolContext) -> list[Tool]:
|
|
167
|
+
return [
|
|
168
|
+
Tool(
|
|
169
|
+
name="todowrite",
|
|
170
|
+
description=(
|
|
171
|
+
"Create and maintain a structured task list for the current "
|
|
172
|
+
"engagement. Tracks progress, organises multi-step work and "
|
|
173
|
+
"surfaces status to the operator in the UI.\n\n"
|
|
174
|
+
"## When to use\n"
|
|
175
|
+
"Use proactively when the task needs 3+ distinct steps, is "
|
|
176
|
+
"non-trivial and benefits from planning, the operator gives "
|
|
177
|
+
"several tasks, or new instructions arrive. Mark a task "
|
|
178
|
+
"`in_progress` (only one at a time) before working on it and "
|
|
179
|
+
"`completed` only when the work is actually done.\n\n"
|
|
180
|
+
"## When NOT to use\n"
|
|
181
|
+
"Skip single, straightforward tasks, purely informational "
|
|
182
|
+
"requests, or when tracking adds no value.\n\n"
|
|
183
|
+
"## States\n"
|
|
184
|
+
"pending - not started; in_progress - actively working (exactly "
|
|
185
|
+
"ONE at a time); completed - finished successfully; cancelled - "
|
|
186
|
+
"no longer needed.\n\n"
|
|
187
|
+
"## Rules\n"
|
|
188
|
+
"- Update status in real time; do not batch completions.\n"
|
|
189
|
+
"- Keep exactly one `in_progress` while work remains.\n"
|
|
190
|
+
"- If blocked, keep it `in_progress` and add a follow-up item.\n"
|
|
191
|
+
"- Items must be specific and actionable."
|
|
192
|
+
),
|
|
193
|
+
parameters={
|
|
194
|
+
"type": "object",
|
|
195
|
+
"properties": {
|
|
196
|
+
"todos": {
|
|
197
|
+
"type": "array",
|
|
198
|
+
"description": "The updated todo list",
|
|
199
|
+
"items": {
|
|
200
|
+
"type": "object",
|
|
201
|
+
"properties": {
|
|
202
|
+
"content": {
|
|
203
|
+
"type": "string",
|
|
204
|
+
"description": "Brief description of the task",
|
|
205
|
+
},
|
|
206
|
+
"status": {
|
|
207
|
+
"type": "string",
|
|
208
|
+
"description": (
|
|
209
|
+
"Current status of the task: "
|
|
210
|
+
"pending, in_progress, completed, cancelled"
|
|
211
|
+
),
|
|
212
|
+
},
|
|
213
|
+
"priority": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"description": (
|
|
216
|
+
"Priority level of the task: high, medium, low"
|
|
217
|
+
),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
"required": ["content", "status", "priority"],
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
"required": ["todos"],
|
|
225
|
+
},
|
|
226
|
+
func=lambda todos: _write_todos(ctx, todos),
|
|
227
|
+
scope="shared",
|
|
228
|
+
),
|
|
229
|
+
Tool(
|
|
230
|
+
name="record_finding",
|
|
231
|
+
description=(
|
|
232
|
+
"Persist a verified vulnerability in the shared context. Always "
|
|
233
|
+
"include concrete evidence and a CVSS v3.1 vector when possible."
|
|
234
|
+
),
|
|
235
|
+
parameters={
|
|
236
|
+
"type": "object",
|
|
237
|
+
"properties": {
|
|
238
|
+
"title": {"type": "string"},
|
|
239
|
+
"description": {"type": "string"},
|
|
240
|
+
"severity": {
|
|
241
|
+
"type": "string",
|
|
242
|
+
"enum": ["critical", "high", "medium", "low", "info"],
|
|
243
|
+
},
|
|
244
|
+
"category": {"type": "string"},
|
|
245
|
+
"cvss_vector": {
|
|
246
|
+
"type": "string",
|
|
247
|
+
"description": "CVSS v3.1 vector, e.g. CVSS:3.1/AV:N/AC:L/...",
|
|
248
|
+
},
|
|
249
|
+
"target": {"type": "string"},
|
|
250
|
+
"endpoint": {"type": "string"},
|
|
251
|
+
"evidence": {"type": "string"},
|
|
252
|
+
"recommendation": {"type": "string"},
|
|
253
|
+
"cwe": {
|
|
254
|
+
"type": "string",
|
|
255
|
+
"description": "CWE id and name, e.g. 'CWE-89: SQL Injection'.",
|
|
256
|
+
},
|
|
257
|
+
"owasp": {
|
|
258
|
+
"type": "string",
|
|
259
|
+
"description": "OWASP category, e.g. 'A03:2021 - Injection'.",
|
|
260
|
+
},
|
|
261
|
+
"impact": {
|
|
262
|
+
"type": "string",
|
|
263
|
+
"description": "Business impact in plain language: what an attacker gains.",
|
|
264
|
+
},
|
|
265
|
+
"reproduction": {
|
|
266
|
+
"type": "string",
|
|
267
|
+
"description": "Numbered steps or exact commands to reproduce the finding.",
|
|
268
|
+
},
|
|
269
|
+
"confidence": {
|
|
270
|
+
"type": "string",
|
|
271
|
+
"enum": ["high", "medium", "low"],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
"required": ["title", "description"],
|
|
275
|
+
},
|
|
276
|
+
func=lambda **kwargs: _record_finding(ctx, **kwargs),
|
|
277
|
+
scope="shared",
|
|
278
|
+
),
|
|
279
|
+
Tool(
|
|
280
|
+
name="list_findings",
|
|
281
|
+
description="List every finding recorded so far with its status.",
|
|
282
|
+
parameters={"type": "object", "properties": {}},
|
|
283
|
+
func=lambda: _list_findings(ctx),
|
|
284
|
+
scope="shared",
|
|
285
|
+
),
|
|
286
|
+
Tool(
|
|
287
|
+
name="get_finding",
|
|
288
|
+
description="Fetch the full detail of a single finding by id.",
|
|
289
|
+
parameters={
|
|
290
|
+
"type": "object",
|
|
291
|
+
"properties": {"finding_id": {"type": "string"}},
|
|
292
|
+
"required": ["finding_id"],
|
|
293
|
+
},
|
|
294
|
+
func=lambda finding_id: _get_finding(ctx, finding_id),
|
|
295
|
+
scope="shared",
|
|
296
|
+
),
|
|
297
|
+
Tool(
|
|
298
|
+
name="record_mitigation",
|
|
299
|
+
description=(
|
|
300
|
+
"Persist a defensive countermeasure linked to a finding. The "
|
|
301
|
+
"finding is marked as mitigated automatically."
|
|
302
|
+
),
|
|
303
|
+
parameters={
|
|
304
|
+
"type": "object",
|
|
305
|
+
"properties": {
|
|
306
|
+
"finding_id": {"type": "string"},
|
|
307
|
+
"title": {"type": "string"},
|
|
308
|
+
"kind": {
|
|
309
|
+
"type": "string",
|
|
310
|
+
"enum": [
|
|
311
|
+
"firewall",
|
|
312
|
+
"patch",
|
|
313
|
+
"config",
|
|
314
|
+
"detection",
|
|
315
|
+
"process",
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
"description": {"type": "string"},
|
|
319
|
+
"content": {
|
|
320
|
+
"type": "string",
|
|
321
|
+
"description": "The rule, patch diff or config to apply.",
|
|
322
|
+
},
|
|
323
|
+
"rationale": {"type": "string"},
|
|
324
|
+
},
|
|
325
|
+
"required": ["finding_id", "title"],
|
|
326
|
+
},
|
|
327
|
+
func=lambda **kwargs: _record_mitigation(ctx, **kwargs),
|
|
328
|
+
scope="shared",
|
|
329
|
+
),
|
|
330
|
+
Tool(
|
|
331
|
+
name="read_shared_context",
|
|
332
|
+
description=(
|
|
333
|
+
"Read the shared context: target, scope and the running digest "
|
|
334
|
+
"of findings and mitigations."
|
|
335
|
+
),
|
|
336
|
+
parameters={"type": "object", "properties": {}},
|
|
337
|
+
func=lambda: _read_context(ctx),
|
|
338
|
+
scope="shared",
|
|
339
|
+
),
|
|
340
|
+
Tool(
|
|
341
|
+
name="propose_engagement",
|
|
342
|
+
description=(
|
|
343
|
+
"Propose an engagement plan for the operator to approve. Call "
|
|
344
|
+
"this once you understand the objective, scope and constraints, "
|
|
345
|
+
"BEFORE running any scan or probe. It shows the operator a plan "
|
|
346
|
+
"with an Approve button; nothing is tested until they approve."
|
|
347
|
+
),
|
|
348
|
+
parameters={
|
|
349
|
+
"type": "object",
|
|
350
|
+
"properties": {
|
|
351
|
+
"objective": {
|
|
352
|
+
"type": "string",
|
|
353
|
+
"description": "What the engagement should achieve.",
|
|
354
|
+
},
|
|
355
|
+
"scope": {
|
|
356
|
+
"type": "array",
|
|
357
|
+
"items": {"type": "string"},
|
|
358
|
+
"description": "Hosts/URLs that are in scope.",
|
|
359
|
+
},
|
|
360
|
+
"out_of_scope": {
|
|
361
|
+
"type": "array",
|
|
362
|
+
"items": {"type": "string"},
|
|
363
|
+
"description": "Hosts/URLs that must never be touched.",
|
|
364
|
+
},
|
|
365
|
+
"phases": {
|
|
366
|
+
"type": "array",
|
|
367
|
+
"items": {"type": "string"},
|
|
368
|
+
"description": "Ordered list of work phases.",
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
"required": ["objective"],
|
|
372
|
+
},
|
|
373
|
+
func=lambda **kwargs: _propose_engagement(ctx, **kwargs),
|
|
374
|
+
scope="shared",
|
|
375
|
+
),
|
|
376
|
+
]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Reconnaissance tools (attack-surface mapping)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import socket
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from splitagent.tools.base import Tool, ToolContext
|
|
10
|
+
|
|
11
|
+
WELL_KNOWN = {
|
|
12
|
+
21: "ftp",
|
|
13
|
+
22: "ssh",
|
|
14
|
+
23: "telnet",
|
|
15
|
+
25: "smtp",
|
|
16
|
+
53: "dns",
|
|
17
|
+
80: "http",
|
|
18
|
+
110: "pop3",
|
|
19
|
+
111: "rpcbind",
|
|
20
|
+
135: "msrpc",
|
|
21
|
+
139: "netbios",
|
|
22
|
+
143: "imap",
|
|
23
|
+
443: "https",
|
|
24
|
+
445: "smb",
|
|
25
|
+
1433: "mssql",
|
|
26
|
+
1521: "oracle",
|
|
27
|
+
2049: "nfs",
|
|
28
|
+
3000: "http-alt",
|
|
29
|
+
3306: "mysql",
|
|
30
|
+
3389: "rdp",
|
|
31
|
+
5432: "postgres",
|
|
32
|
+
5900: "vnc",
|
|
33
|
+
6379: "redis",
|
|
34
|
+
8000: "http-alt",
|
|
35
|
+
8080: "http-proxy",
|
|
36
|
+
8443: "https-alt",
|
|
37
|
+
9000: "http-alt",
|
|
38
|
+
9200: "elasticsearch",
|
|
39
|
+
11211: "memcached",
|
|
40
|
+
27017: "mongodb",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
DEFAULT_PORTS = sorted(WELL_KNOWN)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _probe(host: str, port: int, timeout: float) -> dict[str, Any] | None:
|
|
47
|
+
try:
|
|
48
|
+
reader, writer = await asyncio.wait_for(
|
|
49
|
+
asyncio.open_connection(host, port), timeout=timeout
|
|
50
|
+
)
|
|
51
|
+
except (asyncio.TimeoutError, OSError):
|
|
52
|
+
return None
|
|
53
|
+
banner = ""
|
|
54
|
+
try:
|
|
55
|
+
if port in (80, 8080, 8000, 3000, 9000):
|
|
56
|
+
writer.write(b"HEAD / HTTP/1.0\r\n\r\n")
|
|
57
|
+
await writer.drain()
|
|
58
|
+
data = await asyncio.wait_for(reader.read(256), timeout=timeout)
|
|
59
|
+
banner = data.decode("utf-8", "replace").strip().splitlines()[0] if data else ""
|
|
60
|
+
except (asyncio.TimeoutError, OSError):
|
|
61
|
+
banner = ""
|
|
62
|
+
finally:
|
|
63
|
+
writer.close()
|
|
64
|
+
try:
|
|
65
|
+
await writer.wait_closed()
|
|
66
|
+
except (OSError, RuntimeError):
|
|
67
|
+
pass
|
|
68
|
+
return {
|
|
69
|
+
"port": port,
|
|
70
|
+
"state": "open",
|
|
71
|
+
"service": WELL_KNOWN.get(port, "unknown"),
|
|
72
|
+
"banner": banner[:160],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Ports a full sweep covers when the target is a host rather than a web app.
|
|
77
|
+
# A pentester does not guess: the whole range is checked, cheaply, in batches.
|
|
78
|
+
FULL_TCP_RANGE = range(1, 65536)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def tcp_scan(
|
|
82
|
+
ctx: ToolContext,
|
|
83
|
+
host: str = "",
|
|
84
|
+
ports: list[int] | None = None,
|
|
85
|
+
timeout: float = 0.8,
|
|
86
|
+
concurrency: int = 512,
|
|
87
|
+
full: bool = False,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""TCP connect scan.
|
|
90
|
+
|
|
91
|
+
``full=True`` sweeps every port. Anything less leaves services the agent
|
|
92
|
+
never sees, which is exactly how a lab target keeps a root shell on 6200
|
|
93
|
+
or an IRC backdoor on 6667 undiscovered.
|
|
94
|
+
"""
|
|
95
|
+
host = host or (ctx.target.effective_hosts() or ["localhost"])[0]
|
|
96
|
+
ctx.check_scope(host)
|
|
97
|
+
if full and not ports:
|
|
98
|
+
port_list = list(FULL_TCP_RANGE)
|
|
99
|
+
# A full sweep deserves more patience per port.
|
|
100
|
+
timeout = min(timeout, 0.35)
|
|
101
|
+
else:
|
|
102
|
+
port_list = ports or ctx.target.ports or DEFAULT_PORTS
|
|
103
|
+
port_list = [int(p) for p in port_list][:65535]
|
|
104
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
105
|
+
|
|
106
|
+
async def worker(port: int) -> dict[str, Any] | None:
|
|
107
|
+
async with semaphore:
|
|
108
|
+
return await _probe(host, port, timeout)
|
|
109
|
+
|
|
110
|
+
results = await asyncio.gather(*(worker(p) for p in port_list))
|
|
111
|
+
open_ports = [r for r in results if r]
|
|
112
|
+
open_ports.sort(key=lambda r: r["port"])
|
|
113
|
+
return {
|
|
114
|
+
"host": host,
|
|
115
|
+
"scanned": len(port_list),
|
|
116
|
+
"open": open_ports,
|
|
117
|
+
"count": len(open_ports),
|
|
118
|
+
"full": full,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def dns_lookup(host: str = "") -> dict[str, Any]:
|
|
123
|
+
target = host or "localhost"
|
|
124
|
+
try:
|
|
125
|
+
infos = socket.getaddrinfo(target, None)
|
|
126
|
+
except socket.gaierror as exc:
|
|
127
|
+
return {"host": target, "error": str(exc)}
|
|
128
|
+
addresses = sorted({info[4][0] for info in infos})
|
|
129
|
+
return {"host": target, "addresses": addresses}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def red_recon_tools(ctx: ToolContext) -> list[Tool]:
|
|
133
|
+
async def _scan(
|
|
134
|
+
host: str = "", ports: list[int] | None = None, full: bool = False
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
return await tcp_scan(ctx, host=host, ports=ports, full=full)
|
|
137
|
+
|
|
138
|
+
return [
|
|
139
|
+
Tool(
|
|
140
|
+
name="dns_lookup",
|
|
141
|
+
description=(
|
|
142
|
+
"Resolve a hostname to IP addresses. Use it to confirm the target "
|
|
143
|
+
"is reachable before scanning."
|
|
144
|
+
),
|
|
145
|
+
parameters={
|
|
146
|
+
"type": "object",
|
|
147
|
+
"properties": {"host": {"type": "string"}},
|
|
148
|
+
},
|
|
149
|
+
func=dns_lookup,
|
|
150
|
+
scope="red",
|
|
151
|
+
),
|
|
152
|
+
Tool(
|
|
153
|
+
name="port_scan",
|
|
154
|
+
description=(
|
|
155
|
+
"TCP connect scan against the authorised target. Returns open "
|
|
156
|
+
"ports with service guesses and captured banners. Set "
|
|
157
|
+
"`full=true` to sweep all 65535 ports: do this before "
|
|
158
|
+
"concluding, because most interesting services hide on "
|
|
159
|
+
"non-standard ports (backdoors on 6200, IRC on 6667, NFS on "
|
|
160
|
+
"2049). Scanning only the common ports is how a root shell "
|
|
161
|
+
"goes unnoticed."
|
|
162
|
+
),
|
|
163
|
+
parameters={
|
|
164
|
+
"type": "object",
|
|
165
|
+
"properties": {
|
|
166
|
+
"host": {"type": "string"},
|
|
167
|
+
"ports": {
|
|
168
|
+
"type": "array",
|
|
169
|
+
"items": {"type": "integer"},
|
|
170
|
+
"description": "Explicit port list; defaults to common ports.",
|
|
171
|
+
},
|
|
172
|
+
"full": {
|
|
173
|
+
"type": "boolean",
|
|
174
|
+
"description": "Sweep every TCP port 1-65535.",
|
|
175
|
+
"default": False,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
func=_scan,
|
|
180
|
+
scope="red",
|
|
181
|
+
),
|
|
182
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Tool registry assembling the correct toolset per agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from splitagent.llm.types import ToolSpec
|
|
6
|
+
from splitagent.tools.base import Tool, ToolContext
|
|
7
|
+
from splitagent.tools.defense import blue_tools
|
|
8
|
+
from splitagent.tools.exploit import red_exploit_tools
|
|
9
|
+
from splitagent.tools.knowledge import knowledge_tools
|
|
10
|
+
from splitagent.tools.recon import red_recon_tools
|
|
11
|
+
from splitagent.tools.validate import exploitation_tools
|
|
12
|
+
from splitagent.tools.web import red_web_tools
|
|
13
|
+
from splitagent.tools.workspace_tools import workspace_tools
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ToolRegistry:
|
|
17
|
+
def __init__(self, tools: list[Tool] | None = None) -> None:
|
|
18
|
+
self._tools: dict[str, Tool] = {}
|
|
19
|
+
if tools:
|
|
20
|
+
self.add_many(tools)
|
|
21
|
+
|
|
22
|
+
def register(self, tool: Tool) -> None:
|
|
23
|
+
self._tools[tool.name] = tool
|
|
24
|
+
|
|
25
|
+
def add_many(self, tools: list[Tool]) -> None:
|
|
26
|
+
for tool in tools:
|
|
27
|
+
self.register(tool)
|
|
28
|
+
|
|
29
|
+
def get(self, name: str) -> Tool | None:
|
|
30
|
+
return self._tools.get(name)
|
|
31
|
+
|
|
32
|
+
def names(self) -> list[str]:
|
|
33
|
+
return sorted(self._tools)
|
|
34
|
+
|
|
35
|
+
def specs(self) -> list[ToolSpec]:
|
|
36
|
+
return [tool.spec() for tool in self._tools.values()]
|
|
37
|
+
|
|
38
|
+
def subset(self, scope: str) -> ToolRegistry:
|
|
39
|
+
registry = ToolRegistry()
|
|
40
|
+
for tool in self._tools.values():
|
|
41
|
+
if tool.scope in ("shared", scope):
|
|
42
|
+
registry.register(tool)
|
|
43
|
+
return registry
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_registry(ctx: ToolContext, agent: str) -> ToolRegistry:
|
|
47
|
+
"""Build the toolset for ``agent`` (``red`` or ``blue``)."""
|
|
48
|
+
registry = ToolRegistry(knowledge_tools(ctx))
|
|
49
|
+
registry.add_many(workspace_tools(ctx))
|
|
50
|
+
if agent == "red":
|
|
51
|
+
registry.add_many(red_recon_tools(ctx))
|
|
52
|
+
registry.add_many(red_web_tools(ctx))
|
|
53
|
+
registry.add_many(red_exploit_tools(ctx))
|
|
54
|
+
registry.add_many(exploitation_tools(ctx))
|
|
55
|
+
elif agent == "blue":
|
|
56
|
+
registry.add_many(blue_tools(ctx))
|
|
57
|
+
else:
|
|
58
|
+
registry.add_many(red_recon_tools(ctx))
|
|
59
|
+
registry.add_many(red_web_tools(ctx))
|
|
60
|
+
registry.add_many(red_exploit_tools(ctx))
|
|
61
|
+
registry.add_many(blue_tools(ctx))
|
|
62
|
+
return registry
|