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,310 @@
|
|
|
1
|
+
"""Data structures shared across the engine, the agents and the reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
SEVERITIES = ("critical", "high", "medium", "low", "info")
|
|
11
|
+
|
|
12
|
+
SEVERITY_ORDER = {name: index for index, name in enumerate(SEVERITIES)}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _now() -> str:
|
|
16
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def new_id(prefix: str) -> str:
|
|
20
|
+
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_severity(value: str | None, score: float | None = None) -> str:
|
|
24
|
+
if value:
|
|
25
|
+
value = value.strip().lower()
|
|
26
|
+
if value in SEVERITY_ORDER:
|
|
27
|
+
return value
|
|
28
|
+
if score is not None:
|
|
29
|
+
if score >= 9.0:
|
|
30
|
+
return "critical"
|
|
31
|
+
if score >= 7.0:
|
|
32
|
+
return "high"
|
|
33
|
+
if score >= 4.0:
|
|
34
|
+
return "medium"
|
|
35
|
+
if score > 0:
|
|
36
|
+
return "low"
|
|
37
|
+
return "info"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Finding:
|
|
42
|
+
"""A vulnerability or weakness discovered by the Red Agent."""
|
|
43
|
+
|
|
44
|
+
id: str = field(default_factory=lambda: new_id("F"))
|
|
45
|
+
title: str = ""
|
|
46
|
+
category: str = "general"
|
|
47
|
+
description: str = ""
|
|
48
|
+
severity: str = "info"
|
|
49
|
+
cvss_vector: str = ""
|
|
50
|
+
cvss_score: float = 0.0
|
|
51
|
+
target: str = ""
|
|
52
|
+
endpoint: str = ""
|
|
53
|
+
evidence: str = ""
|
|
54
|
+
recommendation: str = ""
|
|
55
|
+
# Report fields: keep the AI's contribution to a fixed, structured shape so
|
|
56
|
+
# the generated document is consistent from run to run.
|
|
57
|
+
cwe: str = ""
|
|
58
|
+
owasp: str = ""
|
|
59
|
+
impact: str = ""
|
|
60
|
+
reproduction: str = ""
|
|
61
|
+
references: list[str] = field(default_factory=list)
|
|
62
|
+
confidence: str = "medium"
|
|
63
|
+
discovered_by: str = "red"
|
|
64
|
+
round: int = 0
|
|
65
|
+
status: str = "open" # open | mitigated | accepted | false-positive
|
|
66
|
+
created_at: str = field(default_factory=_now)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
return asdict(self)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, data: dict[str, Any]) -> Finding:
|
|
73
|
+
if not isinstance(data, dict):
|
|
74
|
+
return cls()
|
|
75
|
+
known = set(cls.__dataclass_fields__)
|
|
76
|
+
filtered = {k: v for k, v in data.items() if k in known}
|
|
77
|
+
# Coerce the fields the report formats numerically; a corrupt session
|
|
78
|
+
# must degrade, not crash the renderer.
|
|
79
|
+
try:
|
|
80
|
+
filtered["cvss_score"] = float(filtered.get("cvss_score") or 0.0)
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
filtered["cvss_score"] = 0.0
|
|
83
|
+
try:
|
|
84
|
+
filtered["round"] = int(filtered.get("round") or 0)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
filtered["round"] = 0
|
|
87
|
+
if not isinstance(filtered.get("references"), list):
|
|
88
|
+
filtered["references"] = []
|
|
89
|
+
return cls(**filtered)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Mitigation:
|
|
94
|
+
"""A defensive countermeasure produced by the Blue Agent."""
|
|
95
|
+
|
|
96
|
+
id: str = field(default_factory=lambda: new_id("M"))
|
|
97
|
+
finding_id: str = ""
|
|
98
|
+
title: str = ""
|
|
99
|
+
kind: str = "config" # firewall | patch | config | detection | process
|
|
100
|
+
description: str = ""
|
|
101
|
+
content: str = ""
|
|
102
|
+
rationale: str = ""
|
|
103
|
+
status: str = "proposed" # proposed | applied | verified | rejected
|
|
104
|
+
verified: bool = False
|
|
105
|
+
round: int = 0
|
|
106
|
+
created_at: str = field(default_factory=_now)
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, Any]:
|
|
109
|
+
return asdict(self)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_dict(cls, data: dict[str, Any]) -> Mitigation:
|
|
113
|
+
if not isinstance(data, dict):
|
|
114
|
+
return cls()
|
|
115
|
+
known = set(cls.__dataclass_fields__)
|
|
116
|
+
filtered = {k: v for k, v in data.items() if k in known}
|
|
117
|
+
try:
|
|
118
|
+
filtered["round"] = int(filtered.get("round") or 0)
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
filtered["round"] = 0
|
|
121
|
+
return cls(**filtered)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Round:
|
|
126
|
+
"""A single Red -> Blue cycle."""
|
|
127
|
+
|
|
128
|
+
index: int = 1
|
|
129
|
+
red_summary: str = ""
|
|
130
|
+
blue_summary: str = ""
|
|
131
|
+
finding_ids: list[str] = field(default_factory=list)
|
|
132
|
+
mitigation_ids: list[str] = field(default_factory=list)
|
|
133
|
+
started_at: str = field(default_factory=_now)
|
|
134
|
+
ended_at: str = ""
|
|
135
|
+
|
|
136
|
+
def to_dict(self) -> dict[str, Any]:
|
|
137
|
+
return asdict(self)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: dict[str, Any]) -> Round:
|
|
141
|
+
if not isinstance(data, dict):
|
|
142
|
+
return cls()
|
|
143
|
+
known = set(cls.__dataclass_fields__)
|
|
144
|
+
filtered = {k: v for k, v in data.items() if k in known}
|
|
145
|
+
try:
|
|
146
|
+
filtered["index"] = int(filtered.get("index") or 1)
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
filtered["index"] = 1
|
|
149
|
+
if not isinstance(filtered.get("finding_ids"), list):
|
|
150
|
+
filtered["finding_ids"] = []
|
|
151
|
+
if not isinstance(filtered.get("mitigation_ids"), list):
|
|
152
|
+
filtered["mitigation_ids"] = []
|
|
153
|
+
return cls(**filtered)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class SessionState:
|
|
158
|
+
"""The complete, serialisable state of an audit session."""
|
|
159
|
+
|
|
160
|
+
id: str = field(default_factory=lambda: new_id("S"))
|
|
161
|
+
name: str = "splitagent"
|
|
162
|
+
target: str = ""
|
|
163
|
+
target_kind: str = "web"
|
|
164
|
+
scope: list[str] = field(default_factory=list)
|
|
165
|
+
model: str = ""
|
|
166
|
+
provider: str = ""
|
|
167
|
+
started_at: str = field(default_factory=_now)
|
|
168
|
+
ended_at: str = ""
|
|
169
|
+
rounds: list[Round] = field(default_factory=list)
|
|
170
|
+
findings: list[Finding] = field(default_factory=list)
|
|
171
|
+
mitigations: list[Mitigation] = field(default_factory=list)
|
|
172
|
+
notes: list[str] = field(default_factory=list)
|
|
173
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
174
|
+
checkpoints: list[dict[str, Any]] = field(default_factory=list)
|
|
175
|
+
traces: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
|
|
176
|
+
todos: list[dict[str, str]] = field(default_factory=list)
|
|
177
|
+
|
|
178
|
+
# -- convenience ------------------------------------------------------- #
|
|
179
|
+
def add_finding(self, finding: Finding) -> Finding:
|
|
180
|
+
self.findings.append(finding)
|
|
181
|
+
return finding
|
|
182
|
+
|
|
183
|
+
def add_mitigation(self, mitigation: Mitigation) -> Mitigation:
|
|
184
|
+
self.mitigations.append(mitigation)
|
|
185
|
+
return mitigation
|
|
186
|
+
|
|
187
|
+
def get_finding(self, finding_id: str) -> Finding | None:
|
|
188
|
+
return next((f for f in self.findings if f.id == finding_id), None)
|
|
189
|
+
|
|
190
|
+
def rounds_by_index(self) -> dict[int, Round]:
|
|
191
|
+
return {r.index: r for r in self.rounds}
|
|
192
|
+
|
|
193
|
+
def latest_round(self) -> Round | None:
|
|
194
|
+
return self.rounds[-1] if self.rounds else None
|
|
195
|
+
|
|
196
|
+
def severity_counts(self) -> dict[str, int]:
|
|
197
|
+
counts = dict.fromkeys(SEVERITIES, 0)
|
|
198
|
+
for finding in self.findings:
|
|
199
|
+
counts[normalize_severity(finding.severity, finding.cvss_score)] += 1
|
|
200
|
+
return counts
|
|
201
|
+
|
|
202
|
+
SEVERITY_WEIGHTS: ClassVar[dict[str, float]] = {
|
|
203
|
+
"critical": 10.0,
|
|
204
|
+
"high": 6.0,
|
|
205
|
+
"medium": 3.0,
|
|
206
|
+
"low": 1.0,
|
|
207
|
+
"info": 0.25,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
def finding_weight(self, finding: Finding) -> float:
|
|
211
|
+
severity = normalize_severity(finding.severity, finding.cvss_score)
|
|
212
|
+
return self.SEVERITY_WEIGHTS.get(severity, 0.25)
|
|
213
|
+
|
|
214
|
+
def resilience_score(self) -> float:
|
|
215
|
+
"""0-100: how much of the discovered attack surface is actually closed.
|
|
216
|
+
|
|
217
|
+
Only a mitigation that was *re-tested* counts. Proposing a firewall
|
|
218
|
+
rule changes nothing on the target, so scoring proposals as fixes
|
|
219
|
+
reports a confident number that is simply untrue - which is worse than
|
|
220
|
+
reporting nothing, because the operator stops looking.
|
|
221
|
+
"""
|
|
222
|
+
if not self.findings:
|
|
223
|
+
return 100.0
|
|
224
|
+
total = 0.0
|
|
225
|
+
covered = 0.0
|
|
226
|
+
for finding in self.findings:
|
|
227
|
+
weight = self.finding_weight(finding)
|
|
228
|
+
total += weight
|
|
229
|
+
if self.finding_is_closed(finding):
|
|
230
|
+
covered += weight
|
|
231
|
+
if total <= 0:
|
|
232
|
+
return 100.0
|
|
233
|
+
return round((covered / total) * 100.0, 1)
|
|
234
|
+
|
|
235
|
+
def finding_is_closed(self, finding: Finding) -> bool:
|
|
236
|
+
"""True only when a verified mitigation addresses this finding."""
|
|
237
|
+
if finding.status in ("false-positive", "accepted"):
|
|
238
|
+
# Not a real issue, or consciously accepted: not an open risk.
|
|
239
|
+
return True
|
|
240
|
+
return any(
|
|
241
|
+
m.finding_id == finding.id and m.verified and m.status == "verified"
|
|
242
|
+
for m in self.mitigations
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def resilience_breakdown(self) -> dict[str, Any]:
|
|
246
|
+
"""The number plus the evidence behind it, for the report and the UI."""
|
|
247
|
+
proposed = sum(1 for m in self.mitigations if not m.verified)
|
|
248
|
+
verified = sum(1 for m in self.mitigations if m.verified)
|
|
249
|
+
open_critical = [
|
|
250
|
+
f.id
|
|
251
|
+
for f in self.findings
|
|
252
|
+
if not self.finding_is_closed(f)
|
|
253
|
+
and normalize_severity(f.severity, f.cvss_score) == "critical"
|
|
254
|
+
]
|
|
255
|
+
return {
|
|
256
|
+
"score": self.resilience_score(),
|
|
257
|
+
"findings_total": len(self.findings),
|
|
258
|
+
"findings_closed": sum(1 for f in self.findings if self.finding_is_closed(f)),
|
|
259
|
+
"open_critical": open_critical,
|
|
260
|
+
"mitigations_proposed": proposed,
|
|
261
|
+
"mitigations_verified": verified,
|
|
262
|
+
"verified_is_zero": verified == 0 and bool(self.findings),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
def to_dict(self) -> dict[str, Any]:
|
|
266
|
+
return {
|
|
267
|
+
"id": self.id,
|
|
268
|
+
"name": self.name,
|
|
269
|
+
"target": self.target,
|
|
270
|
+
"target_kind": self.target_kind,
|
|
271
|
+
"scope": list(self.scope),
|
|
272
|
+
"model": self.model,
|
|
273
|
+
"provider": self.provider,
|
|
274
|
+
"started_at": self.started_at,
|
|
275
|
+
"ended_at": self.ended_at,
|
|
276
|
+
"rounds": [r.to_dict() for r in self.rounds],
|
|
277
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
278
|
+
"mitigations": [m.to_dict() for m in self.mitigations],
|
|
279
|
+
"notes": list(self.notes),
|
|
280
|
+
"usage": dict(self.usage),
|
|
281
|
+
"checkpoints": list(self.checkpoints),
|
|
282
|
+
"traces": self.traces,
|
|
283
|
+
"todos": list(self.todos),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def from_dict(cls, data: dict[str, Any]) -> SessionState:
|
|
288
|
+
return cls(
|
|
289
|
+
id=data.get("id", new_id("S")),
|
|
290
|
+
name=data.get("name", "splitagent"),
|
|
291
|
+
target=data.get("target", ""),
|
|
292
|
+
target_kind=data.get("target_kind", "web"),
|
|
293
|
+
scope=list(data.get("scope", [])),
|
|
294
|
+
model=data.get("model", ""),
|
|
295
|
+
provider=data.get("provider", ""),
|
|
296
|
+
started_at=data.get("started_at", _now()),
|
|
297
|
+
ended_at=data.get("ended_at", ""),
|
|
298
|
+
rounds=[Round.from_dict(r) for r in data.get("rounds", []) if isinstance(r, dict)],
|
|
299
|
+
findings=[
|
|
300
|
+
Finding.from_dict(f) for f in data.get("findings", []) if isinstance(f, dict)
|
|
301
|
+
],
|
|
302
|
+
mitigations=[
|
|
303
|
+
Mitigation.from_dict(m) for m in data.get("mitigations", []) if isinstance(m, dict)
|
|
304
|
+
],
|
|
305
|
+
notes=list(data.get("notes", [])),
|
|
306
|
+
usage=dict(data.get("usage", {})),
|
|
307
|
+
checkpoints=list(data.get("checkpoints", [])),
|
|
308
|
+
traces=dict(data.get("traces", {})),
|
|
309
|
+
todos=[dict(item) for item in data.get("todos", [])],
|
|
310
|
+
)
|
splitagent/core/proc.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Subprocess helpers that never flash a console window on Windows.
|
|
2
|
+
|
|
3
|
+
When SplitAgent runs under ``pythonw.exe`` (the desktop app) there is no
|
|
4
|
+
console attached. Any child process spawned without the right flags makes
|
|
5
|
+
Windows create one, which shows up as a storm of CMD windows spamming the
|
|
6
|
+
screen - especially during a Docker build or an apt install that spawns many
|
|
7
|
+
subprocesses. Every command SplitAgent runs goes through here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
# Windows process-creation flags.
|
|
17
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
18
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
19
|
+
DETACHED_PROCESS = 0x00000008
|
|
20
|
+
|
|
21
|
+
IS_WINDOWS = os.name == "nt"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hidden_flags(detach: bool = False) -> int:
|
|
25
|
+
"""Creation flags that keep a child process invisible and independent."""
|
|
26
|
+
if not IS_WINDOWS:
|
|
27
|
+
return 0
|
|
28
|
+
flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
|
|
29
|
+
if detach:
|
|
30
|
+
flags |= DETACHED_PROCESS
|
|
31
|
+
return flags
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(
|
|
35
|
+
argv: list[str],
|
|
36
|
+
*,
|
|
37
|
+
cwd: str | None = None,
|
|
38
|
+
timeout: int | None = None,
|
|
39
|
+
env: dict[str, str] | None = None,
|
|
40
|
+
text: bool = False,
|
|
41
|
+
shell: bool = False,
|
|
42
|
+
) -> subprocess.CompletedProcess[Any]:
|
|
43
|
+
"""``subprocess.run`` with the console window suppressed on Windows."""
|
|
44
|
+
return subprocess.run(
|
|
45
|
+
argv,
|
|
46
|
+
cwd=cwd,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
env=env,
|
|
50
|
+
check=False,
|
|
51
|
+
shell=shell,
|
|
52
|
+
creationflags=hidden_flags(),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def popen(
|
|
57
|
+
argv: list[str],
|
|
58
|
+
*,
|
|
59
|
+
cwd: str | None = None,
|
|
60
|
+
env: dict[str, str] | None = None,
|
|
61
|
+
detach: bool = True,
|
|
62
|
+
) -> subprocess.Popen[Any]:
|
|
63
|
+
"""Launch a background process with no console window."""
|
|
64
|
+
return subprocess.Popen(
|
|
65
|
+
argv,
|
|
66
|
+
cwd=cwd,
|
|
67
|
+
env=env,
|
|
68
|
+
stdout=subprocess.DEVNULL,
|
|
69
|
+
stderr=subprocess.DEVNULL,
|
|
70
|
+
stdin=subprocess.DEVNULL,
|
|
71
|
+
creationflags=hidden_flags(detach=detach),
|
|
72
|
+
close_fds=True,
|
|
73
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Ephemeral Docker sandbox management.
|
|
2
|
+
|
|
3
|
+
The sandbox gives the agents a disposable target: a container is started on
|
|
4
|
+
an isolated bridge network, exercised for the duration of the audit and then
|
|
5
|
+
removed. When Docker is unavailable the framework degrades gracefully to
|
|
6
|
+
"local mode" (the agents operate against the configured target only).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import shutil
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from splitagent.config import SandboxConfig
|
|
17
|
+
from splitagent.core import proc
|
|
18
|
+
from splitagent.errors import SandboxError
|
|
19
|
+
|
|
20
|
+
# Ready-to-use intentionally vulnerable targets for demos / training.
|
|
21
|
+
# ``port_map`` is {host_port: container_port}: the publish argument is
|
|
22
|
+
# ``-p host:container`` and the URL is derived from the host port.
|
|
23
|
+
TARGET_PRESETS: dict[str, dict[str, Any]] = {
|
|
24
|
+
"juice-shop": {
|
|
25
|
+
"image": "bkimminich/juice-shop:latest",
|
|
26
|
+
"port_map": {"3000": 3000},
|
|
27
|
+
"url": "http://localhost:3000",
|
|
28
|
+
},
|
|
29
|
+
"dvwa": {
|
|
30
|
+
"image": "vulnerables/web-dvwa:latest",
|
|
31
|
+
"port_map": {"8080": 80},
|
|
32
|
+
"url": "http://localhost:8080",
|
|
33
|
+
},
|
|
34
|
+
"bwapp": {
|
|
35
|
+
"image": "raesene/bwapp:latest",
|
|
36
|
+
"port_map": {"8081": 80},
|
|
37
|
+
"url": "http://localhost:8081",
|
|
38
|
+
},
|
|
39
|
+
"webgoat": {
|
|
40
|
+
"image": "webgoat/webgoat:latest",
|
|
41
|
+
"port_map": {"8082": 8080},
|
|
42
|
+
"url": "http://localhost:8082/WebGoat",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SandboxStatus:
|
|
49
|
+
available: bool
|
|
50
|
+
running: bool = False
|
|
51
|
+
container_id: str = ""
|
|
52
|
+
name: str = ""
|
|
53
|
+
url: str = ""
|
|
54
|
+
message: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DockerSandbox:
|
|
58
|
+
"""Thin wrapper around the ``docker`` CLI."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, config: SandboxConfig, session_id: str = "run") -> None:
|
|
61
|
+
self.config = config
|
|
62
|
+
self.name = f"splitagent-target-{session_id}"
|
|
63
|
+
self.container_id = ""
|
|
64
|
+
self.url = ""
|
|
65
|
+
self.engine = config.engine or "docker"
|
|
66
|
+
|
|
67
|
+
# -- detection --------------------------------------------------------- #
|
|
68
|
+
@staticmethod
|
|
69
|
+
def docker_available() -> bool:
|
|
70
|
+
return shutil.which("docker") is not None
|
|
71
|
+
|
|
72
|
+
async def _run(self, *args: str, check: bool = False) -> tuple[int, str, str]:
|
|
73
|
+
try:
|
|
74
|
+
child = await asyncio.create_subprocess_exec(
|
|
75
|
+
self.engine,
|
|
76
|
+
*args,
|
|
77
|
+
stdout=asyncio.subprocess.PIPE,
|
|
78
|
+
stderr=asyncio.subprocess.PIPE,
|
|
79
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
80
|
+
# No console window on Windows (the desktop app has none).
|
|
81
|
+
creationflags=proc.hidden_flags(),
|
|
82
|
+
)
|
|
83
|
+
except FileNotFoundError as exc: # pragma: no cover - guarded by detection
|
|
84
|
+
raise SandboxError(
|
|
85
|
+
f"'{self.engine}' was not found on PATH. Disable the sandbox or install Docker."
|
|
86
|
+
) from exc
|
|
87
|
+
stdout, stderr = await child.communicate()
|
|
88
|
+
code = child.returncode or 0
|
|
89
|
+
out = stdout.decode("utf-8", "replace").strip()
|
|
90
|
+
err = stderr.decode("utf-8", "replace").strip()
|
|
91
|
+
if check and code != 0:
|
|
92
|
+
raise SandboxError(err or out or f"{self.engine} exited with {code}")
|
|
93
|
+
return code, out, err
|
|
94
|
+
|
|
95
|
+
# -- lifecycle --------------------------------------------------------- #
|
|
96
|
+
async def up(self) -> SandboxStatus:
|
|
97
|
+
if not self.config.enabled:
|
|
98
|
+
return SandboxStatus(available=head_ok(), message="sandbox disabled")
|
|
99
|
+
if not self.docker_available():
|
|
100
|
+
return SandboxStatus(
|
|
101
|
+
available=False,
|
|
102
|
+
message="Docker not found; running without sandbox.",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
await self._ensure_network()
|
|
106
|
+
await self.down(quiet=True)
|
|
107
|
+
|
|
108
|
+
args = [
|
|
109
|
+
"run",
|
|
110
|
+
"-d",
|
|
111
|
+
"--name",
|
|
112
|
+
self.name,
|
|
113
|
+
"--network",
|
|
114
|
+
self.config.network or "splitagent-net",
|
|
115
|
+
]
|
|
116
|
+
if self.config.auto_remove:
|
|
117
|
+
args.append("--rm")
|
|
118
|
+
for host_port, container_port in (self.config.port_map or {}).items():
|
|
119
|
+
args += ["-p", f"{host_port}:{container_port}"]
|
|
120
|
+
args.append(self.config.image)
|
|
121
|
+
|
|
122
|
+
code, out, err = await self._run(*args)
|
|
123
|
+
if code != 0:
|
|
124
|
+
return SandboxStatus(
|
|
125
|
+
available=True,
|
|
126
|
+
running=False,
|
|
127
|
+
message=err or "failed to start container",
|
|
128
|
+
)
|
|
129
|
+
self.container_id = out.splitlines()[-1].strip()
|
|
130
|
+
self.url = self._infer_url()
|
|
131
|
+
return SandboxStatus(
|
|
132
|
+
available=True,
|
|
133
|
+
running=True,
|
|
134
|
+
container_id=self.container_id,
|
|
135
|
+
name=self.name,
|
|
136
|
+
url=self.url,
|
|
137
|
+
message="sandbox started",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def down(self, quiet: bool = False) -> None:
|
|
141
|
+
if not self.docker_available():
|
|
142
|
+
return
|
|
143
|
+
await self._run("rm", "-f", self.name)
|
|
144
|
+
self.container_id = ""
|
|
145
|
+
|
|
146
|
+
async def status(self) -> SandboxStatus:
|
|
147
|
+
if not self.docker_available():
|
|
148
|
+
return SandboxStatus(available=False, message="Docker not found")
|
|
149
|
+
code, out, _ = await self._run(
|
|
150
|
+
"ps", "--filter", f"name={self.name}", "--format", "{{.ID}} {{.Names}}"
|
|
151
|
+
)
|
|
152
|
+
running = bool(out.strip()) and code == 0
|
|
153
|
+
return SandboxStatus(
|
|
154
|
+
available=True,
|
|
155
|
+
running=running,
|
|
156
|
+
name=self.name,
|
|
157
|
+
message="running" if running else "stopped",
|
|
158
|
+
url=self.url or self._infer_url(),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def logs(self, tail: int = 100) -> str:
|
|
162
|
+
_, out, err = await self._run("logs", "--tail", str(tail), self.name)
|
|
163
|
+
return out or err
|
|
164
|
+
|
|
165
|
+
async def exec(self, *command: str) -> str:
|
|
166
|
+
code, out, err = await self._run("exec", self.name, *command)
|
|
167
|
+
return out if code == 0 else (err or out)
|
|
168
|
+
|
|
169
|
+
async def _ensure_network(self) -> None:
|
|
170
|
+
network = self.config.network or "splitagent-net"
|
|
171
|
+
_code, out, _ = await self._run("network", "ls", "--format", "{{.Name}}")
|
|
172
|
+
if network in out.splitlines():
|
|
173
|
+
return
|
|
174
|
+
await self._run("network", "create", network)
|
|
175
|
+
|
|
176
|
+
def _infer_url(self) -> str:
|
|
177
|
+
if not self.config.port_map:
|
|
178
|
+
return ""
|
|
179
|
+
host_port = next(iter(self.config.port_map))
|
|
180
|
+
return f"http://localhost:{host_port}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def head_ok() -> bool:
|
|
184
|
+
return True
|