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,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"]
|
splitagent/tools/base.py
ADDED
|
@@ -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
|
+
]
|