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.
Files changed (56) hide show
  1. splitagent/__init__.py +8 -0
  2. splitagent/__main__.py +6 -0
  3. splitagent/agents/__init__.py +10 -0
  4. splitagent/agents/base.py +477 -0
  5. splitagent/agents/blue.py +57 -0
  6. splitagent/agents/chat.py +60 -0
  7. splitagent/agents/prompts.py +462 -0
  8. splitagent/agents/red.py +75 -0
  9. splitagent/cli.py +701 -0
  10. splitagent/config.py +697 -0
  11. splitagent/core/__init__.py +19 -0
  12. splitagent/core/bus.py +62 -0
  13. splitagent/core/context.py +587 -0
  14. splitagent/core/context_manager.py +381 -0
  15. splitagent/core/engine.py +424 -0
  16. splitagent/core/models.py +310 -0
  17. splitagent/core/proc.py +73 -0
  18. splitagent/core/sandbox.py +184 -0
  19. splitagent/core/toolbox.py +520 -0
  20. splitagent/core/workspace.py +420 -0
  21. splitagent/desktop/__init__.py +7 -0
  22. splitagent/desktop/api.py +525 -0
  23. splitagent/desktop/app.py +1131 -0
  24. splitagent/desktop/web/app.js +3067 -0
  25. splitagent/desktop/web/assets/Inter.ttf +0 -0
  26. splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  27. splitagent/desktop/web/index.html +760 -0
  28. splitagent/desktop/web/styles.css +1612 -0
  29. splitagent/errors.py +27 -0
  30. splitagent/llm/__init__.py +8 -0
  31. splitagent/llm/client.py +488 -0
  32. splitagent/llm/types.py +172 -0
  33. splitagent/report/__init__.py +9 -0
  34. splitagent/report/cvss.py +93 -0
  35. splitagent/report/generator.py +733 -0
  36. splitagent/tools/__init__.py +8 -0
  37. splitagent/tools/base.py +135 -0
  38. splitagent/tools/defense.py +475 -0
  39. splitagent/tools/exploit.py +318 -0
  40. splitagent/tools/http_pool.py +109 -0
  41. splitagent/tools/knowledge.py +376 -0
  42. splitagent/tools/recon.py +182 -0
  43. splitagent/tools/registry.py +62 -0
  44. splitagent/tools/validate.py +908 -0
  45. splitagent/tools/web.py +386 -0
  46. splitagent/tools/workspace_tools.py +411 -0
  47. splitagent/ui/__init__.py +5 -0
  48. splitagent/ui/app.py +389 -0
  49. splitagent/ui/stream.py +234 -0
  50. splitagent/ui/theme.py +72 -0
  51. splitagent-0.0.3.dist-info/METADATA +987 -0
  52. splitagent-0.0.3.dist-info/RECORD +56 -0
  53. splitagent-0.0.3.dist-info/WHEEL +5 -0
  54. splitagent-0.0.3.dist-info/entry_points.txt +2 -0
  55. splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
  56. splitagent-0.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,8 @@
