@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.
Files changed (112) hide show
  1. package/.claude-plugin/plugin.json +8 -3
  2. package/README.md +8 -0
  3. package/commands/pentest.md +5 -0
  4. package/package.json +8 -3
  5. package/skills/analyzing-tls-config/SKILL.md +221 -0
  6. package/skills/analyzing-tls-config/references/AUTHORIZATION.md +133 -0
  7. package/skills/analyzing-tls-config/references/PLAYBOOK.md +267 -0
  8. package/skills/analyzing-tls-config/references/THEORY.md +128 -0
  9. package/skills/analyzing-tls-config/scripts/analyze_tls.py +415 -0
  10. package/skills/auditing-cors-policy/SKILL.md +186 -0
  11. package/skills/auditing-cors-policy/references/PLAYBOOK.md +220 -0
  12. package/skills/auditing-cors-policy/references/THEORY.md +142 -0
  13. package/skills/auditing-cors-policy/scripts/audit_cors.py +350 -0
  14. package/skills/auditing-npm-dependencies/SKILL.md +254 -0
  15. package/skills/auditing-npm-dependencies/references/PLAYBOOK.md +175 -0
  16. package/skills/auditing-npm-dependencies/references/THEORY.md +122 -0
  17. package/skills/auditing-npm-dependencies/scripts/audit_npm.py +408 -0
  18. package/skills/auditing-python-dependencies/SKILL.md +251 -0
  19. package/skills/auditing-python-dependencies/references/PLAYBOOK.md +193 -0
  20. package/skills/auditing-python-dependencies/references/THEORY.md +122 -0
  21. package/skills/auditing-python-dependencies/scripts/audit_python.py +459 -0
  22. package/skills/checking-http-security-headers/SKILL.md +176 -0
  23. package/skills/checking-http-security-headers/references/PLAYBOOK.md +212 -0
  24. package/skills/checking-http-security-headers/references/THEORY.md +137 -0
  25. package/skills/checking-http-security-headers/scripts/check_headers.py +362 -0
  26. package/skills/checking-license-compliance/SKILL.md +225 -0
  27. package/skills/checking-license-compliance/references/PLAYBOOK.md +161 -0
  28. package/skills/checking-license-compliance/references/THEORY.md +152 -0
  29. package/skills/checking-license-compliance/scripts/check_licenses.py +461 -0
  30. package/skills/composing-vulnerability-report/SKILL.md +212 -0
  31. package/skills/composing-vulnerability-report/references/PLAYBOOK.md +180 -0
  32. package/skills/composing-vulnerability-report/references/THEORY.md +178 -0
  33. package/skills/composing-vulnerability-report/scripts/compose_report.py +396 -0
  34. package/skills/confirming-pentest-authorization/SKILL.md +247 -0
  35. package/skills/confirming-pentest-authorization/references/PLAYBOOK.md +189 -0
  36. package/skills/confirming-pentest-authorization/references/THEORY.md +167 -0
  37. package/skills/confirming-pentest-authorization/scripts/check_authorization.py +457 -0
  38. package/skills/defining-pentest-scope/SKILL.md +227 -0
  39. package/skills/defining-pentest-scope/references/PLAYBOOK.md +238 -0
  40. package/skills/defining-pentest-scope/references/THEORY.md +170 -0
  41. package/skills/defining-pentest-scope/scripts/define_scope.py +472 -0
  42. package/skills/detecting-command-injection-patterns/SKILL.md +144 -0
  43. package/skills/detecting-command-injection-patterns/references/PLAYBOOK.md +302 -0
  44. package/skills/detecting-command-injection-patterns/references/THEORY.md +206 -0
  45. package/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py +290 -0
  46. package/skills/detecting-debug-endpoints/SKILL.md +207 -0
  47. package/skills/detecting-debug-endpoints/references/PLAYBOOK.md +402 -0
  48. package/skills/detecting-debug-endpoints/references/THEORY.md +218 -0
  49. package/skills/detecting-debug-endpoints/scripts/probe_debug.py +518 -0
  50. package/skills/detecting-directory-listing/SKILL.md +206 -0
  51. package/skills/detecting-directory-listing/references/PLAYBOOK.md +277 -0
  52. package/skills/detecting-directory-listing/references/THEORY.md +203 -0
  53. package/skills/detecting-directory-listing/scripts/probe_directory_listing.py +180 -0
  54. package/skills/detecting-eval-exec-usage/SKILL.md +128 -0
  55. package/skills/detecting-eval-exec-usage/references/PLAYBOOK.md +306 -0
  56. package/skills/detecting-eval-exec-usage/references/THEORY.md +159 -0
  57. package/skills/detecting-eval-exec-usage/scripts/scan_eval.py +223 -0
  58. package/skills/detecting-exposed-secrets-files/SKILL.md +179 -0
  59. package/skills/detecting-exposed-secrets-files/references/PLAYBOOK.md +274 -0
  60. package/skills/detecting-exposed-secrets-files/references/THEORY.md +174 -0
  61. package/skills/detecting-exposed-secrets-files/scripts/probe_secrets.py +207 -0
  62. package/skills/detecting-insecure-deserialization/SKILL.md +148 -0
  63. package/skills/detecting-insecure-deserialization/references/PLAYBOOK.md +333 -0
  64. package/skills/detecting-insecure-deserialization/references/THEORY.md +199 -0
  65. package/skills/detecting-insecure-deserialization/scripts/scan_deserialization.py +250 -0
  66. package/skills/detecting-sql-injection-patterns/SKILL.md +161 -0
  67. package/skills/detecting-sql-injection-patterns/references/PLAYBOOK.md +317 -0
  68. package/skills/detecting-sql-injection-patterns/references/THEORY.md +261 -0
  69. package/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py +354 -0
  70. package/skills/detecting-ssl-cert-issues/SKILL.md +182 -0
  71. package/skills/detecting-ssl-cert-issues/references/PLAYBOOK.md +203 -0
  72. package/skills/detecting-ssl-cert-issues/references/THEORY.md +133 -0
  73. package/skills/detecting-ssl-cert-issues/scripts/check_cert_chain.py +481 -0
  74. package/skills/detecting-weak-cryptography/SKILL.md +147 -0
  75. package/skills/detecting-weak-cryptography/references/PLAYBOOK.md +466 -0
  76. package/skills/detecting-weak-cryptography/references/THEORY.md +194 -0
  77. package/skills/detecting-weak-cryptography/scripts/scan_weak_crypto.py +417 -0
  78. package/skills/fingerprinting-server-software/SKILL.md +191 -0
  79. package/skills/fingerprinting-server-software/references/PLAYBOOK.md +337 -0
  80. package/skills/fingerprinting-server-software/references/THEORY.md +183 -0
  81. package/skills/fingerprinting-server-software/scripts/fingerprint_server.py +347 -0
  82. package/skills/generating-executive-summary/SKILL.md +261 -0
  83. package/skills/generating-executive-summary/references/PLAYBOOK.md +201 -0
  84. package/skills/generating-executive-summary/references/THEORY.md +195 -0
  85. package/skills/generating-executive-summary/scripts/exec_summary.py +538 -0
  86. package/skills/mapping-findings-to-owasp-top10/SKILL.md +235 -0
  87. package/skills/mapping-findings-to-owasp-top10/references/PLAYBOOK.md +193 -0
  88. package/skills/mapping-findings-to-owasp-top10/references/THEORY.md +160 -0
  89. package/skills/mapping-findings-to-owasp-top10/scripts/map_owasp.py +540 -0
  90. package/skills/performing-penetration-testing/SKILL.md +282 -190
  91. package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +22 -0
  92. package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +46 -0
  93. package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +41 -0
  94. package/skills/performing-penetration-testing/scripts/code_security_scanner.py +144 -79
  95. package/skills/performing-penetration-testing/scripts/dependency_auditor.py +116 -93
  96. package/skills/performing-penetration-testing/scripts/security_scanner.py +574 -446
  97. package/skills/probing-dangerous-http-methods/SKILL.md +182 -0
  98. package/skills/probing-dangerous-http-methods/references/PLAYBOOK.md +234 -0
  99. package/skills/probing-dangerous-http-methods/references/THEORY.md +145 -0
  100. package/skills/probing-dangerous-http-methods/scripts/probe_methods.py +263 -0
  101. package/skills/recording-pentest-engagement/SKILL.md +253 -0
  102. package/skills/recording-pentest-engagement/references/PLAYBOOK.md +203 -0
  103. package/skills/recording-pentest-engagement/references/THEORY.md +195 -0
  104. package/skills/recording-pentest-engagement/scripts/record_engagement.py +461 -0
  105. package/skills/scanning-for-hardcoded-secrets/SKILL.md +215 -0
  106. package/skills/scanning-for-hardcoded-secrets/references/PLAYBOOK.md +325 -0
  107. package/skills/scanning-for-hardcoded-secrets/references/THEORY.md +175 -0
  108. package/skills/scanning-for-hardcoded-secrets/scripts/scan_secrets.py +395 -0
  109. package/skills/tracing-transitive-vulnerabilities/SKILL.md +235 -0
  110. package/skills/tracing-transitive-vulnerabilities/references/PLAYBOOK.md +233 -0
  111. package/skills/tracing-transitive-vulnerabilities/references/THEORY.md +138 -0
  112. 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)