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.
@@ -0,0 +1,399 @@
1
+ """Finds where the repositories of a GitHub organization pin or declare a PHP runtime.
2
+
3
+ scan_github_org lists the repositories of an organization through the REST API and reads, in the
4
+ default branch of each, the files scan_path reads in a checkout. The reading is repo.py's: the same
5
+ file names, the same parsers, the same limits, and the same rule that a file the scan could not
6
+ read holds its repository at INDETERMINATE. This module only fetches.
7
+
8
+ The token goes to api.github.com and nowhere else. Every URL is checked before it is requested, so
9
+ a redirect or a pagination link that names another host is refused. A rate limit is waited out in
10
+ short steps up to a ceiling, and then the scan stops. Other branches are not read. A submodule is
11
+ not entered, because it is another repository.
12
+ """
13
+ import base64
14
+ import json
15
+ import string
16
+ import time
17
+ from typing import Callable, Iterator, NamedTuple, Optional
18
+ from urllib.parse import quote, urljoin, urlsplit
19
+
20
+ from php_radar import http
21
+ from php_radar.model import Evidence
22
+ # The parsing seam that the local scan shares. A rename in repo.py breaks this import at once, so
23
+ # the two scans cannot drift apart unnoticed.
24
+ from php_radar.sources.repo import _MAX_BYTES, _SKIP_DIRS, _decode, _finds, _reader, _skipped
25
+
26
+ _API = "https://api.github.com"
27
+
28
+ # Seconds, for each request.
29
+ _TIMEOUT = 20.0
30
+
31
+ # The most the API puts on a page.
32
+ _PER_PAGE = 100
33
+
34
+ # An organization past this many pages (10,000 repositories) is listed no further.
35
+ _MAX_PAGES = 100
36
+
37
+ # A redirect chain longer than this is a loop.
38
+ _MAX_HOPS = 3
39
+
40
+ # How often one request waits out a rate limit before the scan gives up, and the longest single
41
+ # wait. A limit that asks for more is not waited out, because a scan that sat for an hour would
42
+ # look hung and the person can run it again once the limit lifts.
43
+ _MAX_RETRIES = 3
44
+ _MAX_WAIT = 120
45
+
46
+ # GitHub says to wait at least a minute when a secondary limit names no time.
47
+ _DEFAULT_WAIT = 60
48
+
49
+ # This many requests in a row with no answer or a 5xx answer, and the scan decides GitHub is down.
50
+ # Without the stop, each remaining file would cost another timeout or another error.
51
+ _MAX_MISSES = 3
52
+
53
+ # Files read in one repository. Each is a request against a limit that the person's other tools
54
+ # share, and a repository with thousands of package manifests would spend all of it.
55
+ _MAX_FILES = 300
56
+
57
+ # How much of an answer is searched for the words of a rate limit message.
58
+ _PEEK = 4096
59
+
60
+ _NAME_CHARS = frozenset(string.ascii_letters + string.digits + "-._")
61
+ _HEX = frozenset("0123456789abcdef")
62
+ _REDIRECTS = frozenset({301, 302, 303, 307, 308})
63
+ _WHY = {403: "forbidden", 404: "not found, or hidden from this token"}
64
+ _TOO_BIG = f"it is larger than {_MAX_BYTES} bytes"
65
+
66
+ # Waiting goes through these two names so that a test never sleeps.
67
+ _sleep = time.sleep
68
+ _now = time.time
69
+
70
+
71
+ class _Failed(Exception):
72
+ """A request that gave no usable answer. The message is the reason to record."""
73
+
74
+ def __init__(self, reason: str, answer: Optional[http.Response] = None) -> None:
75
+ super().__init__(reason)
76
+ self.answer = answer
77
+
78
+
79
+ class _Stop(Exception):
80
+ """The scan cannot go on, because every request after this one would fail the same way."""
81
+
82
+
83
+ class _Repo(NamedTuple):
84
+ ident: str # org/name, the repo signal of each find
85
+ name: str
86
+ branch: str
87
+
88
+
89
+ def _is_name(text: object) -> bool:
90
+ """True for what GitHub allows in the name of an account or a repository."""
91
+ return (isinstance(text, str) and 0 < len(text) <= 100 and text not in (".", "..")
92
+ and _NAME_CHARS.issuperset(text))
93
+
94
+
95
+ def _is_sha(text: object) -> bool:
96
+ return isinstance(text, str) and len(text) in (40, 64) and _HEX.issuperset(text)
97
+
98
+
99
+ def _is_api(url: str) -> bool:
100
+ """True when url is on the API host, with the same scheme and no port or user name."""
101
+ try:
102
+ parts = urlsplit(url)
103
+ except ValueError:
104
+ return False
105
+ base = urlsplit(_API)
106
+ return parts.scheme == base.scheme and parts.netloc.lower() == base.netloc
107
+
108
+
109
+ def _header(answer: http.Response, name: str) -> str:
110
+ return next((value.strip() for key, value in answer.headers.items() if key.lower() == name), "")
111
+
112
+
113
+ def _seconds(text: str) -> Optional[int]:
114
+ """The whole number in a header value, or None for anything else."""
115
+ return int(text) if 0 < len(text) <= 12 and text.isascii() and text.isdigit() else None
116
+
117
+
118
+ def _limit_wait(answer: http.Response) -> Optional[float]:
119
+ """Seconds GitHub asks for when the answer is a rate limit, or None when it is no limit."""
120
+ if answer.status not in (403, 429):
121
+ return None
122
+ after = _seconds(_header(answer, "retry-after"))
123
+ if after is not None:
124
+ return max(after, 1)
125
+ if _header(answer, "x-ratelimit-remaining") == "0":
126
+ reset = _seconds(_header(answer, "x-ratelimit-reset"))
127
+ return _DEFAULT_WAIT if reset is None else max(reset - _now(), 0) + 1
128
+ # A secondary limit can come with neither header, and then only the message says so.
129
+ if b"rate limit" in answer.body[:_PEEK].lower():
130
+ return _DEFAULT_WAIT
131
+ return None
132
+
133
+
134
+ def _next_link(header: str) -> str:
135
+ """The target of the rel="next" entry of a Link header, or an empty string."""
136
+ for entry in header.split("<")[1:]:
137
+ target, _, params = entry.partition(">")
138
+ # An entry that another follows ends in the comma between them.
139
+ for param in params.rstrip(" \t,").split(";"):
140
+ name, _, value = param.partition("=")
141
+ if name.strip().lower() == "rel" and "next" in value.strip().strip('"').lower().split():
142
+ return target.strip()
143
+ return ""
144
+
145
+
146
+ def _why(exc: OSError) -> str:
147
+ # A TransportError is worded to be shown. Any other error names its class, because its own
148
+ # text alone ("boom") says too little.
149
+ text = str(exc) if isinstance(exc, http.TransportError) else f"{type(exc).__name__}: {exc}"
150
+ return text[:200]
151
+
152
+
153
+ def _status(code: int) -> str:
154
+ why = _WHY.get(code)
155
+ return f"HTTP {code}, {why}" if why else f"HTTP {code}"
156
+
157
+
158
+ def _not_understood(count: int, where: str) -> str:
159
+ entries, verb = ("entry", "was") if count == 1 else ("entries", "were")
160
+ return f"{count} {entries} in {where} {verb} not understood"
161
+
162
+
163
+ def _load(answer: http.Response) -> object:
164
+ try:
165
+ return json.loads(answer.body)
166
+ except (ValueError, RecursionError):
167
+ raise _Failed("the answer is not JSON") from None
168
+
169
+
170
+ class _Api:
171
+ """The requests of one scan. It holds the headers, and with them the only copy of the token."""
172
+
173
+ def __init__(self, get: Callable[[str, dict, float], http.Response], token: str) -> None:
174
+ self._get = get
175
+ self._headers = {"Accept": "application/vnd.github+json", "User-Agent": "php-radar"}
176
+ if token:
177
+ self._headers["Authorization"] = "Bearer " + token
178
+ self._misses = 0 # requests in a row that got no answer or a 5xx
179
+
180
+ def _miss(self, reason: str) -> None:
181
+ self._misses += 1
182
+ if self._misses >= _MAX_MISSES:
183
+ raise _Stop(f"GitHub keeps failing ({reason})")
184
+
185
+ def fetch(self, url: str) -> http.Response:
186
+ """The 200 answer for url. Redirects stay inside the API, and a rate limit is waited out."""
187
+ hops = retries = 0
188
+ while True:
189
+ # Every request is checked, the redirected and the paginated ones included. So the
190
+ # token goes nowhere else, whatever put the URL here.
191
+ if not _is_api(url):
192
+ raise _Failed("the link leads away from api.github.com")
193
+ try:
194
+ answer = self._get(url, self._headers, _TIMEOUT)
195
+ except OSError as exc:
196
+ self._miss(_why(exc))
197
+ raise _Failed(_why(exc)) from exc
198
+ if answer.status >= 500:
199
+ self._miss(_status(answer.status))
200
+ else:
201
+ self._misses = 0
202
+ if answer.status in _REDIRECTS:
203
+ where = _header(answer, "location")
204
+ hops += 1
205
+ if not where:
206
+ raise _Failed("a redirect names no place")
207
+ if hops > _MAX_HOPS:
208
+ raise _Failed("too many redirects")
209
+ url = urljoin(url, where)
210
+ continue
211
+ wait = _limit_wait(answer)
212
+ if wait is not None:
213
+ wait *= 2 ** retries
214
+ if wait > _MAX_WAIT:
215
+ raise _Stop("the GitHub rate limit was reached, and the wait would run past "
216
+ f"{_MAX_WAIT} seconds")
217
+ if retries == _MAX_RETRIES:
218
+ raise _Stop(f"the GitHub rate limit was still in force after {retries} waits")
219
+ retries += 1
220
+ _sleep(wait)
221
+ continue
222
+ if answer.status == 401:
223
+ raise _Stop("GitHub rejected the token (HTTP 401)")
224
+ if answer.status != 200:
225
+ raise _Failed(_status(answer.status), answer)
226
+ return answer
227
+
228
+ def fetch_json(self, url: str) -> object:
229
+ return _load(self.fetch(url))
230
+
231
+
232
+ def _repo(org: str, item: object) -> Optional[_Repo]:
233
+ if not isinstance(item, dict):
234
+ return None
235
+ name, branch = item.get("name"), item.get("default_branch")
236
+ if not _is_name(name) or not isinstance(branch, str) or not branch:
237
+ return None
238
+ return _Repo(f"{org}/{name}", name, branch)
239
+
240
+
241
+ def _repos(api: _Api, org: str, gaps: list[str]) -> Iterator[_Repo]:
242
+ """The repositories of org, a page at a time. gaps takes the reasons the list falls short."""
243
+ url = f"{_API}/orgs/{quote(org, safe='')}/repos?per_page={_PER_PAGE}&sort=full_name"
244
+ seen = set() # a repository created while the pages are read can turn up on two of them
245
+ pages = 0
246
+ while url:
247
+ if pages == _MAX_PAGES:
248
+ gaps.append(f"the list of repositories runs past {_MAX_PAGES} pages, and only those "
249
+ "were read")
250
+ return
251
+ pages += 1
252
+ answer = api.fetch(url)
253
+ page = _load(answer)
254
+ if not isinstance(page, list):
255
+ raise _Failed("the answer is not a list")
256
+ odd = 0
257
+ for item in page:
258
+ repo = _repo(org, item)
259
+ if repo is None:
260
+ odd += 1
261
+ elif repo.name not in seen:
262
+ seen.add(repo.name)
263
+ yield repo
264
+ if odd:
265
+ gaps.append(_not_understood(odd, "the list of repositories"))
266
+ url = _next_link(_header(answer, "link"))
267
+
268
+
269
+ def _blob(api: _Api, url: str) -> bytes:
270
+ data = api.fetch_json(url)
271
+ content = data.get("content") if isinstance(data, dict) else None
272
+ encoding = data.get("encoding") if isinstance(data, dict) else None
273
+ if encoding != "base64" or not isinstance(content, str):
274
+ raise _Failed("the answer holds no base64 content")
275
+ try:
276
+ # GitHub breaks the encoding into lines. Any other byte outside the alphabet is an error,
277
+ # and the decoder would otherwise drop it and return a shorter file.
278
+ return base64.b64decode("".join(content.split()), validate=True)
279
+ except ValueError:
280
+ raise _Failed("the content is not valid base64") from None
281
+
282
+
283
+ def _scan_file(api: _Api, base: str, ident: str, path: str, entry: dict,
284
+ reader) -> Iterator[Evidence]:
285
+ if entry.get("mode") == "120000":
286
+ yield _skipped(ident, path, "it is a symbolic link, and links are not followed")
287
+ return
288
+ size, sha = entry.get("size"), entry.get("sha")
289
+ if isinstance(size, int) and size > _MAX_BYTES:
290
+ yield _skipped(ident, path, _TOO_BIG)
291
+ return
292
+ if not _is_sha(sha):
293
+ yield _skipped(ident, path, "cannot read it (its object id is not understood)")
294
+ return
295
+ try:
296
+ data = _blob(api, f"{base}/blobs/{sha}")
297
+ except _Failed as exc:
298
+ yield _skipped(ident, path, f"cannot read it ({exc})")
299
+ return
300
+ if len(data) > _MAX_BYTES:
301
+ yield _skipped(ident, path, _TOO_BIG)
302
+ return
303
+ yield from _finds(ident, path, reader, _decode(data))
304
+
305
+
306
+ def _is_empty(failure: _Failed) -> bool:
307
+ answer = failure.answer
308
+ return answer is not None and answer.status == 409 and b"empty" in answer.body[:_PEEK].lower()
309
+
310
+
311
+ def _scan_repo(api: _Api, org: str, repo: _Repo) -> Iterator[Evidence]:
312
+ ident = repo.ident
313
+ base = f"{_API}/repos/{quote(org, safe='')}/{quote(repo.name, safe='')}/git"
314
+ try:
315
+ tree = api.fetch_json(f"{base}/trees/{quote(repo.branch, safe='')}?recursive=1")
316
+ except _Failed as exc:
317
+ # A repository with no commits has no files to read, and GitHub says so with a 409.
318
+ if not _is_empty(exc):
319
+ yield _skipped(ident, ".", f"cannot list its files ({exc})")
320
+ return
321
+ entries = tree.get("tree") if isinstance(tree, dict) else None
322
+ if not isinstance(entries, list):
323
+ yield _skipped(ident, ".", "cannot list its files (the answer is not a file list)")
324
+ return
325
+ wanted = []
326
+ odd = 0
327
+ for entry in entries:
328
+ path = entry.get("path") if isinstance(entry, dict) else None
329
+ kind = entry.get("type") if isinstance(entry, dict) else None
330
+ if not isinstance(path, str) or not isinstance(kind, str):
331
+ odd += 1
332
+ elif kind == "blob" and not _SKIP_DIRS.intersection(path.split("/")[:-1]):
333
+ reader = _reader(path)
334
+ if reader is not None:
335
+ wanted.append((path, entry, reader))
336
+ # The files nearest the root come first, because they are the project's own, and the cap
337
+ # drops the deepest.
338
+ wanted.sort(key=lambda found: (found[0].count("/"), found[0]))
339
+ for path, entry, reader in wanted[:_MAX_FILES]:
340
+ yield from _scan_file(api, base, ident, path, entry, reader)
341
+ if len(wanted) > _MAX_FILES:
342
+ yield _skipped(ident, ".", f"it holds {len(wanted)} files the scan reads, and only the "
343
+ f"first {_MAX_FILES} were read")
344
+ if odd:
345
+ yield _skipped(ident, ".", _not_understood(odd, "its file list"))
346
+ if tree.get("truncated") is True:
347
+ yield _skipped(ident, ".", "its file list was cut short by GitHub, so some files were "
348
+ "not read")
349
+
350
+
351
+ def scan_github_org(org: str, token: str,
352
+ get: Callable[[str, dict, float], http.Response] = http.get) -> list[Evidence]:
353
+ """One Evidence per PHP pin in the default branches of the repositories of the organization org.
354
+
355
+ Each find has the repo signal org/name and, as its location, the path of the file within that
356
+ repository. The token is a GitHub token that can read the contents of the repositories. It goes
357
+ to api.github.com and to no other host, and it appears in no evidence, note, or message. The
358
+ caller reads it from the environment and passes it in. An empty token scans without one, and
359
+ GitHub allows that 60 requests an hour. get takes (url, headers, timeout) and returns an
360
+ http.Response, as http.get does. The guarantee about the token holds while get follows no
361
+ redirect, and http.get follows none. The host check runs on the URLs this function requests,
362
+ so it cannot see a hop that a custom get makes inside itself.
363
+
364
+ A repository with no PHP pins leaves no evidence. Failures of the network and of GitHub never
365
+ raise. Each leaves a skipped row with the reason in note, and the row's repo signal is the
366
+ repository, or the organization when its repositories cannot be listed. That row holds the
367
+ repository at INDETERMINATE, as an unread file does in a local scan. ValueError means org or
368
+ token cannot be used, and nothing was sent.
369
+ """
370
+ if not _is_name(org):
371
+ raise ValueError("org is not the name of a GitHub organization")
372
+ token = (token or "").strip()
373
+ if not (token.isascii() and token.isprintable() and " " not in token):
374
+ raise ValueError("token holds a character that cannot go in an HTTP header")
375
+ api = _Api(get, token)
376
+ found: list[Evidence] = []
377
+ gaps: list[str] = []
378
+ repos: list[_Repo] = []
379
+ stop = "" # once set, the reason no request will be made
380
+ try:
381
+ for repo in _repos(api, org, gaps):
382
+ repos.append(repo)
383
+ except _Failed as exc:
384
+ gaps.append(f"cannot list all the repositories ({exc})")
385
+ except _Stop as halt:
386
+ stop = str(halt)
387
+ gaps.append(f"cannot list all the repositories ({halt})")
388
+ found.extend(_skipped(org, ".", gap) for gap in gaps)
389
+ for repo in repos:
390
+ if stop:
391
+ found.append(_skipped(repo.ident, ".", f"not scanned ({stop})"))
392
+ continue
393
+ try:
394
+ for ev in _scan_repo(api, org, repo):
395
+ found.append(ev)
396
+ except _Stop as halt:
397
+ stop = str(halt)
398
+ found.append(_skipped(repo.ident, ".", f"not fully scanned ({stop})"))
399
+ return found