github-security-report 0.11.0__tar.gz → 0.11.3__tar.gz

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.
Files changed (60) hide show
  1. {github_security_report-0.11.0 → github_security_report-0.11.3}/PKG-INFO +14 -1
  2. {github_security_report-0.11.0 → github_security_report-0.11.3}/README.md +13 -0
  3. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/_version.py +2 -2
  4. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/__init__.py +5 -2
  5. github_security_report-0.11.3/src/github_security_report/client/org_reads.py +239 -0
  6. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/reads.py +11 -146
  7. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/transport.py +36 -13
  8. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/writes.py +6 -5
  9. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/context.py +5 -5
  10. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/extras.py +1 -0
  11. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/models.py +8 -1
  12. github_security_report-0.11.3/src/github_security_report/posture/__init__.py +57 -0
  13. github_security_report-0.11.3/src/github_security_report/posture/enablement.py +161 -0
  14. github_security_report-0.11.3/src/github_security_report/posture/facts.py +37 -0
  15. github_security_report-0.11.0/src/github_security_report/posture.py → github_security_report-0.11.3/src/github_security_report/posture/releases.py +24 -201
  16. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/slack.py +156 -92
  17. github_security_report-0.11.3/src/github_security_report/render/slack_limits.py +287 -0
  18. {github_security_report-0.11.0 → github_security_report-0.11.3}/.gitignore +0 -0
  19. {github_security_report-0.11.0 → github_security_report-0.11.3}/LICENSE +0 -0
  20. {github_security_report-0.11.0 → github_security_report-0.11.3}/LICENSES/Apache-2.0.txt +0 -0
  21. {github_security_report-0.11.0 → github_security_report-0.11.3}/pyproject.toml +0 -0
  22. {github_security_report-0.11.0 → github_security_report-0.11.3}/scripts/README.md +0 -0
  23. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/__init__.py +0 -0
  24. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/categories.py +0 -0
  25. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/classify.py +0 -0
  26. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/__init__.py +0 -0
  27. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/__main__.py +0 -0
  28. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/app.py +0 -0
  29. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/modes.py +0 -0
  30. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/outputs.py +0 -0
  31. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/serialise.py +0 -0
  32. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/endpoints.py +0 -0
  33. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/parsers.py +0 -0
  34. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/queries.py +0 -0
  35. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/__init__.py +0 -0
  36. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/org.py +0 -0
  37. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/protocols.py +0 -0
  38. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/repo.py +0 -0
  39. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/config/__init__.py +0 -0
  40. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/config/loader.py +0 -0
  41. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/config/models.py +0 -0
  42. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/config/schema.py +0 -0
  43. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/gating.py +0 -0
  44. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/gitctx.py +0 -0
  45. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/issues.py +0 -0
  46. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/ordering.py +0 -0
  47. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/py.typed +0 -0
  48. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/remediate.py +0 -0
  49. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/__init__.py +0 -0
  50. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/html.py +0 -0
  51. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/markdown.py +0 -0
  52. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/terminal.py +0 -0
  53. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/report.py +0 -0
  54. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/rulesets.py +0 -0
  55. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/runner.py +0 -0
  56. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/scope.py +0 -0
  57. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/severity.py +0 -0
  58. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/summary.py +0 -0
  59. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/templates/index.html.j2 +0 -0
  60. {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/templates/report.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github-security-report
3
- Version: 0.11.0
3
+ Version: 0.11.3
4
4
  Summary: Security and quality reporting across GitHub organisations
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/github-security-report-action
6
6
  Project-URL: Repository, https://github.com/lfreleng-actions/github-security-report-action
@@ -393,6 +393,19 @@ config asked for an uncapped one. `0` means "no limit" at every level. In a
393
393
  shared Slack channel the most generous value any contributing org configured for
394
394
  that category wins, matching the visibility rule above.
395
395
 
396
+ On Slack, `0` is best-effort rather than absolute. Slack imposes hard structural
397
+ limits on a message — 50 blocks per post, 3,000 characters per text object (a
398
+ section body or a context note) and 150 for a header — and rejects the **whole**
399
+ post if any is breached, so an uncapped table would cost the entire digest rather
400
+ than merely overflowing. The digest therefore sizes itself to fit: repository
401
+ name lists are trimmed first, then table rows, and whatever is left out is
402
+ reported by the usual `… and N more` tally so the numbers on screen stay honest.
403
+ Counts are never dropped, only names and rows. The other three surfaces have no
404
+ Slack-style ceiling, but they still apply their own row limits — only the
405
+ `report.json` artifact is unconditionally complete. The digest links to the
406
+ GitHub Pages report whenever `pages_url` is set and short enough to render as a
407
+ link.
408
+
396
409
  ### Per-category row ordering
397
410
 
398
411
  Each table ships a sensible default ordering — largest backlog first, stalest
@@ -349,6 +349,19 @@ config asked for an uncapped one. `0` means "no limit" at every level. In a
349
349
  shared Slack channel the most generous value any contributing org configured for
350
350
  that category wins, matching the visibility rule above.
351
351
 
352
+ On Slack, `0` is best-effort rather than absolute. Slack imposes hard structural
353
+ limits on a message — 50 blocks per post, 3,000 characters per text object (a
354
+ section body or a context note) and 150 for a header — and rejects the **whole**
355
+ post if any is breached, so an uncapped table would cost the entire digest rather
356
+ than merely overflowing. The digest therefore sizes itself to fit: repository
357
+ name lists are trimmed first, then table rows, and whatever is left out is
358
+ reported by the usual `… and N more` tally so the numbers on screen stay honest.
359
+ Counts are never dropped, only names and rows. The other three surfaces have no
360
+ Slack-style ceiling, but they still apply their own row limits — only the
361
+ `report.json` artifact is unconditionally complete. The digest links to the
362
+ GitHub Pages report whenever `pages_url` is set and short enough to render as a
363
+ link.
364
+
352
365
  ### Per-category row ordering
353
366
 
354
367
  Each table ships a sensible default ordering — largest backlog first, stalest
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.11.0'
22
- __version_tuple__ = version_tuple = (0, 11, 0)
21
+ __version__ = version = '0.11.3'
22
+ __version_tuple__ = version_tuple = (0, 11, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -11,8 +11,9 @@ See ``docs/BRIEF.md`` sections 9, 13 and ``docs/phase0-findings.md``.
11
11
  The implementation is split across ``endpoints`` (environment-resolved API URLs
12
12
  and the shared retry/backoff policy), ``queries`` (GraphQL documents),
13
13
  ``parsers`` (pure header/node parsing), ``transport`` (connection lifecycle and
14
- the retrying request primitives), ``reads`` (the reporting reads) and ``writes``
15
- (the remediation writes plus the public :class:`GitHubClient`). This module
14
+ the retrying request primitives), ``org_reads`` (the reads issued once per
15
+ organisation), ``reads`` (the per-repository reads) and ``writes`` (the
16
+ remediation writes plus the public :class:`GitHubClient`). This module
16
17
  re-exports the public surface, so importing from
17
18
  ``github_security_report.client`` is unchanged.
18
19
  """
@@ -32,6 +33,7 @@ from github_security_report.client.endpoints import (
32
33
  SCORECARD_API,
33
34
  _https_endpoint,
34
35
  )
36
+ from github_security_report.client.org_reads import OrgReadClient
35
37
  from github_security_report.client.parsers import (
36
38
  _last_published,
37
39
  _next_page_url,
@@ -66,6 +68,7 @@ __all__ = [
66
68
  "SCORECARD_API",
67
69
  "GitHubClient",
68
70
  "NetworkError",
71
+ "OrgReadClient",
69
72
  "ReadClient",
70
73
  "Transport",
71
74
  "_CODE_SCANNING_SIGNAL_TOOLS",
@@ -0,0 +1,239 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Org-scope GitHub reads: repository listing, bulk sweeps and prefetch.
4
+
5
+ :class:`OrgReadClient` holds the reads issued once per organisation -- the
6
+ repository listing, the org-bulk alert sweeps, the org ruleset fetch and the
7
+ batched GraphQL prefetch -- leaving the per-repository probes to
8
+ :class:`~github_security_report.client.reads.ReadClient`, which extends it.
9
+ Methods return raw parsed JSON (and HTTP status where the status itself is the
10
+ signal, e.g. 404 = feature disabled).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+
17
+ from github_security_report.client.endpoints import BULK_KINDS
18
+ from github_security_report.client.parsers import _parse_iso, _parse_repo_node
19
+ from github_security_report.client.queries import _REPO_GRAPH_FRAGMENT
20
+ from github_security_report.client.transport import NetworkError, Transport
21
+ from github_security_report.models import Repo, RepoGraphData
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ def _aliases_with_errors(errors: object, alias_count: int) -> set[str]:
27
+ """Alias keys implicated by a batched query's ``errors`` array.
28
+
29
+ GitHub reports a *field-level* failure with HTTP 200: the alias is still a
30
+ populated dictionary, the field that failed is null, and an ``errors``
31
+ entry carries its path (e.g. ``["r3", "latestRelease"]``). Parsing such a
32
+ node would convert a read failure into a confident negative -- a nulled
33
+ ``latestRelease`` is indistinguishable from "never released" -- so the
34
+ whole alias is treated as unreadable rather than partially trusted.
35
+
36
+ The alias is failed wholesale rather than per field: a finer-grained flag
37
+ per field would have to be threaded through every table to be honest
38
+ about which half of a row is trustworthy, whereas one unknown repository
39
+ is already a state every table renders correctly.
40
+
41
+ An error whose path names no alias cannot be attributed, so it implicates
42
+ every alias in the batch: with no way to tell which repositories it
43
+ touched, treating any of them as successfully read would be a guess.
44
+ """
45
+ all_aliases = {f"r{i}" for i in range(alias_count)}
46
+ if not isinstance(errors, list):
47
+ return set()
48
+ affected: set[str] = set()
49
+ for entry in errors:
50
+ path = entry.get("path") if isinstance(entry, dict) else None
51
+ head = path[0] if isinstance(path, list) and path else None
52
+ if isinstance(head, str) and head in all_aliases:
53
+ affected.add(head)
54
+ else:
55
+ return all_aliases
56
+ return affected
57
+
58
+
59
+ class OrgReadClient(Transport):
60
+ """The org-scope reads: listing, bulk sweeps, rulesets and prefetch."""
61
+
62
+ # ------------------------------------------------------------------ #
63
+ # Repositories
64
+ # ------------------------------------------------------------------ #
65
+ async def list_org_repos(self, org: str) -> tuple[int, list[Repo]]:
66
+ """List an organisation's repositories, skipping disabled/empty ones.
67
+
68
+ Returns the listing status alongside the repos: a non-200 (a failed or
69
+ mid-pagination-truncated listing) means the set is incomplete, so the
70
+ caller can flag a partial report rather than silently omitting repos
71
+ (and their offenders).
72
+ """
73
+ status, raws = await self._get_list(
74
+ f"{self._api_url}/orgs/{org}/repos", type="all"
75
+ )
76
+ repos: list[Repo] = []
77
+ for raw in raws:
78
+ if raw.get("disabled") or raw.get("size", 0) == 0:
79
+ log.info("skipping %s: disabled or empty", raw.get("full_name"))
80
+ continue
81
+ repos.append(
82
+ Repo(
83
+ name=raw["name"],
84
+ full_name=raw["full_name"],
85
+ html_url=raw["html_url"],
86
+ archived=raw.get("archived", False),
87
+ fork=raw.get("fork", False),
88
+ is_template=raw.get("is_template", False),
89
+ private=raw.get("private", False),
90
+ created_at=_parse_iso(raw.get("created_at")),
91
+ )
92
+ )
93
+ return status, repos
94
+
95
+ # ------------------------------------------------------------------ #
96
+ # Org-bulk alert sweeps
97
+ # ------------------------------------------------------------------ #
98
+ async def org_bulk_alerts(self, org: str, kind: str) -> tuple[int, list[dict]]:
99
+ """Sweep all open alerts of one kind across the org.
100
+
101
+ Returns the first-page HTTP status alongside the alerts so callers can
102
+ tell an authoritative empty result (200 ``[]``) apart from an unreadable
103
+ sweep (403/404/5xx), which must never be reported as "clean".
104
+ """
105
+ path = BULK_KINDS[kind]
106
+ return await self._get_list(f"{self._api_url}/orgs/{org}/{path}", state="open")
107
+
108
+ # ------------------------------------------------------------------ #
109
+ # Organisation rulesets (workflow-driven tool enablement)
110
+ # ------------------------------------------------------------------ #
111
+ async def org_workflow_rulesets(self, org: str) -> tuple[int, list[dict]]:
112
+ """Active, branch-targeted org rulesets, each with full rule details.
113
+
114
+ Returns ``(status, details)``; status is the org-rulesets list status
115
+ (e.g. 403 when the token lacks org access) so coverage can degrade
116
+ gracefully. The list endpoint returns summaries, so each active branch
117
+ ruleset is fetched in detail to expose its rules and conditions.
118
+ """
119
+ status, summaries = await self._get_list(f"{self._api_url}/orgs/{org}/rulesets")
120
+ if status != 200:
121
+ return status, []
122
+ details: list[dict] = []
123
+ for summary in summaries:
124
+ if summary.get("enforcement") != "active":
125
+ continue
126
+ if summary.get("target") not in (None, "branch"):
127
+ continue
128
+ resp = await self._request(
129
+ "GET", f"{self._api_url}/orgs/{org}/rulesets/{summary['id']}"
130
+ )
131
+ if resp.status_code == 200:
132
+ details.append(resp.json())
133
+ await resp.aclose() # release the connection once the body is read
134
+ return 200, details
135
+
136
+ # ------------------------------------------------------------------ #
137
+ # Batched per-repo prefetch (one query for many repositories)
138
+ # ------------------------------------------------------------------ #
139
+ async def repo_graph_batch(
140
+ self, org: str, names: list[str]
141
+ ) -> dict[str, RepoGraphData]:
142
+ """Prefetch per-repo data for many repositories in one GraphQL query.
143
+
144
+ Returns a ``RepoGraphData`` per requested name. This data is
145
+ load-bearing for whole report sections (releases/tags, Dependabot
146
+ enablement, open issues), and its defaults are indistinguishable from
147
+ confident negatives ("never released"), so a wholly failed query --
148
+ a non-200 response that survived the shared retry/backoff policy, or
149
+ a 200 carrying no ``data`` object -- raises :class:`NetworkError` to
150
+ abort the run rather than fabricating results. A repository that
151
+ cannot be fully read -- a ``null`` alias, or a populated alias whose
152
+ ``errors`` entry shows a field failed to resolve -- degrades to
153
+ ``RepoGraphData(unreadable=True)`` so downstream tables report it as
154
+ unknown. An empty ``names`` issues no request.
155
+ """
156
+ # Seed every requested name as unreadable; only a successfully parsed
157
+ # alias replaces its entry, so nothing failed can masquerade as read.
158
+ out = {name: RepoGraphData(unreadable=True) for name in names}
159
+ if not names:
160
+ return out
161
+ aliases = "\n".join(
162
+ f" r{i}: repository(owner: $owner, name: $n{i}) {{ ...RepoData }}"
163
+ for i in range(len(names))
164
+ )
165
+ var_decls = "".join(f", $n{i}: String!" for i in range(len(names)))
166
+ query = (
167
+ f"query($owner: String!{var_decls}) {{\n{aliases}\n}}\n"
168
+ f"{_REPO_GRAPH_FRAGMENT}"
169
+ )
170
+ variables: dict[str, str] = {"owner": org}
171
+ for i, name in enumerate(names):
172
+ variables[f"n{i}"] = name
173
+ resp = await self._request(
174
+ "POST",
175
+ self._graphql_url,
176
+ json={"query": query, "variables": variables},
177
+ )
178
+ if resp.status_code != 200:
179
+ status = resp.status_code
180
+ await resp.aclose() # unread body would leak a pooled connection
181
+ raise NetworkError(
182
+ f"GraphQL prefetch for {org} failed with HTTP {status} after "
183
+ "exhausting retries; aborting because the release/tag, "
184
+ "Dependabot-enablement and open-issues data for "
185
+ f"{len(names)} repositories would otherwise be fabricated "
186
+ "from defaults (e.g. reported as never released)."
187
+ )
188
+ body = resp.json()
189
+ data = body.get("data")
190
+ await resp.aclose() # release the connection once the body is read
191
+ # GitHub answers a partially-refused query with HTTP 200: the readable
192
+ # aliases populated, the rest null or missing individual fields, and an
193
+ # ``errors`` array explaining why. The paths are both logged for
194
+ # diagnosis and used to fail the affected aliases, since a field nulled
195
+ # by a failed read is indistinguishable from a genuine absence.
196
+ errors = body.get("errors")
197
+ errored_aliases = _aliases_with_errors(errors, len(names))
198
+ if errors:
199
+ log.warning(
200
+ "GraphQL prefetch for %s returned %d error(s); affected data is "
201
+ "reported as unknown: %s",
202
+ org,
203
+ len(errors),
204
+ "; ".join(
205
+ f"{'.'.join(str(p) for p in (e.get('path') or []))}: "
206
+ f"{e.get('message', '')}"
207
+ for e in errors[:5]
208
+ if isinstance(e, dict)
209
+ ),
210
+ )
211
+ if not isinstance(data, dict):
212
+ # HTTP 200 with no data object at all: the whole batch failed
213
+ # (e.g. a timed-out or refused query). Same stakes as a non-200.
214
+ raise NetworkError(
215
+ f"GraphQL prefetch for {org} returned no data for any of "
216
+ f"{len(names)} repositories; aborting rather than reporting "
217
+ "fabricated defaults. "
218
+ f"errors={errors!r}"
219
+ )
220
+ for i, name in enumerate(names):
221
+ alias = f"r{i}"
222
+ if alias in errored_aliases:
223
+ # A field of this alias failed to resolve, so its null fields
224
+ # cannot be told apart from genuine absences. Leave the
225
+ # pre-seeded unreadable default in place.
226
+ continue
227
+ node = data.get(alias)
228
+ if isinstance(node, dict):
229
+ out[name] = _parse_repo_node(node)
230
+ unreadable = sorted(name for name, d in out.items() if d.unreadable)
231
+ if unreadable:
232
+ log.warning(
233
+ "GraphQL prefetch for %s could not read %d repositories "
234
+ "(reported as unknown): %s",
235
+ org,
236
+ len(unreadable),
237
+ ", ".join(unreadable),
238
+ )
239
+ return out
@@ -1,10 +1,12 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
- """Read-only GitHub API surface: repo listing, sweeps, probes and prefetch.
3
+ """Per-repository GitHub reads: enabled-probes, alerts and posture flags.
4
4
 
5
- :class:`ReadClient` adds every reporting read to :class:`Transport`. Methods
6
- return raw parsed JSON (and HTTP status where the status itself is the signal,
7
- e.g. 404 = feature disabled).
5
+ :class:`ReadClient` completes the reporting read surface by adding the reads
6
+ issued once per repository to the org-scope reads of
7
+ :class:`~github_security_report.client.org_reads.OrgReadClient`. Methods return
8
+ raw parsed JSON (and HTTP status where the status itself is the signal, e.g.
9
+ 404 = feature disabled).
8
10
  """
9
11
 
10
12
  from __future__ import annotations
@@ -12,67 +14,19 @@ from __future__ import annotations
12
14
  import asyncio
13
15
  import logging
14
16
 
15
- from github_security_report.client.endpoints import BULK_KINDS
16
- from github_security_report.client.parsers import _parse_iso, _parse_repo_node
17
+ from github_security_report.client.org_reads import OrgReadClient
18
+ from github_security_report.client.parsers import _parse_iso
17
19
  from github_security_report.client.queries import (
18
20
  _CODE_SCANNING_SIGNAL_TOOLS,
19
21
  _DEPENDABOT_ENABLED_QUERY,
20
- _REPO_GRAPH_FRAGMENT,
21
22
  )
22
- from github_security_report.client.transport import Transport
23
- from github_security_report.models import Repo, RepoGraphData
23
+ from github_security_report.models import Repo
24
24
 
25
25
  log = logging.getLogger(__name__)
26
26
 
27
27
 
28
- class ReadClient(Transport):
29
- """The reporting reads: sweeps, per-repo probes and batched prefetch."""
30
-
31
- # ------------------------------------------------------------------ #
32
- # Repositories
33
- # ------------------------------------------------------------------ #
34
- async def list_org_repos(self, org: str) -> tuple[int, list[Repo]]:
35
- """List an organisation's repositories, skipping disabled/empty ones.
36
-
37
- Returns the listing status alongside the repos: a non-200 (a failed or
38
- mid-pagination-truncated listing) means the set is incomplete, so the
39
- caller can flag a partial report rather than silently omitting repos
40
- (and their offenders).
41
- """
42
- status, raws = await self._get_list(
43
- f"{self._api_url}/orgs/{org}/repos", type="all"
44
- )
45
- repos: list[Repo] = []
46
- for raw in raws:
47
- if raw.get("disabled") or raw.get("size", 0) == 0:
48
- log.info("skipping %s: disabled or empty", raw.get("full_name"))
49
- continue
50
- repos.append(
51
- Repo(
52
- name=raw["name"],
53
- full_name=raw["full_name"],
54
- html_url=raw["html_url"],
55
- archived=raw.get("archived", False),
56
- fork=raw.get("fork", False),
57
- is_template=raw.get("is_template", False),
58
- private=raw.get("private", False),
59
- created_at=_parse_iso(raw.get("created_at")),
60
- )
61
- )
62
- return status, repos
63
-
64
- # ------------------------------------------------------------------ #
65
- # Org-bulk alert sweeps
66
- # ------------------------------------------------------------------ #
67
- async def org_bulk_alerts(self, org: str, kind: str) -> tuple[int, list[dict]]:
68
- """Sweep all open alerts of one kind across the org.
69
-
70
- Returns the first-page HTTP status alongside the alerts so callers can
71
- tell an authoritative empty result (200 ``[]``) apart from an unreadable
72
- sweep (403/404/5xx), which must never be reported as "clean".
73
- """
74
- path = BULK_KINDS[kind]
75
- return await self._get_list(f"{self._api_url}/orgs/{org}/{path}", state="open")
28
+ class ReadClient(OrgReadClient):
29
+ """The reporting reads: org sweeps plus the per-repository probes."""
76
30
 
77
31
  # ------------------------------------------------------------------ #
78
32
  # Per-repo enabled-probes
@@ -193,34 +147,6 @@ class ReadClient(Transport):
193
147
  await resp.aclose() # release the connection once the body is read
194
148
  return 200, score
195
149
 
196
- # ------------------------------------------------------------------ #
197
- # Repository rulesets (workflow-driven tool enablement)
198
- # ------------------------------------------------------------------ #
199
- async def org_workflow_rulesets(self, org: str) -> tuple[int, list[dict]]:
200
- """Active, branch-targeted org rulesets, each with full rule details.
201
-
202
- Returns ``(status, details)``; status is the org-rulesets list status
203
- (e.g. 403 when the token lacks org access) so coverage can degrade
204
- gracefully. The list endpoint returns summaries, so each active branch
205
- ruleset is fetched in detail to expose its rules and conditions.
206
- """
207
- status, summaries = await self._get_list(f"{self._api_url}/orgs/{org}/rulesets")
208
- if status != 200:
209
- return status, []
210
- details: list[dict] = []
211
- for summary in summaries:
212
- if summary.get("enforcement") != "active":
213
- continue
214
- if summary.get("target") not in (None, "branch"):
215
- continue
216
- resp = await self._request(
217
- "GET", f"{self._api_url}/orgs/{org}/rulesets/{summary['id']}"
218
- )
219
- if resp.status_code == 200:
220
- details.append(resp.json())
221
- await resp.aclose() # release the connection once the body is read
222
- return 200, details
223
-
224
150
  async def repo_branch_rules(
225
151
  self, org: str, repo: str, branch: str
226
152
  ) -> tuple[int, list[dict]]:
@@ -328,64 +254,3 @@ class ReadClient(Transport):
328
254
  data = resp.json()
329
255
  await resp.aclose() # release the connection once the body is read
330
256
  return bool(data.get("enabled"))
331
-
332
- async def repo_graph_batch(
333
- self, org: str, names: list[str]
334
- ) -> dict[str, RepoGraphData]:
335
- """Prefetch per-repo data for many repositories in one GraphQL query.
336
-
337
- Returns a ``RepoGraphData`` per requested name. Repositories that cannot
338
- be read (a ``null`` alias) or a wholly failed query degrade to default
339
- ``RepoGraphData``, so they drop out of the dependent tables rather than
340
- being mislabelled. An empty ``names`` issues no request.
341
- """
342
- out = {name: RepoGraphData() for name in names}
343
- if not names:
344
- return out
345
- aliases = "\n".join(
346
- f" r{i}: repository(owner: $owner, name: $n{i}) {{ ...RepoData }}"
347
- for i in range(len(names))
348
- )
349
- var_decls = "".join(f", $n{i}: String!" for i in range(len(names)))
350
- query = (
351
- f"query($owner: String!{var_decls}) {{\n{aliases}\n}}\n"
352
- f"{_REPO_GRAPH_FRAGMENT}"
353
- )
354
- variables: dict[str, str] = {"owner": org}
355
- for i, name in enumerate(names):
356
- variables[f"n{i}"] = name
357
- resp = await self._request(
358
- "POST",
359
- self._graphql_url,
360
- json={"query": query, "variables": variables},
361
- )
362
- if resp.status_code != 200:
363
- await resp.aclose() # unread body would leak a pooled connection
364
- return out
365
- body = resp.json()
366
- data = body.get("data") or {}
367
- await resp.aclose() # release the connection once the body is read
368
- # GitHub answers a partially-refused query with HTTP 200: the readable
369
- # aliases populated, the rest null, and an ``errors`` array explaining
370
- # why. Silently dropping it hides exactly the case where a field was
371
- # served as null because the token could not read it, so the paths are
372
- # logged once per batch for diagnosis.
373
- errors = body.get("errors")
374
- if errors:
375
- log.warning(
376
- "GraphQL prefetch for %s returned %d error(s); affected data is "
377
- "reported as unknown: %s",
378
- org,
379
- len(errors),
380
- "; ".join(
381
- f"{'.'.join(str(p) for p in (e.get('path') or []))}: "
382
- f"{e.get('message', '')}"
383
- for e in errors[:5]
384
- if isinstance(e, dict)
385
- ),
386
- )
387
- for i, name in enumerate(names):
388
- node = data.get(f"r{i}")
389
- if isinstance(node, dict):
390
- out[name] = _parse_repo_node(node)
391
- return out
@@ -35,15 +35,17 @@ _TransportT = TypeVar("_TransportT", bound="Transport")
35
35
 
36
36
 
37
37
  class NetworkError(RuntimeError):
38
- """The GitHub API was unreachable after exhausting the retry budget.
38
+ """The GitHub API was unusable after exhausting the retry budget.
39
39
 
40
40
  Raised for transport-level failures (DNS, connection, TLS, or read
41
41
  timeout) against the GitHub API that persist across every retry within
42
- ``API_MAX_TOTAL_WAIT_SECONDS``. The run aborts rather than rendering a
43
- report from missing data: when the API itself cannot be reached, an empty
44
- or "all clean / all unknown" report is actively misleading. Transport
45
- failures against the third-party Scorecard endpoint do not raise this --
46
- they degrade that one signal instead.
42
+ ``API_MAX_TOTAL_WAIT_SECONDS``, and by callers whose data is load-bearing
43
+ for the whole report (the batched GraphQL prefetch) when GitHub keeps
44
+ answering with server errors. The run aborts rather than rendering a
45
+ report from missing data: when the API itself cannot be relied on, an
46
+ empty or "all clean / all unknown" report is actively misleading.
47
+ Transport failures against the third-party Scorecard endpoint do not
48
+ raise this -- they degrade that one signal instead.
47
49
  """
48
50
 
49
51
 
@@ -163,8 +165,11 @@ class Transport:
163
165
  -- a report built without live data would be misleading. The same
164
166
  failure against the third-party Scorecard endpoint instead degrades to
165
167
  an indeterminate 503, so one flaky external API never aborts the report.
166
- Rate-limit responses (403/429) back off on the same schedule and, once
167
- exhausted, return the throttled response for per-signal degradation.
168
+ Server errors (5xx) and rate-limit responses (403/429) back off on the
169
+ same schedule and, once exhausted, return the response for the caller
170
+ to handle: per-signal probes degrade to unknown, while callers whose
171
+ data is load-bearing (the GraphQL prefetch) abort the run instead of
172
+ fabricating results.
168
173
  """
169
174
  http = client or self._client
170
175
  is_external = http is self._ext_client
@@ -216,10 +221,11 @@ class Transport:
216
221
  waited += delay
217
222
  attempt += 1
218
223
  continue
219
- if resp.status_code not in (403, 429):
224
+ if resp.status_code not in (403, 429) and resp.status_code < 500:
220
225
  return resp
221
- # Reachable but possibly rate limited: distinguish secondary/primary
222
- # rate limiting from a genuine 403, then back off on the shared
226
+ # Reachable but degraded: a 5xx (GitHub infrastructure trouble) or
227
+ # a possible rate limit. Distinguish secondary/primary rate
228
+ # limiting from a genuine 403, then back off on the shared
223
229
  # schedule (honouring Retry-After) within the wait budget.
224
230
  retry_after = resp.headers.get("retry-after")
225
231
  remaining = resp.headers.get("x-ratelimit-remaining")
@@ -235,18 +241,35 @@ class Transport:
235
241
  rate_limited = (
236
242
  resp.status_code == 429 or retry_after is not None or remaining == "0"
237
243
  )
244
+ # Any 5xx is retried: GitHub's infrastructure wobbles produce
245
+ # transient 500/502/503 responses that, if returned un-retried,
246
+ # would silently degrade (or falsify) whole report sections.
247
+ server_error = resp.status_code >= 500
238
248
  delay = (
239
249
  retry_after_secs
240
250
  if retry_after_secs is not None
241
251
  else self._backoff_delay(attempt)
242
252
  )
243
253
  if (
244
- not rate_limited
254
+ not (rate_limited or server_error)
245
255
  or attempt >= self._max_retries
246
256
  or waited + delay > API_MAX_TOTAL_WAIT_SECONDS
247
257
  ):
258
+ # Retries exhausted (or a genuine 403): hand the response back
259
+ # so the caller can degrade its signal to unknown -- or, when
260
+ # its data is load-bearing, abort the run.
248
261
  return resp
249
- log.warning("rate limited on %s; backing off %.0fs", url, delay)
262
+ if server_error:
263
+ log.warning(
264
+ "server error %d on %s; retrying in %.0fs (retry %d of %d)",
265
+ resp.status_code,
266
+ url,
267
+ delay,
268
+ attempt + 1,
269
+ self._max_retries,
270
+ )
271
+ else:
272
+ log.warning("rate limited on %s; backing off %.0fs", url, delay)
250
273
  # The discarded response must be closed; we are retrying and will
251
274
  # not read its body, so leaving it open would leak a pool connection.
252
275
  await resp.aclose()
@@ -2,11 +2,12 @@
2
2
  # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
3
  """Remediation writes, and the public :class:`GitHubClient` façade.
4
4
 
5
- ``GitHubClient`` completes the linear ``Transport -> ReadClient ->
6
- GitHubClient`` chain. The chain exists so each layer stays a readable size --
7
- connection/retry mechanics, reporting reads, and remediation writes are
8
- separable concerns -- while callers still get a single object, because one
9
- client instance serves both the reporting reads and the remediation writes.
5
+ ``GitHubClient`` completes the linear ``Transport -> OrgReadClient ->
6
+ ReadClient -> GitHubClient`` chain. The chain exists so each layer stays a
7
+ readable size -- connection/retry mechanics, org-scope reads, per-repository
8
+ reads, and remediation writes are separable concerns -- while callers still get
9
+ a single object, because one client instance serves both the reporting reads
10
+ and the remediation writes.
10
11
  """
11
12
 
12
13
  from __future__ import annotations
@@ -60,13 +60,13 @@ class OrgCollectContext:
60
60
  probe_scorecard: bool
61
61
 
62
62
  def graph_for(self, name: str) -> RepoGraphData:
63
- """Prefetched GraphQL data for a repository, or empty defaults.
63
+ """Prefetched GraphQL data for a repository, or unreadable defaults.
64
64
 
65
- A repository missing from the prefetch (an unreadable alias, or a wholly
66
- failed batch) degrades to defaults so it drops out of the dependent
67
- tables instead of being mislabelled.
65
+ A repository missing from the prefetch entirely is marked
66
+ ``unreadable`` so the dependent tables report it as unknown instead of
67
+ mislabelling it with confident negatives (e.g. "never released").
68
68
  """
69
- return self.graph.get(name, RepoGraphData())
69
+ return self.graph.get(name, RepoGraphData(unreadable=True))
70
70
 
71
71
  def ruleset_signals(self, name: str) -> set[str]:
72
72
  """Signals an org ruleset enforces for a repository (possibly none)."""
@@ -55,6 +55,7 @@ async def _posture_for_repo(repo: Repo, ctx: OrgCollectContext) -> RepoPosture:
55
55
  )
56
56
  return RepoPosture(
57
57
  repo=repo,
58
+ graph_unreadable=graph.unreadable,
58
59
  dependabot_alerts=graph.dependabot_alerts_enabled,
59
60
  security_updates=security_updates,
60
61
  private_vulnerability_reporting=pvr,