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.
@@ -0,0 +1,3 @@
1
+ """pentest-mcp: authorization-gated penetration testing tools for agentic AI."""
2
+
3
+ __version__ = "0.3.0"
@@ -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