github-security-report 0.11.2__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.2 → github_security_report-0.11.3}/PKG-INFO +1 -1
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/_version.py +2 -2
- {github_security_report-0.11.2 → 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.2 → github_security_report-0.11.3}/src/github_security_report/client/reads.py +11 -220
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/writes.py +6 -5
- 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.2/src/github_security_report/posture.py → github_security_report-0.11.3/src/github_security_report/posture/releases.py +11 -210
- {github_security_report-0.11.2 → github_security_report-0.11.3}/.gitignore +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/LICENSE +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/LICENSES/Apache-2.0.txt +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/README.md +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/pyproject.toml +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/scripts/README.md +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/__init__.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/categories.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/classify.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/__init__.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/__main__.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/app.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/modes.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/outputs.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/serialise.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/endpoints.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/parsers.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/queries.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/transport.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/__init__.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/context.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/extras.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/org.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/protocols.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/repo.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/config/__init__.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/config/loader.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/config/models.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/config/schema.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/gating.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/gitctx.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/issues.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/models.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/ordering.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/py.typed +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/remediate.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/__init__.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/html.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/markdown.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/slack.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/slack_limits.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/terminal.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/report.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/rulesets.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/runner.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/scope.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/severity.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/summary.py +0 -0
- {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/templates/index.html.j2 +0 -0
- {github_security_report-0.11.2 → 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
|
|
@@ -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,100 +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
|
-
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
GitHub reports a *field-level* failure with HTTP 200: the alias is still a
|
|
32
|
-
populated dictionary, the field that failed is null, and an ``errors``
|
|
33
|
-
entry carries its path (e.g. ``["r3", "latestRelease"]``). Parsing such a
|
|
34
|
-
node would convert a read failure into a confident negative -- a nulled
|
|
35
|
-
``latestRelease`` is indistinguishable from "never released" -- so the
|
|
36
|
-
whole alias is treated as unreadable rather than partially trusted.
|
|
37
|
-
|
|
38
|
-
The alias is failed wholesale rather than per field: a finer-grained flag
|
|
39
|
-
per field would have to be threaded through every table to be honest
|
|
40
|
-
about which half of a row is trustworthy, whereas one unknown repository
|
|
41
|
-
is already a state every table renders correctly.
|
|
42
|
-
|
|
43
|
-
An error whose path names no alias cannot be attributed, so it implicates
|
|
44
|
-
every alias in the batch: with no way to tell which repositories it
|
|
45
|
-
touched, treating any of them as successfully read would be a guess.
|
|
46
|
-
"""
|
|
47
|
-
all_aliases = {f"r{i}" for i in range(alias_count)}
|
|
48
|
-
if not isinstance(errors, list):
|
|
49
|
-
return set()
|
|
50
|
-
affected: set[str] = set()
|
|
51
|
-
for entry in errors:
|
|
52
|
-
path = entry.get("path") if isinstance(entry, dict) else None
|
|
53
|
-
head = path[0] if isinstance(path, list) and path else None
|
|
54
|
-
if isinstance(head, str) and head in all_aliases:
|
|
55
|
-
affected.add(head)
|
|
56
|
-
else:
|
|
57
|
-
return all_aliases
|
|
58
|
-
return affected
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class ReadClient(Transport):
|
|
62
|
-
"""The reporting reads: sweeps, per-repo probes and batched prefetch."""
|
|
63
|
-
|
|
64
|
-
# ------------------------------------------------------------------ #
|
|
65
|
-
# Repositories
|
|
66
|
-
# ------------------------------------------------------------------ #
|
|
67
|
-
async def list_org_repos(self, org: str) -> tuple[int, list[Repo]]:
|
|
68
|
-
"""List an organisation's repositories, skipping disabled/empty ones.
|
|
69
|
-
|
|
70
|
-
Returns the listing status alongside the repos: a non-200 (a failed or
|
|
71
|
-
mid-pagination-truncated listing) means the set is incomplete, so the
|
|
72
|
-
caller can flag a partial report rather than silently omitting repos
|
|
73
|
-
(and their offenders).
|
|
74
|
-
"""
|
|
75
|
-
status, raws = await self._get_list(
|
|
76
|
-
f"{self._api_url}/orgs/{org}/repos", type="all"
|
|
77
|
-
)
|
|
78
|
-
repos: list[Repo] = []
|
|
79
|
-
for raw in raws:
|
|
80
|
-
if raw.get("disabled") or raw.get("size", 0) == 0:
|
|
81
|
-
log.info("skipping %s: disabled or empty", raw.get("full_name"))
|
|
82
|
-
continue
|
|
83
|
-
repos.append(
|
|
84
|
-
Repo(
|
|
85
|
-
name=raw["name"],
|
|
86
|
-
full_name=raw["full_name"],
|
|
87
|
-
html_url=raw["html_url"],
|
|
88
|
-
archived=raw.get("archived", False),
|
|
89
|
-
fork=raw.get("fork", False),
|
|
90
|
-
is_template=raw.get("is_template", False),
|
|
91
|
-
private=raw.get("private", False),
|
|
92
|
-
created_at=_parse_iso(raw.get("created_at")),
|
|
93
|
-
)
|
|
94
|
-
)
|
|
95
|
-
return status, repos
|
|
96
|
-
|
|
97
|
-
# ------------------------------------------------------------------ #
|
|
98
|
-
# Org-bulk alert sweeps
|
|
99
|
-
# ------------------------------------------------------------------ #
|
|
100
|
-
async def org_bulk_alerts(self, org: str, kind: str) -> tuple[int, list[dict]]:
|
|
101
|
-
"""Sweep all open alerts of one kind across the org.
|
|
102
|
-
|
|
103
|
-
Returns the first-page HTTP status alongside the alerts so callers can
|
|
104
|
-
tell an authoritative empty result (200 ``[]``) apart from an unreadable
|
|
105
|
-
sweep (403/404/5xx), which must never be reported as "clean".
|
|
106
|
-
"""
|
|
107
|
-
path = BULK_KINDS[kind]
|
|
108
|
-
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."""
|
|
109
30
|
|
|
110
31
|
# ------------------------------------------------------------------ #
|
|
111
32
|
# Per-repo enabled-probes
|
|
@@ -226,34 +147,6 @@ class ReadClient(Transport):
|
|
|
226
147
|
await resp.aclose() # release the connection once the body is read
|
|
227
148
|
return 200, score
|
|
228
149
|
|
|
229
|
-
# ------------------------------------------------------------------ #
|
|
230
|
-
# Repository rulesets (workflow-driven tool enablement)
|
|
231
|
-
# ------------------------------------------------------------------ #
|
|
232
|
-
async def org_workflow_rulesets(self, org: str) -> tuple[int, list[dict]]:
|
|
233
|
-
"""Active, branch-targeted org rulesets, each with full rule details.
|
|
234
|
-
|
|
235
|
-
Returns ``(status, details)``; status is the org-rulesets list status
|
|
236
|
-
(e.g. 403 when the token lacks org access) so coverage can degrade
|
|
237
|
-
gracefully. The list endpoint returns summaries, so each active branch
|
|
238
|
-
ruleset is fetched in detail to expose its rules and conditions.
|
|
239
|
-
"""
|
|
240
|
-
status, summaries = await self._get_list(f"{self._api_url}/orgs/{org}/rulesets")
|
|
241
|
-
if status != 200:
|
|
242
|
-
return status, []
|
|
243
|
-
details: list[dict] = []
|
|
244
|
-
for summary in summaries:
|
|
245
|
-
if summary.get("enforcement") != "active":
|
|
246
|
-
continue
|
|
247
|
-
if summary.get("target") not in (None, "branch"):
|
|
248
|
-
continue
|
|
249
|
-
resp = await self._request(
|
|
250
|
-
"GET", f"{self._api_url}/orgs/{org}/rulesets/{summary['id']}"
|
|
251
|
-
)
|
|
252
|
-
if resp.status_code == 200:
|
|
253
|
-
details.append(resp.json())
|
|
254
|
-
await resp.aclose() # release the connection once the body is read
|
|
255
|
-
return 200, details
|
|
256
|
-
|
|
257
150
|
async def repo_branch_rules(
|
|
258
151
|
self, org: str, repo: str, branch: str
|
|
259
152
|
) -> tuple[int, list[dict]]:
|
|
@@ -361,105 +254,3 @@ class ReadClient(Transport):
|
|
|
361
254
|
data = resp.json()
|
|
362
255
|
await resp.aclose() # release the connection once the body is read
|
|
363
256
|
return bool(data.get("enabled"))
|
|
364
|
-
|
|
365
|
-
async def repo_graph_batch(
|
|
366
|
-
self, org: str, names: list[str]
|
|
367
|
-
) -> dict[str, RepoGraphData]:
|
|
368
|
-
"""Prefetch per-repo data for many repositories in one GraphQL query.
|
|
369
|
-
|
|
370
|
-
Returns a ``RepoGraphData`` per requested name. This data is
|
|
371
|
-
load-bearing for whole report sections (releases/tags, Dependabot
|
|
372
|
-
enablement, open issues), and its defaults are indistinguishable from
|
|
373
|
-
confident negatives ("never released"), so a wholly failed query --
|
|
374
|
-
a non-200 response that survived the shared retry/backoff policy, or
|
|
375
|
-
a 200 carrying no ``data`` object -- raises :class:`NetworkError` to
|
|
376
|
-
abort the run rather than fabricating results. A repository that
|
|
377
|
-
cannot be fully read -- a ``null`` alias, or a populated alias whose
|
|
378
|
-
``errors`` entry shows a field failed to resolve -- degrades to
|
|
379
|
-
``RepoGraphData(unreadable=True)`` so downstream tables report it as
|
|
380
|
-
unknown. An empty ``names`` issues no request.
|
|
381
|
-
"""
|
|
382
|
-
# Seed every requested name as unreadable; only a successfully parsed
|
|
383
|
-
# alias replaces its entry, so nothing failed can masquerade as read.
|
|
384
|
-
out = {name: RepoGraphData(unreadable=True) for name in names}
|
|
385
|
-
if not names:
|
|
386
|
-
return out
|
|
387
|
-
aliases = "\n".join(
|
|
388
|
-
f" r{i}: repository(owner: $owner, name: $n{i}) {{ ...RepoData }}"
|
|
389
|
-
for i in range(len(names))
|
|
390
|
-
)
|
|
391
|
-
var_decls = "".join(f", $n{i}: String!" for i in range(len(names)))
|
|
392
|
-
query = (
|
|
393
|
-
f"query($owner: String!{var_decls}) {{\n{aliases}\n}}\n"
|
|
394
|
-
f"{_REPO_GRAPH_FRAGMENT}"
|
|
395
|
-
)
|
|
396
|
-
variables: dict[str, str] = {"owner": org}
|
|
397
|
-
for i, name in enumerate(names):
|
|
398
|
-
variables[f"n{i}"] = name
|
|
399
|
-
resp = await self._request(
|
|
400
|
-
"POST",
|
|
401
|
-
self._graphql_url,
|
|
402
|
-
json={"query": query, "variables": variables},
|
|
403
|
-
)
|
|
404
|
-
if resp.status_code != 200:
|
|
405
|
-
status = resp.status_code
|
|
406
|
-
await resp.aclose() # unread body would leak a pooled connection
|
|
407
|
-
raise NetworkError(
|
|
408
|
-
f"GraphQL prefetch for {org} failed with HTTP {status} after "
|
|
409
|
-
"exhausting retries; aborting because the release/tag, "
|
|
410
|
-
"Dependabot-enablement and open-issues data for "
|
|
411
|
-
f"{len(names)} repositories would otherwise be fabricated "
|
|
412
|
-
"from defaults (e.g. reported as never released)."
|
|
413
|
-
)
|
|
414
|
-
body = resp.json()
|
|
415
|
-
data = body.get("data")
|
|
416
|
-
await resp.aclose() # release the connection once the body is read
|
|
417
|
-
# GitHub answers a partially-refused query with HTTP 200: the readable
|
|
418
|
-
# aliases populated, the rest null or missing individual fields, and an
|
|
419
|
-
# ``errors`` array explaining why. The paths are both logged for
|
|
420
|
-
# diagnosis and used to fail the affected aliases, since a field nulled
|
|
421
|
-
# by a failed read is indistinguishable from a genuine absence.
|
|
422
|
-
errors = body.get("errors")
|
|
423
|
-
errored_aliases = _aliases_with_errors(errors, len(names))
|
|
424
|
-
if errors:
|
|
425
|
-
log.warning(
|
|
426
|
-
"GraphQL prefetch for %s returned %d error(s); affected data is "
|
|
427
|
-
"reported as unknown: %s",
|
|
428
|
-
org,
|
|
429
|
-
len(errors),
|
|
430
|
-
"; ".join(
|
|
431
|
-
f"{'.'.join(str(p) for p in (e.get('path') or []))}: "
|
|
432
|
-
f"{e.get('message', '')}"
|
|
433
|
-
for e in errors[:5]
|
|
434
|
-
if isinstance(e, dict)
|
|
435
|
-
),
|
|
436
|
-
)
|
|
437
|
-
if not isinstance(data, dict):
|
|
438
|
-
# HTTP 200 with no data object at all: the whole batch failed
|
|
439
|
-
# (e.g. a timed-out or refused query). Same stakes as a non-200.
|
|
440
|
-
raise NetworkError(
|
|
441
|
-
f"GraphQL prefetch for {org} returned no data for any of "
|
|
442
|
-
f"{len(names)} repositories; aborting rather than reporting "
|
|
443
|
-
"fabricated defaults. "
|
|
444
|
-
f"errors={errors!r}"
|
|
445
|
-
)
|
|
446
|
-
for i, name in enumerate(names):
|
|
447
|
-
alias = f"r{i}"
|
|
448
|
-
if alias in errored_aliases:
|
|
449
|
-
# A field of this alias failed to resolve, so its null fields
|
|
450
|
-
# cannot be told apart from genuine absences. Leave the
|
|
451
|
-
# pre-seeded unreadable default in place.
|
|
452
|
-
continue
|
|
453
|
-
node = data.get(alias)
|
|
454
|
-
if isinstance(node, dict):
|
|
455
|
-
out[name] = _parse_repo_node(node)
|
|
456
|
-
unreadable = sorted(name for name, d in out.items() if d.unreadable)
|
|
457
|
-
if unreadable:
|
|
458
|
-
log.warning(
|
|
459
|
-
"GraphQL prefetch for %s could not read %d repositories "
|
|
460
|
-
"(reported as unknown): %s",
|
|
461
|
-
org,
|
|
462
|
-
len(unreadable),
|
|
463
|
-
", ".join(unreadable),
|
|
464
|
-
)
|
|
465
|
-
return out
|
|
@@ -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
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Dependabot configuration posture and release/tag staleness.
|
|
4
|
+
|
|
5
|
+
These reporting categories sit outside the four-state per-signal model: they are
|
|
6
|
+
configuration-posture and freshness checks rendered as plain tables.
|
|
7
|
+
|
|
8
|
+
- **Dependabot** (beneath the open-alert table): three plain tables -- repos
|
|
9
|
+
with vulnerability **alerts** not enabled, repos with **security updates** not
|
|
10
|
+
enabled (two separate single-feature tables, not a combined matrix), and
|
|
11
|
+
configured ecosystems that set no update *cooldown* (a mandatory requirement
|
|
12
|
+
here -- any cooldown value passes). Only the two features GitHub exposes a
|
|
13
|
+
public per-repository API for are checked.
|
|
14
|
+
- **Releases / Tagging**: repositories that have gone too long without a release
|
|
15
|
+
or tag. Repositories younger than a configurable age are excluded (0 = none
|
|
16
|
+
excluded); specific repositories can also be excluded on demand. Releases and
|
|
17
|
+
tags are reported in separate columns and the rows are ranked by release/tag
|
|
18
|
+
staleness alone (repository age only gates scope): a missing release or tag
|
|
19
|
+
counts as the worst possible signal, so a repo with neither ranks first. The
|
|
20
|
+
ranking key itself is never displayed.
|
|
21
|
+
|
|
22
|
+
The implementation is split across ``facts`` (the :class:`RepoPosture` record
|
|
23
|
+
every table reads), ``enablement`` (the boolean feature checks and the
|
|
24
|
+
Dependabot cooldown check) and ``releases`` (the release/tag freshness and
|
|
25
|
+
release-immutability tables). This module re-exports the public surface, so
|
|
26
|
+
importing from ``github_security_report.posture`` is unchanged.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from github_security_report.posture.enablement import (
|
|
32
|
+
build_alerts_table,
|
|
33
|
+
build_cooldown_table,
|
|
34
|
+
build_dependabot_tables,
|
|
35
|
+
build_pvr_table,
|
|
36
|
+
build_security_updates_table,
|
|
37
|
+
cooldown_missing_ecosystems,
|
|
38
|
+
)
|
|
39
|
+
from github_security_report.posture.facts import RepoPosture
|
|
40
|
+
from github_security_report.posture.releases import (
|
|
41
|
+
build_mutable_releases_table,
|
|
42
|
+
build_releases_table,
|
|
43
|
+
is_release_excluded,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"RepoPosture",
|
|
48
|
+
"is_release_excluded",
|
|
49
|
+
"cooldown_missing_ecosystems",
|
|
50
|
+
"build_dependabot_tables",
|
|
51
|
+
"build_releases_table",
|
|
52
|
+
"build_mutable_releases_table",
|
|
53
|
+
"build_alerts_table",
|
|
54
|
+
"build_security_updates_table",
|
|
55
|
+
"build_cooldown_table",
|
|
56
|
+
"build_pvr_table",
|
|
57
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Configuration-posture tables: feature enablement and update cooldowns.
|
|
4
|
+
|
|
5
|
+
Every table here is built from the boolean (or indeterminate) configuration
|
|
6
|
+
facts on :class:`RepoPosture`: the two Dependabot features GitHub exposes a
|
|
7
|
+
public per-repository API for, the private-vulnerability-reporting flag, and
|
|
8
|
+
the per-ecosystem cooldown declared in a repository's ``dependabot.yml``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from github_security_report.categories import CategoryKey, category_meta
|
|
19
|
+
from github_security_report.posture.facts import RepoPosture
|
|
20
|
+
from github_security_report.report import TableRow, TableSection
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cooldown_missing_ecosystems(dependabot_yaml: str) -> tuple[str, ...]:
|
|
26
|
+
"""Ecosystems in a ``dependabot.yml`` that declare no ``cooldown``.
|
|
27
|
+
|
|
28
|
+
Any ``cooldown`` value passes. Returns the ``package-ecosystem`` of each
|
|
29
|
+
``updates`` entry that omits a cooldown, de-duplicated and ordered. A
|
|
30
|
+
malformed document yields an empty tuple (treated as "nothing to flag").
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
data = yaml.safe_load(dependabot_yaml)
|
|
34
|
+
except yaml.YAMLError as exc: # malformed config; do not crash the run
|
|
35
|
+
log.warning("could not parse dependabot.yml: %s", exc)
|
|
36
|
+
return ()
|
|
37
|
+
if not isinstance(data, dict):
|
|
38
|
+
return ()
|
|
39
|
+
updates = data.get("updates")
|
|
40
|
+
if not isinstance(updates, list):
|
|
41
|
+
return ()
|
|
42
|
+
missing: list[str] = []
|
|
43
|
+
for entry in updates:
|
|
44
|
+
if not isinstance(entry, dict):
|
|
45
|
+
continue
|
|
46
|
+
ecosystem = entry.get("package-ecosystem")
|
|
47
|
+
if not isinstance(ecosystem, str) or not ecosystem:
|
|
48
|
+
continue
|
|
49
|
+
if "cooldown" not in entry and ecosystem not in missing:
|
|
50
|
+
missing.append(ecosystem)
|
|
51
|
+
return tuple(missing)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_feature_table(
|
|
55
|
+
postures: list[RepoPosture],
|
|
56
|
+
*,
|
|
57
|
+
category_key: CategoryKey,
|
|
58
|
+
columns: tuple[str, ...],
|
|
59
|
+
enabled_of: Callable[[RepoPosture], bool | None],
|
|
60
|
+
) -> TableSection:
|
|
61
|
+
"""A single-feature enablement table (offenders = feature confirmed off).
|
|
62
|
+
|
|
63
|
+
Shared by the Dependabot alerts and security-updates checks: both list the
|
|
64
|
+
repositories where one boolean feature is explicitly disabled and report the
|
|
65
|
+
enabled/not-enabled/indeterminate split as the standardised footer counts.
|
|
66
|
+
An indeterminate (``None``) reading counts towards neither pass nor fail; it
|
|
67
|
+
becomes the unknown count, so an empty table never over-claims that every
|
|
68
|
+
repository is enabled.
|
|
69
|
+
"""
|
|
70
|
+
rows = [
|
|
71
|
+
TableRow(repo=p.repo, cells=())
|
|
72
|
+
for p in sorted(postures, key=lambda p: p.repo.name)
|
|
73
|
+
if enabled_of(p) is False
|
|
74
|
+
]
|
|
75
|
+
not_enabled = sum(1 for p in postures if enabled_of(p) is False)
|
|
76
|
+
enabled = sum(1 for p in postures if enabled_of(p) is True)
|
|
77
|
+
indeterminate = sum(1 for p in postures if enabled_of(p) is None)
|
|
78
|
+
return TableSection(
|
|
79
|
+
category=category_meta(category_key),
|
|
80
|
+
columns=columns,
|
|
81
|
+
rows=rows,
|
|
82
|
+
pass_count=enabled,
|
|
83
|
+
fail_count=not_enabled,
|
|
84
|
+
unknown_count=indeterminate,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_alerts_table(postures: list[RepoPosture]) -> TableSection:
|
|
89
|
+
"""Repositories where Dependabot vulnerability alerts are not enabled."""
|
|
90
|
+
return _build_feature_table(
|
|
91
|
+
postures,
|
|
92
|
+
category_key=CategoryKey.DEPENDABOT_ALERTS_ENABLED,
|
|
93
|
+
columns=("Repository",),
|
|
94
|
+
enabled_of=lambda p: p.dependabot_alerts,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_security_updates_table(postures: list[RepoPosture]) -> TableSection:
|
|
99
|
+
"""Repositories where Dependabot security updates are not enabled."""
|
|
100
|
+
return _build_feature_table(
|
|
101
|
+
postures,
|
|
102
|
+
category_key=CategoryKey.DEPENDABOT_UPDATES_ENABLED,
|
|
103
|
+
columns=("Repository",),
|
|
104
|
+
enabled_of=lambda p: p.security_updates,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_cooldown_table(postures: list[RepoPosture]) -> TableSection:
|
|
109
|
+
"""A table of repositories/ecosystems that configure no update cooldown."""
|
|
110
|
+
rows = [
|
|
111
|
+
TableRow(repo=p.repo, cells=(joined,), sort_values=(joined,))
|
|
112
|
+
for p in sorted(postures, key=lambda p: p.repo.name)
|
|
113
|
+
if p.cooldown_missing and (joined := ", ".join(p.cooldown_missing))
|
|
114
|
+
]
|
|
115
|
+
missing = sum(1 for p in postures if p.cooldown_missing)
|
|
116
|
+
with_cooldown = sum(
|
|
117
|
+
1 for p in postures if p.has_dependabot_config and not p.cooldown_missing
|
|
118
|
+
)
|
|
119
|
+
# An unreadable prefetch means the dependabot.yml itself is unknown, not
|
|
120
|
+
# absent -- count it as unknown rather than silently dropping the repo.
|
|
121
|
+
indeterminate = sum(1 for p in postures if p.graph_unreadable)
|
|
122
|
+
return TableSection(
|
|
123
|
+
category=category_meta(CategoryKey.DEPENDABOT_COOLDOWN),
|
|
124
|
+
columns=("Repository", "Ecosystems without cooldown"),
|
|
125
|
+
rows=rows,
|
|
126
|
+
pass_count=with_cooldown,
|
|
127
|
+
fail_count=missing,
|
|
128
|
+
unknown_count=indeterminate,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_dependabot_tables(postures: list[RepoPosture]) -> list[TableSection]:
|
|
133
|
+
"""All extra Dependabot posture tables, in render order.
|
|
134
|
+
|
|
135
|
+
The alerts and security-updates enablement checks are deliberately two
|
|
136
|
+
separate single-feature tables (rather than one multi-column matrix): with
|
|
137
|
+
only two public-API features the matrix read as contradictory.
|
|
138
|
+
"""
|
|
139
|
+
return [
|
|
140
|
+
build_alerts_table(postures),
|
|
141
|
+
build_security_updates_table(postures),
|
|
142
|
+
build_cooldown_table(postures),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_pvr_table(postures: list[RepoPosture]) -> TableSection:
|
|
147
|
+
"""Repositories where private vulnerability reporting is not enabled.
|
|
148
|
+
|
|
149
|
+
Like the Dependabot enablement tables this is a single-boolean feature
|
|
150
|
+
check, so it reuses :func:`_build_feature_table`: offenders are repositories
|
|
151
|
+
where the feature is confirmed off; an indeterminate (``None``) reading
|
|
152
|
+
counts towards neither side of the standardised summary footer. The table is
|
|
153
|
+
always built (the flag is probed for every repository); the per-category
|
|
154
|
+
render toggle governs whether it is shown.
|
|
155
|
+
"""
|
|
156
|
+
return _build_feature_table(
|
|
157
|
+
postures,
|
|
158
|
+
category_key=CategoryKey.PRIVATE_VULNERABILITY_REPORTING,
|
|
159
|
+
columns=("Repository",),
|
|
160
|
+
enabled_of=lambda p: p.private_vulnerability_reporting,
|
|
161
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""The per-repository facts the posture and freshness tables are built from."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import datetime as dt
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from github_security_report.models import ReleaseRef, Repo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RepoPosture:
|
|
15
|
+
"""Per-repository configuration/freshness facts for the extra sections."""
|
|
16
|
+
|
|
17
|
+
repo: Repo
|
|
18
|
+
# True when the batched GraphQL prefetch could not read this repository at
|
|
19
|
+
# all: the release/tag and dependabot.yml facts below are then unknown, not
|
|
20
|
+
# absent, and the tables must count the repository as unknown rather than
|
|
21
|
+
# render confident negatives such as "never released".
|
|
22
|
+
graph_unreadable: bool = False
|
|
23
|
+
# Dependabot repo-level feature flags (None = indeterminate).
|
|
24
|
+
dependabot_alerts: bool | None = None
|
|
25
|
+
security_updates: bool | None = None
|
|
26
|
+
# GitHub "private vulnerability reporting" enablement (None = indeterminate).
|
|
27
|
+
private_vulnerability_reporting: bool | None = None
|
|
28
|
+
# Ecosystems declared in .github/dependabot.yml that set no cooldown.
|
|
29
|
+
cooldown_missing: tuple[str, ...] = ()
|
|
30
|
+
# True when .github/dependabot.yml exists and declares version updates.
|
|
31
|
+
has_dependabot_config: bool = False
|
|
32
|
+
# Releases / tagging (UTC; None = none found).
|
|
33
|
+
latest_release_at: dt.datetime | None = None
|
|
34
|
+
latest_tag_at: dt.datetime | None = None
|
|
35
|
+
# Release identities for the immutability check (None = absent).
|
|
36
|
+
latest_release: ReleaseRef | None = None
|
|
37
|
+
last_published_release: ReleaseRef | None = None
|
|
@@ -1,99 +1,32 @@
|
|
|
1
1
|
# SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
-
"""
|
|
3
|
+
"""Release/tag freshness and release-immutability tables.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
- **Dependabot** (beneath the open-alert table): three plain tables -- repos
|
|
9
|
-
with vulnerability **alerts** not enabled, repos with **security updates** not
|
|
10
|
-
enabled (two separate single-feature tables, not a combined matrix), and
|
|
11
|
-
configured ecosystems that set no update *cooldown* (a mandatory requirement
|
|
12
|
-
here -- any cooldown value passes). Only the two features GitHub exposes a
|
|
13
|
-
public per-repository API for are checked.
|
|
14
|
-
- **Releases / Tagging**: repositories that have gone too long without a release
|
|
15
|
-
or tag. Repositories younger than a configurable age are excluded (0 = none
|
|
16
|
-
excluded); specific repositories can also be excluded on demand. Releases and
|
|
17
|
-
tags are reported in separate columns and the rows are ranked by release/tag
|
|
18
|
-
staleness alone (repository age only gates scope): a missing release or tag
|
|
19
|
-
counts as the worst possible signal, so a repo with neither ranks first. The
|
|
20
|
-
ranking key itself is never displayed.
|
|
5
|
+
Repositories that have gone too long without a release or tag, and releases
|
|
6
|
+
that remain mutable. Repositories younger than a configurable age are excluded
|
|
7
|
+
(0 = none excluded); specific repositories can also be excluded on demand.
|
|
21
8
|
"""
|
|
22
9
|
|
|
23
10
|
from __future__ import annotations
|
|
24
11
|
|
|
25
12
|
import datetime as dt
|
|
26
|
-
import logging
|
|
27
|
-
from collections.abc import Callable
|
|
28
|
-
from dataclasses import dataclass
|
|
29
|
-
|
|
30
|
-
import yaml
|
|
31
13
|
|
|
32
14
|
from github_security_report.categories import CategoryKey, category_meta
|
|
33
15
|
from github_security_report.models import ReleaseRef, Repo
|
|
16
|
+
from github_security_report.posture.facts import RepoPosture
|
|
34
17
|
from github_security_report.report import TableRow, TableSection
|
|
35
18
|
|
|
36
|
-
log = logging.getLogger(__name__)
|
|
37
|
-
|
|
38
19
|
# Aware sentinel so releases lacking a publish timestamp sort oldest (last) when
|
|
39
20
|
# ordering most-recent-first, without ever comparing a naive and aware value.
|
|
40
21
|
_MIN_AWARE = dt.datetime.min.replace(tzinfo=dt.timezone.utc)
|
|
41
22
|
|
|
42
23
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# all: the release/tag and dependabot.yml facts below are then unknown, not
|
|
50
|
-
# absent, and the tables must count the repository as unknown rather than
|
|
51
|
-
# render confident negatives such as "never released".
|
|
52
|
-
graph_unreadable: bool = False
|
|
53
|
-
# Dependabot repo-level feature flags (None = indeterminate).
|
|
54
|
-
dependabot_alerts: bool | None = None
|
|
55
|
-
security_updates: bool | None = None
|
|
56
|
-
# GitHub "private vulnerability reporting" enablement (None = indeterminate).
|
|
57
|
-
private_vulnerability_reporting: bool | None = None
|
|
58
|
-
# Ecosystems declared in .github/dependabot.yml that set no cooldown.
|
|
59
|
-
cooldown_missing: tuple[str, ...] = ()
|
|
60
|
-
# True when .github/dependabot.yml exists and declares version updates.
|
|
61
|
-
has_dependabot_config: bool = False
|
|
62
|
-
# Releases / tagging (UTC; None = none found).
|
|
63
|
-
latest_release_at: dt.datetime | None = None
|
|
64
|
-
latest_tag_at: dt.datetime | None = None
|
|
65
|
-
# Release identities for the immutability check (None = absent).
|
|
66
|
-
latest_release: ReleaseRef | None = None
|
|
67
|
-
last_published_release: ReleaseRef | None = None
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def cooldown_missing_ecosystems(dependabot_yaml: str) -> tuple[str, ...]:
|
|
71
|
-
"""Ecosystems in a ``dependabot.yml`` that declare no ``cooldown``.
|
|
72
|
-
|
|
73
|
-
Any ``cooldown`` value passes. Returns the ``package-ecosystem`` of each
|
|
74
|
-
``updates`` entry that omits a cooldown, de-duplicated and ordered. A
|
|
75
|
-
malformed document yields an empty tuple (treated as "nothing to flag").
|
|
76
|
-
"""
|
|
77
|
-
try:
|
|
78
|
-
data = yaml.safe_load(dependabot_yaml)
|
|
79
|
-
except yaml.YAMLError as exc: # malformed config; do not crash the run
|
|
80
|
-
log.warning("could not parse dependabot.yml: %s", exc)
|
|
81
|
-
return ()
|
|
82
|
-
if not isinstance(data, dict):
|
|
83
|
-
return ()
|
|
84
|
-
updates = data.get("updates")
|
|
85
|
-
if not isinstance(updates, list):
|
|
86
|
-
return ()
|
|
87
|
-
missing: list[str] = []
|
|
88
|
-
for entry in updates:
|
|
89
|
-
if not isinstance(entry, dict):
|
|
90
|
-
continue
|
|
91
|
-
ecosystem = entry.get("package-ecosystem")
|
|
92
|
-
if not isinstance(ecosystem, str) or not ecosystem:
|
|
93
|
-
continue
|
|
94
|
-
if "cooldown" not in entry and ecosystem not in missing:
|
|
95
|
-
missing.append(ecosystem)
|
|
96
|
-
return tuple(missing)
|
|
24
|
+
def _age_days(when: dt.datetime | None, now: dt.datetime) -> int | None:
|
|
25
|
+
"""Whole days between ``when`` and ``now`` (>= 0), or None when absent."""
|
|
26
|
+
if when is None:
|
|
27
|
+
return None
|
|
28
|
+
delta = (now - when).days
|
|
29
|
+
return max(delta, 0)
|
|
97
30
|
|
|
98
31
|
|
|
99
32
|
def is_release_excluded(
|
|
@@ -119,14 +52,6 @@ def is_release_excluded(
|
|
|
119
52
|
)
|
|
120
53
|
|
|
121
54
|
|
|
122
|
-
def _age_days(when: dt.datetime | None, now: dt.datetime) -> int | None:
|
|
123
|
-
"""Whole days between ``when`` and ``now`` (>= 0), or None when absent."""
|
|
124
|
-
if when is None:
|
|
125
|
-
return None
|
|
126
|
-
delta = (now - when).days
|
|
127
|
-
return max(delta, 0)
|
|
128
|
-
|
|
129
|
-
|
|
130
55
|
def _release_is_current(
|
|
131
56
|
release_age: int | None, tag_age: int | None, release_max_age_days: int
|
|
132
57
|
) -> bool:
|
|
@@ -157,116 +82,6 @@ def _age_cell(age: int | None) -> str:
|
|
|
157
82
|
return f"{age} days ago"
|
|
158
83
|
|
|
159
84
|
|
|
160
|
-
def _build_feature_table(
|
|
161
|
-
postures: list[RepoPosture],
|
|
162
|
-
*,
|
|
163
|
-
category_key: CategoryKey,
|
|
164
|
-
columns: tuple[str, ...],
|
|
165
|
-
enabled_of: Callable[[RepoPosture], bool | None],
|
|
166
|
-
) -> TableSection:
|
|
167
|
-
"""A single-feature enablement table (offenders = feature confirmed off).
|
|
168
|
-
|
|
169
|
-
Shared by the Dependabot alerts and security-updates checks: both list the
|
|
170
|
-
repositories where one boolean feature is explicitly disabled and report the
|
|
171
|
-
enabled/not-enabled/indeterminate split as the standardised footer counts.
|
|
172
|
-
An indeterminate (``None``) reading counts towards neither pass nor fail; it
|
|
173
|
-
becomes the unknown count, so an empty table never over-claims that every
|
|
174
|
-
repository is enabled.
|
|
175
|
-
"""
|
|
176
|
-
rows = [
|
|
177
|
-
TableRow(repo=p.repo, cells=())
|
|
178
|
-
for p in sorted(postures, key=lambda p: p.repo.name)
|
|
179
|
-
if enabled_of(p) is False
|
|
180
|
-
]
|
|
181
|
-
not_enabled = sum(1 for p in postures if enabled_of(p) is False)
|
|
182
|
-
enabled = sum(1 for p in postures if enabled_of(p) is True)
|
|
183
|
-
indeterminate = sum(1 for p in postures if enabled_of(p) is None)
|
|
184
|
-
return TableSection(
|
|
185
|
-
category=category_meta(category_key),
|
|
186
|
-
columns=columns,
|
|
187
|
-
rows=rows,
|
|
188
|
-
pass_count=enabled,
|
|
189
|
-
fail_count=not_enabled,
|
|
190
|
-
unknown_count=indeterminate,
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def build_alerts_table(postures: list[RepoPosture]) -> TableSection:
|
|
195
|
-
"""Repositories where Dependabot vulnerability alerts are not enabled."""
|
|
196
|
-
return _build_feature_table(
|
|
197
|
-
postures,
|
|
198
|
-
category_key=CategoryKey.DEPENDABOT_ALERTS_ENABLED,
|
|
199
|
-
columns=("Repository",),
|
|
200
|
-
enabled_of=lambda p: p.dependabot_alerts,
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def build_security_updates_table(postures: list[RepoPosture]) -> TableSection:
|
|
205
|
-
"""Repositories where Dependabot security updates are not enabled."""
|
|
206
|
-
return _build_feature_table(
|
|
207
|
-
postures,
|
|
208
|
-
category_key=CategoryKey.DEPENDABOT_UPDATES_ENABLED,
|
|
209
|
-
columns=("Repository",),
|
|
210
|
-
enabled_of=lambda p: p.security_updates,
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def build_cooldown_table(postures: list[RepoPosture]) -> TableSection:
|
|
215
|
-
"""A table of repositories/ecosystems that configure no update cooldown."""
|
|
216
|
-
rows = [
|
|
217
|
-
TableRow(repo=p.repo, cells=(joined,), sort_values=(joined,))
|
|
218
|
-
for p in sorted(postures, key=lambda p: p.repo.name)
|
|
219
|
-
if p.cooldown_missing and (joined := ", ".join(p.cooldown_missing))
|
|
220
|
-
]
|
|
221
|
-
missing = sum(1 for p in postures if p.cooldown_missing)
|
|
222
|
-
with_cooldown = sum(
|
|
223
|
-
1 for p in postures if p.has_dependabot_config and not p.cooldown_missing
|
|
224
|
-
)
|
|
225
|
-
# An unreadable prefetch means the dependabot.yml itself is unknown, not
|
|
226
|
-
# absent -- count it as unknown rather than silently dropping the repo.
|
|
227
|
-
indeterminate = sum(1 for p in postures if p.graph_unreadable)
|
|
228
|
-
return TableSection(
|
|
229
|
-
category=category_meta(CategoryKey.DEPENDABOT_COOLDOWN),
|
|
230
|
-
columns=("Repository", "Ecosystems without cooldown"),
|
|
231
|
-
rows=rows,
|
|
232
|
-
pass_count=with_cooldown,
|
|
233
|
-
fail_count=missing,
|
|
234
|
-
unknown_count=indeterminate,
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def build_dependabot_tables(postures: list[RepoPosture]) -> list[TableSection]:
|
|
239
|
-
"""All extra Dependabot posture tables, in render order.
|
|
240
|
-
|
|
241
|
-
The alerts and security-updates enablement checks are deliberately two
|
|
242
|
-
separate single-feature tables (rather than one multi-column matrix): with
|
|
243
|
-
only two public-API features the matrix read as contradictory.
|
|
244
|
-
"""
|
|
245
|
-
return [
|
|
246
|
-
build_alerts_table(postures),
|
|
247
|
-
build_security_updates_table(postures),
|
|
248
|
-
build_cooldown_table(postures),
|
|
249
|
-
]
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def build_pvr_table(postures: list[RepoPosture]) -> TableSection:
|
|
253
|
-
"""Repositories where private vulnerability reporting is not enabled.
|
|
254
|
-
|
|
255
|
-
Like the Dependabot enablement tables this is a single-boolean feature
|
|
256
|
-
check, so it reuses :func:`_build_feature_table`: offenders are repositories
|
|
257
|
-
where the feature is confirmed off; an indeterminate (``None``) reading
|
|
258
|
-
counts towards neither side of the standardised summary footer. The table is
|
|
259
|
-
always built (the flag is probed for every repository); the per-category
|
|
260
|
-
render toggle governs whether it is shown.
|
|
261
|
-
"""
|
|
262
|
-
return _build_feature_table(
|
|
263
|
-
postures,
|
|
264
|
-
category_key=CategoryKey.PRIVATE_VULNERABILITY_REPORTING,
|
|
265
|
-
columns=("Repository",),
|
|
266
|
-
enabled_of=lambda p: p.private_vulnerability_reporting,
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
|
|
270
85
|
def build_releases_table(
|
|
271
86
|
postures: list[RepoPosture],
|
|
272
87
|
*,
|
|
@@ -425,17 +240,3 @@ def build_mutable_releases_table(postures: list[RepoPosture]) -> TableSection:
|
|
|
425
240
|
fail_count=finding_count,
|
|
426
241
|
unknown_count=indeterminate_count,
|
|
427
242
|
)
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
__all__ = [
|
|
431
|
-
"RepoPosture",
|
|
432
|
-
"is_release_excluded",
|
|
433
|
-
"cooldown_missing_ecosystems",
|
|
434
|
-
"build_dependabot_tables",
|
|
435
|
-
"build_releases_table",
|
|
436
|
-
"build_mutable_releases_table",
|
|
437
|
-
"build_alerts_table",
|
|
438
|
-
"build_security_updates_table",
|
|
439
|
-
"build_cooldown_table",
|
|
440
|
-
"build_pvr_table",
|
|
441
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/gating.py
RENAMED
|
File without changes
|
{github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/gitctx.py
RENAMED
|
File without changes
|
{github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/issues.py
RENAMED
|
File without changes
|
{github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/models.py
RENAMED
|
File without changes
|
|
File without changes
|
{github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/report.py
RENAMED
|
File without changes
|
|
File without changes
|
{github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/runner.py
RENAMED
|
File without changes
|
{github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/scope.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|