@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,472 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""defining-pentest-scope — parse + validate + normalize a scope definition.
|
|
3
|
+
|
|
4
|
+
Reads a ROE YAML, parses every in-scope and out-of-scope target into a
|
|
5
|
+
structured form (host / cidr / url / cloud-account / saas-tenant), validates
|
|
6
|
+
syntax, detects overlap between in-scope and out-of-scope ranges, flags
|
|
7
|
+
reserved or known third-party SaaS ranges, and emits Findings via
|
|
8
|
+
lib/finding.py. Optionally writes a flat IP allowlist file ready for nmap /
|
|
9
|
+
WAF / Burp consumption.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python3 define_scope.py [--roe FILE] [--extension FILE]
|
|
13
|
+
[--emit-allowlist FILE] [--emit-targets FILE]
|
|
14
|
+
[--output FILE] [--format json|jsonl|markdown]
|
|
15
|
+
[--min-severity sev]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import ipaddress
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
# --- lib/ import -------------------------------------------------------------
|
|
31
|
+
_LIB_ROOT = Path(__file__).resolve().parents[3]
|
|
32
|
+
sys.path.insert(0, str(_LIB_ROOT))
|
|
33
|
+
|
|
34
|
+
from lib.finding import Finding, Severity # noqa: E402
|
|
35
|
+
from lib import report # noqa: E402
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import yaml # type: ignore[import-not-found]
|
|
39
|
+
|
|
40
|
+
_HAS_PYYAML = True
|
|
41
|
+
except ImportError:
|
|
42
|
+
yaml = None
|
|
43
|
+
_HAS_PYYAML = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
SKILL_ID = "defining-pentest-scope"
|
|
47
|
+
CATEGORY = "engagement-scope"
|
|
48
|
+
|
|
49
|
+
# Known third-party SaaS / cloud edge ranges (illustrative, NOT exhaustive).
|
|
50
|
+
# These are not authoritative; real engagements should consult published
|
|
51
|
+
# IP ranges (e.g. AWS ip-ranges.json, Cloudflare cidr lists).
|
|
52
|
+
KNOWN_SAAS_RANGES = {
|
|
53
|
+
"AWS": [
|
|
54
|
+
"3.0.0.0/9",
|
|
55
|
+
"13.32.0.0/15",
|
|
56
|
+
"52.0.0.0/8",
|
|
57
|
+
],
|
|
58
|
+
"Cloudflare": [
|
|
59
|
+
"104.16.0.0/13",
|
|
60
|
+
"172.64.0.0/13",
|
|
61
|
+
"131.0.72.0/22",
|
|
62
|
+
],
|
|
63
|
+
"GitHub": [
|
|
64
|
+
"140.82.112.0/20",
|
|
65
|
+
"143.55.64.0/20",
|
|
66
|
+
],
|
|
67
|
+
"GCP": [
|
|
68
|
+
"34.0.0.0/8",
|
|
69
|
+
"35.184.0.0/13",
|
|
70
|
+
],
|
|
71
|
+
"Azure": [
|
|
72
|
+
"13.64.0.0/11",
|
|
73
|
+
"20.0.0.0/8",
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
RESERVED_RANGES = [
|
|
78
|
+
"10.0.0.0/8",
|
|
79
|
+
"172.16.0.0/12",
|
|
80
|
+
"192.168.0.0/16",
|
|
81
|
+
"169.254.0.0/16",
|
|
82
|
+
"127.0.0.0/8",
|
|
83
|
+
"224.0.0.0/4",
|
|
84
|
+
"::1/128",
|
|
85
|
+
"fe80::/10",
|
|
86
|
+
"fc00::/7",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- YAML loading ------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
94
|
+
if not path.exists():
|
|
95
|
+
return {}
|
|
96
|
+
text = path.read_text(encoding="utf-8")
|
|
97
|
+
if _HAS_PYYAML:
|
|
98
|
+
return yaml.safe_load(text) or {}
|
|
99
|
+
# Minimal fallback — relies on the simple structure of ROE files
|
|
100
|
+
return _minimal_yaml_load(text)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _minimal_yaml_load(text: str) -> dict[str, Any]:
|
|
104
|
+
root: dict[str, Any] = {}
|
|
105
|
+
stack: list[tuple[int, Any]] = [(-1, root)]
|
|
106
|
+
for raw in text.splitlines():
|
|
107
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
108
|
+
continue
|
|
109
|
+
indent = len(raw) - len(raw.lstrip())
|
|
110
|
+
line = raw.strip()
|
|
111
|
+
while stack and stack[-1][0] >= indent:
|
|
112
|
+
stack.pop()
|
|
113
|
+
container = stack[-1][1]
|
|
114
|
+
if line.startswith("- "):
|
|
115
|
+
item = line[2:].strip()
|
|
116
|
+
if isinstance(container, dict):
|
|
117
|
+
# Demote last key to a list
|
|
118
|
+
for k in list(container.keys())[::-1]:
|
|
119
|
+
if container[k] in (None, "", {}):
|
|
120
|
+
container[k] = []
|
|
121
|
+
container = container[k]
|
|
122
|
+
stack.append((indent, container))
|
|
123
|
+
break
|
|
124
|
+
if isinstance(container, list):
|
|
125
|
+
if ":" in item:
|
|
126
|
+
k, _, v = item.partition(":")
|
|
127
|
+
container.append({k.strip(): v.strip().strip('"').strip("'")})
|
|
128
|
+
stack.append((indent + 2, container[-1]))
|
|
129
|
+
else:
|
|
130
|
+
container.append(item.strip('"').strip("'"))
|
|
131
|
+
elif ":" in line:
|
|
132
|
+
key, _, value = line.partition(":")
|
|
133
|
+
key = key.strip()
|
|
134
|
+
value = value.strip().strip('"').strip("'")
|
|
135
|
+
if isinstance(container, dict):
|
|
136
|
+
if value == "":
|
|
137
|
+
container[key] = {}
|
|
138
|
+
stack.append((indent, container[key]))
|
|
139
|
+
else:
|
|
140
|
+
container[key] = value
|
|
141
|
+
return root
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --- Target classification --------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def classify_target(entry: Any) -> tuple[str, str, str | None]:
|
|
148
|
+
"""Return (raw, type, normalized) for a scope entry.
|
|
149
|
+
|
|
150
|
+
type ∈ {"hostname","wildcard","ipv4","ipv6","cidrv4","cidrv6","url","cloud","saas","malformed"}
|
|
151
|
+
"""
|
|
152
|
+
if isinstance(entry, dict):
|
|
153
|
+
if "host" in entry:
|
|
154
|
+
return classify_target(entry["host"])
|
|
155
|
+
if "cidr" in entry:
|
|
156
|
+
return classify_target(entry["cidr"])
|
|
157
|
+
if "url" in entry:
|
|
158
|
+
return classify_target(entry["url"])
|
|
159
|
+
if "cloud_account" in entry:
|
|
160
|
+
return entry["cloud_account"], "cloud", entry["cloud_account"]
|
|
161
|
+
if "saas_tenant" in entry:
|
|
162
|
+
return entry["saas_tenant"], "saas", entry["saas_tenant"]
|
|
163
|
+
return str(entry), "malformed", None
|
|
164
|
+
s = str(entry).strip()
|
|
165
|
+
if not s:
|
|
166
|
+
return s, "malformed", None
|
|
167
|
+
|
|
168
|
+
# URL
|
|
169
|
+
if s.startswith("http://") or s.startswith("https://"):
|
|
170
|
+
try:
|
|
171
|
+
parsed = urlparse(s)
|
|
172
|
+
if parsed.netloc:
|
|
173
|
+
return s, "url", s
|
|
174
|
+
except ValueError:
|
|
175
|
+
return s, "malformed", None
|
|
176
|
+
|
|
177
|
+
# Cloud account / SaaS tenant prefixes
|
|
178
|
+
if re.match(r"^(aws|gcp|azure):", s, flags=re.I):
|
|
179
|
+
return s, "cloud", s
|
|
180
|
+
if re.match(r"^(okta|auth0|saml|google-workspace|microsoft365):", s, flags=re.I):
|
|
181
|
+
return s, "saas", s
|
|
182
|
+
|
|
183
|
+
# CIDR / IP
|
|
184
|
+
if "/" in s:
|
|
185
|
+
try:
|
|
186
|
+
net = ipaddress.ip_network(s, strict=False)
|
|
187
|
+
return s, "cidrv6" if net.version == 6 else "cidrv4", str(net)
|
|
188
|
+
except ValueError:
|
|
189
|
+
return s, "malformed", None
|
|
190
|
+
try:
|
|
191
|
+
addr = ipaddress.ip_address(s)
|
|
192
|
+
return s, "ipv6" if addr.version == 6 else "ipv4", str(addr)
|
|
193
|
+
except ValueError:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
# Wildcard
|
|
197
|
+
if s.startswith("*."):
|
|
198
|
+
if re.match(r"^\*\.[A-Za-z0-9.-]+$", s):
|
|
199
|
+
return s, "wildcard", s.lower()
|
|
200
|
+
return s, "malformed", None
|
|
201
|
+
|
|
202
|
+
# Hostname
|
|
203
|
+
if re.match(r"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$", s):
|
|
204
|
+
return s, "hostname", s.lower()
|
|
205
|
+
|
|
206
|
+
return s, "malformed", None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --- Overlap / SaaS / Reserved detection ------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _to_network(entry: str) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None:
|
|
213
|
+
try:
|
|
214
|
+
return ipaddress.ip_network(entry, strict=False)
|
|
215
|
+
except ValueError:
|
|
216
|
+
try:
|
|
217
|
+
return ipaddress.ip_network(f"{entry}/32" if ":" not in entry else f"{entry}/128", strict=False)
|
|
218
|
+
except ValueError:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cidr_overlap(a: str, b: str) -> bool:
|
|
223
|
+
na = _to_network(a)
|
|
224
|
+
nb = _to_network(b)
|
|
225
|
+
if na is None or nb is None or na.version != nb.version:
|
|
226
|
+
return False
|
|
227
|
+
return na.overlaps(nb)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def detect_saas(entry: str) -> str | None:
|
|
231
|
+
net = _to_network(entry)
|
|
232
|
+
if net is None:
|
|
233
|
+
return None
|
|
234
|
+
for vendor, ranges in KNOWN_SAAS_RANGES.items():
|
|
235
|
+
for r in ranges:
|
|
236
|
+
try:
|
|
237
|
+
saas_net = ipaddress.ip_network(r, strict=False)
|
|
238
|
+
if net.version == saas_net.version and net.overlaps(saas_net):
|
|
239
|
+
return vendor
|
|
240
|
+
except ValueError:
|
|
241
|
+
continue
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def detect_reserved(entry: str) -> str | None:
|
|
246
|
+
net = _to_network(entry)
|
|
247
|
+
if net is None:
|
|
248
|
+
return None
|
|
249
|
+
for r in RESERVED_RANGES:
|
|
250
|
+
try:
|
|
251
|
+
reserved_net = ipaddress.ip_network(r)
|
|
252
|
+
if net.version == reserved_net.version and net.overlaps(reserved_net):
|
|
253
|
+
return r
|
|
254
|
+
except ValueError:
|
|
255
|
+
continue
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# --- Finding generation -----------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _f(
|
|
263
|
+
severity: Severity,
|
|
264
|
+
title: str,
|
|
265
|
+
target: str,
|
|
266
|
+
detail: str,
|
|
267
|
+
remediation: str,
|
|
268
|
+
evidence: tuple[tuple[str, Any], ...] = (),
|
|
269
|
+
) -> Finding:
|
|
270
|
+
return Finding(
|
|
271
|
+
skill_id=SKILL_ID,
|
|
272
|
+
title=title,
|
|
273
|
+
severity=severity,
|
|
274
|
+
target=target,
|
|
275
|
+
detail=detail,
|
|
276
|
+
remediation=remediation,
|
|
277
|
+
evidence=evidence,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def evaluate_scope(
|
|
282
|
+
in_scope: list[Any], out_of_scope: list[Any], target_label: str
|
|
283
|
+
) -> tuple[list[Finding], list[dict[str, Any]], list[str]]:
|
|
284
|
+
findings: list[Finding] = []
|
|
285
|
+
normalized: list[dict[str, Any]] = []
|
|
286
|
+
allowlist: list[str] = []
|
|
287
|
+
seen = Counter()
|
|
288
|
+
|
|
289
|
+
classified_in: list[tuple[Any, str, str, str | None]] = []
|
|
290
|
+
for entry in in_scope:
|
|
291
|
+
raw, t, norm = classify_target(entry)
|
|
292
|
+
seen[norm or raw] += 1
|
|
293
|
+
classified_in.append((entry, raw, t, norm))
|
|
294
|
+
|
|
295
|
+
classified_out: list[tuple[Any, str, str, str | None]] = []
|
|
296
|
+
for entry in out_of_scope:
|
|
297
|
+
raw, t, norm = classify_target(entry)
|
|
298
|
+
classified_out.append((entry, raw, t, norm))
|
|
299
|
+
|
|
300
|
+
# Per-entry validation
|
|
301
|
+
for entry, raw, t, norm in classified_in:
|
|
302
|
+
if t == "malformed":
|
|
303
|
+
findings.append(
|
|
304
|
+
_f(
|
|
305
|
+
Severity.HIGH,
|
|
306
|
+
f"malformed in-scope target: {raw}",
|
|
307
|
+
raw,
|
|
308
|
+
f"Entry `{raw}` does not parse as host / CIDR / URL / cloud account / SaaS tenant.",
|
|
309
|
+
"Fix the entry's syntax in the ROE and re-run this skill.",
|
|
310
|
+
evidence=(("raw", raw),),
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
continue
|
|
314
|
+
normalized.append({"raw": raw, "type": t, "normalized": norm})
|
|
315
|
+
if t in ("ipv4", "ipv6", "cidrv4", "cidrv6"):
|
|
316
|
+
allowlist.append(norm or raw)
|
|
317
|
+
saas = detect_saas(norm or raw)
|
|
318
|
+
if saas is not None:
|
|
319
|
+
findings.append(
|
|
320
|
+
_f(
|
|
321
|
+
Severity.HIGH,
|
|
322
|
+
f"{raw} appears to be in {saas} infrastructure",
|
|
323
|
+
raw,
|
|
324
|
+
f"`{raw}` overlaps a known {saas} range. Testing third-party "
|
|
325
|
+
f"SaaS infrastructure requires SEPARATE authorization from "
|
|
326
|
+
f"the SaaS vendor, not just the customer.",
|
|
327
|
+
f"Either confirm {saas} has authorized testing OR remove this entry from scope.",
|
|
328
|
+
evidence=(("vendor", saas), ("entry", raw)),
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
reserved = detect_reserved(norm or raw)
|
|
332
|
+
if reserved is not None and t in ("ipv4", "cidrv4"):
|
|
333
|
+
# RFC1918 in internal pentest is expected; only flag for external context
|
|
334
|
+
findings.append(
|
|
335
|
+
_f(
|
|
336
|
+
Severity.MEDIUM,
|
|
337
|
+
f"{raw} is in a reserved range ({reserved})",
|
|
338
|
+
raw,
|
|
339
|
+
f"`{raw}` falls within reserved range {reserved}. Acceptable "
|
|
340
|
+
f"for internal pentests; verify this is intentional.",
|
|
341
|
+
"If this is an internal pentest, no action needed. Otherwise, remove from external scope.",
|
|
342
|
+
evidence=(("reserved", reserved), ("entry", raw)),
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
if seen[norm or raw] > 1:
|
|
346
|
+
findings.append(
|
|
347
|
+
_f(
|
|
348
|
+
Severity.INFO,
|
|
349
|
+
f"duplicate in-scope entry: {raw}",
|
|
350
|
+
raw,
|
|
351
|
+
f"`{raw}` appears {seen[norm or raw]} times.",
|
|
352
|
+
"Deduplicate; harmless but indicates ROE hygiene issue.",
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
if t == "wildcard":
|
|
356
|
+
findings.append(
|
|
357
|
+
_f(
|
|
358
|
+
Severity.INFO,
|
|
359
|
+
f"wildcard entry: {raw}",
|
|
360
|
+
raw,
|
|
361
|
+
f"`{raw}` will be expanded to specific subdomains at scan time. "
|
|
362
|
+
f"Wildcard scope is broad; ensure the customer intended it.",
|
|
363
|
+
"Document the expected scope of the wildcard in the ROE rules section.",
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Overlap detection: any in-scope CIDR/IP that intersects an out-of-scope CIDR.
|
|
368
|
+
for in_entry, in_raw, in_t, in_norm in classified_in:
|
|
369
|
+
if in_t not in ("ipv4", "ipv6", "cidrv4", "cidrv6"):
|
|
370
|
+
continue
|
|
371
|
+
for out_entry, out_raw, out_t, out_norm in classified_out:
|
|
372
|
+
if out_t not in ("ipv4", "ipv6", "cidrv4", "cidrv6"):
|
|
373
|
+
continue
|
|
374
|
+
if cidr_overlap(in_norm or in_raw, out_norm or out_raw):
|
|
375
|
+
findings.append(
|
|
376
|
+
_f(
|
|
377
|
+
Severity.CRITICAL,
|
|
378
|
+
f"in-scope {in_raw} overlaps out-of-scope {out_raw}",
|
|
379
|
+
in_raw,
|
|
380
|
+
f"In-scope entry `{in_raw}` overlaps with out-of-scope "
|
|
381
|
+
f"entry `{out_raw}`. Probing the overlap is NOT authorized.",
|
|
382
|
+
f"Either narrow `{in_raw}` to exclude `{out_raw}`, or remove "
|
|
383
|
+
f"`{out_raw}` from the out-of-scope list (the authorizer's call).",
|
|
384
|
+
evidence=(("in_scope", in_raw), ("out_of_scope", out_raw)),
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return findings, normalized, allowlist
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# --- CLI ---------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
395
|
+
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
396
|
+
p.add_argument("--roe", default="roe.yaml")
|
|
397
|
+
p.add_argument("--extension", default=None)
|
|
398
|
+
p.add_argument("--emit-allowlist", default=None)
|
|
399
|
+
p.add_argument("--emit-targets", default=None)
|
|
400
|
+
p.add_argument("--output", default=None)
|
|
401
|
+
p.add_argument("--format", default="markdown", choices=["json", "jsonl", "markdown"])
|
|
402
|
+
p.add_argument(
|
|
403
|
+
"--min-severity",
|
|
404
|
+
default="info",
|
|
405
|
+
choices=["info", "low", "medium", "high", "critical"],
|
|
406
|
+
)
|
|
407
|
+
return p
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _filter_min_severity(findings: list[Finding], min_sev: str) -> list[Finding]:
|
|
411
|
+
floor = Severity(min_sev).numeric
|
|
412
|
+
return [f for f in findings if f.severity.numeric >= floor]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def main(argv: list[str] | None = None) -> int:
|
|
416
|
+
args = _build_arg_parser().parse_args(argv)
|
|
417
|
+
roe_path = Path(args.roe).resolve()
|
|
418
|
+
if not roe_path.exists():
|
|
419
|
+
f = _f(
|
|
420
|
+
Severity.CRITICAL,
|
|
421
|
+
"ROE file missing",
|
|
422
|
+
str(roe_path),
|
|
423
|
+
f"No ROE at {roe_path}; cannot define scope.",
|
|
424
|
+
"Create or path-correct the ROE and re-run.",
|
|
425
|
+
)
|
|
426
|
+
report.emit([f], args.output, args.format, scan_target=str(roe_path))
|
|
427
|
+
return 1
|
|
428
|
+
|
|
429
|
+
data = _load_yaml(roe_path)
|
|
430
|
+
in_scope = data.get("in_scope_targets") or []
|
|
431
|
+
out_of_scope = data.get("out_of_scope_targets") or []
|
|
432
|
+
|
|
433
|
+
if args.extension:
|
|
434
|
+
ext_data = _load_yaml(Path(args.extension).resolve())
|
|
435
|
+
in_scope = list(in_scope) + list(ext_data.get("in_scope_targets") or [])
|
|
436
|
+
|
|
437
|
+
if not in_scope:
|
|
438
|
+
f = _f(
|
|
439
|
+
Severity.CRITICAL,
|
|
440
|
+
"in_scope_targets is empty",
|
|
441
|
+
str(roe_path),
|
|
442
|
+
"Cannot define scope without at least one in-scope target.",
|
|
443
|
+
"Add in-scope targets to the ROE and re-sign.",
|
|
444
|
+
)
|
|
445
|
+
report.emit([f], args.output, args.format, scan_target=str(roe_path))
|
|
446
|
+
return 1
|
|
447
|
+
|
|
448
|
+
findings, normalized, allowlist = evaluate_scope(in_scope, out_of_scope, str(roe_path))
|
|
449
|
+
|
|
450
|
+
if args.emit_allowlist:
|
|
451
|
+
Path(args.emit_allowlist).write_text("\n".join(sorted(set(allowlist))) + "\n", encoding="utf-8")
|
|
452
|
+
if args.emit_targets:
|
|
453
|
+
Path(args.emit_targets).write_text(json.dumps(normalized, indent=2, sort_keys=True), encoding="utf-8")
|
|
454
|
+
|
|
455
|
+
if not findings:
|
|
456
|
+
findings = [
|
|
457
|
+
_f(
|
|
458
|
+
Severity.INFO,
|
|
459
|
+
"scope is clean",
|
|
460
|
+
str(roe_path),
|
|
461
|
+
f"All {len(normalized)} targets validated. No overlaps, no malformed "
|
|
462
|
+
f"entries, no third-party SaaS conflicts.",
|
|
463
|
+
"Hand off the allowlist + normalized target list to scan skills.",
|
|
464
|
+
)
|
|
465
|
+
]
|
|
466
|
+
findings = _filter_min_severity(findings, args.min_severity)
|
|
467
|
+
report.emit(findings, args.output, args.format, scan_target=str(roe_path))
|
|
468
|
+
return report.exit_code(findings)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
if __name__ == "__main__":
|
|
472
|
+
sys.exit(main())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: detecting-command-injection-patterns
|
|
3
|
+
description: |
|
|
4
|
+
Scan a source tree for command-injection vulnerable patterns:
|
|
5
|
+
shell=True calls in Python subprocess, os.system / os.popen with
|
|
6
|
+
interpolated strings, Node child_process.exec with template
|
|
7
|
+
literals, Ruby backticks / Kernel#system / Kernel#exec with
|
|
8
|
+
interpolation, Go exec.Command with shell wrapping, PHP system /
|
|
9
|
+
passthru / shell_exec / backticks with $-interpolation, Java
|
|
10
|
+
Runtime.exec with concatenated args.
|
|
11
|
+
Use when: pre-commit gate on code that calls out to shell utilities,
|
|
12
|
+
audit of file-processing / archive-handling / image-conversion
|
|
13
|
+
code, post-bug-report investigation for "we shell out to a tool."
|
|
14
|
+
Threshold: any shell-invocation API called with a string that
|
|
15
|
+
contains a variable interpolation, OR shell=True with anything
|
|
16
|
+
other than a fixed literal.
|
|
17
|
+
Trigger with: "scan command injection", "shell=True audit",
|
|
18
|
+
"find exec calls", "check os.system".
|
|
19
|
+
allowed-tools:
|
|
20
|
+
- Read
|
|
21
|
+
- Bash(python3:*)
|
|
22
|
+
- Glob
|
|
23
|
+
- Grep
|
|
24
|
+
disallowed-tools:
|
|
25
|
+
- Bash(rm:*)
|
|
26
|
+
- Bash(curl:*)
|
|
27
|
+
version: 3.0.0-dev
|
|
28
|
+
author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
29
|
+
license: MIT
|
|
30
|
+
compatibility: Designed for Claude Code
|
|
31
|
+
tags:
|
|
32
|
+
- security
|
|
33
|
+
- static-analysis
|
|
34
|
+
- command-injection
|
|
35
|
+
- pentest
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
# Detecting Command Injection Patterns
|
|
39
|
+
|
|
40
|
+
## Overview
|
|
41
|
+
|
|
42
|
+
Command injection (CWE-78, OWASP A03:2021) shows up wherever an
|
|
43
|
+
application shells out to a binary. Image conversion (`convert`),
|
|
44
|
+
archive extraction (`tar`, `unzip`), video processing (`ffmpeg`),
|
|
45
|
+
DNS lookup (`dig`), and "we just need to call this CLI tool once"
|
|
46
|
+
are the common origins.
|
|
47
|
+
|
|
48
|
+
The vulnerability shape is universal: a string is built including
|
|
49
|
+
user input, then handed to a shell interpreter. The shell parses
|
|
50
|
+
the string with normal shell semantics — including `;`, `|`, `&`,
|
|
51
|
+
`$()`, backticks. Any of those in the user-controlled portion
|
|
52
|
+
becomes shell-executable.
|
|
53
|
+
|
|
54
|
+
## When the skill produces findings
|
|
55
|
+
|
|
56
|
+
| Finding | Severity | Threshold | Affected control |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| Python `subprocess.run(..., shell=True)` with interpolation | **CRITICAL** | f-string / concat / format argument with `shell=True` | CWE-78 |
|
|
59
|
+
| Python `os.system(...)` with interpolation | **CRITICAL** | non-literal argument | CWE-78 |
|
|
60
|
+
| Python `os.popen(...)` with interpolation | **CRITICAL** | non-literal argument | CWE-78 |
|
|
61
|
+
| Node `child_process.exec(...)` with template literal | **CRITICAL** | `${...}` in the command string | CWE-78 |
|
|
62
|
+
| Node `child_process.execSync(...)` with template | **CRITICAL** | same | CWE-78 |
|
|
63
|
+
| Ruby backticks with interpolation | **CRITICAL** | `` `cmd #{var}` `` | CWE-78 |
|
|
64
|
+
| Ruby `Kernel#system(string)` with interpolation | **CRITICAL** | `system("cmd #{var}")` | CWE-78 |
|
|
65
|
+
| Go `exec.Command("sh", "-c", ...)` with interpolation | **HIGH** | shell wrapper with var | CWE-78 |
|
|
66
|
+
| PHP `system / exec / passthru / shell_exec` with $-interp | **CRITICAL** | `system("cmd $var")` | CWE-78 |
|
|
67
|
+
| Java `Runtime.exec(String)` with concat | **HIGH** | single-string form (vs array) with var | CWE-78 |
|
|
68
|
+
|
|
69
|
+
## Prerequisites
|
|
70
|
+
|
|
71
|
+
- Python 3.9+
|
|
72
|
+
- Target source tree on local filesystem
|
|
73
|
+
|
|
74
|
+
## Instructions
|
|
75
|
+
|
|
76
|
+
### Step 1 — Run the scanner
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python3 ${CLAUDE_PLUGIN_ROOT}/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py /path/to/repo
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Options:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
Usage: scan_cmdi.py PATH [OPTIONS]
|
|
86
|
+
|
|
87
|
+
Options:
|
|
88
|
+
--output FILE Write findings to FILE
|
|
89
|
+
--format FMT json | jsonl | markdown (default: markdown)
|
|
90
|
+
--min-severity SEV (default: info)
|
|
91
|
+
--include-tests Include test directories (default: excluded)
|
|
92
|
+
--languages LIST Comma-separated subset to scan
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Step 2 — Interpret findings
|
|
96
|
+
|
|
97
|
+
CRITICAL = direct user-input → shell construction. Fix immediately.
|
|
98
|
+
|
|
99
|
+
HIGH = pattern where the shell layer exists but user-input reachability
|
|
100
|
+
needs verification.
|
|
101
|
+
|
|
102
|
+
### Step 3 — Remediation
|
|
103
|
+
|
|
104
|
+
The universal fix: pass arguments as a list (array), not a single
|
|
105
|
+
string. Most APIs have a list form that bypasses shell entirely.
|
|
106
|
+
|
|
107
|
+
See `references/PLAYBOOK.md` for per-language patterns.
|
|
108
|
+
|
|
109
|
+
## Examples
|
|
110
|
+
|
|
111
|
+
### Example 1 — Pre-commit on a media-processing service
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
python3 ${CLAUDE_PLUGIN_ROOT}/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py \
|
|
115
|
+
--min-severity high $(git diff --name-only main...HEAD | tr '\n' ' ')
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Example 2 — CI gate
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
- name: Command-injection scan
|
|
122
|
+
run: |
|
|
123
|
+
python3 plugins/security/penetration-tester/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py \
|
|
124
|
+
. --min-severity high
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Output
|
|
128
|
+
|
|
129
|
+
JSON / JSONL / Markdown. Exit codes: 0 clean, 1 high/critical, 2 error.
|
|
130
|
+
|
|
131
|
+
## Error Handling
|
|
132
|
+
|
|
133
|
+
False positives common in build scripts that interpolate fixed
|
|
134
|
+
build constants. Verify each finding by reading whether the
|
|
135
|
+
interpolated value is user-reachable.
|
|
136
|
+
|
|
137
|
+
## Resources
|
|
138
|
+
|
|
139
|
+
- `references/THEORY.md` — Why shell=True is the default footgun,
|
|
140
|
+
per-language shell-out idioms, argument-vector vs command-string
|
|
141
|
+
semantics
|
|
142
|
+
- `references/PLAYBOOK.md` — Per-language safe-shellout patterns
|
|
143
|
+
(Python subprocess list-args, Node spawn, Ruby Open3.capture3,
|
|
144
|
+
Go exec.Command list-args, Java ProcessBuilder, PHP escapeshellarg)
|