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 +0 -0
- php_radar/__main__.py +6 -0
- php_radar/cli.py +375 -0
- php_radar/data/php2shell.json +57 -0
- php_radar/http.py +276 -0
- php_radar/knowledge.py +339 -0
- php_radar/model.py +143 -0
- php_radar/report.py +430 -0
- php_radar/scope.py +174 -0
- php_radar/sources/__init__.py +0 -0
- php_radar/sources/cloud/__init__.py +64 -0
- php_radar/sources/cloud/aws.py +435 -0
- php_radar/sources/github.py +399 -0
- php_radar/sources/host.py +571 -0
- php_radar/sources/network.py +375 -0
- php_radar/sources/repo.py +579 -0
- php_radar/versions.py +51 -0
- php_radar-0.1.0.dist-info/METADATA +97 -0
- php_radar-0.1.0.dist-info/RECORD +24 -0
- php_radar-0.1.0.dist-info/WHEEL +5 -0
- php_radar-0.1.0.dist-info/entry_points.txt +2 -0
- php_radar-0.1.0.dist-info/licenses/LICENSE +202 -0
- php_radar-0.1.0.dist-info/licenses/NOTICE +4 -0
- php_radar-0.1.0.dist-info/top_level.txt +1 -0
php_radar/http.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""A small HTTP client for the sources that read from a server.
|
|
2
|
+
|
|
3
|
+
get makes one GET and returns the answer, whatever its status. It never follows a redirect and
|
|
4
|
+
never uses a proxy. A header such as Authorization therefore reaches only the host it was written
|
|
5
|
+
for. A Location header cannot lead a probe out of its scope. A caller that wants to follow a
|
|
6
|
+
redirect reads the Location of the answer and decides.
|
|
7
|
+
|
|
8
|
+
The certificate of an https host is checked. Only verify=False turns that off. A scanner uses it to
|
|
9
|
+
read what a host presents, and it believes none of that. get then takes only a User-Agent and an
|
|
10
|
+
Accept from the caller. Any other header is refused. http.client adds Host and Accept-Encoding to
|
|
11
|
+
every request.
|
|
12
|
+
"""
|
|
13
|
+
import functools
|
|
14
|
+
import ssl
|
|
15
|
+
import time
|
|
16
|
+
from http.client import HTTPConnection, HTTPException, HTTPResponse, HTTPSConnection
|
|
17
|
+
from typing import NamedTuple, Optional
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
# The most of an answer that is read. The caps bound what one hostile or broken server can cost.
|
|
21
|
+
_MAX_BODY = 32 << 20
|
|
22
|
+
|
|
23
|
+
# The most of the status line and headers that is read, in all. http.client alone allows 100 lines
|
|
24
|
+
# of 64 KiB each.
|
|
25
|
+
_MAX_HEAD = 64 << 10
|
|
26
|
+
|
|
27
|
+
_CHUNK = 1 << 16
|
|
28
|
+
|
|
29
|
+
# The whole read of an answer, from its status line to the end of its body, may take this many
|
|
30
|
+
# timeouts. A timeout limits one wait on the socket, so a server that sends a byte per wait could
|
|
31
|
+
# otherwise hold the read for as long as it liked.
|
|
32
|
+
_READ_WAITS = 4
|
|
33
|
+
|
|
34
|
+
# The clock goes through this name so that a test can move it.
|
|
35
|
+
_now = time.monotonic
|
|
36
|
+
|
|
37
|
+
# The only headers a caller may pass where the certificate is not checked. That peer may be an
|
|
38
|
+
# impostor, so no credential may reach it. A list of the credential headers would miss the next one
|
|
39
|
+
# (X-Api-Key, X-Auth-Token, a name with a space beside it, a name given as bytes), so this lists
|
|
40
|
+
# the two headers that carry none.
|
|
41
|
+
_UNVERIFIED_HEADERS = frozenset({"user-agent", "accept"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TransportError(OSError):
|
|
45
|
+
"""There was no answer: the host did not respond, the exchange broke off, or the answer ran
|
|
46
|
+
past the cap. The message says which, and never holds a request header."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Response(NamedTuple):
|
|
50
|
+
status: int
|
|
51
|
+
headers: dict[str, str] # lower-case names, and a repeated header joined with a comma
|
|
52
|
+
body: bytes
|
|
53
|
+
cert: Optional[bytes] = None # DER of the certificate an https server presented, else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Budget:
|
|
57
|
+
"""The time allowed for reading an answer. One timeout limits a wait. This limits them all."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, seconds: float) -> None:
|
|
60
|
+
self._seconds = seconds
|
|
61
|
+
self._end = _now() + seconds
|
|
62
|
+
|
|
63
|
+
def check(self) -> None:
|
|
64
|
+
"""Call before each wait. It raises once the time is up. A wait that began just before
|
|
65
|
+
can still run for a timeout, so a read ends within one timeout of its deadline."""
|
|
66
|
+
if _now() >= self._end:
|
|
67
|
+
raise TransportError(f"the answer took longer than {self._seconds:g} seconds to arrive")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Reader:
|
|
71
|
+
"""The file an answer reads from, with each wait on the socket counted against the budget.
|
|
72
|
+
|
|
73
|
+
A buffered file blocks in readline and read until a whole line or size has arrived, one wait
|
|
74
|
+
after another, and nothing in between can stop it. Here each wait is taken alone, by peek
|
|
75
|
+
and read1, and the budget is checked before it. Everything else goes to the file.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, fp, budget: _Budget) -> None:
|
|
79
|
+
self._fp = fp
|
|
80
|
+
self._budget = budget
|
|
81
|
+
|
|
82
|
+
def __getattr__(self, name: str):
|
|
83
|
+
return getattr(self._fp, name)
|
|
84
|
+
|
|
85
|
+
def readline(self, size: Optional[int] = -1) -> bytes:
|
|
86
|
+
limit = None if size is None or size < 0 else size
|
|
87
|
+
line = bytearray()
|
|
88
|
+
while limit is None or len(line) < limit:
|
|
89
|
+
self._budget.check()
|
|
90
|
+
ready = self._fp.peek(1) # at most one wait, and it returns all that is buffered
|
|
91
|
+
if not ready:
|
|
92
|
+
break
|
|
93
|
+
end = ready.find(b"\n") + 1 or len(ready)
|
|
94
|
+
line += self._fp.read(end if limit is None else min(end, limit - len(line)))
|
|
95
|
+
if line.endswith(b"\n"):
|
|
96
|
+
break
|
|
97
|
+
return bytes(line)
|
|
98
|
+
|
|
99
|
+
def read(self, size: Optional[int] = -1) -> bytes:
|
|
100
|
+
limit = None if size is None or size < 0 else size
|
|
101
|
+
data = bytearray()
|
|
102
|
+
while limit is None or len(data) < limit:
|
|
103
|
+
self._budget.check()
|
|
104
|
+
part = self._fp.read1(_CHUNK if limit is None else limit - len(data))
|
|
105
|
+
if not part:
|
|
106
|
+
break
|
|
107
|
+
data += part
|
|
108
|
+
return bytes(data)
|
|
109
|
+
|
|
110
|
+
def read1(self, size: int = -1) -> bytes:
|
|
111
|
+
self._budget.check()
|
|
112
|
+
return self._fp.read1(size)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Head:
|
|
116
|
+
"""The file an answer reads its status line and headers from, cut off after _MAX_HEAD bytes.
|
|
117
|
+
|
|
118
|
+
Everything but readline goes to the file underneath, because http.client also closes it from
|
|
119
|
+
there.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, fp) -> None:
|
|
123
|
+
self._fp = fp
|
|
124
|
+
self._left = _MAX_HEAD
|
|
125
|
+
|
|
126
|
+
def __getattr__(self, name: str):
|
|
127
|
+
return getattr(self._fp, name)
|
|
128
|
+
|
|
129
|
+
def readline(self, size: int = -1) -> bytes:
|
|
130
|
+
# One byte more than is left is asked for, so that a head over the cap is seen as over.
|
|
131
|
+
limit = self._left + 1 if size is None or size < 0 else min(size, self._left + 1)
|
|
132
|
+
line = self._fp.readline(limit)
|
|
133
|
+
self._left -= len(line)
|
|
134
|
+
if self._left < 0:
|
|
135
|
+
raise TransportError(f"the status line and headers are longer than {_MAX_HEAD} bytes")
|
|
136
|
+
return line
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _Answer(HTTPResponse):
|
|
140
|
+
"""An HTTPResponse that reads within a budget of time, and holds its head to _MAX_HEAD bytes."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, sock, *args, budget: _Budget, **kwargs) -> None:
|
|
143
|
+
super().__init__(sock, *args, **kwargs)
|
|
144
|
+
self.fp = _Reader(self.fp, budget)
|
|
145
|
+
|
|
146
|
+
def begin(self) -> None:
|
|
147
|
+
# The cap covers the head. The body is read from the reader itself, under its own cap. When
|
|
148
|
+
# http.client has already closed the answer it has set fp to None, and that stays.
|
|
149
|
+
reader = self.fp
|
|
150
|
+
head = self.fp = _Head(reader)
|
|
151
|
+
try:
|
|
152
|
+
super().begin()
|
|
153
|
+
finally:
|
|
154
|
+
if self.fp is head:
|
|
155
|
+
self.fp = reader
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _headers(answer) -> dict[str, str]:
|
|
159
|
+
joined: dict[str, str] = {}
|
|
160
|
+
for name, value in answer.getheaders():
|
|
161
|
+
name = name.lower()
|
|
162
|
+
joined[name] = f"{joined[name]}, {value}" if name in joined else value
|
|
163
|
+
return joined
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _body(answer) -> bytes:
|
|
167
|
+
body = bytearray()
|
|
168
|
+
while True:
|
|
169
|
+
chunk = answer.read1(_CHUNK)
|
|
170
|
+
if not chunk:
|
|
171
|
+
break
|
|
172
|
+
body += chunk
|
|
173
|
+
if len(body) > _MAX_BODY:
|
|
174
|
+
raise TransportError(f"the answer is larger than {_MAX_BODY} bytes")
|
|
175
|
+
# A read of a set size does not raise when the server hangs up early. What is still owed of a
|
|
176
|
+
# declared length is left in .length, so this check catches a cut-off answer before anything
|
|
177
|
+
# parses it as a whole one.
|
|
178
|
+
if answer.length:
|
|
179
|
+
raise TransportError("the answer ended before its declared length")
|
|
180
|
+
return bytes(body)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _send(connection, target: str, headers: dict) -> None:
|
|
184
|
+
refused = False
|
|
185
|
+
try:
|
|
186
|
+
# The connecting happens in here too, so a host name that cannot be encoded lands below.
|
|
187
|
+
connection.request("GET", target, headers=headers)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
# A TLS certificate failure is a ValueError as well as an OSError. It is raised again here
|
|
190
|
+
# for get to name.
|
|
191
|
+
if isinstance(exc, OSError):
|
|
192
|
+
raise
|
|
193
|
+
refused = True
|
|
194
|
+
if refused:
|
|
195
|
+
# http.client quotes a refused header value in its message, and for Authorization that
|
|
196
|
+
# value is the token. This is raised once the handler above has closed, so no exception is
|
|
197
|
+
# active and nothing is left in __context__.
|
|
198
|
+
raise TransportError("the request cannot be sent as written")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _unverified() -> ssl.SSLContext:
|
|
202
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
203
|
+
# The host name check goes first, because CERT_NONE is refused while it is on.
|
|
204
|
+
context.check_hostname = False
|
|
205
|
+
context.verify_mode = ssl.CERT_NONE
|
|
206
|
+
return context
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _certificate(connection) -> Optional[bytes]:
|
|
210
|
+
# Read before getresponse, because an answer that closes the connection takes its socket away.
|
|
211
|
+
return connection.sock.getpeercert(binary_form=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get(url: str, headers: dict, timeout: float, verify: bool = True,
|
|
215
|
+
read_body: bool = True) -> Response:
|
|
216
|
+
"""The answer to one GET of url. Any status comes back, and only a missing answer raises.
|
|
217
|
+
|
|
218
|
+
Raises TransportError when the URL is not an http or https URL with a host, when the host
|
|
219
|
+
cannot be reached or stops answering, or when the answer is malformed or over a cap. The
|
|
220
|
+
status line and headers may run to 64 KiB together, and the body to 32 MiB. timeout is in
|
|
221
|
+
seconds and limits each wait on the socket, so it must be positive, and ValueError says
|
|
222
|
+
otherwise. The whole read of the answer, from its status line to the end of its body, may
|
|
223
|
+
take four timeouts, so a server that sends a byte per wait is cut off, and the read ends
|
|
224
|
+
within one timeout of that. The connect and the TLS handshake are bounded by timeout alone,
|
|
225
|
+
and a name that is slow to resolve is not bounded.
|
|
226
|
+
|
|
227
|
+
The certificate and host name of an https host are verified. Only the literal False for verify
|
|
228
|
+
turns that off, so a config value such as the string "false" keeps it on. The answer then
|
|
229
|
+
comes from a host the caller cannot vouch for, and no credential may reach it. Any header
|
|
230
|
+
other than User-Agent and Accept raises ValueError before anything is sent, whatever it is
|
|
231
|
+
called. For an https answer, cert holds the certificate the server presented, as DER.
|
|
232
|
+
|
|
233
|
+
Only the literal False for read_body skips the body. The answer then comes back with an empty
|
|
234
|
+
body, whatever it was, and the rest of it is left unread when the connection closes.
|
|
235
|
+
"""
|
|
236
|
+
if timeout is None or not timeout > 0:
|
|
237
|
+
raise ValueError("timeout must be a positive number of seconds")
|
|
238
|
+
if verify is False and not all(
|
|
239
|
+
isinstance(name, str) and name.lower() in _UNVERIFIED_HEADERS for name in headers):
|
|
240
|
+
raise ValueError("only User-Agent and Accept are sent where the certificate is not checked")
|
|
241
|
+
try:
|
|
242
|
+
parts = urlsplit(url)
|
|
243
|
+
host, port = parts.hostname, parts.port
|
|
244
|
+
except ValueError:
|
|
245
|
+
raise TransportError("not a valid URL") from None
|
|
246
|
+
if parts.scheme not in ("http", "https") or not host:
|
|
247
|
+
raise TransportError("not an http or https URL")
|
|
248
|
+
secure = parts.scheme == "https"
|
|
249
|
+
# A context is passed only to switch verification off. HTTPConnection takes none, and the
|
|
250
|
+
# default of HTTPSConnection is the verifying one.
|
|
251
|
+
options = {"context": _unverified()} if secure and verify is False else {}
|
|
252
|
+
# The port is always given. http.client reads the tail of an IPv6 literal as a port otherwise.
|
|
253
|
+
connection = (HTTPSConnection if secure else HTTPConnection)(
|
|
254
|
+
host, port or (443 if secure else 80), timeout=timeout, **options)
|
|
255
|
+
target = (parts.path or "/") + ("?" + parts.query if parts.query else "")
|
|
256
|
+
answer = None
|
|
257
|
+
try:
|
|
258
|
+
_send(connection, target, headers)
|
|
259
|
+
cert = _certificate(connection) if secure else None
|
|
260
|
+
# The read starts here. The connect and the handshake are bounded by timeout alone, and a
|
|
261
|
+
# name that is slow to resolve is not bounded at all.
|
|
262
|
+
budget = _Budget(_READ_WAITS * timeout)
|
|
263
|
+
connection.response_class = functools.partial(_Answer, budget=budget)
|
|
264
|
+
answer = connection.getresponse()
|
|
265
|
+
body = b"" if read_body is False else _body(answer)
|
|
266
|
+
return Response(answer.status, _headers(answer), body, cert)
|
|
267
|
+
except TransportError:
|
|
268
|
+
raise
|
|
269
|
+
except (OSError, HTTPException) as exc:
|
|
270
|
+
raise TransportError(f"{type(exc).__name__}: {exc}") from exc
|
|
271
|
+
finally:
|
|
272
|
+
connection.close()
|
|
273
|
+
# An answer that closes the connection leaves its socket with the answer, and a body that
|
|
274
|
+
# was not read leaves the answer open.
|
|
275
|
+
if answer is not None:
|
|
276
|
+
answer.close()
|
php_radar/knowledge.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Grades an asset against the PHP2Shell matrix in data/php2shell.json.
|
|
2
|
+
|
|
3
|
+
Anything the matrix does not recognize grades INDETERMINATE. A packaged build is graded by its
|
|
4
|
+
build string and never by its upstream number, because a backport makes that number lie.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import string
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Iterable, NamedTuple, Optional
|
|
11
|
+
|
|
12
|
+
from php_radar.model import SOURCE_PRECEDENCE, Asset, Confidence, Evidence, Source, Verdict
|
|
13
|
+
from php_radar.versions import PhpVersion, parse_branch
|
|
14
|
+
|
|
15
|
+
DATA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "php2shell.json")
|
|
16
|
+
|
|
17
|
+
# A string longer than this is not a package version. It also keeps int() inside its size limit.
|
|
18
|
+
_MAX_BUILD = 128
|
|
19
|
+
|
|
20
|
+
_STRONGEST_FIRST = (Confidence.HIGH, Confidence.MEDIUM, Confidence.LOW)
|
|
21
|
+
|
|
22
|
+
# A composer constraint declares a minimum. It does not say which PHP runs, so it never grades
|
|
23
|
+
# as a build and counts as a read that saw nothing.
|
|
24
|
+
_FLOORS = frozenset({"composer-constraint"})
|
|
25
|
+
|
|
26
|
+
# Tags that make a build something other than the release they follow.
|
|
27
|
+
_PRERELEASE = ("rc", "alpha", "beta", "dev")
|
|
28
|
+
|
|
29
|
+
_BACKPORT_VERDICTS = {"LIKELY_PATCHED": Verdict.LIKELY_PATCHED, "EXPOSED": Verdict.EXPOSED}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MatrixError(ValueError):
|
|
33
|
+
"""The matrix file is missing, unreadable, or malformed."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Backport:
|
|
38
|
+
distro: str
|
|
39
|
+
build: str
|
|
40
|
+
verdict: Verdict
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _at_or_after(known: str, got: str) -> bool:
|
|
44
|
+
"""True when got is the known build or a later revision of it."""
|
|
45
|
+
if got == known:
|
|
46
|
+
return True
|
|
47
|
+
# A bare upstream version covers every release built from it: 8.3.36 and 8.3.36-150700.3.24.1.
|
|
48
|
+
bare = not known.strip(string.digits + ".")
|
|
49
|
+
if bare and got.startswith(known) and got[len(known)] in "-+":
|
|
50
|
+
return True
|
|
51
|
+
# Same series, higher revision: ubuntu2.28 to ubuntu2.29, ~deb13u1 to ~deb13u2, -r0 to -r1.
|
|
52
|
+
head = known.rstrip(string.digits)
|
|
53
|
+
tail, rev = known[len(head):], got[len(head):]
|
|
54
|
+
if not tail or not got.startswith(head):
|
|
55
|
+
return False
|
|
56
|
+
# The last run of a bare version is a micro, and a micro keeps the width the matrix gives it.
|
|
57
|
+
# 8.3.360 is no later than 8.3.36. Neither is 8.3.351, which is 8.3.35 with a release number
|
|
58
|
+
# glued on. A revision after a distro tag is a counter, so 2.28 to 2.100 is fine.
|
|
59
|
+
if bare and len(rev) > len(tail):
|
|
60
|
+
return False
|
|
61
|
+
return rev.isascii() and rev.isdigit() and int(rev) >= int(tail)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Matrix:
|
|
65
|
+
def __init__(self, fixed: dict[tuple[int, int], int], eol_through: tuple[int, int],
|
|
66
|
+
backports: list[Backport]) -> None:
|
|
67
|
+
self._fixed = fixed
|
|
68
|
+
self._eol_through = eol_through
|
|
69
|
+
self._backports = backports
|
|
70
|
+
|
|
71
|
+
def fixed_micro(self, branch: str) -> Optional[int]:
|
|
72
|
+
key = parse_branch(branch)
|
|
73
|
+
return None if key is None else self._fixed.get(key)
|
|
74
|
+
|
|
75
|
+
def is_eol(self, branch: str) -> bool:
|
|
76
|
+
key = parse_branch(branch)
|
|
77
|
+
return key is not None and key <= self._eol_through
|
|
78
|
+
|
|
79
|
+
def backport_verdict(self, distro: str, package_version: str) -> Optional[Verdict]:
|
|
80
|
+
distro, build = distro.strip().lower(), package_version.strip()
|
|
81
|
+
if not distro or not build or len(build) > _MAX_BUILD:
|
|
82
|
+
return None
|
|
83
|
+
hits = set()
|
|
84
|
+
for b in self._backports:
|
|
85
|
+
if b.distro != distro:
|
|
86
|
+
continue
|
|
87
|
+
# A patched build carries forward, since later revisions include its fix. A known
|
|
88
|
+
# vulnerable build does not: the revision after it may be the fix.
|
|
89
|
+
if b.verdict is Verdict.LIKELY_PATCHED:
|
|
90
|
+
if _at_or_after(b.build, build):
|
|
91
|
+
hits.add(b.verdict)
|
|
92
|
+
elif build == b.build:
|
|
93
|
+
hits.add(b.verdict)
|
|
94
|
+
# An exact known-vulnerable build outranks a patched series that happens to cover it.
|
|
95
|
+
for verdict in (Verdict.EXPOSED, Verdict.LIKELY_PATCHED):
|
|
96
|
+
if verdict in hits:
|
|
97
|
+
return verdict
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _unique(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
102
|
+
# json keeps the last of two equal keys, so in a hand-edited file a stale micro could
|
|
103
|
+
# silently win.
|
|
104
|
+
out: dict[str, object] = {}
|
|
105
|
+
for key, value in pairs:
|
|
106
|
+
if key in out:
|
|
107
|
+
raise ValueError(f"duplicate key {key!r}")
|
|
108
|
+
out[key] = value
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_matrix(path: str) -> Matrix:
|
|
113
|
+
def bad(where: str, why: str) -> MatrixError:
|
|
114
|
+
return MatrixError(f"{path}: {where}: {why}")
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
with open(path, encoding="utf-8") as f:
|
|
118
|
+
data = json.load(f, object_pairs_hook=_unique)
|
|
119
|
+
except (OSError, ValueError) as exc:
|
|
120
|
+
raise MatrixError(f"{path}: {exc}") from exc
|
|
121
|
+
if not isinstance(data, dict):
|
|
122
|
+
raise bad("top level", "must be an object")
|
|
123
|
+
|
|
124
|
+
def section(name: str, kind: type, article: str):
|
|
125
|
+
value = data.get(name)
|
|
126
|
+
if not isinstance(value, kind):
|
|
127
|
+
raise bad(name, f"missing, or not {article}")
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
fixed: dict[tuple[int, int], int] = {}
|
|
131
|
+
for branch, row in section("fixed", dict, "an object").items():
|
|
132
|
+
key = parse_branch(branch)
|
|
133
|
+
if key is None:
|
|
134
|
+
raise bad(f"fixed[{branch}]", "the key must be a branch like 8.3")
|
|
135
|
+
if key in fixed:
|
|
136
|
+
raise bad(f"fixed[{branch}]", "this branch is listed twice")
|
|
137
|
+
micro = row.get("micro") if isinstance(row, dict) else None
|
|
138
|
+
if type(micro) is not int or micro < 0:
|
|
139
|
+
raise bad(f"fixed[{branch}]", "needs a whole number micro")
|
|
140
|
+
fixed[key] = micro
|
|
141
|
+
|
|
142
|
+
eol = section("eol", dict, "an object")
|
|
143
|
+
through = parse_branch(eol["through"]) if isinstance(eol.get("through"), str) else None
|
|
144
|
+
if through is None:
|
|
145
|
+
raise bad("eol", "through must name a branch like 8.1")
|
|
146
|
+
for major, minor in fixed:
|
|
147
|
+
if (major, minor) <= through:
|
|
148
|
+
raise bad("fixed", f"{major}.{minor} is inside the end-of-life range")
|
|
149
|
+
|
|
150
|
+
backports = []
|
|
151
|
+
for i, row in enumerate(section("backports", list, "a list")):
|
|
152
|
+
where = f"backports[{i}]"
|
|
153
|
+
if not isinstance(row, dict):
|
|
154
|
+
raise bad(where, "must be an object")
|
|
155
|
+
distro, build, verdict = row.get("distro"), row.get("build"), row.get("verdict")
|
|
156
|
+
if not (isinstance(distro, str) and distro.strip()):
|
|
157
|
+
raise bad(where, "needs a distro")
|
|
158
|
+
if not (isinstance(build, str) and build.strip()) or len(build) > _MAX_BUILD:
|
|
159
|
+
raise bad(where, "needs a build string")
|
|
160
|
+
if not (isinstance(verdict, str) and verdict in _BACKPORT_VERDICTS):
|
|
161
|
+
raise bad(where, "verdict must be LIKELY_PATCHED or EXPOSED")
|
|
162
|
+
backports.append(Backport(distro.strip().lower(), build.strip(),
|
|
163
|
+
_BACKPORT_VERDICTS[verdict]))
|
|
164
|
+
|
|
165
|
+
return Matrix(fixed, through, backports)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _s(value: object) -> str:
|
|
169
|
+
# A signal that is None or not a string must not crash grading.
|
|
170
|
+
return str(value or "").strip()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _sig(ev: Evidence, key: str) -> str:
|
|
174
|
+
return _s(ev.signals.get(key))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _digit(c: str) -> bool:
|
|
178
|
+
return len(c) == 1 and c in string.digits
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_revision(version: PhpVersion) -> bool:
|
|
182
|
+
# A distro revision follows the micro as -1ubuntu2.28, -1~deb13u1, -r0 or -150700.3.24.1.
|
|
183
|
+
# A variant like -fpm or -zts and a pre-release tag like RC1 or -dev are no distro build.
|
|
184
|
+
s = version.suffix
|
|
185
|
+
return len(s) > 1 and s[0] in "-+~" and (_digit(s[1]) or (s[1] == "r" and _digit(s[2:3])))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_prerelease(version: PhpVersion) -> bool:
|
|
189
|
+
return version.suffix.lstrip("-+~_").lower().startswith(_PRERELEASE)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _agrees(detected: str, build: str) -> bool:
|
|
193
|
+
# The detected version is the build itself or its upstream part: 8.1.2 for 8.1.2-1ubuntu2.28.
|
|
194
|
+
if not build.startswith(detected):
|
|
195
|
+
return False
|
|
196
|
+
rest = build[len(detected):]
|
|
197
|
+
return not rest or rest[0] not in string.digits + "."
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _build(package: str, text: str, version: Optional[PhpVersion]) -> str:
|
|
201
|
+
"""The build string a packaged PHP is recognized by, or "" when the signals disagree."""
|
|
202
|
+
if not package:
|
|
203
|
+
return text # the detected version carries a distro revision, so it is the build
|
|
204
|
+
# With a parseable detected version, the two signals have to describe the same PHP.
|
|
205
|
+
return package if version is None or _agrees(text, package) else ""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _declared(ev: Evidence) -> Confidence:
|
|
209
|
+
# A repo scanner grades how exact its own pin is, and the grader takes that as given.
|
|
210
|
+
name = _sig(ev, "confidence").upper()
|
|
211
|
+
return Confidence[name] if name in Confidence.__members__ else Confidence.LOW
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _confidence(ev: Evidence, verdict: Verdict) -> Confidence:
|
|
215
|
+
if verdict is Verdict.INDETERMINATE:
|
|
216
|
+
# PHP is there and only its build is unknown. A repo pin declares a runtime, so LOW.
|
|
217
|
+
return Confidence.LOW if ev.source is Source.REPO else Confidence.MEDIUM
|
|
218
|
+
if ev.source is Source.REPO:
|
|
219
|
+
return _declared(ev)
|
|
220
|
+
if ev.source is Source.NETWORK:
|
|
221
|
+
# A banner is easy to spoof or rewrite. From outside, a distro backport is invisible too.
|
|
222
|
+
# A network read earns LOW for whatever it claims, a fix as much as an exposure.
|
|
223
|
+
return Confidence.LOW
|
|
224
|
+
return Confidence.HIGH
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _judge(ev: Evidence, matrix: Matrix) -> tuple[Verdict, Confidence, bool]:
|
|
228
|
+
"""Grade one evidence, and say whether it confirms PHP is there.
|
|
229
|
+
|
|
230
|
+
A read can confirm PHP without placing its build. That differs from a read that saw nothing.
|
|
231
|
+
"""
|
|
232
|
+
if _sig(ev, "kind").lower() in _FLOORS:
|
|
233
|
+
# A constraint declares a minimum. It says nothing about what is installed or built.
|
|
234
|
+
return Verdict.INDETERMINATE, Confidence.LOW, False
|
|
235
|
+
text = _s(ev.detected_version)
|
|
236
|
+
version = PhpVersion.parse(text) if text else None
|
|
237
|
+
package = _sig(ev, "package_version")
|
|
238
|
+
present = _sig(ev, "php_present").lower()
|
|
239
|
+
|
|
240
|
+
if package or (version is not None and _is_revision(version)):
|
|
241
|
+
# Package evidence. The upstream number of a packaged PHP lies, so only a build the
|
|
242
|
+
# matrix recognizes settles it.
|
|
243
|
+
verdict = matrix.backport_verdict(_sig(ev, "distro"), _build(package, text, version))
|
|
244
|
+
if verdict is None:
|
|
245
|
+
verdict = Verdict.INDETERMINATE
|
|
246
|
+
return verdict, _confidence(ev, verdict), True
|
|
247
|
+
|
|
248
|
+
if version is not None:
|
|
249
|
+
fixed = matrix.fixed_micro(version.branch)
|
|
250
|
+
if fixed is None:
|
|
251
|
+
# No fix to compare with: end of life is exposed, and a newer branch is unknown.
|
|
252
|
+
verdict = Verdict.EXPOSED if matrix.is_eol(version.branch) else Verdict.INDETERMINATE
|
|
253
|
+
elif version.micro < fixed:
|
|
254
|
+
verdict = Verdict.EXPOSED
|
|
255
|
+
elif _is_prerelease(version):
|
|
256
|
+
verdict = Verdict.INDETERMINATE # an RC or dev build of the fixed release is no release
|
|
257
|
+
else:
|
|
258
|
+
verdict = Verdict.LIKELY_PATCHED
|
|
259
|
+
return verdict, _confidence(ev, verdict), True
|
|
260
|
+
|
|
261
|
+
if text or present == "true":
|
|
262
|
+
return Verdict.INDETERMINATE, _confidence(ev, Verdict.INDETERMINATE), True
|
|
263
|
+
|
|
264
|
+
# Silence clears nothing. Only a host read that says there is no PHP does.
|
|
265
|
+
if present == "false" and ev.source is Source.HOST and not _s(ev.note):
|
|
266
|
+
return Verdict.NOT_PHP, Confidence.HIGH, True
|
|
267
|
+
return Verdict.INDETERMINATE, Confidence.LOW, False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def grade_evidence(ev: Evidence, matrix: Matrix) -> tuple[Verdict, Confidence]:
|
|
271
|
+
"""Grade one evidence. Every signal is read as a stripped string. The ones that matter:
|
|
272
|
+
|
|
273
|
+
package_version: the build of a packaged PHP, looked up in the matrix for the distro. With
|
|
274
|
+
none, a detected_version carrying a distro revision such as -1ubuntu2.28 is the build.
|
|
275
|
+
Either is package evidence. A packaged PHP never grades on its upstream number.
|
|
276
|
+
distro: picks the matrix rows to look in. A name alone is no package evidence, and a bare
|
|
277
|
+
version still grades against the fixed micros, as it does for PHP built from source.
|
|
278
|
+
kind: a composer-constraint declares a minimum, so it never grades as a build and counts as a
|
|
279
|
+
read that saw nothing.
|
|
280
|
+
php_present: "true" means PHP was seen without a version, and a host read says "false" when it
|
|
281
|
+
found none.
|
|
282
|
+
confidence: a repo scanner's own grade of its pin, taken as given for repo evidence.
|
|
283
|
+
note: a note on a host read marks a problem with the read, so that read cannot clear.
|
|
284
|
+
|
|
285
|
+
INDETERMINATE comes in two kinds. PHP confirmed but unplaced carries MEDIUM, or LOW for a repo
|
|
286
|
+
pin, which declares a runtime and confirms nothing. A read that saw nothing carries LOW.
|
|
287
|
+
"""
|
|
288
|
+
verdict, confidence, _ = _judge(ev, matrix)
|
|
289
|
+
return verdict, confidence
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class _Read(NamedTuple):
|
|
293
|
+
rank: int
|
|
294
|
+
verdict: Verdict
|
|
295
|
+
confidence: Confidence
|
|
296
|
+
sees_php: bool
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _strongest(confidences: Iterable[Confidence]) -> Confidence:
|
|
300
|
+
return min(confidences, key=_STRONGEST_FIRST.index)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _weakest(confidences: Iterable[Confidence]) -> Confidence:
|
|
304
|
+
return max(confidences, key=_STRONGEST_FIRST.index)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def grade(asset: Asset, matrix: Matrix) -> tuple[Verdict, Confidence]:
|
|
308
|
+
"""The verdict and confidence for one asset.
|
|
309
|
+
|
|
310
|
+
A read with no sign of PHP settles nothing and is dropped. Of the rest, an EXPOSED stands
|
|
311
|
+
unless a more authoritative source has a LIKELY_PATCHED read. A higher source that only saw
|
|
312
|
+
PHP, or found none, cannot clear it. With no exposure left, the most authoritative source
|
|
313
|
+
decides, worst first: PHP seen but unplaced, then LIKELY_PATCHED, then NOT_PHP. So a weaker
|
|
314
|
+
source never clears a read that saw PHP. A tie on EXPOSED keeps the strongest confidence, and
|
|
315
|
+
a tie on anything milder keeps the weakest.
|
|
316
|
+
"""
|
|
317
|
+
reads = [_Read(SOURCE_PRECEDENCE[e.source], *_judge(e, matrix)) for e in asset.evidence]
|
|
318
|
+
live = [r for r in reads if r.sees_php]
|
|
319
|
+
if not live:
|
|
320
|
+
return Verdict.INDETERMINATE, Confidence.LOW
|
|
321
|
+
|
|
322
|
+
exposed = [r for r in live if r.verdict is Verdict.EXPOSED]
|
|
323
|
+
patched = [r.rank for r in live if r.verdict is Verdict.LIKELY_PATCHED]
|
|
324
|
+
if exposed:
|
|
325
|
+
top = max(r.rank for r in exposed)
|
|
326
|
+
# Only a patched read on a strictly more authoritative source clears an exposure.
|
|
327
|
+
if top >= max(patched, default=-1):
|
|
328
|
+
return Verdict.EXPOSED, _strongest(r.confidence for r in exposed if r.rank == top)
|
|
329
|
+
|
|
330
|
+
# An exposure still here sits below a patched read, so it cannot reach the top tier.
|
|
331
|
+
top = max(r.rank for r in live)
|
|
332
|
+
tier = [r for r in live if r.rank == top]
|
|
333
|
+
unplaced = [r.confidence for r in tier if r.verdict is Verdict.INDETERMINATE]
|
|
334
|
+
if unplaced:
|
|
335
|
+
# PHP is there and its build is unknown. A weaker source never clears that.
|
|
336
|
+
return Verdict.INDETERMINATE, _strongest(unplaced)
|
|
337
|
+
patched_here = any(r.verdict is Verdict.LIKELY_PATCHED for r in tier)
|
|
338
|
+
verdict = Verdict.LIKELY_PATCHED if patched_here else Verdict.NOT_PHP
|
|
339
|
+
return verdict, _weakest(r.confidence for r in tier if r.verdict is verdict)
|