pentester-mcp 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pentest_agent/__init__.py +3 -0
- pentest_agent/assets.py +104 -0
- pentest_agent/audit.py +62 -0
- pentest_agent/engagement.example.json +32 -0
- pentest_agent/engagement.py +320 -0
- pentest_agent/findings.py +75 -0
- pentest_agent/installer.py +314 -0
- pentest_agent/patterns.py +87 -0
- pentest_agent/runner.py +186 -0
- pentest_agent/server.py +754 -0
- pentest_agent/skills/pentest-guardrails/SKILL.md +94 -0
- pentest_agent/skills/pentest-workflow/SKILL.md +154 -0
- pentest_agent/state.py +68 -0
- pentest_agent/tools/__init__.py +1 -0
- pentest_agent/tools/ctf.py +832 -0
- pentest_agent/tools/exploit.py +380 -0
- pentest_agent/tools/integrations.py +562 -0
- pentest_agent/tools/orchestrate.py +102 -0
- pentest_agent/tools/osint.py +345 -0
- pentest_agent/tools/recon.py +265 -0
- pentest_agent/tools/report.py +488 -0
- pentest_agent/tools/vulnscan.py +411 -0
- pentest_agent/tools/webapp.py +721 -0
- pentester_mcp-0.3.0.dist-info/METADATA +429 -0
- pentester_mcp-0.3.0.dist-info/RECORD +28 -0
- pentester_mcp-0.3.0.dist-info/WHEEL +4 -0
- pentester_mcp-0.3.0.dist-info/entry_points.txt +2 -0
- pentester_mcp-0.3.0.dist-info/licenses/LICENSE +21 -0
pentest_agent/assets.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Asset inventory: what the assessment discovered, in one place.
|
|
2
|
+
|
|
3
|
+
Every recon tool records hosts, services and technologies here so the final
|
|
4
|
+
report can present an attack-surface view instead of isolated tool output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AssetStore:
|
|
16
|
+
def __init__(self, path: str | Path | None = None):
|
|
17
|
+
self.path = Path(path) if path else Path("evidence") / "assets.json"
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
self._data: dict = {"hosts": {}, "services": {}, "technologies": {}, "updated_at": ""}
|
|
20
|
+
self._load()
|
|
21
|
+
|
|
22
|
+
def _load(self) -> None:
|
|
23
|
+
if self.path.is_file():
|
|
24
|
+
try:
|
|
25
|
+
self._data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
26
|
+
except (json.JSONDecodeError, OSError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def _save(self) -> None:
|
|
30
|
+
self._data["updated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
31
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self.path.write_text(json.dumps(self._data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
def record_host(self, host: str, ips: list[str] | tuple[str, ...] = (), source: str = "") -> None:
|
|
35
|
+
host = (host or "").strip().lower().rstrip(".")
|
|
36
|
+
if not host:
|
|
37
|
+
return
|
|
38
|
+
with self._lock:
|
|
39
|
+
entry = self._data["hosts"].setdefault(host, {"ips": [], "sources": []})
|
|
40
|
+
for ip in ips:
|
|
41
|
+
if ip and ip not in entry["ips"]:
|
|
42
|
+
entry["ips"].append(ip)
|
|
43
|
+
if source and source not in entry["sources"]:
|
|
44
|
+
entry["sources"].append(source)
|
|
45
|
+
self._save()
|
|
46
|
+
|
|
47
|
+
def record_service(self, host: str, port: int, proto: str = "tcp", banner: str = "", source: str = "") -> None:
|
|
48
|
+
host = (host or "").strip().lower().rstrip(".")
|
|
49
|
+
if not host:
|
|
50
|
+
return
|
|
51
|
+
with self._lock:
|
|
52
|
+
key = f"{host}:{port}/{proto}"
|
|
53
|
+
entry = self._data["services"].setdefault(
|
|
54
|
+
key, {"host": host, "port": port, "proto": proto, "banner": "", "source": source}
|
|
55
|
+
)
|
|
56
|
+
if banner and not entry["banner"]:
|
|
57
|
+
entry["banner"] = banner[:200]
|
|
58
|
+
host_entry = self._data["hosts"].setdefault(host, {"ips": [], "sources": []})
|
|
59
|
+
src = source or "service-scan"
|
|
60
|
+
if src not in host_entry["sources"]:
|
|
61
|
+
host_entry["sources"].append(src)
|
|
62
|
+
self._save()
|
|
63
|
+
|
|
64
|
+
def record_tech(self, url: str, technologies: list[str] | tuple[str, ...], source: str = "") -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
entry = self._data["technologies"].setdefault(url, {"tech": [], "source": source})
|
|
67
|
+
for tech in technologies:
|
|
68
|
+
if tech and tech not in entry["tech"]:
|
|
69
|
+
entry["tech"].append(tech)
|
|
70
|
+
self._save()
|
|
71
|
+
|
|
72
|
+
def inventory(self) -> dict:
|
|
73
|
+
with self._lock:
|
|
74
|
+
hosts = self._data.get("hosts", {})
|
|
75
|
+
services = self._data.get("services", {})
|
|
76
|
+
tech = self._data.get("technologies", {})
|
|
77
|
+
return {
|
|
78
|
+
"hosts": [
|
|
79
|
+
{"host": h, **info,
|
|
80
|
+
"services": sorted(
|
|
81
|
+
(s for s in services if services[s]["host"] == h), key=_svc_key
|
|
82
|
+
)}
|
|
83
|
+
for h, info in sorted(hosts.items())
|
|
84
|
+
],
|
|
85
|
+
"service_count": len(services),
|
|
86
|
+
"technologies": [{"url": u, **info} for u, info in sorted(tech.items())],
|
|
87
|
+
"updated_at": self._data.get("updated_at", ""),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def counts(self) -> dict:
|
|
91
|
+
with self._lock:
|
|
92
|
+
return {
|
|
93
|
+
"hosts": len(self._data.get("hosts", {})),
|
|
94
|
+
"services": len(self._data.get("services", {})),
|
|
95
|
+
"urls_with_tech": len(self._data.get("technologies", {})),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _svc_key(key: str) -> tuple:
|
|
100
|
+
try:
|
|
101
|
+
port = int(key.rsplit(":", 1)[1].split("/")[0])
|
|
102
|
+
except (ValueError, IndexError):
|
|
103
|
+
port = 0
|
|
104
|
+
return (port, key)
|
pentest_agent/audit.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Append-only audit trail. Every tool call is recorded before it runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuditLog:
|
|
12
|
+
def __init__(self, path: str | Path | None = None):
|
|
13
|
+
self.path = Path(path) if path else Path.home() / ".pentest-mcp" / "audit.jsonl"
|
|
14
|
+
self._lock = threading.Lock()
|
|
15
|
+
|
|
16
|
+
def record(
|
|
17
|
+
self,
|
|
18
|
+
action: str,
|
|
19
|
+
target: str = "",
|
|
20
|
+
params: dict | None = None,
|
|
21
|
+
outcome: str = "started",
|
|
22
|
+
detail: str = "",
|
|
23
|
+
) -> None:
|
|
24
|
+
entry = {
|
|
25
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
26
|
+
"action": action,
|
|
27
|
+
"target": target,
|
|
28
|
+
"params": _redact(params or {}),
|
|
29
|
+
"outcome": outcome,
|
|
30
|
+
"detail": detail[:2000],
|
|
31
|
+
}
|
|
32
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
line = json.dumps(entry, ensure_ascii=False)
|
|
34
|
+
with self._lock:
|
|
35
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
36
|
+
fh.write(line + "\n")
|
|
37
|
+
|
|
38
|
+
def tail(self, n: int = 50) -> list[dict]:
|
|
39
|
+
if not self.path.is_file():
|
|
40
|
+
return []
|
|
41
|
+
with self._lock:
|
|
42
|
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
|
43
|
+
out = []
|
|
44
|
+
for line in lines[-n:]:
|
|
45
|
+
try:
|
|
46
|
+
out.append(json.loads(line))
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
continue
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_SECRET_KEYS = {"password", "passwd", "secret", "token", "authorization", "api_key", "apikey"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _redact(params: dict) -> dict:
|
|
56
|
+
clean = {}
|
|
57
|
+
for key, value in params.items():
|
|
58
|
+
if key.lower() in _SECRET_KEYS:
|
|
59
|
+
clean[key] = "[redacted]"
|
|
60
|
+
else:
|
|
61
|
+
clean[key] = value
|
|
62
|
+
return clean
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"engagement_id": "ENG-2026-001",
|
|
3
|
+
"client": "PT Contoh Sejahtera",
|
|
4
|
+
"operator": "Nama Penguji",
|
|
5
|
+
"authorization": {
|
|
6
|
+
"statement": "Written authorisation obtained from CISO via RoA/2026/001 on 2026-01-05, covering external web application testing only.",
|
|
7
|
+
"reference": "RoA/2026/001",
|
|
8
|
+
"valid_from": "2026-01-05",
|
|
9
|
+
"valid_until": "2026-01-20"
|
|
10
|
+
},
|
|
11
|
+
"scope": {
|
|
12
|
+
"domains": ["example.com", "*.example.com"],
|
|
13
|
+
"cidrs": ["203.0.113.0/28"],
|
|
14
|
+
"ips": ["203.0.113.10"],
|
|
15
|
+
"urls": ["https://app.example.com"],
|
|
16
|
+
"excluded": ["pay.example.com", "203.0.113.13"]
|
|
17
|
+
},
|
|
18
|
+
"rules": {
|
|
19
|
+
"active_scanning": true,
|
|
20
|
+
"exploit_authorized": false,
|
|
21
|
+
"allow_payload_execution": false,
|
|
22
|
+
"credential_testing": false,
|
|
23
|
+
"rate_limit_rps": 2.0,
|
|
24
|
+
"no_dos": true,
|
|
25
|
+
"window": {
|
|
26
|
+
"start": "09:00",
|
|
27
|
+
"end": "18:00",
|
|
28
|
+
"timezone": "Asia/Jakarta"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"evidence_dir": "./evidence/ENG-2026-001"
|
|
32
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Engagement scope + authorization gate.
|
|
2
|
+
|
|
3
|
+
Every network-touching tool in this package refuses to act on a target that is
|
|
4
|
+
not inside the loaded engagement scope. The engagement file is written and
|
|
5
|
+
signed off by the client before testing starts; it is the single source of
|
|
6
|
+
truth for what may be tested, how hard, and when.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ipaddress
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import date, datetime, time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
from zoneinfo import ZoneInfo
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ScopeError(Exception):
|
|
21
|
+
"""Raised when an action would fall outside the engagement scope."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Authorization:
|
|
26
|
+
statement: str
|
|
27
|
+
reference: str = ""
|
|
28
|
+
valid_from: str = ""
|
|
29
|
+
valid_until: str = ""
|
|
30
|
+
|
|
31
|
+
def is_valid(self, today: date | None = None) -> bool:
|
|
32
|
+
today = today or date.today()
|
|
33
|
+
try:
|
|
34
|
+
if self.valid_from and today < date.fromisoformat(self.valid_from):
|
|
35
|
+
return False
|
|
36
|
+
if self.valid_until and today > date.fromisoformat(self.valid_until):
|
|
37
|
+
return False
|
|
38
|
+
except ValueError:
|
|
39
|
+
return False
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Window:
|
|
45
|
+
start: str = ""
|
|
46
|
+
end: str = ""
|
|
47
|
+
timezone: str = "UTC"
|
|
48
|
+
|
|
49
|
+
def allows(self, now: datetime | None = None) -> bool:
|
|
50
|
+
if not (self.start and self.end):
|
|
51
|
+
return True
|
|
52
|
+
try:
|
|
53
|
+
tz = ZoneInfo(self.timezone)
|
|
54
|
+
start = time.fromisoformat(self.start)
|
|
55
|
+
end = time.fromisoformat(self.end)
|
|
56
|
+
except (ValueError, KeyError):
|
|
57
|
+
return True
|
|
58
|
+
now = now or datetime.now(tz)
|
|
59
|
+
current = now.astimezone(tz).time()
|
|
60
|
+
if start <= end:
|
|
61
|
+
return start <= current <= end
|
|
62
|
+
return current >= start or current <= end
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Rules:
|
|
67
|
+
active_scanning: bool = False
|
|
68
|
+
exploit_authorized: bool = False
|
|
69
|
+
allow_payload_execution: bool = False
|
|
70
|
+
credential_testing: bool = False
|
|
71
|
+
rate_limit_rps: float = 2.0
|
|
72
|
+
no_dos: bool = True
|
|
73
|
+
window: Window = field(default_factory=Window)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Engagement:
|
|
78
|
+
engagement_id: str
|
|
79
|
+
client: str
|
|
80
|
+
operator: str
|
|
81
|
+
authorization: Authorization
|
|
82
|
+
domains: tuple[str, ...] = ()
|
|
83
|
+
cidrs: tuple[str, ...] = ()
|
|
84
|
+
ips: tuple[str, ...] = ()
|
|
85
|
+
urls: tuple[str, ...] = ()
|
|
86
|
+
excluded: tuple[str, ...] = ()
|
|
87
|
+
rules: Rules = field(default_factory=Rules)
|
|
88
|
+
evidence_dir: str = ""
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- load
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_file(cls, path: str | Path) -> "Engagement":
|
|
93
|
+
p = Path(path).expanduser().resolve()
|
|
94
|
+
if not p.is_file():
|
|
95
|
+
raise ScopeError(f"engagement file not found: {p}")
|
|
96
|
+
try:
|
|
97
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
98
|
+
except json.JSONDecodeError as exc:
|
|
99
|
+
raise ScopeError(f"engagement file is not valid JSON: {exc}") from exc
|
|
100
|
+
|
|
101
|
+
auth_raw = raw.get("authorization") or {}
|
|
102
|
+
auth = Authorization(
|
|
103
|
+
statement=str(auth_raw.get("statement", "")).strip(),
|
|
104
|
+
reference=str(auth_raw.get("reference", "")),
|
|
105
|
+
valid_from=str(auth_raw.get("valid_from", "")),
|
|
106
|
+
valid_until=str(auth_raw.get("valid_until", "")),
|
|
107
|
+
)
|
|
108
|
+
if not auth.statement:
|
|
109
|
+
raise ScopeError(
|
|
110
|
+
"authorization.statement is required: record who authorized this test and when"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
rules_raw = raw.get("rules") or {}
|
|
114
|
+
window_raw = rules_raw.get("window") or {}
|
|
115
|
+
rules = Rules(
|
|
116
|
+
active_scanning=bool(rules_raw.get("active_scanning", False)),
|
|
117
|
+
exploit_authorized=bool(rules_raw.get("exploit_authorized", False)),
|
|
118
|
+
allow_payload_execution=bool(rules_raw.get("allow_payload_execution", False)),
|
|
119
|
+
credential_testing=bool(rules_raw.get("credential_testing", False)),
|
|
120
|
+
rate_limit_rps=float(rules_raw.get("rate_limit_rps", 2.0)),
|
|
121
|
+
no_dos=bool(rules_raw.get("no_dos", True)),
|
|
122
|
+
window=Window(
|
|
123
|
+
start=str(window_raw.get("start", "")),
|
|
124
|
+
end=str(window_raw.get("end", "")),
|
|
125
|
+
timezone=str(window_raw.get("timezone", "UTC")),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
scope_raw = raw.get("scope") or {}
|
|
130
|
+
eng = cls(
|
|
131
|
+
engagement_id=str(raw.get("engagement_id", "")).strip() or "ENG-UNSET",
|
|
132
|
+
client=str(raw.get("client", "")).strip(),
|
|
133
|
+
operator=str(raw.get("operator", "")).strip(),
|
|
134
|
+
authorization=auth,
|
|
135
|
+
domains=_tup(scope_raw.get("domains")),
|
|
136
|
+
cidrs=_tup(scope_raw.get("cidrs")),
|
|
137
|
+
ips=_tup(scope_raw.get("ips")),
|
|
138
|
+
urls=_tup(scope_raw.get("urls")),
|
|
139
|
+
excluded=_tup(scope_raw.get("excluded")),
|
|
140
|
+
rules=rules,
|
|
141
|
+
evidence_dir=str(raw.get("evidence_dir", "")),
|
|
142
|
+
)
|
|
143
|
+
if not (eng.domains or eng.cidrs or eng.ips or eng.urls):
|
|
144
|
+
raise ScopeError("scope is empty: nothing is authorised for testing")
|
|
145
|
+
return eng
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------- scope
|
|
148
|
+
def _norm_host(self, host: str) -> str:
|
|
149
|
+
return host.strip().lower().rstrip(".")
|
|
150
|
+
|
|
151
|
+
def _host_in(self, host: str, patterns: tuple[str, ...]) -> bool:
|
|
152
|
+
host = self._norm_host(host)
|
|
153
|
+
for pat in patterns:
|
|
154
|
+
pat = self._norm_host(pat)
|
|
155
|
+
if pat.startswith("*."):
|
|
156
|
+
suffix = pat[1:] # ".example.com"
|
|
157
|
+
if host.endswith(suffix) and len(host) > len(suffix):
|
|
158
|
+
return True
|
|
159
|
+
elif host == pat or host.endswith("." + pat):
|
|
160
|
+
return True
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def _ip_in(self, ip: str, patterns: tuple[str, ...]) -> bool:
|
|
164
|
+
try:
|
|
165
|
+
addr = ipaddress.ip_address(ip)
|
|
166
|
+
except ValueError:
|
|
167
|
+
return False
|
|
168
|
+
for pat in patterns:
|
|
169
|
+
try:
|
|
170
|
+
if addr in ipaddress.ip_network(pat, strict=False):
|
|
171
|
+
return True
|
|
172
|
+
except ValueError:
|
|
173
|
+
continue
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
def _parse_target(self, target: str) -> tuple[str, str]:
|
|
177
|
+
"""Return (kind, value) where kind in {host, ip} and value is the host/IP."""
|
|
178
|
+
t = target.strip()
|
|
179
|
+
if "://" in t:
|
|
180
|
+
parsed = urlparse(t)
|
|
181
|
+
host = parsed.hostname or ""
|
|
182
|
+
return ("host" if host else "ip", self._norm_host(host))
|
|
183
|
+
if "/" in t and not t.startswith("/"):
|
|
184
|
+
# CIDR input: treat as network, checked separately.
|
|
185
|
+
return ("cidr", t.strip())
|
|
186
|
+
host = t.split("/")[0].split("@")[-1]
|
|
187
|
+
host = host.split(":")[0].strip()
|
|
188
|
+
if not host:
|
|
189
|
+
raise ScopeError(f"cannot parse target: {target!r}")
|
|
190
|
+
return ("host", self._norm_host(host))
|
|
191
|
+
|
|
192
|
+
def is_in_scope(self, target: str) -> tuple[bool, str]:
|
|
193
|
+
"""Check a target against the scope. Returns (allowed, reason)."""
|
|
194
|
+
kind, value = self._parse_target(target)
|
|
195
|
+
|
|
196
|
+
if self._host_in(value, self.excluded) or self._ip_in(value, self.excluded):
|
|
197
|
+
return False, f"{value!r} is explicitly excluded from scope"
|
|
198
|
+
if kind == "cidr":
|
|
199
|
+
try:
|
|
200
|
+
net = ipaddress.ip_network(value, strict=False)
|
|
201
|
+
except ValueError:
|
|
202
|
+
return False, f"invalid CIDR: {value!r}"
|
|
203
|
+
for pat in self.cidrs:
|
|
204
|
+
other = ipaddress.ip_network(pat, strict=False)
|
|
205
|
+
if net.subnet_of(other):
|
|
206
|
+
return True, f"CIDR {value} inside scope network {pat}"
|
|
207
|
+
return False, f"CIDR {value!r} is not inside a scoped network"
|
|
208
|
+
|
|
209
|
+
if kind == "ip" or _looks_like_ip(value):
|
|
210
|
+
if self._ip_in(value, self.cidrs) or self._ip_in(value, self.ips):
|
|
211
|
+
return True, f"IP {value} is in scope"
|
|
212
|
+
return False, f"IP {value!r} is not in scope (scope.ips / scope.cidrs)"
|
|
213
|
+
|
|
214
|
+
if self._host_in(value, self.domains):
|
|
215
|
+
return True, f"host {value} matches scoped domain"
|
|
216
|
+
for u in self.urls:
|
|
217
|
+
parsed = urlparse(u if "://" in u else "https://" + u)
|
|
218
|
+
if self._norm_host(parsed.hostname or "") == value:
|
|
219
|
+
return True, f"host {value} matches scoped URL {u}"
|
|
220
|
+
return False, f"host {value!r} is not in scope (scope.domains / scope.urls)"
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------- gate
|
|
223
|
+
def assert_target(self, target: str) -> str:
|
|
224
|
+
"""Raise ScopeError unless the target is authorised. Returns the target."""
|
|
225
|
+
if not self.authorization.is_valid():
|
|
226
|
+
raise ScopeError(
|
|
227
|
+
"engagement authorization is outside its validity window "
|
|
228
|
+
f"({self.authorization.valid_from} .. {self.authorization.valid_until})"
|
|
229
|
+
)
|
|
230
|
+
if not self.rules.window.allows():
|
|
231
|
+
raise ScopeError(
|
|
232
|
+
"outside the agreed testing window "
|
|
233
|
+
f"{self.rules.window.start}-{self.rules.window.end} {self.rules.window.timezone}"
|
|
234
|
+
)
|
|
235
|
+
ok, reason = self.is_in_scope(target)
|
|
236
|
+
if not ok:
|
|
237
|
+
raise ScopeError(f"refused: {reason}")
|
|
238
|
+
return target.strip()
|
|
239
|
+
|
|
240
|
+
def assert_active(self) -> None:
|
|
241
|
+
if not self.rules.active_scanning:
|
|
242
|
+
raise ScopeError(
|
|
243
|
+
"active scanning is not authorised in this engagement "
|
|
244
|
+
"(rules.active_scanning = false); passive/recon-only mode"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def assert_exploit(self, payload: bool = False) -> None:
|
|
248
|
+
if not self.rules.exploit_authorized:
|
|
249
|
+
raise ScopeError(
|
|
250
|
+
"exploitation is not authorised in this engagement "
|
|
251
|
+
"(rules.exploit_authorized = false)"
|
|
252
|
+
)
|
|
253
|
+
if payload and not self.rules.allow_payload_execution:
|
|
254
|
+
raise ScopeError(
|
|
255
|
+
"payload execution is not authorised (rules.allow_payload_execution = false); "
|
|
256
|
+
"verification/check mode only"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def assert_credentials(self) -> None:
|
|
260
|
+
if not self.rules.credential_testing:
|
|
261
|
+
raise ScopeError(
|
|
262
|
+
"credential testing is not authorised in this engagement "
|
|
263
|
+
"(rules.credential_testing = false)"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def summary(self) -> dict:
|
|
267
|
+
return {
|
|
268
|
+
"engagement_id": self.engagement_id,
|
|
269
|
+
"client": self.client,
|
|
270
|
+
"operator": self.operator,
|
|
271
|
+
"authorization": {
|
|
272
|
+
"reference": self.authorization.reference,
|
|
273
|
+
"valid_from": self.authorization.valid_from,
|
|
274
|
+
"valid_until": self.authorization.valid_until,
|
|
275
|
+
"valid_today": self.authorization.is_valid(),
|
|
276
|
+
},
|
|
277
|
+
"scope": {
|
|
278
|
+
"domains": list(self.domains),
|
|
279
|
+
"cidrs": list(self.cidrs),
|
|
280
|
+
"ips": list(self.ips),
|
|
281
|
+
"urls": list(self.urls),
|
|
282
|
+
"excluded": list(self.excluded),
|
|
283
|
+
},
|
|
284
|
+
"rules": {
|
|
285
|
+
"active_scanning": self.rules.active_scanning,
|
|
286
|
+
"exploit_authorized": self.rules.exploit_authorized,
|
|
287
|
+
"allow_payload_execution": self.rules.allow_payload_execution,
|
|
288
|
+
"credential_testing": self.rules.credential_testing,
|
|
289
|
+
"rate_limit_rps": self.rules.rate_limit_rps,
|
|
290
|
+
"no_dos": self.rules.no_dos,
|
|
291
|
+
"window": {
|
|
292
|
+
"start": self.rules.window.start,
|
|
293
|
+
"end": self.rules.window.end,
|
|
294
|
+
"timezone": self.rules.window.timezone,
|
|
295
|
+
"now_allowed": self.rules.window.allows(),
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
"evidence_dir": self.evidence_dir,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
def evidence_path(self) -> Path:
|
|
302
|
+
base = Path(self.evidence_dir).expanduser() if self.evidence_dir else Path("evidence")
|
|
303
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
return base
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _tup(value) -> tuple[str, ...]:
|
|
308
|
+
if not value:
|
|
309
|
+
return ()
|
|
310
|
+
if isinstance(value, str):
|
|
311
|
+
return (value,)
|
|
312
|
+
return tuple(str(v) for v in value)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _looks_like_ip(value: str) -> bool:
|
|
316
|
+
try:
|
|
317
|
+
ipaddress.ip_address(value)
|
|
318
|
+
return True
|
|
319
|
+
except ValueError:
|
|
320
|
+
return False
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Finding model + JSONL store. Findings are the plugin's durable output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SEVERITIES = ("info", "low", "medium", "high", "critical")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Finding:
|
|
17
|
+
title: str
|
|
18
|
+
severity: str
|
|
19
|
+
target: str
|
|
20
|
+
description: str = ""
|
|
21
|
+
evidence: str = ""
|
|
22
|
+
recommendation: str = ""
|
|
23
|
+
cvss: float | None = None
|
|
24
|
+
cve: str = ""
|
|
25
|
+
cwe: str = ""
|
|
26
|
+
tool: str = "manual"
|
|
27
|
+
status: str = "open" # open | verified | false-positive
|
|
28
|
+
id: str = field(default_factory=lambda: f"F-{uuid.uuid4().hex[:8]}")
|
|
29
|
+
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds"))
|
|
30
|
+
|
|
31
|
+
def __post_init__(self):
|
|
32
|
+
if self.severity not in SEVERITIES:
|
|
33
|
+
raise ValueError(f"severity must be one of {SEVERITIES}, got {self.severity!r}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FindingStore:
|
|
37
|
+
def __init__(self, path: str | Path | None = None):
|
|
38
|
+
self.path = Path(path) if path else Path("evidence") / "findings.jsonl"
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
def add(self, finding: Finding) -> Finding:
|
|
42
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
with self._lock:
|
|
44
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
45
|
+
fh.write(json.dumps(asdict(finding), ensure_ascii=False) + "\n")
|
|
46
|
+
return finding
|
|
47
|
+
|
|
48
|
+
def load(self) -> list[Finding]:
|
|
49
|
+
if not self.path.is_file():
|
|
50
|
+
return []
|
|
51
|
+
out: list[Finding] = []
|
|
52
|
+
with self._lock:
|
|
53
|
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
|
54
|
+
for line in lines:
|
|
55
|
+
if not line.strip():
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
out.append(Finding(**json.loads(line)))
|
|
59
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
60
|
+
continue
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
def list(self, severity: str | None = None, status: str | None = None) -> list[Finding]:
|
|
64
|
+
items = self.load()
|
|
65
|
+
if severity:
|
|
66
|
+
items = [f for f in items if f.severity == severity]
|
|
67
|
+
if status:
|
|
68
|
+
items = [f for f in items if f.status == status]
|
|
69
|
+
return items
|
|
70
|
+
|
|
71
|
+
def counts(self) -> dict[str, int]:
|
|
72
|
+
counts = {s: 0 for s in SEVERITIES}
|
|
73
|
+
for f in self.load():
|
|
74
|
+
counts[f.severity] += 1
|
|
75
|
+
return counts
|