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
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""Finds where a local checkout pins or declares a PHP runtime.
|
|
2
|
+
|
|
3
|
+
scan_path walks a tree and reads the files that name a PHP version. Those are Dockerfiles, compose
|
|
4
|
+
files, GitHub Actions workflows that call setup-php, .php-version, .tool-versions, composer.json,
|
|
5
|
+
and devcontainer.json. Each file is read as text and picked apart with string handling. Nothing in
|
|
6
|
+
the tree is imported, run, or evaluated, and no link leads the scan out of it.
|
|
7
|
+
|
|
8
|
+
Each find is one Evidence, and its confidence follows how exactly the file names a build. An image
|
|
9
|
+
tag with a patch release scores high, and a floating tag such as 8.3-apache scores low. A Composer
|
|
10
|
+
requirement only sets a floor, so it carries the kind composer-constraint and no version, and the
|
|
11
|
+
grader sets it aside.
|
|
12
|
+
"""
|
|
13
|
+
import codecs
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import stat
|
|
18
|
+
import string
|
|
19
|
+
from functools import partial
|
|
20
|
+
from typing import Callable, Iterator, NamedTuple, Optional
|
|
21
|
+
|
|
22
|
+
from php_radar.model import Confidence, Evidence, Sapi, Source
|
|
23
|
+
from php_radar.versions import PhpVersion
|
|
24
|
+
|
|
25
|
+
_Pins = list[tuple[str, Confidence]]
|
|
26
|
+
|
|
27
|
+
# No config file is this big. The cap bounds what one hostile file can cost to read and parse.
|
|
28
|
+
_MAX_BYTES = 1 << 20
|
|
29
|
+
|
|
30
|
+
# Past this many pins a file is a flood and not a config.
|
|
31
|
+
_MAX_PINS = 100
|
|
32
|
+
|
|
33
|
+
# A tag, version, or constraint longer than this is none of those. Docker caps a tag at 128
|
|
34
|
+
# characters. A longer value is reported as skipped and never read as a pin.
|
|
35
|
+
_MAX_TEXT = 256
|
|
36
|
+
|
|
37
|
+
# Folders that hold other people's code. Each dependency in vendor/ declares the PHP it supports.
|
|
38
|
+
# That says nothing about the project that installed it.
|
|
39
|
+
_SKIP_DIRS = frozenset({".git", "node_modules", "vendor"})
|
|
40
|
+
|
|
41
|
+
# A file with one of these endings documents a Dockerfile and is not one, so Dockerfile.md can
|
|
42
|
+
# quote any FROM line it likes.
|
|
43
|
+
_DOC_ENDINGS = (".md", ".markdown", ".rst", ".txt")
|
|
44
|
+
|
|
45
|
+
# What can follow the version in a tag of the official php image.
|
|
46
|
+
_VARIANTS = frozenset({
|
|
47
|
+
"cli", "fpm", "apache", "zts", "alpine", "jessie", "stretch", "buster", "bullseye", "bookworm",
|
|
48
|
+
"trixie", "windowsservercore", "nanoserver"})
|
|
49
|
+
# The variants that end in a number, like alpine3.20 and ltsc2022.
|
|
50
|
+
_NUMBERED = frozenset({"alpine", "ltsc"})
|
|
51
|
+
|
|
52
|
+
# Digest algorithm to hex length. Docker prints digests in lower case.
|
|
53
|
+
_DIGESTS = {"sha256": 64, "sha512": 128}
|
|
54
|
+
_HEX = frozenset("0123456789abcdef")
|
|
55
|
+
|
|
56
|
+
# A tag that names its server API says which SAPI the image runs. The first match wins.
|
|
57
|
+
_SAPIS = (("apache", Sapi.MOD_PHP), ("fpm", Sapi.FPM), ("cli", Sapi.CLI), ("zts", Sapi.CLI))
|
|
58
|
+
|
|
59
|
+
# The grader sets this kind aside, because a requirement says nothing about what runs.
|
|
60
|
+
_FLOOR_KIND = "composer-constraint"
|
|
61
|
+
|
|
62
|
+
# Confidence is declared strongest first.
|
|
63
|
+
_RANK = {c: i for i, c in enumerate(Confidence)}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_digest(text: str) -> bool:
|
|
67
|
+
algorithm, _, digits = text.partition(":")
|
|
68
|
+
return len(digits) == _DIGESTS.get(algorithm, -1) and _HEX.issuperset(digits)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _known(word: str) -> bool:
|
|
72
|
+
return word in _VARIANTS or word.rstrip(string.digits + ".") in _NUMBERED
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _release(tag: str) -> Optional[PhpVersion]:
|
|
76
|
+
# The digest is no part of the version, and parse would read its @ as junk after the micro.
|
|
77
|
+
return PhpVersion.parse(tag.partition("@")[0])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def tag_confidence(tag: str) -> Confidence:
|
|
81
|
+
"""How exactly an image tag names a build.
|
|
82
|
+
|
|
83
|
+
HIGH for a digest, or for a full major.minor.micro followed by nothing or by variants of the
|
|
84
|
+
official php image (8.3.35, 8.3.35-fpm-alpine3.20). MEDIUM for a full version with a suffix
|
|
85
|
+
those images do not use, since the tag may belong to some other image. LOW for anything that
|
|
86
|
+
floats: 8.3, 8.3-apache, 8, latest, or a tag that a build variable fills in.
|
|
87
|
+
"""
|
|
88
|
+
tag = tag.strip()
|
|
89
|
+
name, at, digest = tag.partition("@")
|
|
90
|
+
if _is_digest(digest if at else name):
|
|
91
|
+
return Confidence.HIGH
|
|
92
|
+
release = _release(tag)
|
|
93
|
+
if release is None:
|
|
94
|
+
return Confidence.LOW
|
|
95
|
+
words = release.suffix.lstrip("-+~_").split("-")
|
|
96
|
+
return Confidence.HIGH if not release.suffix or all(map(_known, words)) else Confidence.MEDIUM
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _pin_confidence(text: str) -> Confidence:
|
|
100
|
+
# A version named outside an image tag, in .php-version or a workflow, is not what gets built.
|
|
101
|
+
# So it stops at MEDIUM, and a version with no micro is LOW.
|
|
102
|
+
return Confidence.MEDIUM if PhpVersion.parse(text) is not None else Confidence.LOW
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _at_most(confidence: Confidence, ceiling: Confidence) -> Confidence:
|
|
106
|
+
return max(confidence, ceiling, key=_RANK.get)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _uncomment(line: str) -> str:
|
|
110
|
+
"""line up to its comment. A # opens one at the start of the line or after whitespace."""
|
|
111
|
+
for at, ch in enumerate(line):
|
|
112
|
+
if ch == "#" and (not at or line[at - 1].isspace()):
|
|
113
|
+
return line[:at]
|
|
114
|
+
return line
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _scalar(value: str) -> str:
|
|
118
|
+
"""A YAML scalar without its quotes or its trailing comment."""
|
|
119
|
+
value = value.strip()
|
|
120
|
+
if value[:1] in ("'", '"'):
|
|
121
|
+
end = value.find(value[0], 1)
|
|
122
|
+
return value[1:end] if end > 0 else value[1:]
|
|
123
|
+
return _uncomment(value).rstrip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pair(line: str) -> tuple[str, str]:
|
|
127
|
+
"""The key and scalar value of a YAML "key: value" line, or two empty strings."""
|
|
128
|
+
key, colon, value = line.strip().lstrip("- ").partition(":")
|
|
129
|
+
# YAML needs a space after the colon, so image:php:8 is one string and no pair.
|
|
130
|
+
if not colon or (value and not value[0].isspace()):
|
|
131
|
+
return "", ""
|
|
132
|
+
return key.strip().strip("\"'"), _scalar(value)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _indent(line: str) -> int:
|
|
136
|
+
return len(line) - len(line.lstrip(" "))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _column(line: str) -> int:
|
|
140
|
+
"""Where the first key starts, past the indentation and a list dash."""
|
|
141
|
+
return len(line) - len(line.lstrip(" -"))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unique(pins: _Pins) -> _Pins:
|
|
145
|
+
seen: dict[str, Confidence] = {}
|
|
146
|
+
for text, confidence in pins:
|
|
147
|
+
if text:
|
|
148
|
+
seen.setdefault(text, confidence)
|
|
149
|
+
return list(seen.items())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _member(node: object, key: str) -> object:
|
|
153
|
+
return node.get(key) if isinstance(node, dict) else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _split_ref(ref: str) -> tuple[str, str, str]:
|
|
157
|
+
"""The repository, tag, and digest of an image reference. The tag and digest may be empty."""
|
|
158
|
+
name, _, digest = ref.partition("@")
|
|
159
|
+
head, slash, last = name.rpartition("/")
|
|
160
|
+
base, _, tag = last.partition(":")
|
|
161
|
+
return head + slash + base, tag, digest
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_php(repository: str) -> bool:
|
|
165
|
+
path = repository.lower().split("/")
|
|
166
|
+
# A first part with a dot or a colon is a registry host, as in localhost:5000/php.
|
|
167
|
+
if len(path) > 1 and ("." in path[0] or ":" in path[0] or path[0] == "localhost"):
|
|
168
|
+
path = path[1:]
|
|
169
|
+
return path == ["php"] or path[-2:] in (["library", "php"], ["devcontainers", "php"])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _php_image_tag(ref: str) -> Optional[str]:
|
|
173
|
+
"""The tag of a reference to the official php image, or None for any other image.
|
|
174
|
+
|
|
175
|
+
No tag means latest. A digest stays on the end of the tag, where tag_confidence reads it.
|
|
176
|
+
"""
|
|
177
|
+
repository, tag, digest = _split_ref(ref.strip())
|
|
178
|
+
if not _is_php(repository):
|
|
179
|
+
return None
|
|
180
|
+
if digest:
|
|
181
|
+
return tag + "@" + digest
|
|
182
|
+
return tag or "latest"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _sapi(tag: str) -> Sapi:
|
|
186
|
+
words = tag.partition("@")[0].split("-")
|
|
187
|
+
return next((sapi for word, sapi in _SAPIS if word in words), Sapi.UNKNOWN)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _image_pin(tag: Optional[str]) -> _Pins:
|
|
191
|
+
return [] if tag is None else [(tag, tag_confidence(tag))]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _instructions(text: str) -> Iterator[str]:
|
|
195
|
+
"""Dockerfile lines with backslash continuations joined and comment lines dropped."""
|
|
196
|
+
parts: list[str] = []
|
|
197
|
+
for raw in text.splitlines():
|
|
198
|
+
line = raw.strip()
|
|
199
|
+
if line.startswith("#"):
|
|
200
|
+
continue
|
|
201
|
+
if line.endswith("\\"):
|
|
202
|
+
parts.append(line[:-1])
|
|
203
|
+
continue
|
|
204
|
+
parts.append(line)
|
|
205
|
+
yield " ".join(parts)
|
|
206
|
+
parts = []
|
|
207
|
+
if parts:
|
|
208
|
+
yield " ".join(parts)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_dockerfile(text: str) -> _Pins:
|
|
212
|
+
"""The tag of each FROM line that pulls the official php image."""
|
|
213
|
+
found: _Pins = []
|
|
214
|
+
for line in _instructions(text):
|
|
215
|
+
words = line.split()
|
|
216
|
+
if len(words) > 1 and words[0].upper() == "FROM":
|
|
217
|
+
# Flags such as --platform come before the image.
|
|
218
|
+
image = next((w for w in words[1:] if not w.startswith("--")), "")
|
|
219
|
+
found += _image_pin(_php_image_tag(image))
|
|
220
|
+
return _unique(found)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def parse_compose(text: str) -> _Pins:
|
|
224
|
+
"""The tag of each image: entry that names the official php image."""
|
|
225
|
+
found: _Pins = []
|
|
226
|
+
for line in text.splitlines():
|
|
227
|
+
key, value = _pair(line)
|
|
228
|
+
if key == "image":
|
|
229
|
+
found += _image_pin(_php_image_tag(value))
|
|
230
|
+
return _unique(found)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_ci_setup_php(text: str) -> _Pins:
|
|
234
|
+
"""The php-version of each shivammathur/setup-php step in a GitHub Actions workflow.
|
|
235
|
+
|
|
236
|
+
A step runs from its uses: line to the first line indented less than that line's keys. The
|
|
237
|
+
version is returned as written, so a matrix expression comes back as ${{ matrix.php }}.
|
|
238
|
+
"""
|
|
239
|
+
found: _Pins = []
|
|
240
|
+
floor: Optional[int] = None # the column of the keys of the setup-php step now open
|
|
241
|
+
for line in text.splitlines():
|
|
242
|
+
if not line.strip() or line.strip().startswith("#"):
|
|
243
|
+
continue
|
|
244
|
+
if floor is not None and _indent(line) < floor:
|
|
245
|
+
floor = None
|
|
246
|
+
key, value = _pair(line)
|
|
247
|
+
if key == "uses" and value.lower().startswith("shivammathur/setup-php"):
|
|
248
|
+
floor = _column(line)
|
|
249
|
+
elif key == "php-version" and floor is not None and _indent(line) > floor:
|
|
250
|
+
found.append((value, _pin_confidence(value)))
|
|
251
|
+
return _unique(found)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def parse_dotfiles(name: str, text: str) -> _Pins:
|
|
255
|
+
"""The versions in a .php-version file (its first line) or a .tool-versions file (php lines)."""
|
|
256
|
+
found: list[str] = []
|
|
257
|
+
for line in text.splitlines():
|
|
258
|
+
words = _uncomment(line).split()
|
|
259
|
+
if not words:
|
|
260
|
+
continue
|
|
261
|
+
if name == ".php-version":
|
|
262
|
+
found.append(words[0])
|
|
263
|
+
break
|
|
264
|
+
if name == ".tool-versions" and words[0] == "php":
|
|
265
|
+
found.extend(words[1:])
|
|
266
|
+
return _unique([(word, _pin_confidence(word)) for word in found])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _no_number(text: str) -> None:
|
|
270
|
+
# Neither parser reads a number. Turning a megabyte of digits into an int takes seconds before
|
|
271
|
+
# Python 3.11, and 3.11 and later refuse it, which would cost the file its real pins.
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _load(text: str) -> object:
|
|
276
|
+
return json.loads(text, parse_int=_no_number, parse_float=_no_number)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def parse_composer(text: str) -> _Pins:
|
|
280
|
+
"""The php requirement and the config.platform.php entry of a composer.json.
|
|
281
|
+
|
|
282
|
+
Both only say what the project needs or asks Composer to assume, never what runs, so each
|
|
283
|
+
comes back LOW. Raises ValueError or RecursionError on text that is not JSON.
|
|
284
|
+
"""
|
|
285
|
+
data = _load(text)
|
|
286
|
+
declared = (_member(_member(data, "require"), "php"),
|
|
287
|
+
_member(_member(_member(data, "config"), "platform"), "php"))
|
|
288
|
+
return _unique([(d.strip(), Confidence.LOW) for d in declared if isinstance(d, str)])
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _strip_jsonc(text: str) -> str:
|
|
292
|
+
"""text with its // and /* */ comments and trailing commas removed. VS Code allows both."""
|
|
293
|
+
out: list[str] = []
|
|
294
|
+
in_string = False
|
|
295
|
+
comma = -1 # where in out a comma sits that no other token has followed yet
|
|
296
|
+
i, n = 0, len(text)
|
|
297
|
+
while i < n:
|
|
298
|
+
c = text[i]
|
|
299
|
+
if in_string:
|
|
300
|
+
out.append(c)
|
|
301
|
+
if c == "\\":
|
|
302
|
+
out.append(text[i + 1:i + 2]) # the escaped character, whatever it is
|
|
303
|
+
i += 1
|
|
304
|
+
elif c == '"':
|
|
305
|
+
in_string = False
|
|
306
|
+
i += 1
|
|
307
|
+
elif text.startswith("//", i):
|
|
308
|
+
end = text.find("\n", i)
|
|
309
|
+
i = n if end < 0 else end
|
|
310
|
+
elif text.startswith("/*", i):
|
|
311
|
+
end = text.find("*/", i + 2)
|
|
312
|
+
i = n if end < 0 else end + 2
|
|
313
|
+
else:
|
|
314
|
+
if c in "}]" and comma >= 0:
|
|
315
|
+
out[comma] = ""
|
|
316
|
+
if c == ",":
|
|
317
|
+
comma = len(out)
|
|
318
|
+
elif not c.isspace():
|
|
319
|
+
comma = -1
|
|
320
|
+
in_string = c == '"'
|
|
321
|
+
out.append(c)
|
|
322
|
+
i += 1
|
|
323
|
+
return "".join(out)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def parse_devcontainer(text: str) -> _Pins:
|
|
327
|
+
"""The image tag and the php feature's version in a devcontainer.json.
|
|
328
|
+
|
|
329
|
+
A dev container is not what ships, so even an exact tag stops at MEDIUM. Raises ValueError or
|
|
330
|
+
RecursionError on text that is not JSON once its comments are gone.
|
|
331
|
+
"""
|
|
332
|
+
data = _load(_strip_jsonc(text))
|
|
333
|
+
image = _member(data, "image")
|
|
334
|
+
tag = _php_image_tag(image) if isinstance(image, str) else None
|
|
335
|
+
found: _Pins = []
|
|
336
|
+
if tag is not None:
|
|
337
|
+
found.append((tag, _at_most(tag_confidence(tag), Confidence.MEDIUM)))
|
|
338
|
+
features = _member(data, "features")
|
|
339
|
+
if not isinstance(features, dict):
|
|
340
|
+
features = {}
|
|
341
|
+
for ref, options in features.items():
|
|
342
|
+
if _split_ref(ref)[0].rsplit("/", 1)[-1] == "php":
|
|
343
|
+
# A bare string is the spec's shorthand for the version option, and latest its default.
|
|
344
|
+
version = options.get("version") if isinstance(options, dict) else options
|
|
345
|
+
version = version.strip() if isinstance(version, str) and version.strip() else "latest"
|
|
346
|
+
found.append((version, _pin_confidence(version)))
|
|
347
|
+
return _unique(found)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class _Reader(NamedTuple):
|
|
351
|
+
kind: str # the kind signal of what it finds
|
|
352
|
+
parse: Callable[[str], _Pins]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
_DOCKERFILE = _Reader("dockerfile", parse_dockerfile)
|
|
356
|
+
_COMPOSE = _Reader("compose", parse_compose)
|
|
357
|
+
_CI = _Reader("ci-setup-php", parse_ci_setup_php)
|
|
358
|
+
_COMPOSER = _Reader(_FLOOR_KIND, parse_composer)
|
|
359
|
+
_DEVCONTAINER = _Reader("devcontainer", parse_devcontainer)
|
|
360
|
+
_BY_NAME = {
|
|
361
|
+
".php-version": _Reader("php-version-file", partial(parse_dotfiles, ".php-version")),
|
|
362
|
+
".tool-versions": _Reader("tool-versions", partial(parse_dotfiles, ".tool-versions")),
|
|
363
|
+
"composer.json": _COMPOSER,
|
|
364
|
+
"devcontainer.json": _DEVCONTAINER,
|
|
365
|
+
".devcontainer.json": _DEVCONTAINER,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _is_dockerfile(name: str) -> bool:
|
|
370
|
+
if name.endswith(_DOC_ENDINGS):
|
|
371
|
+
return False
|
|
372
|
+
for stem in ("dockerfile", "containerfile"):
|
|
373
|
+
if name == stem or (name.startswith(stem) and name[len(stem)] in ".-_"):
|
|
374
|
+
return True
|
|
375
|
+
return name.endswith((".dockerfile", ".containerfile"))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _is_compose(name: str) -> bool:
|
|
379
|
+
stem = name.rsplit(".", 1)[0]
|
|
380
|
+
return stem in ("compose", "docker-compose") or stem.startswith(
|
|
381
|
+
("compose.", "compose-", "docker-compose.", "docker-compose-"))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _reader(rel: str) -> Optional[_Reader]:
|
|
385
|
+
"""The reader for a file, by its path relative to the repository, or None to leave it alone."""
|
|
386
|
+
folder, _, name = rel.rpartition("/")
|
|
387
|
+
name = name.lower()
|
|
388
|
+
if _is_dockerfile(name):
|
|
389
|
+
return _DOCKERFILE
|
|
390
|
+
if name.endswith((".yml", ".yaml")):
|
|
391
|
+
if folder == ".github/workflows" or folder.endswith("/.github/workflows"):
|
|
392
|
+
return _CI
|
|
393
|
+
return _COMPOSE if _is_compose(name) else None
|
|
394
|
+
return _BY_NAME.get(name)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _why(exc: OSError) -> str:
|
|
398
|
+
# The message alone, because str(exc) carries the file name and so the absolute path.
|
|
399
|
+
return exc.strerror or type(exc).__name__
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _skipped(repo: str, rel: str, reason: str) -> Evidence:
|
|
403
|
+
"""Evidence that a file or folder was passed over, with the reason in note.
|
|
404
|
+
|
|
405
|
+
A skip is a gap in coverage. The scan knew the file by a name it reads, or met a folder it
|
|
406
|
+
could not list or would not enter, and so could not see whether anything there pins an old
|
|
407
|
+
build. It counts as PHP seen with no version. That holds the repo at INDETERMINATE while the
|
|
408
|
+
gap is open, because a patched pin elsewhere must not clear a repo that has unread content.
|
|
409
|
+
Every skip follows this one rule, including a link (its real content can sit under a name the
|
|
410
|
+
scan does not read), a linked folder that leads out of the tree, and a composer.json.
|
|
411
|
+
"""
|
|
412
|
+
signals = {"repo": repo, "kind": "skipped", "confidence": Confidence.LOW.name,
|
|
413
|
+
"php_present": "true"}
|
|
414
|
+
return Evidence(Source.REPO, rel, None, Sapi.UNKNOWN, signals, "skipped: " + reason)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _evidence(repo: str, rel: str, reader: _Reader, declared: str,
|
|
418
|
+
confidence: Confidence) -> Evidence:
|
|
419
|
+
signals = {"repo": repo, "kind": reader.kind, "confidence": confidence.name,
|
|
420
|
+
"declared": declared}
|
|
421
|
+
version: Optional[str] = None
|
|
422
|
+
if reader.kind == _FLOOR_KIND:
|
|
423
|
+
# Not a version at all: the report shows detected_version as the PHP of an asset that
|
|
424
|
+
# grades INDETERMINATE, and a requirement must not read as a build.
|
|
425
|
+
note = f"php {declared} is a requirement, not the running build"
|
|
426
|
+
else:
|
|
427
|
+
release = _release(declared)
|
|
428
|
+
if release is None:
|
|
429
|
+
signals["php_present"] = "true"
|
|
430
|
+
note = f"{declared} pins no patch release"
|
|
431
|
+
else:
|
|
432
|
+
version = f"{release.major}.{release.minor}.{release.micro}"
|
|
433
|
+
note = ""
|
|
434
|
+
return Evidence(Source.REPO, rel, version, _sapi(declared), signals, note)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _finds(repo: str, rel: str, reader: _Reader, text: str) -> list[Evidence]:
|
|
438
|
+
"""The evidence in one file's text. It never raises, whatever the text holds."""
|
|
439
|
+
try:
|
|
440
|
+
pins = reader.parse(text)
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
# The parsers read hostile text. Whatever one bad file makes them raise ends that file,
|
|
443
|
+
# and the scan goes on with the next.
|
|
444
|
+
return [_skipped(repo, rel, f"cannot parse it ({type(exc).__name__})")]
|
|
445
|
+
fits = [pin for pin in pins if len(pin[0]) <= _MAX_TEXT]
|
|
446
|
+
found = [_evidence(repo, rel, reader, declared, confidence)
|
|
447
|
+
for declared, confidence in fits[:_MAX_PINS]]
|
|
448
|
+
if len(fits) > _MAX_PINS:
|
|
449
|
+
found.append(_skipped(
|
|
450
|
+
repo, rel, f"it names more than {_MAX_PINS} PHP pins, and only the first {_MAX_PINS} "
|
|
451
|
+
"were read"))
|
|
452
|
+
if len(fits) < len(pins):
|
|
453
|
+
over = len(pins) - len(fits)
|
|
454
|
+
found.append(_skipped(
|
|
455
|
+
repo, rel, f"it holds {over} {'value' if over == 1 else 'values'} over {_MAX_TEXT} "
|
|
456
|
+
"characters, too long to be read as a pin"))
|
|
457
|
+
return found
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class _Skip(Exception):
|
|
461
|
+
"""A file to pass over. The message is the reason to record."""
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _decode(data: bytes) -> str:
|
|
465
|
+
# PowerShell writes UTF-16 with a byte order mark when it redirects, and editors add a UTF-8
|
|
466
|
+
# one. A file in any other encoding still reads, with the odd character replaced, because the
|
|
467
|
+
# tokens that matter are ASCII.
|
|
468
|
+
if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
|
469
|
+
return data.decode("utf-16", errors="replace")
|
|
470
|
+
return data.decode("utf-8-sig", errors="replace")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _read(path: str) -> str:
|
|
474
|
+
if not stat.S_ISREG(os.lstat(path).st_mode):
|
|
475
|
+
# A named pipe with no writer would block the open for good.
|
|
476
|
+
raise _Skip("it is not a regular file")
|
|
477
|
+
with open(path, "rb") as f:
|
|
478
|
+
# One byte past the cap tells a file at the limit from one over it. The size on disk is
|
|
479
|
+
# no guide, because a pseudo-file can report none and then never end.
|
|
480
|
+
data = f.read(_MAX_BYTES + 1)
|
|
481
|
+
if len(data) > _MAX_BYTES:
|
|
482
|
+
raise _Skip(f"it is larger than {_MAX_BYTES} bytes")
|
|
483
|
+
return _decode(data)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _scan_file(repo: str, rel: str, path: str, reader: _Reader) -> list[Evidence]:
|
|
487
|
+
try:
|
|
488
|
+
text = _read(path)
|
|
489
|
+
except _Skip as skip:
|
|
490
|
+
return [_skipped(repo, rel, str(skip))]
|
|
491
|
+
except OSError as exc:
|
|
492
|
+
return [_skipped(repo, rel, f"cannot read it ({_why(exc)})")]
|
|
493
|
+
return _finds(repo, rel, reader, text)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _is_plain(path: str) -> bool:
|
|
497
|
+
# scandir calls a Windows junction an ordinary folder, but realpath resolves it, and then the
|
|
498
|
+
# two spellings of the path differ.
|
|
499
|
+
return os.path.normcase(os.path.realpath(path)) == os.path.normcase(path)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _inside(root: str, path: str) -> bool:
|
|
503
|
+
"""True when path is root or sits under it. Both are resolved paths."""
|
|
504
|
+
base = os.path.normcase(root).rstrip(os.sep)
|
|
505
|
+
path = os.path.normcase(path)
|
|
506
|
+
# The separator after the root keeps /srv/site2 from counting as inside /srv/site. A
|
|
507
|
+
# filesystem root already ends in one, which is why the root is stripped before it is added.
|
|
508
|
+
return path == base or path.startswith(base + os.sep)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _repo_name(top: str) -> str:
|
|
512
|
+
# The directory name keeps the id readable. Eight hex characters of a hash of the resolved path
|
|
513
|
+
# keep /a/app and /b/app apart in one inventory, and only those eight leave the machine, not
|
|
514
|
+
# the path. A filesystem root has no name, so its path stands in. The joiner is a tilde because
|
|
515
|
+
# the report masks the value in name:value when the name holds a word like auth or token.
|
|
516
|
+
digest = hashlib.sha256(os.fsencode(top)).hexdigest()[:8]
|
|
517
|
+
return f"{os.path.basename(top) or top}~{digest}"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def scan_path(root: str) -> list[Evidence]:
|
|
521
|
+
"""One Evidence per PHP pin under root, in a fixed order, located relative to root.
|
|
522
|
+
|
|
523
|
+
The repo signal on each is the name of the directory scanned, a tilde, and eight hex characters
|
|
524
|
+
of a hash of its resolved path, so two checkouts that share a name stay two assets. Links
|
|
525
|
+
inside the tree are not followed. A linked folder that leads out of the tree, and a linked
|
|
526
|
+
file with a name the scan reads, are reported as skipped, and so is a file that cannot be read,
|
|
527
|
+
is too large, or will not parse, with the reason in note. A folder link that stays inside the
|
|
528
|
+
tree adds nothing, because the folder is read where it really sits. A skip counts as PHP seen
|
|
529
|
+
with no version, so a repo with one cannot grade as patched. A root that is itself a link is
|
|
530
|
+
resolved first. A root that cannot be listed raises OSError, because an empty list would read
|
|
531
|
+
as a clean repository.
|
|
532
|
+
"""
|
|
533
|
+
top = os.path.realpath(root)
|
|
534
|
+
repo = _repo_name(top)
|
|
535
|
+
found: list[Evidence] = []
|
|
536
|
+
todo = [("", top)] # folders still to read, as (path relative to root, path on disk)
|
|
537
|
+
while todo:
|
|
538
|
+
rel_dir, directory = todo.pop()
|
|
539
|
+
try:
|
|
540
|
+
with os.scandir(directory) as entries:
|
|
541
|
+
listing = sorted(entries, key=lambda entry: entry.name)
|
|
542
|
+
except OSError as exc:
|
|
543
|
+
if not rel_dir:
|
|
544
|
+
raise
|
|
545
|
+
found.append(_skipped(repo, rel_dir, f"cannot list it ({_why(exc)})"))
|
|
546
|
+
continue
|
|
547
|
+
subdirs = []
|
|
548
|
+
for entry in listing:
|
|
549
|
+
rel = f"{rel_dir}/{entry.name}" if rel_dir else entry.name
|
|
550
|
+
try:
|
|
551
|
+
linked = entry.is_symlink()
|
|
552
|
+
folder = entry.is_dir(follow_symlinks=False)
|
|
553
|
+
# A link leads to a folder when its target is one. A dangling link leads nowhere.
|
|
554
|
+
leads_to_folder = linked and entry.is_dir()
|
|
555
|
+
except OSError as exc:
|
|
556
|
+
found.append(_skipped(repo, rel, f"cannot inspect it ({_why(exc)})"))
|
|
557
|
+
continue
|
|
558
|
+
if folder or leads_to_folder:
|
|
559
|
+
if entry.name in _SKIP_DIRS:
|
|
560
|
+
continue
|
|
561
|
+
if folder and _is_plain(entry.path):
|
|
562
|
+
subdirs.append((rel, entry.path))
|
|
563
|
+
elif not _inside(top, os.path.realpath(entry.path)):
|
|
564
|
+
# A symlink, or a junction that scandir takes for an ordinary folder, that
|
|
565
|
+
# leads out of this tree. Nothing in it is read, and a patched pin elsewhere
|
|
566
|
+
# must not clear the repo past it. A link that stays inside leads to a folder
|
|
567
|
+
# the walk reads where it really sits, so it is no gap and gets no row.
|
|
568
|
+
found.append(_skipped(repo, rel, "linked directory not traversed"))
|
|
569
|
+
continue
|
|
570
|
+
reader = _reader(rel)
|
|
571
|
+
if reader is None:
|
|
572
|
+
continue
|
|
573
|
+
if linked:
|
|
574
|
+
found.append(
|
|
575
|
+
_skipped(repo, rel, "it is a symbolic link, and links are not followed"))
|
|
576
|
+
else:
|
|
577
|
+
found += _scan_file(repo, rel, entry.path, reader)
|
|
578
|
+
todo.extend(reversed(subdirs))
|
|
579
|
+
return found
|
php_radar/versions.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import string
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _num(s: str) -> Optional[int]:
|
|
7
|
+
# ASCII digits only, because int() also takes Arabic-Indic digits and "3_5". The length cap
|
|
8
|
+
# keeps a hostile header from tripping the int size limit on 3.11 and later, and no PHP
|
|
9
|
+
# component is this long.
|
|
10
|
+
return int(s) if s.isascii() and s.isdigit() and len(s) <= 4 else None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_branch(text: str) -> Optional[tuple[int, int]]:
|
|
14
|
+
"""Turn "8.3" into (8, 3). Anything that is not exactly major.minor is None."""
|
|
15
|
+
major, dot, minor = text.strip().partition(".")
|
|
16
|
+
a, b = _num(major), _num(minor)
|
|
17
|
+
return (a, b) if dot and a is not None and b is not None else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, order=True)
|
|
21
|
+
class PhpVersion:
|
|
22
|
+
major: int
|
|
23
|
+
minor: int
|
|
24
|
+
micro: int
|
|
25
|
+
# The text after the micro, such as -fpm or -1ubuntu2.28. It stays out of equality, hashing
|
|
26
|
+
# and ordering, so 8.3.35-fpm is the same version as 8.3.35. Only the grader reads it.
|
|
27
|
+
suffix: str = field(default="", compare=False)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def branch(self) -> str:
|
|
31
|
+
return f"{self.major}.{self.minor}"
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def parse(cls, text: str) -> Optional["PhpVersion"]:
|
|
35
|
+
"""Read major.minor.micro off the front of text, ignoring a build suffix like -fpm.
|
|
36
|
+
|
|
37
|
+
A floating tag ("8.3-apache"), a constraint (">=8.3.36"), or a header line has no
|
|
38
|
+
single micro to grade, so none of them parse.
|
|
39
|
+
"""
|
|
40
|
+
major, dot1, rest = text.strip().partition(".")
|
|
41
|
+
minor, dot2, tail = rest.partition(".")
|
|
42
|
+
if not (dot1 and dot2):
|
|
43
|
+
return None
|
|
44
|
+
suffix = tail.lstrip(string.digits)
|
|
45
|
+
micro = tail[:len(tail) - len(suffix)]
|
|
46
|
+
# What follows the micro has to be a build suffix (-fpm, +dfsg, RC1). A further number or
|
|
47
|
+
# a space means this is a range or a header line.
|
|
48
|
+
if suffix and suffix[0] not in "-+~_" + string.ascii_letters:
|
|
49
|
+
return None
|
|
50
|
+
nums = (_num(major), _num(minor), _num(micro))
|
|
51
|
+
return None if None in nums else cls(*nums, suffix)
|