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 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())