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.
- {github_security_report-0.11.0 → github_security_report-0.11.3}/PKG-INFO +14 -1
- {github_security_report-0.11.0 → github_security_report-0.11.3}/README.md +13 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/_version.py +2 -2
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/__init__.py +5 -2
- github_security_report-0.11.3/src/github_security_report/client/org_reads.py +239 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/reads.py +11 -146
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/transport.py +36 -13
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/writes.py +6 -5
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/context.py +5 -5
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/extras.py +1 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/models.py +8 -1
- github_security_report-0.11.3/src/github_security_report/posture/__init__.py +57 -0
- github_security_report-0.11.3/src/github_security_report/posture/enablement.py +161 -0
- github_security_report-0.11.3/src/github_security_report/posture/facts.py +37 -0
- 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
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/slack.py +156 -92
- github_security_report-0.11.3/src/github_security_report/render/slack_limits.py +287 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/.gitignore +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/LICENSE +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/LICENSES/Apache-2.0.txt +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/pyproject.toml +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/scripts/README.md +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/categories.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/classify.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/__main__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/app.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/modes.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/outputs.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/cli/serialise.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/endpoints.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/parsers.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/client/queries.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/org.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/protocols.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/collect/repo.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/config/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/config/loader.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/config/models.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/config/schema.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/gating.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/gitctx.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/issues.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/ordering.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/py.typed +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/remediate.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/html.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/markdown.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/render/terminal.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/report.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/rulesets.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/runner.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/scope.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/severity.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/summary.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.3}/src/github_security_report/templates/index.html.j2 +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 11,
|
|
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), ``
|
|
15
|
-
|
|
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
|
-
"""
|
|
3
|
+
"""Per-repository GitHub reads: enabled-probes, alerts and posture flags.
|
|
4
4
|
|
|
5
|
-
:class:`ReadClient`
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
16
|
-
from github_security_report.client.parsers import _parse_iso
|
|
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.
|
|
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(
|
|
29
|
-
"""The reporting reads: sweeps
|
|
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
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
167
|
-
exhausted, return the
|
|
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
|
|
222
|
-
#
|
|
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
|
-
|
|
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 ->
|
|
6
|
-
GitHubClient`` chain. The chain exists so each layer stays a
|
|
7
|
-
connection/retry mechanics,
|
|
8
|
-
separable concerns -- while callers still get
|
|
9
|
-
client instance serves both the reporting reads
|
|
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
|
|
63
|
+
"""Prefetched GraphQL data for a repository, or unreadable defaults.
|
|
64
64
|
|
|
65
|
-
A repository missing from the prefetch
|
|
66
|
-
|
|
67
|
-
|
|
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,
|