github-security-report 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.
- github_security_report/__init__.py +13 -0
- github_security_report/_version.py +24 -0
- github_security_report/classify.py +208 -0
- github_security_report/cli.py +448 -0
- github_security_report/client.py +493 -0
- github_security_report/collect.py +376 -0
- github_security_report/config.py +343 -0
- github_security_report/gitctx.py +66 -0
- github_security_report/models.py +172 -0
- github_security_report/posture.py +264 -0
- github_security_report/py.typed +1 -0
- github_security_report/render/__init__.py +3 -0
- github_security_report/render/html.py +142 -0
- github_security_report/render/markdown.py +172 -0
- github_security_report/render/slack.py +215 -0
- github_security_report/render/terminal.py +163 -0
- github_security_report/report.py +173 -0
- github_security_report/rulesets.py +137 -0
- github_security_report/runner.py +147 -0
- github_security_report/scope.py +97 -0
- github_security_report/severity.py +83 -0
- github_security_report/templates/index.html.j2 +57 -0
- github_security_report/templates/report.html.j2 +162 -0
- github_security_report-0.1.0.dist-info/METADATA +318 -0
- github_security_report-0.1.0.dist-info/RECORD +28 -0
- github_security_report-0.1.0.dist-info/WHEEL +4 -0
- github_security_report-0.1.0.dist-info/entry_points.txt +2 -0
- github_security_report-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Async GitHub transport: hybrid REST + GraphQL.
|
|
4
|
+
|
|
5
|
+
Implements the Phase 0 strategy: prefer org-bulk alert sweeps, fall back to
|
|
6
|
+
per-repo enabled-probes, with bounded concurrency and backoff that honours
|
|
7
|
+
``Retry-After`` and secondary rate limits. Methods return raw parsed JSON (and
|
|
8
|
+
HTTP status where the status itself is the signal, e.g. 404 = feature disabled).
|
|
9
|
+
See ``docs/BRIEF.md`` sections 9, 13 and ``docs/phase0-findings.md``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from github_security_report.models import Repo
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
GITHUB_API = "https://api.github.com"
|
|
25
|
+
GRAPHQL_API = "https://api.github.com/graphql"
|
|
26
|
+
SCORECARD_API = "https://api.securityscorecards.dev"
|
|
27
|
+
|
|
28
|
+
# org-bulk alert endpoints, keyed by signal family.
|
|
29
|
+
BULK_KINDS = {
|
|
30
|
+
"code-scanning": "code-scanning/alerts",
|
|
31
|
+
"dependabot": "dependabot/alerts",
|
|
32
|
+
"secret-scanning": "secret-scanning/alerts",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_DEPENDABOT_ENABLED_QUERY = """
|
|
36
|
+
query($owner: String!, $name: String!) {
|
|
37
|
+
repository(owner: $owner, name: $name) {
|
|
38
|
+
hasVulnerabilityAlertsEnabled
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# The code-scanning-derived signal tools whose enablement we probe per repo.
|
|
44
|
+
# Each is checked via the analyses ``tool_name`` filter (a definitive presence
|
|
45
|
+
# test) rather than scanning the analysis history, which a busy repo could push
|
|
46
|
+
# a low-frequency tool out of.
|
|
47
|
+
_CODE_SCANNING_SIGNAL_TOOLS = ("CodeQL", "Scorecard", "zizmor")
|
|
48
|
+
|
|
49
|
+
# Most-recent tag (by underlying commit date) for the releases/tagging section.
|
|
50
|
+
# A tag's target is a Commit (lightweight) or a Tag object (annotated), whose
|
|
51
|
+
# own target is the Commit -- both branches are read for the committed date.
|
|
52
|
+
_LATEST_TAG_QUERY = """
|
|
53
|
+
query($owner: String!, $name: String!) {
|
|
54
|
+
repository(owner: $owner, name: $name) {
|
|
55
|
+
refs(refPrefix: "refs/tags/", first: 1,
|
|
56
|
+
orderBy: {field: TAG_COMMIT_DATE, direction: DESC}) {
|
|
57
|
+
nodes {
|
|
58
|
+
target {
|
|
59
|
+
__typename
|
|
60
|
+
... on Commit { committedDate }
|
|
61
|
+
... on Tag { target { ... on Commit { committedDate } } }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_iso(value: object) -> dt.datetime | None:
|
|
71
|
+
"""Parse a GitHub ISO-8601 timestamp (``...Z``) into an aware datetime."""
|
|
72
|
+
if not isinstance(value, str) or not value:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
76
|
+
except ValueError:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GitHubClient:
|
|
81
|
+
"""Thin async client over the GitHub REST + GraphQL APIs."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
token: str,
|
|
86
|
+
*,
|
|
87
|
+
api_url: str = GITHUB_API,
|
|
88
|
+
graphql_url: str = GRAPHQL_API,
|
|
89
|
+
scorecard_url: str = SCORECARD_API,
|
|
90
|
+
concurrency: int = 6,
|
|
91
|
+
max_retries: int = 4,
|
|
92
|
+
timeout: float = 30.0,
|
|
93
|
+
) -> None:
|
|
94
|
+
self._api_url = api_url.rstrip("/")
|
|
95
|
+
self._graphql_url = graphql_url
|
|
96
|
+
self._scorecard_url = scorecard_url.rstrip("/")
|
|
97
|
+
self._max_retries = max_retries
|
|
98
|
+
self._sem = asyncio.Semaphore(concurrency)
|
|
99
|
+
self._client = httpx.AsyncClient(
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
headers={
|
|
102
|
+
"Authorization": f"Bearer {token}",
|
|
103
|
+
"Accept": "application/vnd.github+json",
|
|
104
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
105
|
+
"User-Agent": "github-security-report",
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
# Separate, UNAUTHENTICATED client for third-party endpoints (the
|
|
109
|
+
# external Scorecard API): the GitHub token must never be sent there.
|
|
110
|
+
self._ext_client = httpx.AsyncClient(
|
|
111
|
+
timeout=timeout, headers={"User-Agent": "github-security-report"}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def __aenter__(self) -> GitHubClient:
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
118
|
+
await self.aclose()
|
|
119
|
+
|
|
120
|
+
async def aclose(self) -> None:
|
|
121
|
+
await self._client.aclose()
|
|
122
|
+
await self._ext_client.aclose()
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ #
|
|
125
|
+
# Low-level request with backoff
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
async def _request(
|
|
128
|
+
self,
|
|
129
|
+
method: str,
|
|
130
|
+
url: str,
|
|
131
|
+
*,
|
|
132
|
+
client: httpx.AsyncClient | None = None,
|
|
133
|
+
**kwargs: object,
|
|
134
|
+
) -> httpx.Response:
|
|
135
|
+
"""Issue a request, retrying on rate-limit responses with backoff.
|
|
136
|
+
|
|
137
|
+
``client`` selects the transport (default: the authenticated GitHub
|
|
138
|
+
client). External calls pass the unauthenticated client so the GitHub
|
|
139
|
+
token is never leaked to third parties.
|
|
140
|
+
"""
|
|
141
|
+
http = client or self._client
|
|
142
|
+
attempt = 0
|
|
143
|
+
while True:
|
|
144
|
+
try:
|
|
145
|
+
async with self._sem:
|
|
146
|
+
resp = await http.request(method, url, **kwargs) # type: ignore[arg-type]
|
|
147
|
+
except httpx.HTTPError as exc:
|
|
148
|
+
# Transport failure (DNS/TLS/connect or read timeout). Signals
|
|
149
|
+
# degrade independently, so convert this into an indeterminate
|
|
150
|
+
# 503 response rather than aborting the whole run; callers treat
|
|
151
|
+
# any non-200 as not-clean/unknown.
|
|
152
|
+
log.warning("request to %s failed: %s", url, exc)
|
|
153
|
+
return httpx.Response(503, request=httpx.Request(method, url))
|
|
154
|
+
if resp.status_code not in (403, 429):
|
|
155
|
+
return resp
|
|
156
|
+
# Distinguish secondary/primary rate limiting from a genuine 403.
|
|
157
|
+
retry_after = resp.headers.get("retry-after")
|
|
158
|
+
remaining = resp.headers.get("x-ratelimit-remaining")
|
|
159
|
+
rate_limited = retry_after is not None or remaining == "0"
|
|
160
|
+
if not rate_limited or attempt >= self._max_retries:
|
|
161
|
+
return resp
|
|
162
|
+
delay = float(retry_after) if retry_after else min(2**attempt, 60)
|
|
163
|
+
log.warning("rate limited on %s; backing off %.0fs", url, delay)
|
|
164
|
+
# The discarded response must be closed; we are retrying and will
|
|
165
|
+
# not read its body, so leaving it open would leak a pool connection.
|
|
166
|
+
await resp.aclose()
|
|
167
|
+
await asyncio.sleep(delay)
|
|
168
|
+
attempt += 1
|
|
169
|
+
|
|
170
|
+
async def _get_list(self, url: str, **params: object) -> tuple[int, list[dict]]:
|
|
171
|
+
"""GET a paginated list, returning (status, items collected).
|
|
172
|
+
|
|
173
|
+
The status is itself a signal for these endpoints (404 = feature
|
|
174
|
+
disabled). If a *later* page fails, the partial items gathered so far
|
|
175
|
+
are returned alongside that failing status (not 200): the data is
|
|
176
|
+
incomplete, so callers must be able to degrade to UNKNOWN rather than
|
|
177
|
+
treat an undercount as authoritative. The failed response is closed to
|
|
178
|
+
avoid leaking a pooled connection (its body is never read).
|
|
179
|
+
"""
|
|
180
|
+
resp = await self._request("GET", url, params={**params, "per_page": 100})
|
|
181
|
+
if resp.status_code != 200:
|
|
182
|
+
status = resp.status_code
|
|
183
|
+
await resp.aclose() # unread body would leak a pooled connection
|
|
184
|
+
return status, []
|
|
185
|
+
items = list(resp.json())
|
|
186
|
+
next_url = resp.links.get("next", {}).get("url")
|
|
187
|
+
await resp.aclose() # release the connection once body/links are read
|
|
188
|
+
while next_url:
|
|
189
|
+
resp = await self._request("GET", next_url)
|
|
190
|
+
if resp.status_code != 200:
|
|
191
|
+
log.warning(
|
|
192
|
+
"pagination stopped early: %s -> %s (results may be partial)",
|
|
193
|
+
next_url,
|
|
194
|
+
resp.status_code,
|
|
195
|
+
)
|
|
196
|
+
await resp.aclose()
|
|
197
|
+
return resp.status_code, items
|
|
198
|
+
items.extend(resp.json())
|
|
199
|
+
next_url = resp.links.get("next", {}).get("url")
|
|
200
|
+
await resp.aclose()
|
|
201
|
+
return 200, items
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------ #
|
|
204
|
+
# Repositories
|
|
205
|
+
# ------------------------------------------------------------------ #
|
|
206
|
+
async def list_org_repos(self, org: str) -> tuple[int, list[Repo]]:
|
|
207
|
+
"""List an organisation's repositories, skipping disabled/empty ones.
|
|
208
|
+
|
|
209
|
+
Returns the listing status alongside the repos: a non-200 (a failed or
|
|
210
|
+
mid-pagination-truncated listing) means the set is incomplete, so the
|
|
211
|
+
caller can flag a partial report rather than silently omitting repos
|
|
212
|
+
(and their offenders).
|
|
213
|
+
"""
|
|
214
|
+
status, raws = await self._get_list(
|
|
215
|
+
f"{self._api_url}/orgs/{org}/repos", type="all"
|
|
216
|
+
)
|
|
217
|
+
repos: list[Repo] = []
|
|
218
|
+
for raw in raws:
|
|
219
|
+
if raw.get("disabled") or raw.get("size", 0) == 0:
|
|
220
|
+
log.info("skipping %s: disabled or empty", raw.get("full_name"))
|
|
221
|
+
continue
|
|
222
|
+
repos.append(
|
|
223
|
+
Repo(
|
|
224
|
+
name=raw["name"],
|
|
225
|
+
full_name=raw["full_name"],
|
|
226
|
+
html_url=raw["html_url"],
|
|
227
|
+
archived=raw.get("archived", False),
|
|
228
|
+
fork=raw.get("fork", False),
|
|
229
|
+
is_template=raw.get("is_template", False),
|
|
230
|
+
private=raw.get("private", False),
|
|
231
|
+
created_at=_parse_iso(raw.get("created_at")),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return status, repos
|
|
235
|
+
|
|
236
|
+
# ------------------------------------------------------------------ #
|
|
237
|
+
# Org-bulk alert sweeps
|
|
238
|
+
# ------------------------------------------------------------------ #
|
|
239
|
+
async def org_bulk_alerts(self, org: str, kind: str) -> tuple[int, list[dict]]:
|
|
240
|
+
"""Sweep all open alerts of one kind across the org.
|
|
241
|
+
|
|
242
|
+
Returns the first-page HTTP status alongside the alerts so callers can
|
|
243
|
+
tell an authoritative empty result (200 ``[]``) apart from an unreadable
|
|
244
|
+
sweep (403/404/5xx), which must never be reported as "clean".
|
|
245
|
+
"""
|
|
246
|
+
path = BULK_KINDS[kind]
|
|
247
|
+
return await self._get_list(
|
|
248
|
+
f"{self._api_url}/orgs/{org}/{path}", state="open"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------ #
|
|
252
|
+
# Per-repo enabled-probes
|
|
253
|
+
# ------------------------------------------------------------------ #
|
|
254
|
+
async def code_scanning_tools(self, org: str, repo: str) -> tuple[int, set[str]]:
|
|
255
|
+
"""Return (status, enabled signal tool names) from code-scanning analyses.
|
|
256
|
+
|
|
257
|
+
Each tool in ``_CODE_SCANNING_SIGNAL_TOOLS`` is probed with the analyses
|
|
258
|
+
``tool_name`` filter, a definitive presence test that does not depend on
|
|
259
|
+
how many analyses a busy repo has accumulated (the previous page-by-page
|
|
260
|
+
scan could miss a low-frequency tool past its page cap and wrongly nag
|
|
261
|
+
it). The first probe's status is authoritative for the endpoint (404 =
|
|
262
|
+
code scanning disabled, 403 = forbidden, 5xx/0 = indeterminate); a later
|
|
263
|
+
per-tool probe that fails is skipped (its tool goes undetected for this
|
|
264
|
+
run) rather than discarding the whole result.
|
|
265
|
+
"""
|
|
266
|
+
url = f"{self._api_url}/repos/{org}/{repo}/code-scanning/analyses"
|
|
267
|
+
tools: set[str] = set()
|
|
268
|
+
for index, tool in enumerate(_CODE_SCANNING_SIGNAL_TOOLS):
|
|
269
|
+
resp = await self._request(
|
|
270
|
+
"GET", url, params={"per_page": 1, "tool_name": tool}
|
|
271
|
+
)
|
|
272
|
+
if resp.status_code != 200:
|
|
273
|
+
status = resp.status_code
|
|
274
|
+
await resp.aclose() # unread body would leak a pooled connection
|
|
275
|
+
if index == 0:
|
|
276
|
+
return status, set()
|
|
277
|
+
continue
|
|
278
|
+
has_analyses = bool(resp.json())
|
|
279
|
+
await resp.aclose() # release the connection once the body is read
|
|
280
|
+
if has_analyses:
|
|
281
|
+
tools.add(tool)
|
|
282
|
+
return 200, tools
|
|
283
|
+
|
|
284
|
+
async def secret_scanning_status(self, org: str, repo: str) -> int:
|
|
285
|
+
"""HTTP status of the secret-scanning alerts endpoint (404 = disabled)."""
|
|
286
|
+
resp = await self._request(
|
|
287
|
+
"GET",
|
|
288
|
+
f"{self._api_url}/repos/{org}/{repo}/secret-scanning/alerts",
|
|
289
|
+
params={"per_page": 1, "state": "open"},
|
|
290
|
+
)
|
|
291
|
+
status = int(resp.status_code)
|
|
292
|
+
await resp.aclose() # only the status is needed; release the connection
|
|
293
|
+
return status
|
|
294
|
+
|
|
295
|
+
async def dependabot_enabled(self, org: str, repo: str) -> bool | None:
|
|
296
|
+
"""Whether Dependabot alerts are enabled (None when indeterminate)."""
|
|
297
|
+
resp = await self._request(
|
|
298
|
+
"POST",
|
|
299
|
+
self._graphql_url,
|
|
300
|
+
json={
|
|
301
|
+
"query": _DEPENDABOT_ENABLED_QUERY,
|
|
302
|
+
"variables": {"owner": org, "name": repo},
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
if resp.status_code != 200:
|
|
306
|
+
await resp.aclose() # unread body would leak a pooled connection
|
|
307
|
+
return None
|
|
308
|
+
node = (resp.json().get("data") or {}).get("repository")
|
|
309
|
+
await resp.aclose() # release the connection once the body is read
|
|
310
|
+
if not node:
|
|
311
|
+
return None
|
|
312
|
+
return bool(node.get("hasVulnerabilityAlertsEnabled"))
|
|
313
|
+
|
|
314
|
+
async def scorecard_score(self, org: str, repo: str) -> tuple[int, float | None]:
|
|
315
|
+
"""External OpenSSF Scorecard aggregate score (status, score|None).
|
|
316
|
+
|
|
317
|
+
Transport failures to this third-party API are handled centrally by
|
|
318
|
+
``_request`` (which returns an indeterminate 503), so a network blip
|
|
319
|
+
degrades the Scorecard signal rather than aborting the run.
|
|
320
|
+
"""
|
|
321
|
+
url = f"{self._scorecard_url}/projects/github.com/{org}/{repo}"
|
|
322
|
+
resp = await self._request("GET", url, client=self._ext_client)
|
|
323
|
+
if resp.status_code != 200:
|
|
324
|
+
status = resp.status_code
|
|
325
|
+
await resp.aclose() # unread body would leak a pooled connection
|
|
326
|
+
return status, None
|
|
327
|
+
score = resp.json().get("score")
|
|
328
|
+
await resp.aclose() # release the connection once the body is read
|
|
329
|
+
return 200, score
|
|
330
|
+
|
|
331
|
+
# ------------------------------------------------------------------ #
|
|
332
|
+
# Repository rulesets (workflow-driven tool enablement)
|
|
333
|
+
# ------------------------------------------------------------------ #
|
|
334
|
+
async def org_workflow_rulesets(self, org: str) -> tuple[int, list[dict]]:
|
|
335
|
+
"""Active, branch-targeted org rulesets, each with full rule details.
|
|
336
|
+
|
|
337
|
+
Returns ``(status, details)``; status is the org-rulesets list status
|
|
338
|
+
(e.g. 403 when the token lacks org access) so coverage can degrade
|
|
339
|
+
gracefully. The list endpoint returns summaries, so each active branch
|
|
340
|
+
ruleset is fetched in detail to expose its rules and conditions.
|
|
341
|
+
"""
|
|
342
|
+
status, summaries = await self._get_list(f"{self._api_url}/orgs/{org}/rulesets")
|
|
343
|
+
if status != 200:
|
|
344
|
+
return status, []
|
|
345
|
+
details: list[dict] = []
|
|
346
|
+
for summary in summaries:
|
|
347
|
+
if summary.get("enforcement") != "active":
|
|
348
|
+
continue
|
|
349
|
+
if summary.get("target") not in (None, "branch"):
|
|
350
|
+
continue
|
|
351
|
+
resp = await self._request(
|
|
352
|
+
"GET", f"{self._api_url}/orgs/{org}/rulesets/{summary['id']}"
|
|
353
|
+
)
|
|
354
|
+
if resp.status_code == 200:
|
|
355
|
+
details.append(resp.json())
|
|
356
|
+
await resp.aclose() # release the connection once the body is read
|
|
357
|
+
return 200, details
|
|
358
|
+
|
|
359
|
+
async def repo_branch_rules(
|
|
360
|
+
self, org: str, repo: str, branch: str
|
|
361
|
+
) -> tuple[int, list[dict]]:
|
|
362
|
+
"""Effective branch rules for a repo (includes inherited org rulesets)."""
|
|
363
|
+
resp = await self._request(
|
|
364
|
+
"GET", f"{self._api_url}/repos/{org}/{repo}/rules/branches/{branch}"
|
|
365
|
+
)
|
|
366
|
+
if resp.status_code != 200:
|
|
367
|
+
status = resp.status_code
|
|
368
|
+
await resp.aclose() # unread body would leak a pooled connection
|
|
369
|
+
return status, []
|
|
370
|
+
rules = list(resp.json())
|
|
371
|
+
await resp.aclose() # release the connection once the body is read
|
|
372
|
+
return 200, rules
|
|
373
|
+
|
|
374
|
+
# ------------------------------------------------------------------ #
|
|
375
|
+
# Per-repo data (repo mode)
|
|
376
|
+
# ------------------------------------------------------------------ #
|
|
377
|
+
async def get_repo(self, org: str, repo: str) -> Repo | None:
|
|
378
|
+
"""Fetch a single repository's identity."""
|
|
379
|
+
resp = await self._request("GET", f"{self._api_url}/repos/{org}/{repo}")
|
|
380
|
+
if resp.status_code != 200:
|
|
381
|
+
await resp.aclose() # unread body would leak a pooled connection
|
|
382
|
+
return None
|
|
383
|
+
raw = resp.json()
|
|
384
|
+
await resp.aclose() # release the connection once the body is read
|
|
385
|
+
return Repo(
|
|
386
|
+
name=raw["name"],
|
|
387
|
+
full_name=raw["full_name"],
|
|
388
|
+
html_url=raw["html_url"],
|
|
389
|
+
archived=raw.get("archived", False),
|
|
390
|
+
fork=raw.get("fork", False),
|
|
391
|
+
is_template=raw.get("is_template", False),
|
|
392
|
+
private=raw.get("private", False),
|
|
393
|
+
default_branch=raw.get("default_branch", "main"),
|
|
394
|
+
created_at=_parse_iso(raw.get("created_at")),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
async def repo_code_scanning_alerts(self, org: str, repo: str) -> tuple[int, list[dict]]:
|
|
398
|
+
"""Open code-scanning alerts for one repo (status, alerts)."""
|
|
399
|
+
return await self._get_list(
|
|
400
|
+
f"{self._api_url}/repos/{org}/{repo}/code-scanning/alerts", state="open"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
async def repo_secret_scanning(self, org: str, repo: str) -> tuple[int, int]:
|
|
404
|
+
"""Open secret-scanning alert (status, open count) for one repo."""
|
|
405
|
+
status, items = await self._get_list(
|
|
406
|
+
f"{self._api_url}/repos/{org}/{repo}/secret-scanning/alerts", state="open"
|
|
407
|
+
)
|
|
408
|
+
return status, len(items)
|
|
409
|
+
|
|
410
|
+
async def repo_dependabot_alerts(self, org: str, repo: str) -> tuple[int, list[dict]]:
|
|
411
|
+
"""Open Dependabot alerts for one repo (status, alerts)."""
|
|
412
|
+
return await self._get_list(
|
|
413
|
+
f"{self._api_url}/repos/{org}/{repo}/dependabot/alerts", state="open"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# ------------------------------------------------------------------ #
|
|
417
|
+
# Dependabot posture + release/tag freshness (extra sections)
|
|
418
|
+
# ------------------------------------------------------------------ #
|
|
419
|
+
async def automated_security_fixes(self, org: str, repo: str) -> bool | None:
|
|
420
|
+
"""Whether Dependabot security updates are enabled (None = indeterminate).
|
|
421
|
+
|
|
422
|
+
``GET .../automated-security-fixes`` returns ``{enabled, paused}`` (200)
|
|
423
|
+
or 404 when the feature is disabled; any other status is indeterminate.
|
|
424
|
+
"""
|
|
425
|
+
resp = await self._request(
|
|
426
|
+
"GET", f"{self._api_url}/repos/{org}/{repo}/automated-security-fixes"
|
|
427
|
+
)
|
|
428
|
+
status = resp.status_code
|
|
429
|
+
if status == 404:
|
|
430
|
+
await resp.aclose() # release the connection; 404 = disabled
|
|
431
|
+
return False
|
|
432
|
+
if status != 200:
|
|
433
|
+
await resp.aclose() # unread body would leak a pooled connection
|
|
434
|
+
return None
|
|
435
|
+
data = resp.json()
|
|
436
|
+
await resp.aclose() # release the connection once the body is read
|
|
437
|
+
return bool(data.get("enabled"))
|
|
438
|
+
|
|
439
|
+
async def dependabot_config(self, org: str, repo: str) -> tuple[int, str]:
|
|
440
|
+
"""Raw ``.github/dependabot.yml`` for one repo (status, text).
|
|
441
|
+
|
|
442
|
+
404 means the repo has no Dependabot configuration. The raw media type
|
|
443
|
+
returns the file body directly (no base64 decode).
|
|
444
|
+
"""
|
|
445
|
+
resp = await self._request(
|
|
446
|
+
"GET",
|
|
447
|
+
f"{self._api_url}/repos/{org}/{repo}/contents/.github/dependabot.yml",
|
|
448
|
+
headers={"Accept": "application/vnd.github.raw+json"},
|
|
449
|
+
)
|
|
450
|
+
status = resp.status_code
|
|
451
|
+
if status != 200:
|
|
452
|
+
await resp.aclose() # unread body would leak a pooled connection
|
|
453
|
+
return status, ""
|
|
454
|
+
text = resp.text
|
|
455
|
+
await resp.aclose() # release the connection once the body is read
|
|
456
|
+
return 200, text
|
|
457
|
+
|
|
458
|
+
async def latest_release_at(self, org: str, repo: str) -> dt.datetime | None:
|
|
459
|
+
"""Publish time of the latest release (None when there is none)."""
|
|
460
|
+
resp = await self._request(
|
|
461
|
+
"GET", f"{self._api_url}/repos/{org}/{repo}/releases/latest"
|
|
462
|
+
)
|
|
463
|
+
if resp.status_code != 200:
|
|
464
|
+
await resp.aclose() # 404 = no release; release the connection
|
|
465
|
+
return None
|
|
466
|
+
data = resp.json()
|
|
467
|
+
await resp.aclose() # release the connection once the body is read
|
|
468
|
+
return _parse_iso(data.get("published_at") or data.get("created_at"))
|
|
469
|
+
|
|
470
|
+
async def latest_tag_at(self, org: str, repo: str) -> dt.datetime | None:
|
|
471
|
+
"""Commit date of the most-recent tag (None when there are no tags)."""
|
|
472
|
+
resp = await self._request(
|
|
473
|
+
"POST",
|
|
474
|
+
self._graphql_url,
|
|
475
|
+
json={
|
|
476
|
+
"query": _LATEST_TAG_QUERY,
|
|
477
|
+
"variables": {"owner": org, "name": repo},
|
|
478
|
+
},
|
|
479
|
+
)
|
|
480
|
+
if resp.status_code != 200:
|
|
481
|
+
await resp.aclose() # unread body would leak a pooled connection
|
|
482
|
+
return None
|
|
483
|
+
data = resp.json()
|
|
484
|
+
await resp.aclose() # release the connection once the body is read
|
|
485
|
+
repo_node = (data.get("data") or {}).get("repository") or {}
|
|
486
|
+
nodes = (repo_node.get("refs") or {}).get("nodes") or []
|
|
487
|
+
if not nodes:
|
|
488
|
+
return None
|
|
489
|
+
target = nodes[0].get("target") or {}
|
|
490
|
+
committed = target.get("committedDate")
|
|
491
|
+
if committed is None: # annotated tag: the Tag's target is the Commit
|
|
492
|
+
committed = (target.get("target") or {}).get("committedDate")
|
|
493
|
+
return _parse_iso(committed)
|