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