@intentsolutionsio/penetration-tester 2.0.0 → 3.0.4
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.
- package/.claude-plugin/plugin.json +8 -3
- package/README.md +8 -0
- package/commands/pentest.md +5 -0
- package/package.json +8 -3
- package/skills/analyzing-tls-config/SKILL.md +221 -0
- package/skills/analyzing-tls-config/references/AUTHORIZATION.md +133 -0
- package/skills/analyzing-tls-config/references/PLAYBOOK.md +267 -0
- package/skills/analyzing-tls-config/references/THEORY.md +128 -0
- package/skills/analyzing-tls-config/scripts/analyze_tls.py +415 -0
- package/skills/auditing-cors-policy/SKILL.md +186 -0
- package/skills/auditing-cors-policy/references/PLAYBOOK.md +220 -0
- package/skills/auditing-cors-policy/references/THEORY.md +142 -0
- package/skills/auditing-cors-policy/scripts/audit_cors.py +350 -0
- package/skills/auditing-npm-dependencies/SKILL.md +254 -0
- package/skills/auditing-npm-dependencies/references/PLAYBOOK.md +175 -0
- package/skills/auditing-npm-dependencies/references/THEORY.md +122 -0
- package/skills/auditing-npm-dependencies/scripts/audit_npm.py +408 -0
- package/skills/auditing-python-dependencies/SKILL.md +251 -0
- package/skills/auditing-python-dependencies/references/PLAYBOOK.md +193 -0
- package/skills/auditing-python-dependencies/references/THEORY.md +122 -0
- package/skills/auditing-python-dependencies/scripts/audit_python.py +459 -0
- package/skills/checking-http-security-headers/SKILL.md +176 -0
- package/skills/checking-http-security-headers/references/PLAYBOOK.md +212 -0
- package/skills/checking-http-security-headers/references/THEORY.md +137 -0
- package/skills/checking-http-security-headers/scripts/check_headers.py +362 -0
- package/skills/checking-license-compliance/SKILL.md +225 -0
- package/skills/checking-license-compliance/references/PLAYBOOK.md +161 -0
- package/skills/checking-license-compliance/references/THEORY.md +152 -0
- package/skills/checking-license-compliance/scripts/check_licenses.py +461 -0
- package/skills/composing-vulnerability-report/SKILL.md +212 -0
- package/skills/composing-vulnerability-report/references/PLAYBOOK.md +180 -0
- package/skills/composing-vulnerability-report/references/THEORY.md +178 -0
- package/skills/composing-vulnerability-report/scripts/compose_report.py +396 -0
- package/skills/confirming-pentest-authorization/SKILL.md +247 -0
- package/skills/confirming-pentest-authorization/references/PLAYBOOK.md +189 -0
- package/skills/confirming-pentest-authorization/references/THEORY.md +167 -0
- package/skills/confirming-pentest-authorization/scripts/check_authorization.py +457 -0
- package/skills/defining-pentest-scope/SKILL.md +227 -0
- package/skills/defining-pentest-scope/references/PLAYBOOK.md +238 -0
- package/skills/defining-pentest-scope/references/THEORY.md +170 -0
- package/skills/defining-pentest-scope/scripts/define_scope.py +472 -0
- package/skills/detecting-command-injection-patterns/SKILL.md +144 -0
- package/skills/detecting-command-injection-patterns/references/PLAYBOOK.md +302 -0
- package/skills/detecting-command-injection-patterns/references/THEORY.md +206 -0
- package/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py +290 -0
- package/skills/detecting-debug-endpoints/SKILL.md +207 -0
- package/skills/detecting-debug-endpoints/references/PLAYBOOK.md +402 -0
- package/skills/detecting-debug-endpoints/references/THEORY.md +218 -0
- package/skills/detecting-debug-endpoints/scripts/probe_debug.py +518 -0
- package/skills/detecting-directory-listing/SKILL.md +206 -0
- package/skills/detecting-directory-listing/references/PLAYBOOK.md +277 -0
- package/skills/detecting-directory-listing/references/THEORY.md +203 -0
- package/skills/detecting-directory-listing/scripts/probe_directory_listing.py +180 -0
- package/skills/detecting-eval-exec-usage/SKILL.md +128 -0
- package/skills/detecting-eval-exec-usage/references/PLAYBOOK.md +306 -0
- package/skills/detecting-eval-exec-usage/references/THEORY.md +159 -0
- package/skills/detecting-eval-exec-usage/scripts/scan_eval.py +223 -0
- package/skills/detecting-exposed-secrets-files/SKILL.md +179 -0
- package/skills/detecting-exposed-secrets-files/references/PLAYBOOK.md +274 -0
- package/skills/detecting-exposed-secrets-files/references/THEORY.md +174 -0
- package/skills/detecting-exposed-secrets-files/scripts/probe_secrets.py +207 -0
- package/skills/detecting-insecure-deserialization/SKILL.md +148 -0
- package/skills/detecting-insecure-deserialization/references/PLAYBOOK.md +333 -0
- package/skills/detecting-insecure-deserialization/references/THEORY.md +199 -0
- package/skills/detecting-insecure-deserialization/scripts/scan_deserialization.py +250 -0
- package/skills/detecting-sql-injection-patterns/SKILL.md +161 -0
- package/skills/detecting-sql-injection-patterns/references/PLAYBOOK.md +317 -0
- package/skills/detecting-sql-injection-patterns/references/THEORY.md +261 -0
- package/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py +354 -0
- package/skills/detecting-ssl-cert-issues/SKILL.md +182 -0
- package/skills/detecting-ssl-cert-issues/references/PLAYBOOK.md +203 -0
- package/skills/detecting-ssl-cert-issues/references/THEORY.md +133 -0
- package/skills/detecting-ssl-cert-issues/scripts/check_cert_chain.py +481 -0
- package/skills/detecting-weak-cryptography/SKILL.md +147 -0
- package/skills/detecting-weak-cryptography/references/PLAYBOOK.md +466 -0
- package/skills/detecting-weak-cryptography/references/THEORY.md +194 -0
- package/skills/detecting-weak-cryptography/scripts/scan_weak_crypto.py +417 -0
- package/skills/fingerprinting-server-software/SKILL.md +191 -0
- package/skills/fingerprinting-server-software/references/PLAYBOOK.md +337 -0
- package/skills/fingerprinting-server-software/references/THEORY.md +183 -0
- package/skills/fingerprinting-server-software/scripts/fingerprint_server.py +347 -0
- package/skills/generating-executive-summary/SKILL.md +261 -0
- package/skills/generating-executive-summary/references/PLAYBOOK.md +201 -0
- package/skills/generating-executive-summary/references/THEORY.md +195 -0
- package/skills/generating-executive-summary/scripts/exec_summary.py +538 -0
- package/skills/mapping-findings-to-owasp-top10/SKILL.md +235 -0
- package/skills/mapping-findings-to-owasp-top10/references/PLAYBOOK.md +193 -0
- package/skills/mapping-findings-to-owasp-top10/references/THEORY.md +160 -0
- package/skills/mapping-findings-to-owasp-top10/scripts/map_owasp.py +540 -0
- package/skills/performing-penetration-testing/SKILL.md +282 -190
- package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +22 -0
- package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +46 -0
- package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +41 -0
- package/skills/performing-penetration-testing/scripts/code_security_scanner.py +144 -79
- package/skills/performing-penetration-testing/scripts/dependency_auditor.py +116 -93
- package/skills/performing-penetration-testing/scripts/security_scanner.py +574 -446
- package/skills/probing-dangerous-http-methods/SKILL.md +182 -0
- package/skills/probing-dangerous-http-methods/references/PLAYBOOK.md +234 -0
- package/skills/probing-dangerous-http-methods/references/THEORY.md +145 -0
- package/skills/probing-dangerous-http-methods/scripts/probe_methods.py +263 -0
- package/skills/recording-pentest-engagement/SKILL.md +253 -0
- package/skills/recording-pentest-engagement/references/PLAYBOOK.md +203 -0
- package/skills/recording-pentest-engagement/references/THEORY.md +195 -0
- package/skills/recording-pentest-engagement/scripts/record_engagement.py +461 -0
- package/skills/scanning-for-hardcoded-secrets/SKILL.md +215 -0
- package/skills/scanning-for-hardcoded-secrets/references/PLAYBOOK.md +325 -0
- package/skills/scanning-for-hardcoded-secrets/references/THEORY.md +175 -0
- package/skills/scanning-for-hardcoded-secrets/scripts/scan_secrets.py +395 -0
- package/skills/tracing-transitive-vulnerabilities/SKILL.md +235 -0
- package/skills/tracing-transitive-vulnerabilities/references/PLAYBOOK.md +233 -0
- package/skills/tracing-transitive-vulnerabilities/references/THEORY.md +138 -0
- package/skills/tracing-transitive-vulnerabilities/scripts/trace_vulns.py +484 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""confirming-pentest-authorization — verify ROE before any scan runs.
|
|
3
|
+
|
|
4
|
+
Reads a Rules-of-Engagement YAML attestation, validates required fields
|
|
5
|
+
(authorizer, in_scope_targets, time_window, emergency_contact, signature_block),
|
|
6
|
+
checks signer identity against an allowed-authorizers file (optional), and
|
|
7
|
+
verifies the current time falls within the engagement window. Emits Findings
|
|
8
|
+
via lib/finding.py. The orchestrator routes here FIRST — any CRITICAL finding
|
|
9
|
+
halts engagement before later cluster skills get a chance to run.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python3 check_authorization.py [--roe FILE] [--allowed FILE]
|
|
13
|
+
[--check-target HOST]
|
|
14
|
+
[--output FILE] [--format json|jsonl|markdown]
|
|
15
|
+
[--min-severity sev]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import datetime as dt
|
|
22
|
+
import ipaddress
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
# --- lib/ import -------------------------------------------------------------
|
|
28
|
+
_LIB_ROOT = Path(__file__).resolve().parents[3]
|
|
29
|
+
sys.path.insert(0, str(_LIB_ROOT))
|
|
30
|
+
|
|
31
|
+
from lib.finding import Finding, Severity # noqa: E402
|
|
32
|
+
from lib import report # noqa: E402
|
|
33
|
+
|
|
34
|
+
# --- Optional YAML import (fallback to a minimal parser if PyYAML absent) ---
|
|
35
|
+
try:
|
|
36
|
+
import yaml # type: ignore[import-not-found]
|
|
37
|
+
|
|
38
|
+
_HAS_PYYAML = True
|
|
39
|
+
except ImportError: # pragma: no cover
|
|
40
|
+
yaml = None
|
|
41
|
+
_HAS_PYYAML = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
SKILL_ID = "confirming-pentest-authorization"
|
|
45
|
+
CATEGORY = "engagement-authorization"
|
|
46
|
+
|
|
47
|
+
REQUIRED_FIELDS = (
|
|
48
|
+
"engagement_id",
|
|
49
|
+
"authorizer",
|
|
50
|
+
"in_scope_targets",
|
|
51
|
+
"time_window",
|
|
52
|
+
"emergency_contact",
|
|
53
|
+
"signature_block",
|
|
54
|
+
)
|
|
55
|
+
REQUIRED_AUTHORIZER_FIELDS = ("name", "email", "role")
|
|
56
|
+
REQUIRED_TIME_WINDOW_FIELDS = ("start", "end")
|
|
57
|
+
REQUIRED_SIGNATURE_FIELDS = ("signer", "signed_at", "signature")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- Minimal-YAML fallback parser -------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _minimal_yaml_load(text: str) -> dict[str, Any]:
|
|
64
|
+
"""Very limited YAML parser for environments without PyYAML.
|
|
65
|
+
|
|
66
|
+
Supports: top-level mapping, nested mappings (indentation-sensitive),
|
|
67
|
+
lists of dicts and scalars, ISO date/time strings as strings.
|
|
68
|
+
Refuses any structure it can't parse rather than guess.
|
|
69
|
+
"""
|
|
70
|
+
if _HAS_PYYAML:
|
|
71
|
+
return yaml.safe_load(text) or {}
|
|
72
|
+
|
|
73
|
+
lines = [l for l in text.splitlines() if not l.lstrip().startswith("#")]
|
|
74
|
+
root: dict[str, Any] = {}
|
|
75
|
+
stack: list[tuple[int, Any]] = [(-1, root)]
|
|
76
|
+
|
|
77
|
+
def cur() -> Any:
|
|
78
|
+
return stack[-1][1]
|
|
79
|
+
|
|
80
|
+
for raw in lines:
|
|
81
|
+
if not raw.strip():
|
|
82
|
+
continue
|
|
83
|
+
indent = len(raw) - len(raw.lstrip())
|
|
84
|
+
line = raw.strip()
|
|
85
|
+
while stack and stack[-1][0] >= indent:
|
|
86
|
+
stack.pop()
|
|
87
|
+
|
|
88
|
+
if line.startswith("- "):
|
|
89
|
+
item_text = line[2:].strip()
|
|
90
|
+
container = cur()
|
|
91
|
+
if not isinstance(container, list):
|
|
92
|
+
# parent expected a list
|
|
93
|
+
parent_key, parent_dict = stack[-2] if len(stack) >= 2 else (-1, root)
|
|
94
|
+
# find the key for which we want to make a list — this is best-effort
|
|
95
|
+
container = []
|
|
96
|
+
# re-attach: convert empty value of last set key to a list
|
|
97
|
+
if isinstance(parent_dict, dict):
|
|
98
|
+
for k in list(parent_dict.keys())[::-1]:
|
|
99
|
+
if parent_dict[k] in (None, "", {}):
|
|
100
|
+
parent_dict[k] = container
|
|
101
|
+
break
|
|
102
|
+
if ":" in item_text and not item_text.endswith(":"):
|
|
103
|
+
k, _, v = item_text.partition(":")
|
|
104
|
+
container.append({k.strip(): v.strip()})
|
|
105
|
+
stack.append((indent + 2, container[-1]))
|
|
106
|
+
elif item_text.endswith(":"):
|
|
107
|
+
obj: dict[str, Any] = {}
|
|
108
|
+
container.append(obj)
|
|
109
|
+
stack.append((indent + 2, obj))
|
|
110
|
+
else:
|
|
111
|
+
container.append(item_text)
|
|
112
|
+
elif ":" in line:
|
|
113
|
+
key, _, value = line.partition(":")
|
|
114
|
+
key = key.strip()
|
|
115
|
+
value = value.strip()
|
|
116
|
+
container = cur()
|
|
117
|
+
if not isinstance(container, dict):
|
|
118
|
+
continue
|
|
119
|
+
if value == "":
|
|
120
|
+
container[key] = {}
|
|
121
|
+
stack.append((indent, container[key]))
|
|
122
|
+
elif value == "|":
|
|
123
|
+
# block scalar — collect indented lines (not implemented richly)
|
|
124
|
+
container[key] = ""
|
|
125
|
+
else:
|
|
126
|
+
container[key] = value.strip().strip('"').strip("'")
|
|
127
|
+
return root
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- ROE loading ------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load_roe(path: Path) -> tuple[dict[str, Any] | None, str | None]:
|
|
134
|
+
if not path.exists():
|
|
135
|
+
return None, f"ROE file not found at {path}"
|
|
136
|
+
try:
|
|
137
|
+
text = path.read_text(encoding="utf-8")
|
|
138
|
+
except OSError as e:
|
|
139
|
+
return None, f"Cannot read {path}: {e}"
|
|
140
|
+
try:
|
|
141
|
+
data = _minimal_yaml_load(text) if not _HAS_PYYAML else yaml.safe_load(text)
|
|
142
|
+
except Exception as e: # noqa: BLE001 — YAML errors come in many flavors
|
|
143
|
+
return None, f"YAML parse error: {e}"
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
return None, "ROE top-level structure is not a mapping"
|
|
146
|
+
return data, None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_allowed(path: Path) -> list[str]:
|
|
150
|
+
if not path.exists():
|
|
151
|
+
return []
|
|
152
|
+
return [
|
|
153
|
+
line.strip()
|
|
154
|
+
for line in path.read_text(encoding="utf-8").splitlines()
|
|
155
|
+
if line.strip() and not line.startswith("#")
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- Field validation -------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _info(title: str, target: str, detail: str) -> Finding:
|
|
163
|
+
return Finding(
|
|
164
|
+
skill_id=SKILL_ID,
|
|
165
|
+
title=title,
|
|
166
|
+
severity=Severity.INFO,
|
|
167
|
+
target=target,
|
|
168
|
+
detail=detail,
|
|
169
|
+
remediation="No action required.",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _critical(title: str, target: str, detail: str, remediation: str) -> Finding:
|
|
174
|
+
return Finding(
|
|
175
|
+
skill_id=SKILL_ID,
|
|
176
|
+
title=title,
|
|
177
|
+
severity=Severity.CRITICAL,
|
|
178
|
+
target=target,
|
|
179
|
+
detail=detail,
|
|
180
|
+
remediation=remediation,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _high(title: str, target: str, detail: str, remediation: str) -> Finding:
|
|
185
|
+
return Finding(
|
|
186
|
+
skill_id=SKILL_ID,
|
|
187
|
+
title=title,
|
|
188
|
+
severity=Severity.HIGH,
|
|
189
|
+
target=target,
|
|
190
|
+
detail=detail,
|
|
191
|
+
remediation=remediation,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_iso(s: str) -> dt.datetime | None:
|
|
196
|
+
try:
|
|
197
|
+
# accept "Z" suffix and offset forms
|
|
198
|
+
s2 = s.replace("Z", "+00:00")
|
|
199
|
+
return dt.datetime.fromisoformat(s2)
|
|
200
|
+
except (ValueError, TypeError):
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def validate_roe(data: dict[str, Any], target_label: str, allowed: list[str]) -> list[Finding]:
|
|
205
|
+
findings: list[Finding] = []
|
|
206
|
+
|
|
207
|
+
# Required top-level fields
|
|
208
|
+
for field in REQUIRED_FIELDS:
|
|
209
|
+
if field not in data or data.get(field) in (None, "", [], {}):
|
|
210
|
+
findings.append(
|
|
211
|
+
_critical(
|
|
212
|
+
f"ROE missing required field: {field}",
|
|
213
|
+
target_label,
|
|
214
|
+
f"Field `{field}` is required and was missing or empty.",
|
|
215
|
+
f"Add `{field}` to the ROE and re-sign.",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
authorizer = data.get("authorizer") or {}
|
|
220
|
+
if isinstance(authorizer, dict):
|
|
221
|
+
for sub in REQUIRED_AUTHORIZER_FIELDS:
|
|
222
|
+
if sub not in authorizer or not authorizer.get(sub):
|
|
223
|
+
findings.append(
|
|
224
|
+
_critical(
|
|
225
|
+
f"authorizer.{sub} missing",
|
|
226
|
+
target_label,
|
|
227
|
+
f"The authorizer block is missing `{sub}`.",
|
|
228
|
+
f"Add `authorizer.{sub}` to the ROE and re-sign.",
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
tw = data.get("time_window") or {}
|
|
233
|
+
if isinstance(tw, dict):
|
|
234
|
+
for sub in REQUIRED_TIME_WINDOW_FIELDS:
|
|
235
|
+
if sub not in tw:
|
|
236
|
+
findings.append(
|
|
237
|
+
_critical(
|
|
238
|
+
f"time_window.{sub} missing",
|
|
239
|
+
target_label,
|
|
240
|
+
f"`time_window.{sub}` is required.",
|
|
241
|
+
f"Add `time_window.{sub}` (ISO-8601 timestamp) and re-sign.",
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
start = _parse_iso(str(tw.get("start", "")))
|
|
245
|
+
end = _parse_iso(str(tw.get("end", "")))
|
|
246
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
247
|
+
if start and now < start:
|
|
248
|
+
findings.append(
|
|
249
|
+
_high(
|
|
250
|
+
"engagement time window has not started",
|
|
251
|
+
target_label,
|
|
252
|
+
f"Current time {now.isoformat()} precedes window start {start.isoformat()}.",
|
|
253
|
+
"Wait until the start time, or have the authorizer re-sign with an earlier start.",
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
if end and now > end:
|
|
257
|
+
findings.append(
|
|
258
|
+
_high(
|
|
259
|
+
"engagement time window has expired",
|
|
260
|
+
target_label,
|
|
261
|
+
f"Current time {now.isoformat()} is past window end {end.isoformat()}.",
|
|
262
|
+
"Halt testing immediately. Request a new ROE with an extended end date.",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
sig = data.get("signature_block") or {}
|
|
267
|
+
if isinstance(sig, dict):
|
|
268
|
+
for sub in REQUIRED_SIGNATURE_FIELDS:
|
|
269
|
+
if sub not in sig or not sig.get(sub):
|
|
270
|
+
findings.append(
|
|
271
|
+
_critical(
|
|
272
|
+
f"signature_block.{sub} missing",
|
|
273
|
+
target_label,
|
|
274
|
+
f"The signature_block is missing `{sub}`.",
|
|
275
|
+
"Re-sign the ROE with all signature fields present.",
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
signer = str(sig.get("signer", "")).strip().lower()
|
|
279
|
+
if allowed and signer and signer not in [a.lower() for a in allowed]:
|
|
280
|
+
findings.append(
|
|
281
|
+
_critical(
|
|
282
|
+
"signer not in allowed-authorizers list",
|
|
283
|
+
target_label,
|
|
284
|
+
f"signer `{signer}` is not present in the allowed-authorizers "
|
|
285
|
+
f"file. Engagement cannot be authorized by this signer.",
|
|
286
|
+
"Either add the signer to the allowed list (only do this "
|
|
287
|
+
"for verified authorizers!) or have an allowed signer "
|
|
288
|
+
"re-sign the ROE.",
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
signed_at = _parse_iso(str(sig.get("signed_at", "")))
|
|
293
|
+
if signed_at:
|
|
294
|
+
age_days = (dt.datetime.now(dt.timezone.utc) - signed_at).days
|
|
295
|
+
if age_days > 30:
|
|
296
|
+
findings.append(
|
|
297
|
+
Finding(
|
|
298
|
+
skill_id=SKILL_ID,
|
|
299
|
+
title="ROE is stale (>30 days since signature)",
|
|
300
|
+
severity=Severity.MEDIUM,
|
|
301
|
+
target=target_label,
|
|
302
|
+
detail=f"ROE was signed {age_days} days ago.",
|
|
303
|
+
remediation="Request a refreshed ROE signature.",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
in_scope = data.get("in_scope_targets") or []
|
|
308
|
+
if isinstance(in_scope, list) and len(in_scope) == 0:
|
|
309
|
+
findings.append(
|
|
310
|
+
_high(
|
|
311
|
+
"in_scope_targets list is empty",
|
|
312
|
+
target_label,
|
|
313
|
+
"in_scope_targets must list at least one target.",
|
|
314
|
+
"Add explicit in-scope targets and re-sign.",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return findings
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- Target-in-scope check --------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _normalize_targets(in_scope: list[dict[str, Any] | str]) -> list[str]:
|
|
325
|
+
norm: list[str] = []
|
|
326
|
+
for t in in_scope:
|
|
327
|
+
if isinstance(t, dict):
|
|
328
|
+
if "host" in t:
|
|
329
|
+
norm.append(str(t["host"]).strip().lower())
|
|
330
|
+
elif "cidr" in t:
|
|
331
|
+
norm.append(str(t["cidr"]).strip())
|
|
332
|
+
elif isinstance(t, str):
|
|
333
|
+
norm.append(t.strip().lower())
|
|
334
|
+
return norm
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def target_in_scope(target: str, in_scope: list[str]) -> bool:
|
|
338
|
+
target_n = target.strip().lower()
|
|
339
|
+
# exact hostname match
|
|
340
|
+
if target_n in in_scope:
|
|
341
|
+
return True
|
|
342
|
+
# CIDR membership
|
|
343
|
+
try:
|
|
344
|
+
target_ip = ipaddress.ip_address(target_n)
|
|
345
|
+
for entry in in_scope:
|
|
346
|
+
try:
|
|
347
|
+
net = ipaddress.ip_network(entry, strict=False)
|
|
348
|
+
if target_ip in net:
|
|
349
|
+
return True
|
|
350
|
+
except (ValueError, TypeError):
|
|
351
|
+
continue
|
|
352
|
+
except ValueError:
|
|
353
|
+
pass
|
|
354
|
+
# Suffix match for subdomain wildcards (e.g. "*.acme.example")
|
|
355
|
+
for entry in in_scope:
|
|
356
|
+
if entry.startswith("*.") and target_n.endswith(entry[2:]):
|
|
357
|
+
return True
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def check_targets(targets: list[str], in_scope_raw: list[dict[str, Any] | str], target_label: str) -> list[Finding]:
|
|
362
|
+
in_scope = _normalize_targets(in_scope_raw)
|
|
363
|
+
out: list[Finding] = []
|
|
364
|
+
for t in targets:
|
|
365
|
+
if target_in_scope(t, in_scope):
|
|
366
|
+
out.append(
|
|
367
|
+
_info(
|
|
368
|
+
f"target {t} is in scope",
|
|
369
|
+
target_label,
|
|
370
|
+
f"`{t}` matched an in-scope entry in the ROE.",
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
out.append(
|
|
375
|
+
_critical(
|
|
376
|
+
f"target {t} is NOT in scope",
|
|
377
|
+
target_label,
|
|
378
|
+
f"`{t}` does not match any in-scope entry. Probing this target is not authorized.",
|
|
379
|
+
f"Either confirm the target IS in scope (request an ROE "
|
|
380
|
+
f"amendment) or remove `{t}` from the test plan.",
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
return out
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# --- CLI ---------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
390
|
+
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
391
|
+
p.add_argument("--roe", default="roe.yaml", help="Path to ROE YAML file")
|
|
392
|
+
p.add_argument("--allowed", default=".allowed-authorizers")
|
|
393
|
+
p.add_argument(
|
|
394
|
+
"--check-target",
|
|
395
|
+
action="append",
|
|
396
|
+
default=[],
|
|
397
|
+
help="Verify target is in-scope (repeatable)",
|
|
398
|
+
)
|
|
399
|
+
p.add_argument("--output", default=None)
|
|
400
|
+
p.add_argument("--format", default="markdown", choices=["json", "jsonl", "markdown"])
|
|
401
|
+
p.add_argument(
|
|
402
|
+
"--min-severity",
|
|
403
|
+
default="info",
|
|
404
|
+
choices=["info", "low", "medium", "high", "critical"],
|
|
405
|
+
)
|
|
406
|
+
return p
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _filter_min_severity(findings: list[Finding], min_sev: str) -> list[Finding]:
|
|
410
|
+
floor = Severity(min_sev).numeric
|
|
411
|
+
return [f for f in findings if f.severity.numeric >= floor]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def main(argv: list[str] | None = None) -> int:
|
|
415
|
+
args = _build_arg_parser().parse_args(argv)
|
|
416
|
+
roe_path = Path(args.roe).resolve()
|
|
417
|
+
allowed_path = Path(args.allowed).resolve()
|
|
418
|
+
|
|
419
|
+
data, err = load_roe(roe_path)
|
|
420
|
+
if data is None:
|
|
421
|
+
f = _critical(
|
|
422
|
+
"ROE could not be loaded",
|
|
423
|
+
str(roe_path),
|
|
424
|
+
err or "unknown error",
|
|
425
|
+
f"Create or fix the ROE at {roe_path} and re-run.",
|
|
426
|
+
)
|
|
427
|
+
report.emit([f], args.output, args.format, scan_target=str(roe_path))
|
|
428
|
+
return 1
|
|
429
|
+
|
|
430
|
+
allowed = load_allowed(allowed_path)
|
|
431
|
+
findings = validate_roe(data, str(roe_path), allowed)
|
|
432
|
+
|
|
433
|
+
if args.check_target:
|
|
434
|
+
findings.extend(
|
|
435
|
+
check_targets(
|
|
436
|
+
args.check_target,
|
|
437
|
+
data.get("in_scope_targets") or [],
|
|
438
|
+
str(roe_path),
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if not findings:
|
|
443
|
+
findings = [
|
|
444
|
+
_info(
|
|
445
|
+
"engagement is authorized",
|
|
446
|
+
str(roe_path),
|
|
447
|
+
"All required fields present, signer in allowlist, time window "
|
|
448
|
+
"is active, and all checked targets are in scope.",
|
|
449
|
+
)
|
|
450
|
+
]
|
|
451
|
+
findings = _filter_min_severity(findings, args.min_severity)
|
|
452
|
+
report.emit(findings, args.output, args.format, scan_target=str(roe_path))
|
|
453
|
+
return report.exit_code(findings)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if __name__ == "__main__":
|
|
457
|
+
sys.exit(main())
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: defining-pentest-scope
|
|
3
|
+
description: |
|
|
4
|
+
Parse the ROE scope definition, enumerate every in-scope target
|
|
5
|
+
(hostnames, IPs, CIDRs, URLs, cloud accounts, SaaS tenants),
|
|
6
|
+
validate syntax, detect overlap with out-of-scope or known
|
|
7
|
+
third-party SaaS ranges, and emit a normalized target list plus
|
|
8
|
+
IP allowlist for scanning tools. Runs after confirming-pentest-
|
|
9
|
+
authorization and before any cluster 1-4 scan.
|
|
10
|
+
Use when: starting an engagement, expanding scope mid-engagement,
|
|
11
|
+
validating that a target list matches the ROE, or generating an
|
|
12
|
+
allowlist for an external scanner.
|
|
13
|
+
Threshold: malformed syntax, in-scope overlap with out-of-scope,
|
|
14
|
+
reserved or third-party SaaS ranges without acknowledgement.
|
|
15
|
+
Trigger with: "define scope", "enumerate targets", "validate
|
|
16
|
+
target list", "generate IP allowlist".
|
|
17
|
+
allowed-tools:
|
|
18
|
+
- Read
|
|
19
|
+
- Bash(python3:*)
|
|
20
|
+
- Glob
|
|
21
|
+
disallowed-tools:
|
|
22
|
+
- Bash(rm:*)
|
|
23
|
+
- Bash(curl:*)
|
|
24
|
+
- Bash(wget:*)
|
|
25
|
+
- Bash(nmap:*)
|
|
26
|
+
- Write(.env)
|
|
27
|
+
- Edit(.env)
|
|
28
|
+
version: 3.0.0-dev
|
|
29
|
+
author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
30
|
+
license: MIT
|
|
31
|
+
compatibility: Designed for Claude Code
|
|
32
|
+
tags:
|
|
33
|
+
- security
|
|
34
|
+
- engagement-governance
|
|
35
|
+
- scope
|
|
36
|
+
- target-enumeration
|
|
37
|
+
- pentest
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
# Defining Pentest Scope
|
|
41
|
+
|
|
42
|
+
## Overview
|
|
43
|
+
|
|
44
|
+
A pentest scope is a list of permission boundaries. Get it wrong
|
|
45
|
+
and you either (a) miss real exposure by failing to test something
|
|
46
|
+
the customer expected covered, or (b) probe something you weren't
|
|
47
|
+
allowed to touch and turn the engagement into a liability event.
|
|
48
|
+
Both failure modes share a root cause: the scope list was a vague
|
|
49
|
+
narrative ("test the marketing site and the API") rather than a
|
|
50
|
+
machine-readable, syntactically-validated, conflict-checked
|
|
51
|
+
artifact.
|
|
52
|
+
|
|
53
|
+
This skill takes the in-scope and out-of-scope sections from a ROE
|
|
54
|
+
and produces three deliverables:
|
|
55
|
+
|
|
56
|
+
1. **Normalized target list** — every entry parsed into a
|
|
57
|
+
structured form (host vs CIDR vs URL path vs cloud account vs
|
|
58
|
+
SaaS tenant), with explicit type tagging. Downstream cluster
|
|
59
|
+
1-4 skills consume this list rather than raw strings.
|
|
60
|
+
2. **IP allowlist** — flat list of IPv4 and IPv6 addresses /
|
|
61
|
+
CIDRs ready to paste into scanner configurations (nmap target
|
|
62
|
+
list, Burp scope file, AWS WAF allowlist, etc.).
|
|
63
|
+
3. **Conflict report** — Findings flagging syntactically-malformed
|
|
64
|
+
entries, overlap between in-scope and out-of-scope, inclusion
|
|
65
|
+
of reserved ranges (RFC1918, link-local, multicast), and known
|
|
66
|
+
third-party SaaS infrastructure that needs separate authz.
|
|
67
|
+
|
|
68
|
+
The skill does NOT perform DNS resolution or network probing —
|
|
69
|
+
that would itself be a "first probe" of the target, which by the
|
|
70
|
+
governance model must happen AFTER scope is locked.
|
|
71
|
+
|
|
72
|
+
## When the skill produces findings
|
|
73
|
+
|
|
74
|
+
| Finding | Severity | Threshold | Affected control |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
| Malformed target syntax | **HIGH** | Entry doesn't parse as host / CIDR / URL / account-id | (legal) |
|
|
77
|
+
| In-scope overlaps out-of-scope | **CRITICAL** | An in-scope target falls within an out-of-scope CIDR | (legal) |
|
|
78
|
+
| Reserved range without acknowledgement | **HIGH** | RFC1918, link-local (169.254/16), multicast (224/4), broadcast in in-scope list | (operational) |
|
|
79
|
+
| Known third-party SaaS in scope | **HIGH** | In-scope IP matches a known SaaS range (AWS, Cloudflare, GitHub, etc.) without separate authz | (legal) |
|
|
80
|
+
| Duplicate target | **INFO** | Same target appears multiple times | (operational) |
|
|
81
|
+
| Wildcard subdomain (e.g. `*.acme.example`) | **INFO** | Wildcards expand at scan time | (informational) |
|
|
82
|
+
| All targets validated cleanly | **INFO** | Positive confirmation | (informational) |
|
|
83
|
+
|
|
84
|
+
## Prerequisites
|
|
85
|
+
|
|
86
|
+
- Python 3.9+
|
|
87
|
+
- ROE file at `./roe.yaml` (or pass `--roe FILE`)
|
|
88
|
+
- Optional `.scope-extension.yaml` listing additional targets
|
|
89
|
+
added mid-engagement (each must reference an authz amendment)
|
|
90
|
+
|
|
91
|
+
## Target syntax forms
|
|
92
|
+
|
|
93
|
+
| Form | Example | Notes |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| Hostname | `app.acme.example` | DNS-resolvable name |
|
|
96
|
+
| Wildcard subdomain | `*.acme.example` | Resolved at scan time; flag for explicit acknowledgement |
|
|
97
|
+
| IPv4 address | `203.0.113.10` | Single host |
|
|
98
|
+
| IPv4 CIDR | `203.0.113.0/24` | Network range |
|
|
99
|
+
| IPv6 address | `2001:db8::10` | Single host |
|
|
100
|
+
| IPv6 CIDR | `2001:db8::/32` | Network range |
|
|
101
|
+
| URL with path | `https://app.acme.example/api/v2` | Path-restricted scope |
|
|
102
|
+
| Cloud account ID | `aws:123456789012` or `gcp:acme-prod` | Cloud control-plane scope |
|
|
103
|
+
| SaaS tenant | `okta:acme-corp` or `auth0:acme` | SaaS-tenant scope |
|
|
104
|
+
|
|
105
|
+
## Instructions
|
|
106
|
+
|
|
107
|
+
### Step 1 — Provide the scope source
|
|
108
|
+
|
|
109
|
+
The skill reads the ROE's `in_scope_targets` and
|
|
110
|
+
`out_of_scope_targets` sections by default. Override with
|
|
111
|
+
`--roe FILE` if the engagement ROE isn't at the default path.
|
|
112
|
+
|
|
113
|
+
### Step 2 — Run the scope definition
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python3 ./scripts/define_scope.py --roe engagements/acme-2026-q2/roe.yaml
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Options:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
Usage: define_scope.py [OPTIONS]
|
|
123
|
+
|
|
124
|
+
Options:
|
|
125
|
+
--roe FILE Path to ROE YAML (default: ./roe.yaml)
|
|
126
|
+
--emit-allowlist FILE Write flat IP allowlist to FILE
|
|
127
|
+
--emit-targets FILE Write normalized target list to FILE
|
|
128
|
+
--extension FILE Additional scope extension YAML
|
|
129
|
+
--output FILE Findings output
|
|
130
|
+
--format FMT json | jsonl | markdown (default: markdown)
|
|
131
|
+
--min-severity SEV default info
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Step 3 — Review the conflict report
|
|
135
|
+
|
|
136
|
+
CRITICAL findings (overlap between in-scope and out-of-scope) must
|
|
137
|
+
be resolved before any scan runs. Either narrow the in-scope range
|
|
138
|
+
or remove the out-of-scope overlap; the customer's authorizer
|
|
139
|
+
decides which.
|
|
140
|
+
|
|
141
|
+
HIGH findings (malformed targets, third-party SaaS, reserved
|
|
142
|
+
ranges) require explicit acknowledgement — either fix the entry
|
|
143
|
+
or document in the ROE why the range is intentionally included.
|
|
144
|
+
|
|
145
|
+
### Step 4 — Hand off the allowlist
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
python3 ./scripts/define_scope.py --roe engagements/acme-2026-q2/roe.yaml \
|
|
149
|
+
--emit-allowlist /tmp/allowed-ips.txt \
|
|
150
|
+
--emit-targets /tmp/normalized-targets.json
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The allowlist file is one IP/CIDR per line, ready to paste into:
|
|
154
|
+
|
|
155
|
+
- nmap: `nmap -iL /tmp/allowed-ips.txt`
|
|
156
|
+
- AWS WAF rule: convert to JSON via your standard tooling
|
|
157
|
+
- Burp Suite: paste into Target → Scope → Include
|
|
158
|
+
- This pack's cluster 1 skills: pass via the target argument
|
|
159
|
+
|
|
160
|
+
## Examples
|
|
161
|
+
|
|
162
|
+
### Example 1 — Generate scope artifacts for a new engagement
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
python3 ./scripts/define_scope.py \
|
|
166
|
+
--roe engagements/acme-2026-q2/roe.yaml \
|
|
167
|
+
--emit-allowlist engagements/acme-2026-q2/scope/allowed-ips.txt \
|
|
168
|
+
--emit-targets engagements/acme-2026-q2/scope/normalized-targets.json \
|
|
169
|
+
--output engagements/acme-2026-q2/scope/scope-report.md
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Example 2 — Validate a mid-engagement scope extension
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
python3 ./scripts/define_scope.py \
|
|
176
|
+
--roe engagements/acme-2026-q2/roe.yaml \
|
|
177
|
+
--extension engagements/acme-2026-q2/scope-extension-20260615.yaml
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The extension YAML follows the same target format. The skill
|
|
181
|
+
validates that every extension entry has an associated
|
|
182
|
+
authorization reference and emits a CRITICAL finding for any
|
|
183
|
+
extension entry that doesn't.
|
|
184
|
+
|
|
185
|
+
### Example 3 — Pre-scan validation gate
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
python3 ./scripts/define_scope.py --roe engagements/acme-2026-q2/roe.yaml \
|
|
189
|
+
--min-severity high \
|
|
190
|
+
--format json --output /tmp/scope-issues.json
|
|
191
|
+
jq -e '. == []' /tmp/scope-issues.json || { echo "Scope issues block scan"; exit 1; }
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Output
|
|
195
|
+
|
|
196
|
+
JSON / JSONL / Markdown per `lib/report.py`. Exit codes: 0 clean,
|
|
197
|
+
1 high/critical, 2 error.
|
|
198
|
+
|
|
199
|
+
Each Finding includes:
|
|
200
|
+
|
|
201
|
+
- `id` — `scope::<issue>::<target>` (e.g. `scope::malformed::foo[bar`)
|
|
202
|
+
- `severity` — CRITICAL / HIGH / MEDIUM / INFO
|
|
203
|
+
- `category` — `engagement-scope`
|
|
204
|
+
- `summary` — what's wrong with the entry
|
|
205
|
+
- `evidence` — original entry, parsed form, conflict source, line in ROE
|
|
206
|
+
|
|
207
|
+
## Error Handling
|
|
208
|
+
|
|
209
|
+
- **ROE missing** → emits CRITICAL finding, exits 1.
|
|
210
|
+
- **In-scope section missing or empty** → CRITICAL finding, exits 1.
|
|
211
|
+
- **Unparseable entry** → HIGH finding per entry, scan continues
|
|
212
|
+
for other entries.
|
|
213
|
+
- **Extension file referenced but missing** → HIGH finding.
|
|
214
|
+
- **IPv6 CIDR with very large mask** (e.g. `::/0`) → CRITICAL —
|
|
215
|
+
almost certainly a typo; refuse to expand.
|
|
216
|
+
|
|
217
|
+
## Resources
|
|
218
|
+
|
|
219
|
+
- `references/THEORY.md` — Why scope is the load-bearing artifact
|
|
220
|
+
of pentest legality, target-type taxonomy, known SaaS-range
|
|
221
|
+
classification (AWS, Cloudflare, GCP, Azure), DNS resolution
|
|
222
|
+
policy (when/whether to resolve at scope-definition time),
|
|
223
|
+
CIDR-overlap detection theory
|
|
224
|
+
- `references/PLAYBOOK.md` — Per-engagement-type scope templates
|
|
225
|
+
(web app, internal network, red team, cloud account, SaaS tenant),
|
|
226
|
+
scope-extension protocol, allowlist-emission patterns per
|
|
227
|
+
scanner, common scope-mistake patterns
|