phpwarden 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phpwarden/__init__.py +8 -0
- phpwarden/cli.py +448 -0
- phpwarden/data/php-cve-feed.json +427709 -0
- phpwarden/deps.py +283 -0
- phpwarden/detect.py +194 -0
- phpwarden/engine.py +137 -0
- phpwarden/env.py +82 -0
- phpwarden/feed.py +213 -0
- phpwarden/guard.py +792 -0
- phpwarden/report.py +206 -0
- phpwarden/rules.py +152 -0
- phpwarden/version.py +110 -0
- phpwarden/vulndb.py +186 -0
- phpwarden/wordpress.py +155 -0
- phpwarden-2.0.0.dist-info/METADATA +177 -0
- phpwarden-2.0.0.dist-info/RECORD +20 -0
- phpwarden-2.0.0.dist-info/WHEEL +5 -0
- phpwarden-2.0.0.dist-info/entry_points.txt +3 -0
- phpwarden-2.0.0.dist-info/licenses/LICENSE +202 -0
- phpwarden-2.0.0.dist-info/top_level.txt +1 -0
phpwarden/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""phpwarden — PHP exposure and exploitation guard.
|
|
2
|
+
|
|
3
|
+
A general successor to the single-CVE php2shell checker: it assesses a PHP
|
|
4
|
+
environment against an evolving knowledge base of PHP CVEs (the feed), and can
|
|
5
|
+
watch PHP-FPM for active exploitation. The engine is open; the CVE feed is the
|
|
6
|
+
part a subscription keeps current.
|
|
7
|
+
"""
|
|
8
|
+
__version__ = "2.0.0"
|
phpwarden/cli.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""phpwarden command-line interface.
|
|
2
|
+
|
|
3
|
+
phpwarden scan assess this host against the whole CVE feed
|
|
4
|
+
phpwarden scan --version 8.3.10 --sapi fpm --os linux
|
|
5
|
+
phpwarden feed status show the active feed and whether it is licensed
|
|
6
|
+
phpwarden feed update pull the latest feed (needs a license key)
|
|
7
|
+
phpwarden detect ACCESS_LOG scan a log for exploitation signatures
|
|
8
|
+
|
|
9
|
+
Exit codes (shared by every subcommand; usable as a CI gate):
|
|
10
|
+
0 nothing applies / clean
|
|
11
|
+
1 exposed (scan), vulnerable packages or components found (deps, wordpress),
|
|
12
|
+
signature hits (detect), or the feed update failed
|
|
13
|
+
2 possibly exposed (scan only)
|
|
14
|
+
3 PHP version unknown (scan only)
|
|
15
|
+
4 input error: missing/unreadable file, bad lockfile, cannot write --out
|
|
16
|
+
5 results could not be uploaded to --report-to (only when the local
|
|
17
|
+
result was otherwise 0; a non-zero local result always wins)
|
|
18
|
+
64 usage error (unknown flag, missing argument) — EX_USAGE, never 2, so it
|
|
19
|
+
cannot be confused with "possibly exposed"
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
from phpwarden import __version__
|
|
28
|
+
from phpwarden import report as reportgen
|
|
29
|
+
from phpwarden import rules as rulegen
|
|
30
|
+
from phpwarden import wordpress as wp
|
|
31
|
+
from phpwarden.deps import rank_findings, scan_lock
|
|
32
|
+
from phpwarden.detect import SignatureScanner
|
|
33
|
+
from phpwarden.engine import Environment, assess, summarize
|
|
34
|
+
from phpwarden.env import detect as detect_env
|
|
35
|
+
from phpwarden.feed import FeedManager
|
|
36
|
+
from phpwarden import version as V
|
|
37
|
+
|
|
38
|
+
ICON = {"EXPOSED": "EXPOSED ", "POSSIBLY_EXPOSED": "POSSIBLE", "CLEARED": "cleared "}
|
|
39
|
+
|
|
40
|
+
EX_USAGE = 64 # sysexits.h: command-line usage error
|
|
41
|
+
EX_REPORT_FAILED = 5 # --report-to upload failed on an otherwise clean run
|
|
42
|
+
|
|
43
|
+
EXIT_CODES_HELP = """\
|
|
44
|
+
exit codes:
|
|
45
|
+
0 clean 1 exposed / vulnerable / hits 2 possibly exposed (scan)
|
|
46
|
+
3 version unknown (scan) 4 input or --out error
|
|
47
|
+
5 --report-to upload failed (clean run) 64 usage error
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Parser(argparse.ArgumentParser):
|
|
52
|
+
"""argparse exits 2 on a usage error, which collides with scan's
|
|
53
|
+
"possibly exposed". Exit 64 (EX_USAGE) instead, message on stderr."""
|
|
54
|
+
|
|
55
|
+
def error(self, message):
|
|
56
|
+
self.print_usage(sys.stderr)
|
|
57
|
+
sys.stderr.write(f"{self.prog}: error: {message}\n")
|
|
58
|
+
sys.exit(EX_USAGE)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _combine(rc: int, reported: bool) -> int:
|
|
62
|
+
"""Fold the --report-to outcome into a result code: a non-zero local result
|
|
63
|
+
always wins; a clean run whose upload failed exits 5."""
|
|
64
|
+
if rc:
|
|
65
|
+
return rc
|
|
66
|
+
return 0 if reported else EX_REPORT_FAILED
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _scan(args):
|
|
70
|
+
fm = FeedManager()
|
|
71
|
+
feed = fm.current()
|
|
72
|
+
if args.version:
|
|
73
|
+
ver = V.parse(args.version)
|
|
74
|
+
if ver is None:
|
|
75
|
+
# "8.5" or "garbage" used to be assessed silently as "unknown"
|
|
76
|
+
sys.stderr.write(f"error: --version {args.version!r} is not a PHP version (expected X.Y.Z)\n")
|
|
77
|
+
return 3
|
|
78
|
+
env = Environment(version=ver, sapi=args.sapi, os=args.os)
|
|
79
|
+
else:
|
|
80
|
+
env = detect_env(php_binary=args.php)
|
|
81
|
+
if args.sapi:
|
|
82
|
+
env.sapi = args.sapi
|
|
83
|
+
if args.os:
|
|
84
|
+
env.os = args.os
|
|
85
|
+
findings = assess(env, feed)
|
|
86
|
+
s = summarize(findings)
|
|
87
|
+
|
|
88
|
+
doc = {
|
|
89
|
+
"php_version": str(env.version) if env.version else None,
|
|
90
|
+
"sapi": env.sapi, "os": env.os,
|
|
91
|
+
"feed_version": feed.feed_version, "feed_source": fm.source(),
|
|
92
|
+
"summary": s,
|
|
93
|
+
"findings": [{
|
|
94
|
+
"cve": f.cve.id, "verdict": f.verdict, "confidence": f.confidence,
|
|
95
|
+
"severity": f.cve.severity, "cvss": f.cve.cvss,
|
|
96
|
+
"exploited_in_wild": f.cve.exploited_in_wild, "ransomware": f.cve.ransomware,
|
|
97
|
+
"epss": f.cve.epss, "epss_percentile": f.cve.epss_percentile,
|
|
98
|
+
"title": f.cve.title, "fixed": f.fixed, "reasons": f.reasons,
|
|
99
|
+
"remediation": f.cve.remediation,
|
|
100
|
+
} for f in findings if f.verdict != "CLEARED"],
|
|
101
|
+
}
|
|
102
|
+
if args.json:
|
|
103
|
+
print(json.dumps(doc, indent=2))
|
|
104
|
+
else:
|
|
105
|
+
ver = str(env.version) if env.version else "unknown"
|
|
106
|
+
print(f"phpwarden {__version__} php={ver} sapi={env.sapi or '?'} os={env.os or '?'} "
|
|
107
|
+
f"feed={feed.feed_version} ({fm.source()})")
|
|
108
|
+
print(f" {s['exposed']} exposed, {s['possibly_exposed']} possible, {s['cleared']} cleared; "
|
|
109
|
+
f"worst={s['worst_severity']}")
|
|
110
|
+
if s["exploited_in_wild"]:
|
|
111
|
+
print(f" exploited in the wild: {', '.join(s['exploited_in_wild'])}")
|
|
112
|
+
for f in findings:
|
|
113
|
+
if f.verdict == "CLEARED":
|
|
114
|
+
continue
|
|
115
|
+
print(f"\n [{ICON[f.verdict]}] {f.cve.id} {f.cve.severity} (CVSS {f.cve.cvss})"
|
|
116
|
+
f"{_risk(f.cve.exploited_in_wild, f.cve.epss, f.cve.epss_percentile, f.cve.ransomware)}")
|
|
117
|
+
print(f" {f.cve.title}")
|
|
118
|
+
for r in f.reasons:
|
|
119
|
+
print(f" - {r}")
|
|
120
|
+
if f.fixed:
|
|
121
|
+
print(f" fixed in: {f.fixed}")
|
|
122
|
+
print(f" fix: {f.cve.remediation}")
|
|
123
|
+
if s["exposed"] == 0 and s["possibly_exposed"] == 0:
|
|
124
|
+
print(" No exposure found against the current feed.")
|
|
125
|
+
|
|
126
|
+
# Local results are printed BEFORE the upload is attempted, so a dead SaaS
|
|
127
|
+
# never hides them; the upload outcome only changes the exit code.
|
|
128
|
+
reported = _maybe_report(args, doc)
|
|
129
|
+
# Exit codes (documented): 0 nothing applies, 1 exposed, 2 possibly exposed,
|
|
130
|
+
# 3 PHP version unknown. "Unknown" must win over 2: every CVE is only
|
|
131
|
+
# "possible" BECAUSE the version is unknown, and 3 tells the operator why.
|
|
132
|
+
if env.version is None:
|
|
133
|
+
return _combine(3, reported)
|
|
134
|
+
if s["exposed"]:
|
|
135
|
+
return _combine(1, reported)
|
|
136
|
+
if s["possibly_exposed"]:
|
|
137
|
+
return _combine(2, reported)
|
|
138
|
+
return _combine(0, reported)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _feed(args):
|
|
142
|
+
fm = FeedManager()
|
|
143
|
+
if args.feed_cmd == "update":
|
|
144
|
+
res = fm.update()
|
|
145
|
+
print(json.dumps(res, indent=2) if args.json else _fmt_feed_update(res))
|
|
146
|
+
return 0 if res["status"] in ("updated", "current", "no_license") else 1
|
|
147
|
+
res = fm.status()
|
|
148
|
+
if args.json:
|
|
149
|
+
print(json.dumps(res, indent=2))
|
|
150
|
+
else:
|
|
151
|
+
print(f"feed {res['feed_version']} ({res['channel']}): "
|
|
152
|
+
f"{res['cve_count']} engine CVEs, {res['composer_count']} Composer, "
|
|
153
|
+
f"{res['wp_count']} WordPress ({res['total_advisories']} total)")
|
|
154
|
+
valid = res["license_valid"]
|
|
155
|
+
valid_s = "unknown (run `feed update`)" if valid is None else ("valid" if valid else "REJECTED")
|
|
156
|
+
print(f" source={res['source']}, license_key_set={res['license_key_set']}, "
|
|
157
|
+
f"license_valid={valid_s}")
|
|
158
|
+
if not res["license_key_set"]:
|
|
159
|
+
print(" Running the free community feed. Set PHPWARDEN_LICENSE for the maintained channel.")
|
|
160
|
+
elif valid is False:
|
|
161
|
+
print(" The feed server rejected this license key; the community feed stays in effect.")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _fmt_feed_update(res):
|
|
166
|
+
st = res["status"]
|
|
167
|
+
if st == "updated":
|
|
168
|
+
added = ", ".join(res["added_cves"]) or "none"
|
|
169
|
+
return (f"feed updated {res['from']} -> {res['to']} "
|
|
170
|
+
f"({res['total_cves']} CVEs; new: {added})")
|
|
171
|
+
if st == "no_license":
|
|
172
|
+
return res["message"]
|
|
173
|
+
http = f" [HTTP {res['http_status']}]" if res.get("http_status") else ""
|
|
174
|
+
return f"{st}{http}: {res.get('message', res.get('feed_version',''))}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _detect(args):
|
|
178
|
+
feed = FeedManager().current()
|
|
179
|
+
scanner = SignatureScanner(feed)
|
|
180
|
+
try:
|
|
181
|
+
hits = scanner.scan_file(args.logfile)
|
|
182
|
+
except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as e:
|
|
183
|
+
# a missing or unreadable log must never be reported as "clean"
|
|
184
|
+
sys.stderr.write(f"error: {e}\n")
|
|
185
|
+
return 4
|
|
186
|
+
doc = {"logfile": args.logfile,
|
|
187
|
+
"scanned_lines": scanner.scanned_lines,
|
|
188
|
+
"total_hits": scanner.total_hits,
|
|
189
|
+
"truncated": scanner.truncated,
|
|
190
|
+
"hits": [h.__dict__ for h in hits],
|
|
191
|
+
"worker_corruption_cves": scanner.worker_corruption_cves}
|
|
192
|
+
if scanner.truncated:
|
|
193
|
+
sys.stderr.write(f"note: {scanner.total_hits} hits in {scanner.scanned_lines} lines; "
|
|
194
|
+
f"showing the first {len(hits)}\n")
|
|
195
|
+
if args.json:
|
|
196
|
+
print(json.dumps(doc, indent=2))
|
|
197
|
+
else:
|
|
198
|
+
if not hits:
|
|
199
|
+
print(f"No exploitation signatures found in {args.logfile}.")
|
|
200
|
+
else:
|
|
201
|
+
print(f"{len(hits)} signature hit(s) in {args.logfile}:")
|
|
202
|
+
for h in hits:
|
|
203
|
+
print(f" line {h.line_no}: {h.cve_id} ({h.kind}, pattern {h.pattern!r}) — {h.note}")
|
|
204
|
+
print(f" {h.excerpt}")
|
|
205
|
+
if scanner.worker_corruption_cves:
|
|
206
|
+
print(f"\nNote: {', '.join(scanner.worker_corruption_cves)} need the continuous "
|
|
207
|
+
f"guard daemon (worker-corruption detection), not log signatures.")
|
|
208
|
+
return _combine(1 if hits else 0, _maybe_report(args, doc))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _deps(args):
|
|
212
|
+
from pathlib import Path as _P
|
|
213
|
+
# A missing or malformed lockfile (or a composer.json handed in by mistake)
|
|
214
|
+
# must be an error, not "No known-vulnerable Composer packages".
|
|
215
|
+
lp = _P(args.lockfile)
|
|
216
|
+
if not lp.is_file():
|
|
217
|
+
sys.stderr.write(f"error: lockfile not found: {args.lockfile}\n")
|
|
218
|
+
return 4
|
|
219
|
+
try:
|
|
220
|
+
lock_doc = json.loads(lp.read_text())
|
|
221
|
+
except ValueError as e:
|
|
222
|
+
sys.stderr.write(f"error: {args.lockfile} is not valid JSON: {e}\n")
|
|
223
|
+
return 4
|
|
224
|
+
if not isinstance(lock_doc, dict) or not any(k in lock_doc for k in ("packages", "packages-dev")):
|
|
225
|
+
sys.stderr.write(f"error: {args.lockfile} is not a composer.lock (no \"packages\" section"
|
|
226
|
+
+ (" — pass composer.lock, not composer.json)" if "require" in lock_doc else ")") + "\n")
|
|
227
|
+
return 4
|
|
228
|
+
feed = FeedManager().current()
|
|
229
|
+
findings = scan_lock(args.lockfile, feed)
|
|
230
|
+
# reachability: is each vulnerable package actually referenced in your code?
|
|
231
|
+
reach = {}
|
|
232
|
+
if not getattr(args, "no_reachability", False):
|
|
233
|
+
project = getattr(args, "project", None) or str(_P(args.lockfile).resolve().parent)
|
|
234
|
+
try:
|
|
235
|
+
from phpwarden.deps import analyze_reachability
|
|
236
|
+
reach = analyze_reachability(args.lockfile, project)
|
|
237
|
+
except Exception: # noqa: BLE001 (reachability is best-effort)
|
|
238
|
+
reach = {}
|
|
239
|
+
# KEV > reach (used > unknown > unreferenced > dev-only) > EPSS > CVSS; the
|
|
240
|
+
# JSON list, the text output and the dashboard all share this order.
|
|
241
|
+
findings = rank_findings(findings, reach)
|
|
242
|
+
doc = {"lockfile": args.lockfile, "vulnerable": [{
|
|
243
|
+
"package": f.package, "installed": f.installed, "cve": f.advisory.cve,
|
|
244
|
+
"severity": f.advisory.severity, "cvss": f.advisory.cvss,
|
|
245
|
+
"known_exploited": f.advisory.known_exploited, "ransomware": f.advisory.ransomware,
|
|
246
|
+
"epss": f.advisory.epss, "epss_percentile": f.advisory.epss_percentile,
|
|
247
|
+
"title": f.advisory.title, "fixed": f.fixed,
|
|
248
|
+
"reach": reach.get(f.package, "unknown")} for f in findings]}
|
|
249
|
+
if args.json:
|
|
250
|
+
print(json.dumps(doc, indent=2))
|
|
251
|
+
else:
|
|
252
|
+
if not findings:
|
|
253
|
+
print(f"No known-vulnerable Composer packages in {args.lockfile} (feed {feed.feed_version}).")
|
|
254
|
+
else:
|
|
255
|
+
kev = sum(1 for f in findings if f.advisory.known_exploited)
|
|
256
|
+
print(f"{len(findings)} vulnerable package(s) in {args.lockfile}; "
|
|
257
|
+
f"{kev} actively exploited (KEV). Ranked by exploitation risk, then reachability:")
|
|
258
|
+
for f in findings:
|
|
259
|
+
print(f"\n {f.package} {f.installed} {f.advisory.cve} "
|
|
260
|
+
f"{_sev(f.advisory.severity, f.advisory.cvss)}"
|
|
261
|
+
f"{_risk(f.advisory.known_exploited, f.advisory.epss, f.advisory.epss_percentile, f.advisory.ransomware)}")
|
|
262
|
+
print(f" {f.advisory.title}")
|
|
263
|
+
print(f" fixed in: {', '.join(f.fixed)}")
|
|
264
|
+
r = reach.get(f.package, "unknown")
|
|
265
|
+
if r != "unknown":
|
|
266
|
+
print(f" reachability: {r}")
|
|
267
|
+
return _combine(1 if findings else 0, _maybe_report(args, doc))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _sev(severity, cvss):
|
|
271
|
+
"""Format severity with CVSS only when it carries a real score."""
|
|
272
|
+
if cvss and cvss > 0:
|
|
273
|
+
return f"{severity} (CVSS {cvss:g})"
|
|
274
|
+
return severity if severity != "unknown" else "severity n/a"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _risk(known, epss, pct, ransomware=False):
|
|
278
|
+
"""Compact real-world-exploitation tag: KEV and/or EPSS probability."""
|
|
279
|
+
parts = []
|
|
280
|
+
if known:
|
|
281
|
+
parts.append("KEV·ransomware" if ransomware else "KEV")
|
|
282
|
+
if epss is not None:
|
|
283
|
+
parts.append(f"EPSS {epss * 100:.0f}% (p{pct * 100:.0f})")
|
|
284
|
+
return " [" + ", ".join(parts) + "]" if parts else ""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _maybe_report(args, doc) -> bool:
|
|
288
|
+
"""POST this result to a phpwarden SaaS, if --report-to is set.
|
|
289
|
+
|
|
290
|
+
Returns True when no upload was requested or it succeeded, False when the
|
|
291
|
+
upload failed (network error or non-2xx) — callers fold that into the exit
|
|
292
|
+
code via `_combine`. Callers print their local results BEFORE calling this."""
|
|
293
|
+
import os
|
|
294
|
+
import urllib.request
|
|
295
|
+
url = getattr(args, "report_to", None) or os.environ.get("PHPWARDEN_REPORT_URL")
|
|
296
|
+
token = getattr(args, "report_token", None) or os.environ.get("PHPWARDEN_REPORT_TOKEN")
|
|
297
|
+
if not url:
|
|
298
|
+
return True
|
|
299
|
+
body = json.dumps(doc).encode()
|
|
300
|
+
req = urllib.request.Request(url, data=body, method="POST",
|
|
301
|
+
headers={"Content-Type": "application/json",
|
|
302
|
+
"Authorization": f"Bearer {token}" if token else "",
|
|
303
|
+
"User-Agent": "phpwarden/2"})
|
|
304
|
+
try:
|
|
305
|
+
with urllib.request.urlopen(req, timeout=20) as r: # noqa: S310
|
|
306
|
+
res = json.loads(r.read())
|
|
307
|
+
sys.stderr.write(f"reported to {url}: stored {res.get('stored', '?')} findings"
|
|
308
|
+
+ (f", NEW actively-exploited: {', '.join(res['new_kev'])}"
|
|
309
|
+
if res.get("new_kev") else "") + "\n")
|
|
310
|
+
return True
|
|
311
|
+
except Exception as e: # noqa: BLE001
|
|
312
|
+
detail = f"HTTP {e.code}" if hasattr(e, "code") else e.__class__.__name__
|
|
313
|
+
sys.stderr.write(f"report-to failed ({detail}); results shown locally, exit {EX_REPORT_FAILED} "
|
|
314
|
+
f"if the run was otherwise clean\n")
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _report_args(parser):
|
|
319
|
+
parser.add_argument("--report-to", metavar="URL", help="POST results to a phpwarden SaaS ingest URL")
|
|
320
|
+
parser.add_argument("--report-token", metavar="TOKEN", help="project ingest token (or PHPWARDEN_REPORT_TOKEN)")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _write_out(path, text) -> bool:
|
|
324
|
+
"""Write generated output to --out; a missing directory or unwritable path
|
|
325
|
+
is reported as `error: cannot write ...` instead of a traceback."""
|
|
326
|
+
from pathlib import Path
|
|
327
|
+
try:
|
|
328
|
+
Path(path).write_text(text)
|
|
329
|
+
return True
|
|
330
|
+
except OSError as e:
|
|
331
|
+
sys.stderr.write(f"error: cannot write {path}: {e.strerror or e}\n")
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _rules(args):
|
|
336
|
+
feed = FeedManager().current()
|
|
337
|
+
text = rulegen.generate(feed, args.format)
|
|
338
|
+
if args.out:
|
|
339
|
+
if not _write_out(args.out, text):
|
|
340
|
+
return 4
|
|
341
|
+
print(f"wrote {args.format} rules to {args.out}")
|
|
342
|
+
else:
|
|
343
|
+
print(text)
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _wordpress(args):
|
|
348
|
+
feed = FeedManager().current()
|
|
349
|
+
findings = wp.scan(args.path, feed)
|
|
350
|
+
doc = {"wordpress": args.path, "host": args.host, "findings": [{
|
|
351
|
+
"kind": f.component.kind, "slug": f.component.slug, "version": f.component.version,
|
|
352
|
+
"cve": f.advisory.cve, "severity": f.advisory.severity, "cvss": f.advisory.cvss,
|
|
353
|
+
"known_exploited": f.advisory.known_exploited, "ransomware": f.advisory.ransomware,
|
|
354
|
+
"epss": f.advisory.epss, "epss_percentile": f.advisory.epss_percentile,
|
|
355
|
+
"title": f.advisory.title, "fixed": f.fixed} for f in findings]}
|
|
356
|
+
if args.json:
|
|
357
|
+
print(json.dumps(doc, indent=2))
|
|
358
|
+
else:
|
|
359
|
+
inv = wp.inventory(args.path)
|
|
360
|
+
print(f"WordPress at {args.path}: {len(inv)} components scanned, {len(findings)} vulnerable.")
|
|
361
|
+
for f in findings:
|
|
362
|
+
print(f"\n {f.component.kind} {f.component.slug} {f.component.version} "
|
|
363
|
+
f"{f.advisory.cve} {_sev(f.advisory.severity, f.advisory.cvss)}"
|
|
364
|
+
f"{_risk(f.advisory.known_exploited, f.advisory.epss, f.advisory.epss_percentile, f.advisory.ransomware)}")
|
|
365
|
+
print(f" {f.advisory.title}")
|
|
366
|
+
print(f" fixed in: {', '.join(f.fixed)}")
|
|
367
|
+
if not inv:
|
|
368
|
+
print(" (no WordPress install detected at that path)")
|
|
369
|
+
return _combine(1 if findings else 0, _maybe_report(args, doc))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _report(args):
|
|
373
|
+
rows = reportgen.load_rows(args.files)
|
|
374
|
+
feed = FeedManager().current()
|
|
375
|
+
html_out = reportgen.render_html(rows, feed.feed_version)
|
|
376
|
+
if args.out:
|
|
377
|
+
if not _write_out(args.out, html_out):
|
|
378
|
+
return 4
|
|
379
|
+
print(f"wrote fleet report ({len(rows)} findings) to {args.out}")
|
|
380
|
+
else:
|
|
381
|
+
print(html_out)
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def main(argv=None):
|
|
386
|
+
# _Parser exits 64 on usage errors (argparse's default 2 is "possibly
|
|
387
|
+
# exposed"); add_subparsers() reuses the parent's class, so the subcommand
|
|
388
|
+
# parsers inherit it.
|
|
389
|
+
p = _Parser(prog="phpwarden",
|
|
390
|
+
description="PHP exposure and exploitation guard.",
|
|
391
|
+
epilog=EXIT_CODES_HELP,
|
|
392
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
393
|
+
p.add_argument("--version", action="version", version=f"phpwarden {__version__}")
|
|
394
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
395
|
+
|
|
396
|
+
sc = sub.add_parser("scan", help="assess this host (or a given version) against the feed")
|
|
397
|
+
sc.add_argument("--php", default="php", help="php binary (default: php)")
|
|
398
|
+
sc.add_argument("--version", dest="version", help="assess an explicit version instead of the local one")
|
|
399
|
+
sc.add_argument("--sapi", choices=["fpm", "cgi", "cli"], help="override detected SAPI")
|
|
400
|
+
sc.add_argument("--os", choices=["linux", "windows", "darwin"], help="override detected OS")
|
|
401
|
+
sc.add_argument("--json", action="store_true")
|
|
402
|
+
_report_args(sc)
|
|
403
|
+
sc.set_defaults(func=_scan)
|
|
404
|
+
|
|
405
|
+
fd = sub.add_parser("feed", help="show or update the CVE feed")
|
|
406
|
+
fd.add_argument("feed_cmd", choices=["status", "update"])
|
|
407
|
+
fd.add_argument("--json", action="store_true")
|
|
408
|
+
fd.set_defaults(func=_feed)
|
|
409
|
+
|
|
410
|
+
dt = sub.add_parser("detect", help="scan a log file for exploitation signatures")
|
|
411
|
+
dt.add_argument("logfile")
|
|
412
|
+
dt.add_argument("--json", action="store_true")
|
|
413
|
+
_report_args(dt)
|
|
414
|
+
dt.set_defaults(func=_detect)
|
|
415
|
+
|
|
416
|
+
dp = sub.add_parser("deps", help="scan a composer.lock for vulnerable dependencies")
|
|
417
|
+
dp.add_argument("lockfile")
|
|
418
|
+
dp.add_argument("--json", action="store_true")
|
|
419
|
+
dp.add_argument("--project", metavar="DIR",
|
|
420
|
+
help="project source dir for reachability (default: the lockfile's dir)")
|
|
421
|
+
dp.add_argument("--no-reachability", action="store_true",
|
|
422
|
+
help="skip checking whether vulnerable packages are referenced in code")
|
|
423
|
+
_report_args(dp)
|
|
424
|
+
dp.set_defaults(func=_deps)
|
|
425
|
+
|
|
426
|
+
rl = sub.add_parser("rules", help="generate WAF blocking rules from the feed signatures")
|
|
427
|
+
rl.add_argument("--format", choices=["modsecurity", "nginx"], default="modsecurity")
|
|
428
|
+
rl.add_argument("--out", help="write to a file instead of stdout")
|
|
429
|
+
rl.set_defaults(func=_rules)
|
|
430
|
+
|
|
431
|
+
wpc = sub.add_parser("wordpress", help="scan a WordPress install for vulnerable core/plugins/themes")
|
|
432
|
+
wpc.add_argument("path", help="path to the WordPress root")
|
|
433
|
+
wpc.add_argument("--host", help="host label for fleet reports")
|
|
434
|
+
wpc.add_argument("--json", action="store_true")
|
|
435
|
+
_report_args(wpc)
|
|
436
|
+
wpc.set_defaults(func=_wordpress)
|
|
437
|
+
|
|
438
|
+
rp = sub.add_parser("report", help="aggregate JSON results into an HTML fleet dashboard")
|
|
439
|
+
rp.add_argument("files", nargs="+", help="JSON files from scan/deps/wordpress --json")
|
|
440
|
+
rp.add_argument("--out", help="write HTML to a file instead of stdout")
|
|
441
|
+
rp.set_defaults(func=_report)
|
|
442
|
+
|
|
443
|
+
args = p.parse_args(argv)
|
|
444
|
+
return args.func(args)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
if __name__ == "__main__":
|
|
448
|
+
sys.exit(main())
|