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,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
|