1
+ """Toolkit exposed to the Red and Blue agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from splitagent.tools.base import Tool, ToolContext
6
+ from splitagent.tools.registry import ToolRegistry, build_registry
7
+
8
+ __all__ = ["Tool", "ToolContext", "ToolRegistry", "build_registry"]
@@ -0,0 +1,135 @@
1
+ """Base classes for the agent toolset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import ipaddress
7
+ import json
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ from splitagent.config import RunConfig, TargetConfig
14
+ from splitagent.core.context import SharedContext
15
+ from splitagent.errors import ScopeError
16
+ from splitagent.llm.types import ToolSpec
17
+
18
+
19
+ @dataclass
20
+ class ToolContext:
21
+ """Everything a tool needs to do its job."""
22
+
23
+ target: TargetConfig
24
+ run: RunConfig
25
+ context: SharedContext
26
+ round: int = 1
27
+ agent: str = "red"
28
+ settings: dict[str, Any] = field(default_factory=dict)
29
+
30
+ @property
31
+ def safe_mode(self) -> bool:
32
+ return bool(self.run.safe_mode)
33
+
34
+ def auth_headers(self) -> dict[str, str]:
35
+ """Default headers for authenticated testing, if any were provided."""
36
+ value = self.settings.get("auth_headers")
37
+ return dict(value) if isinstance(value, dict) else {}
38
+
39
+ def allowed_hosts(self) -> set[str]:
40
+ hosts = set(self.target.effective_hosts())
41
+ hosts.update(self.target.scope or [])
42
+ excluded = {h.lower() for h in (self.target.out_of_scope or []) if h}
43
+ return {h.lower() for h in hosts if h and h.lower() not in excluded}
44
+
45
+ def check_scope(self, host_or_url: str) -> None:
46
+ """Refuse targets outside the authorised scope when enforcement is on."""
47
+ if host_or_url.startswith(("http://", "https://")):
48
+ host = urlparse(host_or_url).hostname or ""
49
+ else:
50
+ host = host_or_url
51
+ host = host.split(":")[0].lower()
52
+ if not host:
53
+ return
54
+ # An explicit exclusion always wins, even in allow_network mode.
55
+ if host in {h.lower() for h in (self.target.out_of_scope or []) if h}:
56
+ raise ScopeError(f"'{host}' is explicitly out of scope (target.out_of_scope).")
57
+ if self.run.allow_network:
58
+ return
59
+ allowed = self.allowed_hosts()
60
+ if not allowed:
61
+ return
62
+ if host in allowed:
63
+ return
64
+ # allow loopback aliases for localhost targets
65
+ loopback = {"localhost", "127.0.0.1", "::1"}
66
+ if host in loopback and allowed & loopback:
67
+ return
68
+ try:
69
+ if ipaddress.ip_address(host).is_loopback and any(
70
+ _is_loopback_name(a) for a in allowed
71
+ ):
72
+ return
73
+ except ValueError:
74
+ pass
75
+ raise ScopeError(
76
+ f"'{host}' is outside the authorised scope {sorted(allowed)}. "
77
+ "Extend target.scope or set run.allow_network=true for lab use."
78
+ )
79
+
80
+
81
+ ToolFunc = Callable[..., Any]
82
+
83
+
84
+ @dataclass
85
+ class Tool:
86
+ """A callable exposed to the model."""
87
+
88
+ name: str
89
+ description: str
90
+ parameters: dict[str, Any]
91
+ func: ToolFunc
92
+ scope: str = "shared" # red | blue | shared
93
+ dangerous: bool = False
94
+ # Safe to run concurrently with other tools in the same step. Read-only
95
+ # probes qualify; anything that mutates state or shells out does not.
96
+ parallel_safe: bool = True
97
+
98
+ def spec(self) -> ToolSpec:
99
+ return ToolSpec(name=self.name, description=self.description, parameters=self.parameters)
100
+
101
+ async def run(self, arguments: dict[str, Any]) -> str:
102
+ try:
103
+ result = self.func(**(arguments or {}))
104
+ if hasattr(result, "__await__"):
105
+ result = await result
106
+ except ScopeError as exc:
107
+ return _pack({"error": "out_of_scope", "message": str(exc)})
108
+ except TypeError as exc:
109
+ return _pack({"error": "bad_arguments", "message": str(exc)})
110
+ except Exception as exc:
111
+ return _pack({"error": type(exc).__name__, "message": str(exc)[:600]})
112
+ return _pack(result)
113
+
114
+
115
+ def _pack(result: Any) -> str:
116
+ if isinstance(result, str):
117
+ return result
118
+ try:
119
+ return json.dumps(_serialise(result), ensure_ascii=False, indent=2, default=str)
120
+ except (TypeError, ValueError):
121
+ return str(result)
122
+
123
+
124
+ def _serialise(value: Any) -> Any:
125
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
126
+ return dataclasses.asdict(value)
127
+ if isinstance(value, dict):
128
+ return {k: _serialise(v) for k, v in value.items()}
129
+ if isinstance(value, (list, tuple)):
130
+ return [_serialise(v) for v in value]
131
+ return value
132
+
133
+
134
+ def _is_loopback_name(name: str) -> bool:
135
+ return name.lower() in {"localhost", "127.0.0.1", "::1"}
@@ -0,0 +1,475 @@
1
+ """Defensive tools used by the Blue Agent (log triage, hardening, patching)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from splitagent.tools.base import Tool, ToolContext
10
+ from splitagent.tools.http_pool import get_client
11
+ from splitagent.tools.web import SECURITY_HEADERS
12
+
13
+ FIREWALL_TEMPLATES = {
14
+ "iptables": "iptables -A INPUT -p tcp --dport {port} -s {source} -j {action}",
15
+ "nft": "nft add rule inet filter input tcp dport {port} ip saddr {source} {action}",
16
+ "ufw": "ufw {action_map} from {source} to any port {port} proto tcp",
17
+ "windows": (
18
+ 'New-NetFirewallRule -DisplayName "SplitAgent-{port}" -Direction Inbound '
19
+ "-Protocol TCP -LocalPort {port} -RemoteAddress {source} -Action {action_map}"
20
+ ),
21
+ }
22
+
23
+ HARDEN_SNIPPETS = {
24
+ "nginx": """# /etc/nginx/conf.d/security-headers.conf
25
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
26
+ add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
27
+ add_header X-Content-Type-Options "nosniff" always;
28
+ add_header X-Frame-Options "DENY" always;
29
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
30
+ add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
31
+ server_tokens off;""",
32
+ "apache": """# httpd.conf / .htaccess
33
+ Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
34
+ Header always set Content-Security-Policy "default-src 'self'; frame-ancestors 'none'"
35
+ Header always set X-Content-Type-Options "nosniff"
36
+ Header always set X-Frame-Options "DENY"
37
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
38
+ ServerTokens Prod
39
+ ServerSignature Off""",
40
+ "express": """// app.js (Express)
41
+ import helmet from "helmet";
42
+ app.use(helmet());
43
+ app.disable("x-powered-by");
44
+ app.use((req, res, next) => {
45
+ res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
46
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
47
+ next();
48
+ });""",
49
+ }
50
+
51
+ PATCH_TEMPLATES = {
52
+ "sql_injection": {
53
+ "language": "python",
54
+ "title": "Use parameterised queries",
55
+ "diff": (
56
+ "- cursor.execute(f\"SELECT * FROM users WHERE name = '{name}'\")\n"
57
+ '+ cursor.execute("SELECT * FROM users WHERE name = %s", (name,))'
58
+ ),
59
+ },
60
+ "reflected_xss": {
61
+ "language": "javascript",
62
+ "title": "Context-aware output encoding",
63
+ "diff": (
64
+ "- el.innerHTML = userInput;\n"
65
+ "+ el.textContent = userInput;\n"
66
+ "// or, server side: escape with your template engine's autoescaping"
67
+ ),
68
+ },
69
+ "path_traversal": {
70
+ "language": "python",
71
+ "title": "Canonicalise and confine file paths",
72
+ "diff": (
73
+ "- path = os.path.join(BASE, request.args['file'])\n"
74
+ "+ safe = os.path.basename(request.args['file'])\n"
75
+ "+ path = os.path.realpath(os.path.join(BASE, safe))\n"
76
+ "+ if not path.startswith(os.path.realpath(BASE) + os.sep):\n"
77
+ "+ raise ValueError('path traversal attempt')"
78
+ ),
79
+ },
80
+ "command_injection": {
81
+ "language": "python",
82
+ "title": "Avoid the shell; pass an argument vector",
83
+ "diff": (
84
+ "- os.system('ping -c 1 ' + host)\n"
85
+ "+ subprocess.run(['ping', '-c', '1', host], shell=False, check=True)"
86
+ ),
87
+ },
88
+ "cors_misconfiguration": {
89
+ "language": "python",
90
+ "title": "Restrict CORS to an allow-list",
91
+ "diff": (
92
+ "- values['Access-Control-Allow-Origin'] = '*'\n"
93
+ "+ if origin in ALLOWED_ORIGINS:\n"
94
+ "+ values['Access-Control-Allow-Origin'] = origin\n"
95
+ "+ values['Vary'] = 'Origin'"
96
+ ),
97
+ },
98
+ "open_redirect": {
99
+ "language": "python",
100
+ "title": "Validate redirect targets against an allow-list",
101
+ "diff": (
102
+ "- return redirect(request.args['next'])\n"
103
+ "+ target = urlparse(request.args.get('next', '/'))\n"
104
+ "+ if target.netloc and target.netloc not in ALLOWED_HOSTS:\n"
105
+ "+ return redirect('/')\n"
106
+ "+ return redirect(request.args['next'])"
107
+ ),
108
+ },
109
+ }
110
+
111
+ LOG_PATTERNS = {
112
+ "sqli": r"(union\s+select|or\s+1=1|sleep\(|information_schema)",
113
+ "xss": r"(<script|onerror=|onload=|javascript:)",
114
+ "traversal": r"(\.\./|%2e%2e|/etc/passwd)",
115
+ "scanner": r"(nikto|sqlmap|nmap|masscan|acunetix|nessus|dirbuster|gobuster)",
116
+ "auth_fail": r"(failed password|authentication failure|invalid user|401)",
117
+ "server_error": r"(500 internal server error|traceback|exception|stack trace)",
118
+ }
119
+
120
+
121
+ def _firewall_rule(platform: str, action: str, port: int, source: str = "any") -> dict[str, Any]:
122
+ template = FIREWALL_TEMPLATES.get(platform)
123
+ if not template:
124
+ return {"error": f"unknown platform '{platform}'", "platforms": list(FIREWALL_TEMPLATES)}
125
+ action_map = {
126
+ "block": "DROP" if platform in ("iptables", "nft") else "block",
127
+ "allow": "ACCEPT" if platform in ("iptables", "nft") else "allow",
128
+ }.get(action, action.upper())
129
+ if platform == "ufw":
130
+ rule = template.format(
131
+ port=port, source=source, action_map="deny" if action == "block" else "allow"
132
+ )
133
+ elif platform == "windows":
134
+ rule = template.format(
135
+ port=port, source=source, action_map="Block" if action == "block" else "Allow"
136
+ )
137
+ else:
138
+ rule = template.format(port=port, source=source, action=action_map)
139
+ return {
140
+ "platform": platform,
141
+ "action": action,
142
+ "port": port,
143
+ "source": source,
144
+ "rule": rule,
145
+ }
146
+
147
+
148
+ async def _analyze_logs(
149
+ ctx: ToolContext, path: str = "", source: str = "auto", max_lines: int = 4000
150
+ ) -> dict[str, Any]:
151
+ text = ""
152
+ origin = path
153
+ if source in ("auto", "file") and path:
154
+ candidate = Path(path)
155
+ if candidate.exists() and candidate.is_file():
156
+ text = candidate.read_text(encoding="utf-8", errors="replace")
157
+ else:
158
+ return {"error": f"log file not found: {path}"}
159
+ if not text and source in ("auto", "sandbox"):
160
+ sandbox = ctx.settings.get("sandbox")
161
+ if sandbox is not None:
162
+ text = await sandbox.logs(tail=1000)
163
+ origin = f"sandbox:{sandbox.name}"
164
+ if not text:
165
+ return {
166
+ "info": "no log source available",
167
+ "hint": "pass a path to a log file or enable the Docker sandbox",
168
+ }
169
+ lines = text.splitlines()[-max_lines:]
170
+ matches: dict[str, list[str]] = {}
171
+ for name, pattern in LOG_PATTERNS.items():
172
+ regex = re.compile(pattern, re.IGNORECASE)
173
+ hits = [line.strip()[:300] for line in lines if regex.search(line)]
174
+ if hits:
175
+ matches[name] = hits[-10:]
176
+ return {
177
+ "source": origin,
178
+ "lines_analyzed": len(lines),
179
+ "categories": {k: len(v) for k, v in matches.items()},
180
+ "samples": matches,
181
+ "suspicious": bool(matches),
182
+ }
183
+
184
+
185
+ # Which validator re-proves which finding category. This is what turns a
186
+ # mitigation from "proposed" into "verified": we attack the target again and
187
+ # see whether the hole is actually gone.
188
+ _REPROBE_BY_CATEGORY = {
189
+ "sql_injection": ("test_sql_injection", "parameter"),
190
+ "reflected_xss": ("test_xss", "parameter"),
191
+ "xss": ("test_xss", "parameter"),
192
+ "path_traversal": ("test_path_traversal", "parameter"),
193
+ "command_injection": ("test_command_injection", "parameter"),
194
+ "open_redirect": ("test_open_redirect", "parameter"),
195
+ }
196
+
197
+ # Categories whose proof is a service state, not an HTTP response.
198
+ _REPROBE_SERVICE = {
199
+ "unauthenticated_shell": ("validate_root_shell", "port"),
200
+ "backdoor": ("validate_vsftpd_backdoor", "port"),
201
+ "database_exposure": ("validate_mysql_blank_password", "port"),
202
+ }
203
+
204
+
205
+ def _finding_port(finding: Any) -> int | None:
206
+ """Pull a port out of an endpoint such as ``tcp/1524`` or ``host:3306``."""
207
+ import re
208
+
209
+ blob = f"{finding.endpoint or ''} {finding.target or ''}"
210
+ match = re.search(r"(?:tcp|udp)/\s*(\d{2,5})", blob)
211
+ if match:
212
+ return int(match.group(1))
213
+ match = re.search(r":(\d{2,5})\b", blob)
214
+ return int(match.group(1)) if match else None
215
+
216
+
217
+ def _finding_host(finding: Any, ctx: ToolContext) -> str:
218
+ import re
219
+ from urllib.parse import urlparse
220
+
221
+ blob = f"{finding.endpoint or ''} {finding.target or ''}"
222
+ url_match = re.search(r"https?://([^/\s:]+)", blob)
223
+ if url_match:
224
+ return url_match.group(1)
225
+ if finding.target and "://" not in finding.target:
226
+ return finding.target.split()[0].split(":")[0]
227
+ hosts = ctx.target.effective_hosts()
228
+ return urlparse(hosts[0] if hosts else "localhost").hostname or "localhost"
229
+
230
+
231
+ async def _verify_control(ctx: ToolContext, finding_id: str) -> dict[str, Any]:
232
+ """Re-attack the target to see whether a mitigation actually closed it.
233
+
234
+ This is the difference between a report that says "apply this rule" and one
235
+ that says "this rule is in place and the hole is gone". Only the second is
236
+ worth anything to the operator, and only a confirmed re-test counts toward
237
+ the resilience score.
238
+ """
239
+ finding = ctx.context.state.get_finding(finding_id)
240
+ if finding is None:
241
+ return {"error": f"finding '{finding_id}' not found"}
242
+
243
+ category = (finding.category or "").lower()
244
+ host = _finding_host(finding, ctx)
245
+ result: dict[str, Any] = {"test": "re-probe", "category": category}
246
+
247
+ # 1. Service-level findings: re-run the matching validator.
248
+ if category in _REPROBE_SERVICE:
249
+ from splitagent.tools import validate as validators
250
+
251
+ name, _kind = _REPROBE_SERVICE[category]
252
+ port = _finding_port(finding)
253
+ validator = getattr(validators, name, None)
254
+ if validator is not None:
255
+ try:
256
+ probe = await validator(ctx, host, *((port,) if port else ()))
257
+ except Exception as exc:
258
+ return {"error": f"{type(exc).__name__}: {exc}", "verified": False}
259
+ # The exploit no longer works -> the control holds.
260
+ closed = probe.get("validated") is False
261
+ result.update(
262
+ {
263
+ "verified": closed,
264
+ "still_exploitable": probe.get("validated") is True,
265
+ "evidence": (
266
+ f"Re-ran {name}: "
267
+ + (
268
+ "the exploit no longer succeeds, the control holds."
269
+ if closed
270
+ else "the target is STILL vulnerable: "
271
+ + str(probe.get("evidence"))[:160]
272
+ )
273
+ ),
274
+ }
275
+ )
276
+ return _record_verification(ctx, finding_id, result)
277
+
278
+ # 2. HTTP-level findings: re-run the injection probe.
279
+ if category in _REPROBE_BY_CATEGORY:
280
+ from splitagent.tools import exploit as probes
281
+
282
+ name, _arg = _REPROBE_BY_CATEGORY[category]
283
+ probe_fn = getattr(probes, name, None)
284
+ parameter = _guessed_parameter(finding)
285
+ url = finding.endpoint or ctx.target.url
286
+ if probe_fn is not None and url and parameter:
287
+ try:
288
+ probe = await probe_fn(ctx, url, parameter)
289
+ except Exception as exc:
290
+ return {"error": f"{type(exc).__name__}: {exc}", "verified": False}
291
+ closed = probe.get("vulnerable") is False
292
+ result.update(
293
+ {
294
+ "verified": closed,
295
+ "still_exploitable": probe.get("vulnerable") is True,
296
+ "evidence": (
297
+ f"Re-ran {name} on '{parameter}': "
298
+ + (
299
+ "no longer exploitable, the control holds."
300
+ if closed
301
+ else "STILL exploitable: " + str(probe.get("evidence"))[:160]
302
+ )
303
+ ),
304
+ }
305
+ )
306
+ return _record_verification(ctx, finding_id, result)
307
+
308
+ # 3. Header / configuration findings: compare the actual response.
309
+ url = finding.endpoint or ctx.target.url
310
+ if url:
311
+ ctx.check_scope(url)
312
+ client = await get_client(follow=False, verify=False, timeout=10.0)
313
+ response = await client.get(url)
314
+ headers = {k.lower(): v for k, v in response.headers.items()}
315
+ if category in ("headers", "misconfiguration"):
316
+ missing = [h for h in SECURITY_HEADERS if h not in headers]
317
+ # Half or more of the headers present means the hardening landed.
318
+ verified = len(missing) < len(SECURITY_HEADERS) // 2
319
+ result.update(
320
+ {
321
+ "verified": verified,
322
+ "status": response.status_code,
323
+ "remaining_missing": missing,
324
+ "evidence": (
325
+ f"{len(SECURITY_HEADERS) - len(missing)}/"
326
+ f"{len(SECURITY_HEADERS)} security headers present"
327
+ ),
328
+ }
329
+ )
330
+ return _record_verification(ctx, finding_id, result)
331
+ result.update(
332
+ {
333
+ "verified": None,
334
+ "status": response.status_code,
335
+ "evidence": (
336
+ "The endpoint answers. This category has no automatic re-test; "
337
+ "confirm by hand before marking it closed."
338
+ ),
339
+ }
340
+ )
341
+ return result
342
+
343
+ return {
344
+ "verified": None,
345
+ "reason": "finding has no testable endpoint",
346
+ "evidence": "Nothing to re-test: the finding is informational.",
347
+ }
348
+
349
+
350
+ def _guessed_parameter(finding: Any) -> str:
351
+ """Recover the parameter name a web finding was found on."""
352
+ import re
353
+
354
+ blob = f"{finding.endpoint or ''} {finding.evidence or ''}"
355
+ match = re.search(r"[?&]([A-Za-z_][\w-]{0,30})=", blob)
356
+ if match:
357
+ return match.group(1)
358
+ match = re.search(r"parameter[:\s]+'?([A-Za-z_][\w-]{0,30})", blob, re.IGNORECASE)
359
+ return match.group(1) if match else ""
360
+
361
+
362
+ def _record_verification(
363
+ ctx: ToolContext, finding_id: str, result: dict[str, Any]
364
+ ) -> dict[str, Any]:
365
+ """Persist the verdict so the resilience score can trust it."""
366
+ verified = result.get("verified") is True
367
+ state = ctx.context.state
368
+ for mitigation in state.mitigations:
369
+ if mitigation.finding_id != finding_id:
370
+ continue
371
+ mitigation.verified = verified
372
+ mitigation.status = "verified" if verified else "proposed"
373
+ finding = state.get_finding(finding_id)
374
+ if finding is not None:
375
+ finding.status = "mitigated" if verified else "open"
376
+ ctx.context.save()
377
+ return result
378
+
379
+
380
+ def blue_tools(ctx: ToolContext) -> list[Tool]:
381
+ async def _logs(path: str = "", source: str = "auto") -> dict[str, Any]:
382
+ return await _analyze_logs(ctx, path=path, source=source)
383
+
384
+ return [
385
+ Tool(
386
+ name="analyze_logs",
387
+ description=(
388
+ "Triage logs (from a file or the running sandbox container) for "
389
+ "injection attempts, scanners, auth failures and server errors."
390
+ ),
391
+ parameters={
392
+ "type": "object",
393
+ "properties": {
394
+ "path": {"type": "string"},
395
+ "source": {"type": "string", "enum": ["auto", "file", "sandbox"]},
396
+ },
397
+ },
398
+ func=_logs,
399
+ scope="blue",
400
+ ),
401
+ Tool(
402
+ name="generate_firewall_rule",
403
+ description=(
404
+ "Produce a ready-to-apply firewall rule (iptables, nft, ufw or "
405
+ "Windows) to block or allow a port for a given source."
406
+ ),
407
+ parameters={
408
+ "type": "object",
409
+ "properties": {
410
+ "platform": {
411
+ "type": "string",
412
+ "enum": list(FIREWALL_TEMPLATES),
413
+ },
414
+ "action": {"type": "string", "enum": ["block", "allow"]},
415
+ "port": {"type": "integer"},
416
+ "source": {"type": "string"},
417
+ },
418
+ "required": ["platform", "action", "port"],
419
+ },
420
+ func=lambda platform, action, port, source="any": _firewall_rule(
421
+ platform, action, port, source
422
+ ),
423
+ scope="blue",
424
+ ),
425
+ Tool(
426
+ name="harden_headers",
427
+ description=(
428
+ "Return a server-specific hardening snippet adding the missing "
429
+ "security headers (nginx, apache or express)."
430
+ ),
431
+ parameters={
432
+ "type": "object",
433
+ "properties": {
434
+ "server": {"type": "string", "enum": list(HARDEN_SNIPPETS)},
435
+ },
436
+ "required": ["server"],
437
+ },
438
+ func=lambda server: {
439
+ "server": server,
440
+ "snippet": HARDEN_SNIPPETS.get(server, "unknown server"),
441
+ },
442
+ scope="blue",
443
+ ),
444
+ Tool(
445
+ name="suggest_patch",
446
+ description=(
447
+ "Return a code-level remediation diff for a vulnerability "
448
+ "category (sql_injection, reflected_xss, path_traversal, "
449
+ "command_injection, cors_misconfiguration, open_redirect)."
450
+ ),
451
+ parameters={
452
+ "type": "object",
453
+ "properties": {"category": {"type": "string"}},
454
+ "required": ["category"],
455
+ },
456
+ func=lambda category: PATCH_TEMPLATES.get(
457
+ category, {"error": f"no template for '{category}'"}
458
+ ),
459
+ scope="blue",
460
+ ),
461
+ Tool(
462
+ name="verify_control",
463
+ description=(
464
+ "Re-test a finding's endpoint to confirm whether the applied "
465
+ "mitigation actually closed the issue."
466
+ ),
467
+ parameters={
468
+ "type": "object",
469
+ "properties": {"finding_id": {"type": "string"}},
470
+ "required": ["finding_id"],
471
+ },
472
+ func=lambda finding_id: _verify_control(ctx, finding_id),
473
+ scope="blue",
474
+ ),
475
+ ]