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.
@@ -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)