php-radar 0.1.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.
php_radar/__init__.py ADDED
File without changes
php_radar/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from php_radar.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
php_radar/cli.py ADDED
@@ -0,0 +1,375 @@
1
+ """The php-radar command line, with one subcommand for each way of finding PHP.
2
+
3
+ repo reads a checkout or a GitHub organization, network sweeps a scope file, host reads this
4
+ machine or one reached over ssh, and cloud lists an account. Each gathers evidence into an
5
+ AssetInventory, grades it against the PHP2Shell matrix, and prints a console, JSON, or SARIF
6
+ report. The exit status is 1 when any asset grades EXPOSED and 2 for a usage or scope error.
7
+ Otherwise it is 0 when no read was skipped and 3 when one was, so a scan with a blind spot cannot
8
+ pass as clean. A network sweep exits 0 or 1. A target that does not answer is a finished check,
9
+ and the count line that ends the report shows how much of the scope was reached. A crash in
10
+ php-radar itself exits 4 with its traceback on stderr, and Ctrl-C exits 130. Left alone, a crash
11
+ would exit 1, which reads as an exposed asset.
12
+
13
+ Credentials come from the environment and the standard chains and never from a flag. Notes about
14
+ how far a run reached, such as the regions read or the targets a sweep left out, close a console
15
+ report. They go to stderr as well, except when that report is printed on stdout. So stdout stays
16
+ one document with json or sarif.
17
+ """
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ import traceback
23
+ from typing import NamedTuple, Optional
24
+
25
+ from php_radar.knowledge import DATA_PATH, Matrix, MatrixError, grade, load_matrix
26
+ from php_radar.model import Asset, AssetInventory, Evidence, Source, Verdict
27
+ # The report escapes whatever is not printable ASCII. The notes here carry names and paths typed
28
+ # at the keyboard, and they follow the same rule.
29
+ from php_radar.report import _plain, render_console, render_json, render_sarif
30
+ from php_radar.scope import ScopeError, guard, parse_scope
31
+ from php_radar.sources.cloud import get_cloud_source
32
+ from php_radar.sources.github import scan_github_org
33
+ from php_radar.sources.host import inspect_local, inspect_ssh
34
+ from php_radar.sources.network import sweep
35
+ from php_radar.sources.repo import scan_path
36
+
37
+ # A sweep starts at most this many probes a second. A /24 on the default ports takes about a
38
+ # minute. That pace is gentle on the network under test.
39
+ RATE = 10.0
40
+ DEFAULT_MAX_HOSTS = 1024
41
+ DEFAULT_WORKERS = 16
42
+ # At RATE, with a 5 second wait on a silent host, about 50 probes are in flight at once. More
43
+ # workers than this add nothing at that pace.
44
+ MAX_WORKERS = 64
45
+
46
+ _FORMATS = {"console": render_console, "json": render_json, "sarif": render_sarif}
47
+
48
+
49
+ class _Usage(Exception):
50
+ """The command cannot be carried out as given. The message says why."""
51
+
52
+
53
+ class _Run(NamedTuple):
54
+ evidence: list[Evidence]
55
+ notes: list[str] # what a reader needs to know about how far the run reached
56
+ scan: Optional[dict] = None # the same facts as numbers, for a JSON or SARIF document
57
+
58
+
59
+ def _whole(text: str) -> int:
60
+ try:
61
+ number = int(text)
62
+ except ValueError:
63
+ number = 0
64
+ if number < 1:
65
+ raise argparse.ArgumentTypeError(f"{text!r} is not a whole number of 1 or more")
66
+ return number
67
+
68
+
69
+ def _region(text: str) -> str:
70
+ name = text.strip()
71
+ if not name:
72
+ raise argparse.ArgumentTypeError("a region name cannot be empty")
73
+ return name
74
+
75
+
76
+ def _parser() -> argparse.ArgumentParser:
77
+ shared = argparse.ArgumentParser(add_help=False)
78
+ shared.add_argument("--format", choices=list(_FORMATS), default="console",
79
+ help="report format (default: console)")
80
+ shared.add_argument("--output", metavar="FILE",
81
+ help="write the report to FILE (default: stdout)")
82
+
83
+ parser = argparse.ArgumentParser(
84
+ prog="php-radar", allow_abbrev=False,
85
+ formatter_class=argparse.RawDescriptionHelpFormatter,
86
+ description="Finds PHP and grades each find against PHP2Shell (CVE-2026-109618 and "
87
+ "CVE-2026-109619).\nIt detects. Nothing it sends is meant to trigger the bug.",
88
+ epilog="exit status:\n"
89
+ " 0 no asset grades EXPOSED and no read was skipped\n"
90
+ " 1 at least one asset grades EXPOSED\n"
91
+ " 2 a usage or scope error\n"
92
+ " 3 no asset grades EXPOSED, but a read was skipped (an unreadable file, a\n"
93
+ " failed GitHub or AWS call, a host that could not be inspected), so the\n"
94
+ " scan is incomplete\n"
95
+ " 4 an unexpected error in php-radar itself, with its traceback on stderr\n"
96
+ "\n"
97
+ "Ctrl-C ends a run with status 130.\n"
98
+ "\n"
99
+ "A network sweep exits 0 or 1, because a target that does not answer is a\n"
100
+ "finished check. A sweep that reached nothing exits 0 too. The count line that\n"
101
+ "ends its report (targets probed, reported, and left out) and the notes on its\n"
102
+ "rows show what was reached.\n"
103
+ "\n"
104
+ "Credentials are never taken from a flag. repo reads GITHUB_TOKEN from the\n"
105
+ "environment, and cloud uses the standard AWS credential chain.")
106
+ commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
107
+
108
+ def command(name: str, **kwargs) -> argparse.ArgumentParser:
109
+ # Flags are spelled in full, so --allow cannot stand in for the public override.
110
+ return commands.add_parser(name, parents=[shared], allow_abbrev=False, **kwargs)
111
+
112
+ repo = command("repo", help="find PHP pins in a checkout or a GitHub organization")
113
+ repo.add_argument("path", nargs="?", help="folder to scan, or give --github-org")
114
+ repo.add_argument("--github-org", metavar="NAME",
115
+ help="scan the default branches of this GitHub organization. GITHUB_TOKEN "
116
+ "is used when it is set")
117
+ repo.set_defaults(handler=_repo)
118
+
119
+ network = command(
120
+ "network", help="sweep the targets in a scope file for PHP",
121
+ description="Probes each target in the scope file with one GET of / and reads the "
122
+ "headers of the answer. The scope file is the authorization boundary.")
123
+ network.add_argument("--scope", required=True, metavar="FILE",
124
+ help="the targets to probe, one per line: an IPv4 address, an IPv4 "
125
+ "range in CIDR form, or a hostname, each with an optional :port")
126
+ network.add_argument("--allow-public", action="store_true",
127
+ help="turn on the explicit public override: public addresses and every "
128
+ "hostname but localhost. Without it only private space is probed")
129
+ network.add_argument("--max-hosts", type=_whole, default=DEFAULT_MAX_HOSTS, metavar="N",
130
+ help="most targets to probe. A target is a host and port pair, so a "
131
+ f"host on two ports counts twice (default {DEFAULT_MAX_HOSTS})")
132
+ network.add_argument("--workers", type=_whole, default=DEFAULT_WORKERS, metavar="N",
133
+ help=f"probes that can wait on a host at once, up to {MAX_WORKERS} "
134
+ f"(default {DEFAULT_WORKERS})")
135
+ network.set_defaults(handler=_network)
136
+
137
+ host = command("host", help="read the PHP installed on this machine or over ssh")
138
+ host.add_argument("--ssh", metavar="TARGET",
139
+ help="inspect [USER@]HOST over ssh. Without the flag this machine is read")
140
+ host.set_defaults(handler=_host)
141
+
142
+ cloud = command("cloud", help="list the PHP an account runs, using read-only calls")
143
+ cloud.add_argument("--provider", required=True, metavar="NAME", help="the cloud to read: aws")
144
+ cloud.add_argument("--region", action="append", type=_region, metavar="NAME",
145
+ help="a region to read, repeated for several. Without it the session's "
146
+ "own region is read")
147
+ cloud.set_defaults(handler=_cloud)
148
+ return parser
149
+
150
+
151
+ def _say(line: str) -> None:
152
+ print(_plain(line), file=sys.stderr)
153
+
154
+
155
+ def _count(number: int, noun: str) -> str:
156
+ return f"{number} {noun}" if number == 1 else f"{number} {noun}s"
157
+
158
+
159
+ def _repo(args: argparse.Namespace) -> _Run:
160
+ if (args.path is None) == (args.github_org is None):
161
+ raise _Usage("name a path or --github-org, and only one of them")
162
+ if args.github_org is not None:
163
+ return _github(args.github_org)
164
+ if not args.path.strip():
165
+ # realpath("") is the current folder, and an unset shell variable makes an empty argument.
166
+ raise _Usage("the path is empty, and an empty path would scan the current folder")
167
+ try:
168
+ found = scan_path(args.path)
169
+ except OSError as exc:
170
+ raise _Usage(f"cannot read {args.path}: {exc.strerror or type(exc).__name__}") from exc
171
+ return _Run(found, [] if found else [f"no PHP pins found under {args.path}."])
172
+
173
+
174
+ def _github(org: str) -> _Run:
175
+ token = os.environ.get("GITHUB_TOKEN", "")
176
+ try:
177
+ found = scan_github_org(org, token)
178
+ except ValueError as exc:
179
+ # The client says what it refused and never quotes the token.
180
+ raise _Usage(f"cannot scan {org!r}: {exc}") from exc
181
+ notes = []
182
+ if not token.strip():
183
+ notes.append("GITHUB_TOKEN is empty or not set, so the scan is anonymous. It sees public "
184
+ "repositories only, and GitHub allows 60 requests an hour.")
185
+ if not found:
186
+ notes.append(f"no PHP pins found for {org}. That can mean its repositories pin no PHP, "
187
+ "or that none of them was visible to this scan.")
188
+ return _Run(found, notes)
189
+
190
+
191
+ def _network(args: argparse.Namespace) -> _Run:
192
+ try:
193
+ scope = parse_scope(args.scope)
194
+ targets = guard(scope, args.allow_public, args.max_hosts)
195
+ except ScopeError as exc:
196
+ raise _Usage(str(exc)) from exc
197
+ workers = min(args.workers, MAX_WORKERS)
198
+ if workers < args.workers:
199
+ _say(f"php-radar network: --workers {args.workers} is above the limit of {MAX_WORKERS}, "
200
+ f"so {MAX_WORKERS} are used.")
201
+ _say(f"php-radar network: about to probe {_count(len(targets), 'target')} (host and port "
202
+ f"pairs) from the scope file {args.scope}.")
203
+ _say("php-radar network: authorization acknowledgment. By continuing you confirm that you own "
204
+ "these systems or hold written permission to test them.")
205
+ if args.allow_public:
206
+ _say("php-radar network: --allow-public is set, so public addresses and every hostname "
207
+ "in the scope file can be probed.")
208
+ found = sweep(targets, RATE, workers)
209
+ # sweep drops a host that answered with no sign of PHP, so a clean perimeter comes back
210
+ # empty. The count tells it from a sweep that probed nothing.
211
+ probed, reported = len(targets), len(found)
212
+ left = probed - reported
213
+ note = (f"probed {_count(probed, 'target')}, reported {reported} of them, left out {left} "
214
+ "that answered with no sign of PHP.")
215
+ scan = {"targets_probed": probed, "targets_reported": reported,
216
+ "targets_answered_without_php": left}
217
+ return _Run(found, [note], scan)
218
+
219
+
220
+ def _host(args: argparse.Namespace) -> _Run:
221
+ # An empty --ssh is still a request for ssh. Only an absent flag means this machine.
222
+ ev = inspect_local() if args.ssh is None else inspect_ssh(args.ssh)
223
+ return _Run([ev], [])
224
+
225
+
226
+ def _cloud(args: argparse.Namespace) -> _Run:
227
+ try:
228
+ source = get_cloud_source(args.provider)
229
+ except (RuntimeError, ValueError) as exc:
230
+ # RuntimeError: the extra is missing or no session starts. ValueError: no such provider.
231
+ raise _Usage(str(exc)) from exc
232
+ if args.region:
233
+ # get_cloud_source takes no region, and inventory() reads this attribute on each call. A
234
+ # region named twice would be read twice.
235
+ source.regions = tuple(dict.fromkeys(args.region))
236
+ return _Run(source.inventory(), _regions_note(source, bool(args.region)))
237
+
238
+
239
+ def _regions_note(source: object, chosen: bool) -> list[str]:
240
+ # One region is read by default, so a clean report says nothing about the others.
241
+ regions = getattr(source, "regions", None)
242
+ if regions is None:
243
+ return []
244
+ names = [name for name in regions if name]
245
+ if not names:
246
+ return ["no AWS region is configured, so no region was read. Pass --region or set "
247
+ "AWS_DEFAULT_REGION."]
248
+ note = f"AWS regions read: {', '.join(names)}."
249
+ return [note if chosen else note + " Only these are covered. Repeat --region to read more."]
250
+
251
+
252
+ def _is_skipped(ev: Evidence) -> bool:
253
+ """True for a row that records a read that could not be made or could not conclude."""
254
+ # Network rows never count. A range holds many addresses with no listener, and on an outside
255
+ # sweep each is a finished check. Counting them would make nearly every sweep exit 3.
256
+ if ev.source is Source.NETWORK:
257
+ return False
258
+ # The host source leaves php_present unset and says why in the note. A refused ssh target, a
259
+ # dead link, and a php that would not answer all read that way.
260
+ if ev.source is Source.HOST:
261
+ return not ev.signals.get("php_present")
262
+ # The repo, GitHub, and cloud sources mark a read they could not make with the kind skipped.
263
+ return ev.signals.get("kind") == "skipped"
264
+
265
+
266
+ def _skipped_note(skipped: int) -> list[str]:
267
+ # A read that finds no PHP leaves no row, so the rows cannot say how many reads were made.
268
+ if not skipped:
269
+ return []
270
+ return [f"{_count(skipped, 'read')} skipped, so the result is incomplete. The notes on those "
271
+ "rows give the reasons."]
272
+
273
+
274
+ def _render(fmt: str, assets: list[Asset], matrix: Matrix, scan: Optional[dict]) -> str:
275
+ text = _FORMATS[fmt](assets, matrix)
276
+ if scan is None or fmt == "console":
277
+ return text
278
+ doc = json.loads(text)
279
+ if fmt == "json":
280
+ doc["scan"] = scan
281
+ else:
282
+ doc["runs"][0].setdefault("properties", {})["scan"] = scan
283
+ return json.dumps(doc, indent=2)
284
+
285
+
286
+ def _unwritable(path: str, exc: OSError) -> _Usage:
287
+ return _Usage(f"cannot write {path}: {exc.strerror or type(exc).__name__}")
288
+
289
+
290
+ def _check_output(path: Optional[str]) -> None:
291
+ # Checked before the run, because a sweep can take minutes and a report that cannot be
292
+ # written would lose its findings, an exposed asset among them.
293
+ if path is None:
294
+ return
295
+ if not path.strip():
296
+ raise _Usage("the output path is empty")
297
+ if os.path.isdir(path):
298
+ raise _Usage(f"cannot write {path}: it is a folder")
299
+ if not os.path.isdir(os.path.dirname(os.path.abspath(path))):
300
+ raise _Usage(f"cannot write {path}: its folder does not exist")
301
+ # Open for append, which leaves an existing file as it is. A file made by the probe is removed
302
+ # again, so a run that stops early leaves no empty report behind.
303
+ made = not os.path.lexists(path)
304
+ try:
305
+ with open(path, "ab"):
306
+ pass
307
+ except OSError as exc:
308
+ raise _unwritable(path, exc) from exc
309
+ if made:
310
+ try:
311
+ os.remove(path)
312
+ except OSError:
313
+ pass # an empty file left behind is harmless, since the final write replaces it
314
+
315
+
316
+ def _deliver(args: argparse.Namespace, report: str, notes: list[str]) -> None:
317
+ lines = [f"php-radar {args.command}: {note}" for note in notes]
318
+ console = args.format == "console"
319
+ if console and lines:
320
+ report += "\n\n" + "\n".join(_plain(line) for line in lines)
321
+ if args.output is None:
322
+ print(report)
323
+ else:
324
+ try:
325
+ # LF on every platform, so a report file is byte-identical wherever it was made.
326
+ with open(args.output, "w", encoding="utf-8", newline="\n") as f:
327
+ f.write(report + "\n")
328
+ except OSError as exc:
329
+ raise _unwritable(args.output, exc) from exc
330
+ # A console report on stdout already ends with its notes.
331
+ if not (console and args.output is None):
332
+ for line in lines:
333
+ _say(line)
334
+
335
+
336
+ def _execute(args: argparse.Namespace) -> int:
337
+ # The matrix loads first, so a bad edit to the data file stops the run before any probe.
338
+ matrix = load_matrix(DATA_PATH)
339
+ _check_output(args.output)
340
+ run = args.handler(args)
341
+ inventory = AssetInventory()
342
+ for ev in run.evidence:
343
+ inventory.add(ev)
344
+ assets = inventory.assets()
345
+ skipped = sum(1 for ev in run.evidence if _is_skipped(ev))
346
+ _deliver(args, _render(args.format, assets, matrix, run.scan),
347
+ run.notes + _skipped_note(skipped))
348
+ if any(grade(a, matrix)[0] is Verdict.EXPOSED for a in assets):
349
+ return 1
350
+ return 3 if skipped else 0
351
+
352
+
353
+ def main(argv: Optional[list[str]] = None) -> int:
354
+ """Run php-radar on argv (the arguments after the program name) and return its exit status."""
355
+ try:
356
+ args = _parser().parse_args(argv)
357
+ except SystemExit as exc:
358
+ # argparse has printed its help or its complaint. It exits 0 for help, 2 for a usage error.
359
+ return exc.code if isinstance(exc.code, int) else 2
360
+ try:
361
+ return _execute(args)
362
+ except (_Usage, MatrixError) as exc:
363
+ _say(f"php-radar {args.command}: error: {exc}")
364
+ return 2
365
+ except KeyboardInterrupt:
366
+ _say(f"php-radar {args.command}: interrupted")
367
+ return 130
368
+ except Exception as exc:
369
+ # A bug. Python would exit 1 here, so the crash would read as an exposed asset. The
370
+ # traceback goes through the same escaping as every other line.
371
+ for line in traceback.format_exc().rstrip("\n").split("\n"):
372
+ _say(line)
373
+ _say(f"php-radar {args.command}: unexpected {type(exc).__name__}, so the run did not "
374
+ "finish.")
375
+ return 4
@@ -0,0 +1,57 @@
1
+ {
2
+ "notes": "PHP2Shell (CVE-2026-109618 and CVE-2026-109619), hand-edited from the 2026-10-21 advisories. php-radar reads this file at run time, so a new fix or distro build is an edit here and no code change. JSON has no comments, so every entry carries its own notes field.",
3
+ "fixed": {
4
+ "8.5": {"micro": 12, "notes": "Upstream fix, released 2026-10-21 as 8.5.12."},
5
+ "8.4": {"micro": 27, "notes": "Upstream fix, released 2026-10-21 as 8.4.27."},
6
+ "8.3": {"micro": 36, "notes": "Upstream fix, released 2026-10-21 as 8.3.36."},
7
+ "8.2": {"micro": 35, "notes": "Upstream fix, released 2026-10-21 as 8.2.35."}
8
+ },
9
+ "eol": {
10
+ "through": "8.1",
11
+ "notes": "8.1, 8.0, 7.4 and every older branch are end of life, and upstream shipped no PHP2Shell fix for any of them. A distro or vendor that patched one anyway goes in backports, which the grader checks before this rule."
12
+ },
13
+ "backports": [
14
+ {
15
+ "distro": "ubuntu",
16
+ "build": "8.1.2-1ubuntu2.28",
17
+ "verdict": "LIKELY_PATCHED",
18
+ "notes": "Ubuntu 22.04 LTS php8.1, fixed in USN-8948-1 and USN-8948-2. Ubuntu patched its own 8.1.2 base, so the 8.1.2 that php -v prints says nothing about the fix. Later revisions of the same series count as patched."
19
+ },
20
+ {
21
+ "distro": "debian",
22
+ "build": "8.4.27-1~deb13u1",
23
+ "verdict": "LIKELY_PATCHED",
24
+ "notes": "Debian 13 (trixie) php8.4, fixed in DSA-6574-1. Later revisions of the same series count as patched."
25
+ },
26
+ {
27
+ "distro": "debian",
28
+ "build": "8.2.35-1~deb12u1",
29
+ "verdict": "LIKELY_PATCHED",
30
+ "notes": "Debian 12 (bookworm) php8.2, fixed in DLA-4848-1, an LTS advisory. Later revisions of the same series count as patched."
31
+ },
32
+ {
33
+ "distro": "alpine",
34
+ "build": "8.3.36-r0",
35
+ "verdict": "LIKELY_PATCHED",
36
+ "notes": "Alpine 3.24 php83, listed as fixed in the v3.24 community secdb. Alpine 3.23 and older list no fix, so they are not here."
37
+ },
38
+ {
39
+ "distro": "alpine",
40
+ "build": "8.4.27-r0",
41
+ "verdict": "LIKELY_PATCHED",
42
+ "notes": "Alpine 3.24 php84, listed as fixed in the v3.24 community secdb."
43
+ },
44
+ {
45
+ "distro": "alpine",
46
+ "build": "8.5.12-r0",
47
+ "verdict": "LIKELY_PATCHED",
48
+ "notes": "Alpine 3.24 php85, listed as fixed in the v3.24 community secdb."
49
+ },
50
+ {
51
+ "distro": "suse",
52
+ "build": "8.3.36",
53
+ "verdict": "LIKELY_PATCHED",
54
+ "notes": "SUSE Linux Enterprise 15 SP7 php8. SUSE released it as 8.3.36-150700.3.24.1. The bare version covers every release built from 8.3.36 and any later 8.3 point release."
55
+ }
56
+ ]
57
+ }