little-sister-github 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- little_sister_github/__init__.py +24 -0
- little_sister_github/github.py +1091 -0
- little_sister_github/py.typed +0 -0
- little_sister_github-0.1.0.dist-info/METADATA +141 -0
- little_sister_github-0.1.0.dist-info/RECORD +7 -0
- little_sister_github-0.1.0.dist-info/WHEEL +4 -0
- little_sister_github-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""little-sister check type: ``github``.
|
|
2
|
+
|
|
3
|
+
Importing this package registers the type in little-sister's ``CHECK_TYPES``. A
|
|
4
|
+
deployment therefore needs one line in its ``wsgi.py``, before it imports
|
|
5
|
+
``little_sister.app``::
|
|
6
|
+
|
|
7
|
+
import little_sister_github # noqa: F401 registers its type
|
|
8
|
+
from little_sister.app import app # noqa: E402 builds the engine
|
|
9
|
+
|
|
10
|
+
``require_api`` declares the **check API epoch** this package was built for. It
|
|
11
|
+
refuses at startup, naming both epochs, when the library has moved past the
|
|
12
|
+
surface below — the upgrade an install-time floor cannot see, because a floor has
|
|
13
|
+
no ceiling (little-sister ADR-0051). Without it the same mismatch would surface as
|
|
14
|
+
an ``ImportError`` from inside this package, which reads like our bug.
|
|
15
|
+
"""
|
|
16
|
+
from little_sister.checks import require_api
|
|
17
|
+
|
|
18
|
+
require_api(1)
|
|
19
|
+
|
|
20
|
+
from little_sister_github import github # noqa: E402 registers the type
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = ["github"]
|
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
"""The ``github`` check type: a team's repositories, one child per aspect.
|
|
2
|
+
|
|
3
|
+
Discovers the repositories of a GitHub organization (optionally narrowed to one
|
|
4
|
+
team) and reports **one child per aspect** — aspect-first. Most aspect children
|
|
5
|
+
are leaves listing the repositories they flag; the two severity-carrying security
|
|
6
|
+
aspects split once more into source-severity bands. Ported from a Ruby
|
|
7
|
+
overview-check dashboard; the endpoint-to-grade mapping and what was deliberately
|
|
8
|
+
changed are in ``docs/migrating-overview-checks.md``.
|
|
9
|
+
|
|
10
|
+
Each leaf's lines are **keyed entries** rather than plain strings (little-sister
|
|
11
|
+
ADR-0036), slugged ``<repo>-<kind>-<number>`` from GitHub's own per-repository
|
|
12
|
+
numbering: an engineer who opens a ticket for one finding pins that line and the
|
|
13
|
+
rest of the aspect keeps reporting. The parts are identifiers the provider minted,
|
|
14
|
+
never the rendered text and never a position (little-sister ADR-0050).
|
|
15
|
+
|
|
16
|
+
Registered in little-sister's ``CHECK_TYPES`` on import — importing
|
|
17
|
+
``little_sister_github`` is the one line a deployment's ``wsgi.py`` adds, before
|
|
18
|
+
``little_sister.app``, so the ``github`` type is known when the engine loads the
|
|
19
|
+
check configs.
|
|
20
|
+
|
|
21
|
+
Everything imported from little-sister below is part of its **check-authoring
|
|
22
|
+
surface** (architecture.md §11), which is what the ``require_api(1)`` in this
|
|
23
|
+
package's ``__init__`` pins.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import re
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from little_sister import values
|
|
38
|
+
from little_sister.checks import (
|
|
39
|
+
Check,
|
|
40
|
+
CheckError,
|
|
41
|
+
CheckResult,
|
|
42
|
+
Entry,
|
|
43
|
+
coerce_code,
|
|
44
|
+
config_markdown,
|
|
45
|
+
parse_secret_refs,
|
|
46
|
+
parse_subnodes,
|
|
47
|
+
plain,
|
|
48
|
+
register,
|
|
49
|
+
resolve_text,
|
|
50
|
+
)
|
|
51
|
+
from little_sister.reasons import slug
|
|
52
|
+
from little_sister.status import StatusCode
|
|
53
|
+
|
|
54
|
+
#: This package's own logger. little-sister does not promise its ``logger`` to
|
|
55
|
+
#: check authors and does not need to: the library configures the root handlers,
|
|
56
|
+
#: so an ordinary module logger's records land in the same place, under a name
|
|
57
|
+
#: that says which package emitted them.
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
GITHUB_API = "https://api.github.com"
|
|
61
|
+
|
|
62
|
+
# Source-severity bands, worst first. Dependabot uses the first four; code
|
|
63
|
+
# scanning can also return the analysis severities below them. A deployment may
|
|
64
|
+
# grade every band independently; the defaults below are deliberately strict, and
|
|
65
|
+
# the band shape is what makes overriding one of them a one-line change
|
|
66
|
+
# (little-sister ADR-0042).
|
|
67
|
+
SECURITY_SEVERITY_ORDER = (
|
|
68
|
+
"critical", "high", "medium", "low", "error", "warning", "note", "none",
|
|
69
|
+
)
|
|
70
|
+
DEFAULT_ADVISORY_SEVERITY_MAP = {
|
|
71
|
+
"critical": StatusCode.ERROR,
|
|
72
|
+
"high": StatusCode.ERROR,
|
|
73
|
+
"medium": StatusCode.WARN,
|
|
74
|
+
"low": StatusCode.WARN,
|
|
75
|
+
}
|
|
76
|
+
DEFAULT_CODE_SCANNING_SEVERITY_MAP = dict.fromkeys(
|
|
77
|
+
SECURITY_SEVERITY_ORDER, StatusCode.ERROR)
|
|
78
|
+
|
|
79
|
+
def _severity_map(value: object, field: str) -> dict[str, StatusCode]:
|
|
80
|
+
"""One configured source-severity → dashboard-code mapping."""
|
|
81
|
+
if value is None:
|
|
82
|
+
return {}
|
|
83
|
+
if not isinstance(value, dict):
|
|
84
|
+
raise CheckError(f"github '{field}.severity_map' must be a mapping")
|
|
85
|
+
return {str(severity).lower(): coerce_code(code)
|
|
86
|
+
for severity, code in value.items()}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _positive_int(value: object, field: str) -> int:
|
|
90
|
+
"""A configuration integer that cannot disable a coverage backstop."""
|
|
91
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
92
|
+
raise CheckError(f"github '{field}' must be an integer of at least 1")
|
|
93
|
+
try:
|
|
94
|
+
parsed = int(value)
|
|
95
|
+
except (TypeError, ValueError) as error:
|
|
96
|
+
raise CheckError(
|
|
97
|
+
f"github '{field}' must be an integer of at least 1") from error
|
|
98
|
+
if parsed < 1:
|
|
99
|
+
raise CheckError(f"github '{field}' must be an integer of at least 1")
|
|
100
|
+
return parsed
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
#: The sentence every aspect's `about` ends with, referenced as `{pin_note}` so it
|
|
104
|
+
#: is written once (little-sister ADR-0025). GitHub numbers each finding per
|
|
105
|
+
#: repository, so every line here is separately addressable — which is the part an
|
|
106
|
+
#: operator needs to know before they open a ticket for one of twenty.
|
|
107
|
+
PIN_NOTE = ("Each line is one finding and can be put into maintenance on its own — "
|
|
108
|
+
"pin the line you are working on and the rest keeps reporting.")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
#: Built-in display text for the aspect leaves this check emits (little-sister
|
|
112
|
+
#: ADR-0025) — **type-inherent**, so it is written once here rather than copied into
|
|
113
|
+
#: every deployment config, which matters as soon as the type runs more than once
|
|
114
|
+
#: (one check per team). `{org}` / `{team}` expand from the check's own config
|
|
115
|
+
#: (`_subnode_tokens`). A check config's `subnodes:` block
|
|
116
|
+
#: replaces any of these, or extends one by writing `{default}` into its own text;
|
|
117
|
+
#: `nodes.yaml` still wins over both, per node path.
|
|
118
|
+
#:
|
|
119
|
+
#: What is written here is what is true of the **type** — what the aspect reads
|
|
120
|
+
#: and what the reader is looking at. What an installation *does about it* — a
|
|
121
|
+
#: remediation deadline, the day of the week dependency bumps are cleared, who to
|
|
122
|
+
#: tell — is a deployment's policy and belongs in its own `subnodes:` block,
|
|
123
|
+
#: appended with `{default}`. A promise this file cannot keep for a stranger has
|
|
124
|
+
#: no business shipping in the package.
|
|
125
|
+
SUBNODES: dict[str, dict[str, str]] = {
|
|
126
|
+
"pull_requests": {
|
|
127
|
+
"title": "Pull requests",
|
|
128
|
+
"about": """\
|
|
129
|
+
Open pull requests on the repositories in scope, one line per pull request, with
|
|
130
|
+
its author. Pull requests whose title starts with one of
|
|
131
|
+
`pull_requests.ignore_title_prefixes` are not listed.
|
|
132
|
+
|
|
133
|
+
{pin_note}
|
|
134
|
+
""",
|
|
135
|
+
},
|
|
136
|
+
"security_advisories": {
|
|
137
|
+
"title": "Dependabot advisories",
|
|
138
|
+
"about": """\
|
|
139
|
+
GitHub Security Advisories (Dependabot) found known vulnerabilities in the
|
|
140
|
+
dependencies of this team's repositories.
|
|
141
|
+
[All open advisories](https://github.com/orgs/{org}/security/alerts/dependabot?q=is:open%20team:{team}).
|
|
142
|
+
|
|
143
|
+
Findings are grouped into severity-band children; which bands are reported and
|
|
144
|
+
how each one is graded is this deployment's call
|
|
145
|
+
(`security_advisories.severities` and `.severity_map`).
|
|
146
|
+
|
|
147
|
+
{pin_note}
|
|
148
|
+
""",
|
|
149
|
+
},
|
|
150
|
+
"code_scanning_alerts": {
|
|
151
|
+
"title": "Code-scanning alerts",
|
|
152
|
+
"about": """\
|
|
153
|
+
GitHub Code Scanning found potential vulnerabilities in this team's code.
|
|
154
|
+
[All open alerts](https://github.com/orgs/{org}/security/alerts/code-scanning?query=is%3Aopen+team%3A{team}+).
|
|
155
|
+
|
|
156
|
+
Findings are grouped into severity-band children, graded by
|
|
157
|
+
`code_scanning_alerts.severity_map`.
|
|
158
|
+
|
|
159
|
+
{pin_note}
|
|
160
|
+
""",
|
|
161
|
+
},
|
|
162
|
+
"secret_scanning_alerts": {
|
|
163
|
+
"title": "Secret-scanning alerts",
|
|
164
|
+
"about": """\
|
|
165
|
+
GitHub Secret Scanning found secrets committed to this team's repositories.
|
|
166
|
+
[All open alerts](https://github.com/orgs/{org}/security/alerts/secret-scanning?query=team%3A{team}+is%3Aopen).
|
|
167
|
+
|
|
168
|
+
**Remove the secret, rotate it, and clean the history — take care of this
|
|
169
|
+
immediately.** Secret scanning also covers
|
|
170
|
+
[non-provider patterns](https://docs.github.com/en/enterprise-cloud@latest/code-security/secret-scanning/introduction/supported-secret-scanning-patterns#non-provider-patterns)
|
|
171
|
+
(formerly the "experimental" alerts).
|
|
172
|
+
|
|
173
|
+
A repository flagged **"secret scanning not enabled"** has the feature turned
|
|
174
|
+
off, so nothing is watching it for leaked secrets — enable it under the
|
|
175
|
+
repo's Settings → Code security and analysis at "Secret Protection".
|
|
176
|
+
|
|
177
|
+
{pin_note}
|
|
178
|
+
""",
|
|
179
|
+
},
|
|
180
|
+
"sbom_check": {
|
|
181
|
+
"title": "SBOM presence",
|
|
182
|
+
"about": """\
|
|
183
|
+
Every repository with code must have a dependency graph (SBOM) so its libraries
|
|
184
|
+
can be checked for known issues. A repository listed here has none. Some repos
|
|
185
|
+
are exempt — see `sbom_check.ignore` in this check's config.
|
|
186
|
+
|
|
187
|
+
{pin_note}
|
|
188
|
+
""",
|
|
189
|
+
},
|
|
190
|
+
"issues": {
|
|
191
|
+
"title": "Open issues",
|
|
192
|
+
"about": """\
|
|
193
|
+
Open issues across the team's repositories, one line per issue. **Pull requests are
|
|
194
|
+
excluded**: GitHub's REST API counts every pull request as an issue and returns both
|
|
195
|
+
from this endpoint, and open PRs are already reported by *Pull requests*.
|
|
196
|
+
Repositories listed under `issues.ignore` are skipped.
|
|
197
|
+
|
|
198
|
+
{pin_note}
|
|
199
|
+
""",
|
|
200
|
+
},
|
|
201
|
+
"actions": {
|
|
202
|
+
"title": "Workflow runs",
|
|
203
|
+
"about": """\
|
|
204
|
+
The last completed GitHub Actions verdict per workflow (default branch unless
|
|
205
|
+
configured otherwise), with a newer in-flight run shown on the same line: a failed
|
|
206
|
+
run → ERROR, a run awaiting approval → WARN. Workflows matching
|
|
207
|
+
`actions.ignore_workflow_name_patterns` are skipped.
|
|
208
|
+
|
|
209
|
+
{pin_note}
|
|
210
|
+
""",
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _is_pull_request(row: object) -> bool:
|
|
216
|
+
"""True for a row the *issues* endpoint returned that is really a pull request.
|
|
217
|
+
|
|
218
|
+
GitHub's REST API considers every pull request an issue, so
|
|
219
|
+
``/repos/{owner}/{repo}/issues`` returns both; only a PR row carries a
|
|
220
|
+
``pull_request`` object."""
|
|
221
|
+
return isinstance(row, dict) and "pull_request" in row
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _link(text: str, url: str) -> str:
|
|
225
|
+
"""A Markdown link, or just the text when there is no URL."""
|
|
226
|
+
return f"[{text}]({url})" if url else text
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _entry_slug(repo: Repo, kind: str, number: int = 0, url: str = "") -> str:
|
|
230
|
+
"""The slug for one per-repo finding — ``<repo>-<kind>-<number>``.
|
|
231
|
+
|
|
232
|
+
GitHub numbers pull requests, issues and every alert family **per repository**,
|
|
233
|
+
and the number is minted with the finding and retired with it. That is the
|
|
234
|
+
identity little-sister ADR-0036 asks for, and it stays readable in a URL:
|
|
235
|
+
`platform-a-pr-42`.
|
|
236
|
+
|
|
237
|
+
A payload with no number falls back to the API's own URL, which is unique per
|
|
238
|
+
finding, and finally to ``<repo>-<kind>`` for the aspects that report at most
|
|
239
|
+
one line per repository (a missing SBOM, secret scanning switched off). What it
|
|
240
|
+
never falls back to is the line's position: an entry above it closing would slide
|
|
241
|
+
the pin onto somebody else's finding.
|
|
242
|
+
"""
|
|
243
|
+
if number:
|
|
244
|
+
return slug(repo.name, kind, number)
|
|
245
|
+
return slug(repo.name, kind, url) if url else slug(repo.name, kind)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class Repo:
|
|
250
|
+
"""One repository from discovery — the fields every aspect reads.
|
|
251
|
+
|
|
252
|
+
Built once at the seam (:meth:`from_api`) so the aspects work on typed values
|
|
253
|
+
rather than an ``Any`` bag whose wrong types only surface at runtime
|
|
254
|
+
(little-sister ADR-0026). ``name`` / ``full_name`` are **raw**: Markdown escaping
|
|
255
|
+
is a render-time step (``plain``), and an escaped value can no longer be
|
|
256
|
+
interpolated into a URL.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
name: str
|
|
260
|
+
full_name: str
|
|
261
|
+
archived: bool = False
|
|
262
|
+
fork: bool = False
|
|
263
|
+
default_branch: str = ""
|
|
264
|
+
|
|
265
|
+
@classmethod
|
|
266
|
+
def from_api(cls, row: object) -> Repo:
|
|
267
|
+
"""Read one ``/repos`` row. ``name`` and ``full_name`` are **structural** —
|
|
268
|
+
every aspect addresses the repo by them — so their absence means the payload
|
|
269
|
+
is not what we think and is raised, not defaulted. The rest are display or
|
|
270
|
+
filter fields and default quietly."""
|
|
271
|
+
try:
|
|
272
|
+
return cls(
|
|
273
|
+
name=values.text(row, "name", required=True, where="repository"),
|
|
274
|
+
full_name=values.text(row, "full_name", required=True,
|
|
275
|
+
where="repository"),
|
|
276
|
+
archived=values.flag(row, "archived"),
|
|
277
|
+
fork=values.flag(row, "fork"),
|
|
278
|
+
default_branch=values.text(row, "default_branch"))
|
|
279
|
+
except CheckError as error:
|
|
280
|
+
raise GitHubError(f"unexpected repository payload: {error}") from error
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class GitHubError(Exception):
|
|
284
|
+
"""A GitHub API request failed (``status`` is the HTTP code, when known)."""
|
|
285
|
+
|
|
286
|
+
def __init__(self, message: str, *, status: int | None = None) -> None:
|
|
287
|
+
super().__init__(message)
|
|
288
|
+
self.status = status
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _next_link(link_header: str) -> str | None:
|
|
292
|
+
"""The ``rel="next"`` URL from a GitHub ``Link`` header, if present."""
|
|
293
|
+
for part in link_header.split(","):
|
|
294
|
+
segments = part.split(";")
|
|
295
|
+
if len(segments) < 2:
|
|
296
|
+
continue
|
|
297
|
+
url = segments[0].strip().lstrip("<").rstrip(">")
|
|
298
|
+
if any(seg.strip() == 'rel="next"' for seg in segments[1:]):
|
|
299
|
+
return url
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class GitHubClient:
|
|
304
|
+
"""A minimal GitHub REST client over stdlib ``urllib`` with Link pagination."""
|
|
305
|
+
|
|
306
|
+
def __init__(self, token: str, *, api_url: str = GITHUB_API,
|
|
307
|
+
timeout: float = 30.0) -> None:
|
|
308
|
+
self._token = token
|
|
309
|
+
self._api = api_url.rstrip("/")
|
|
310
|
+
self._timeout = timeout
|
|
311
|
+
|
|
312
|
+
def _request(self, path_or_url: str,
|
|
313
|
+
params: dict[str, Any] | None = None) -> tuple[Any, str]:
|
|
314
|
+
url = (path_or_url if path_or_url.startswith("http")
|
|
315
|
+
else f"{self._api}{path_or_url}")
|
|
316
|
+
if params:
|
|
317
|
+
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
318
|
+
request = urllib.request.Request(url, method="GET")
|
|
319
|
+
request.add_header("Authorization", f"Bearer {self._token}")
|
|
320
|
+
request.add_header("Accept", "application/vnd.github+json")
|
|
321
|
+
request.add_header("X-GitHub-Api-Version", "2022-11-28")
|
|
322
|
+
try:
|
|
323
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
324
|
+
body = response.read().decode("utf-8")
|
|
325
|
+
link = response.headers.get("Link", "")
|
|
326
|
+
except urllib.error.HTTPError as error:
|
|
327
|
+
detail = error.read().decode("utf-8", "replace")[:200]
|
|
328
|
+
raise GitHubError(f"HTTP {error.code} for {url}: {detail}",
|
|
329
|
+
status=error.code) from error
|
|
330
|
+
except Exception as error: # any transport failure
|
|
331
|
+
raise GitHubError(f"request failed for {url}: {error}") from error
|
|
332
|
+
data = json.loads(body) if body else None
|
|
333
|
+
return data, link
|
|
334
|
+
|
|
335
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
336
|
+
data, _ = self._request(path, params)
|
|
337
|
+
return data
|
|
338
|
+
|
|
339
|
+
def get_paginated(self, path: str,
|
|
340
|
+
params: dict[str, Any] | None = None) -> list[Any]:
|
|
341
|
+
merged = dict(params or {})
|
|
342
|
+
merged.setdefault("per_page", 100)
|
|
343
|
+
items: list[Any] = []
|
|
344
|
+
data, link = self._request(path, merged)
|
|
345
|
+
while True:
|
|
346
|
+
if not isinstance(data, list):
|
|
347
|
+
raise GitHubError(f"expected a list from {path}")
|
|
348
|
+
items.extend(data)
|
|
349
|
+
nxt = _next_link(link)
|
|
350
|
+
if not nxt:
|
|
351
|
+
return items
|
|
352
|
+
data, link = self._request(nxt)
|
|
353
|
+
|
|
354
|
+
def rate_limit(self) -> tuple[int, int, int]:
|
|
355
|
+
core = self.get("/rate_limit")["resources"]["core"]
|
|
356
|
+
return int(core["limit"]), int(core["remaining"]), int(core["reset"])
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@register("github")
|
|
360
|
+
class GitHubCheck(Check):
|
|
361
|
+
"""Discover a team's repositories and report one child per aspect.
|
|
362
|
+
|
|
363
|
+
Aspect-first, like the dashboard this was ported from: the check's node
|
|
364
|
+
(``path``) is a container with one child per aspect. Flat aspects list the
|
|
365
|
+
repositories they flag; severity-carrying aspects contain band leaves.
|
|
366
|
+
Discovery is team-scoped and filtered by ``name_prefix`` /
|
|
367
|
+
``include_archived`` / ``include_forks``.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
#: Aspects this check runs. Used for the rate estimate.
|
|
371
|
+
ASPECTS = ("pull_requests", "security_advisories", "code_scanning_alerts",
|
|
372
|
+
"secret_scanning_alerts", "sbom_check", "actions", "issues")
|
|
373
|
+
|
|
374
|
+
def __init__(self, *, org: str, team: str = "", name_prefix: str = "",
|
|
375
|
+
include_archived: bool = False, include_forks: bool = True,
|
|
376
|
+
api_url: str = GITHUB_API, rate_limit_safety_factor: int = 4,
|
|
377
|
+
expect_min_repos: int = 1,
|
|
378
|
+
pr_ignore_prefixes: tuple[str, ...] = (),
|
|
379
|
+
dependabot_severities: tuple[str, ...] = ("critical", "high"),
|
|
380
|
+
advisory_severity_map: dict[str, StatusCode] | None = None,
|
|
381
|
+
code_scanning_severity_map: dict[str, StatusCode] | None = None,
|
|
382
|
+
secret_scanning_require_enabled: bool = True,
|
|
383
|
+
sbom_ignore: tuple[str, ...] = (),
|
|
384
|
+
actions_ignore_patterns: tuple[re.Pattern[str], ...] = (),
|
|
385
|
+
actions_all_branches: bool = False,
|
|
386
|
+
actions_show_healthy: bool = False,
|
|
387
|
+
issues_ignore: tuple[str, ...] = (),
|
|
388
|
+
subnodes: dict[str, dict[str, str]] | None = None,
|
|
389
|
+
token_ref: str, **kwargs: Any) -> None:
|
|
390
|
+
super().__init__(**kwargs)
|
|
391
|
+
# The API token, resolved **once here** from the reference the config
|
|
392
|
+
# names in its `secrets:` block — `env://GITHUB_TOKEN`, or an
|
|
393
|
+
# `aws-sm://…` address (little-sister ADR-0023) — never re-read
|
|
394
|
+
# during a run. An unresolvable reference leaves this empty and records
|
|
395
|
+
# the failure, and the engine pins this check to a visible ERROR without
|
|
396
|
+
# ever calling run(); a malformed one already raised a CheckError.
|
|
397
|
+
self.token = self.resolve_secret(token_ref)
|
|
398
|
+
self.org = org
|
|
399
|
+
self.team = team
|
|
400
|
+
self.name_prefix = name_prefix
|
|
401
|
+
self.include_archived = include_archived
|
|
402
|
+
self.include_forks = include_forks
|
|
403
|
+
self.api_url = api_url
|
|
404
|
+
self.rate_limit_safety_factor = rate_limit_safety_factor
|
|
405
|
+
self.expect_min_repos = _positive_int(expect_min_repos, "expect_min_repos")
|
|
406
|
+
self.pr_ignore_prefixes = pr_ignore_prefixes
|
|
407
|
+
self.dependabot_severities = dependabot_severities
|
|
408
|
+
self.advisory_severity_map = {
|
|
409
|
+
**DEFAULT_ADVISORY_SEVERITY_MAP,
|
|
410
|
+
**(advisory_severity_map or {}),
|
|
411
|
+
}
|
|
412
|
+
self.code_scanning_severity_map = {
|
|
413
|
+
**DEFAULT_CODE_SCANNING_SEVERITY_MAP,
|
|
414
|
+
**(code_scanning_severity_map or {}),
|
|
415
|
+
}
|
|
416
|
+
self.secret_scanning_require_enabled = secret_scanning_require_enabled
|
|
417
|
+
self.sbom_ignore = sbom_ignore
|
|
418
|
+
self.actions_ignore_patterns = actions_ignore_patterns
|
|
419
|
+
self.actions_all_branches = actions_all_branches
|
|
420
|
+
self.actions_show_healthy = actions_show_healthy
|
|
421
|
+
self.issues_ignore = issues_ignore
|
|
422
|
+
# Per-aspect display text (title/about) declared in this check's own config
|
|
423
|
+
# (`subnodes:`), carried onto each aspect child (little-sister
|
|
424
|
+
# ADR-0025). nodes.yaml still overrides per path.
|
|
425
|
+
self.subnodes = subnodes or {}
|
|
426
|
+
|
|
427
|
+
@classmethod
|
|
428
|
+
def _extra_from_config(cls, config: dict[str, Any],
|
|
429
|
+
base_dir: Path) -> dict[str, Any]:
|
|
430
|
+
org = config.get("org")
|
|
431
|
+
if not org:
|
|
432
|
+
raise CheckError("github check requires an 'org'")
|
|
433
|
+
pull_requests = config.get("pull_requests") or {}
|
|
434
|
+
if not isinstance(pull_requests, dict):
|
|
435
|
+
raise CheckError("github 'pull_requests' must be a mapping")
|
|
436
|
+
ignore = pull_requests.get("ignore_title_prefixes") or []
|
|
437
|
+
if not isinstance(ignore, list):
|
|
438
|
+
raise CheckError("pull_requests.ignore_title_prefixes must be a list")
|
|
439
|
+
security = config.get("security_advisories") or {}
|
|
440
|
+
if not isinstance(security, dict):
|
|
441
|
+
raise CheckError("github 'security_advisories' must be a mapping")
|
|
442
|
+
severities = security.get("severities")
|
|
443
|
+
if severities is None:
|
|
444
|
+
severities = ["critical", "high"]
|
|
445
|
+
if not isinstance(severities, list):
|
|
446
|
+
raise CheckError("security_advisories.severities must be a list")
|
|
447
|
+
advisory_severity_map = _severity_map(
|
|
448
|
+
security.get("severity_map"), "security_advisories")
|
|
449
|
+
code_scanning = config.get("code_scanning_alerts") or {}
|
|
450
|
+
if not isinstance(code_scanning, dict):
|
|
451
|
+
raise CheckError("github 'code_scanning_alerts' must be a mapping")
|
|
452
|
+
code_scanning_severity_map = _severity_map(
|
|
453
|
+
code_scanning.get("severity_map"), "code_scanning_alerts")
|
|
454
|
+
secret_scanning = config.get("secret_scanning") or {}
|
|
455
|
+
sbom = config.get("sbom_check") or {}
|
|
456
|
+
sbom_ignore = sbom.get("ignore") or []
|
|
457
|
+
if not isinstance(sbom_ignore, list):
|
|
458
|
+
raise CheckError("sbom_check.ignore must be a list")
|
|
459
|
+
actions = config.get("actions") or {}
|
|
460
|
+
patterns = actions.get("ignore_workflow_name_patterns") or []
|
|
461
|
+
if not isinstance(patterns, list):
|
|
462
|
+
raise CheckError(
|
|
463
|
+
"actions.ignore_workflow_name_patterns must be a list")
|
|
464
|
+
try:
|
|
465
|
+
action_patterns = tuple(
|
|
466
|
+
re.compile(str(p), re.IGNORECASE) for p in patterns)
|
|
467
|
+
except re.error as error:
|
|
468
|
+
raise CheckError(
|
|
469
|
+
f"actions.ignore_workflow_name_patterns: {error}") from error
|
|
470
|
+
issues = config.get("issues") or {}
|
|
471
|
+
issues_ignore = issues.get("ignore") or []
|
|
472
|
+
if not isinstance(issues_ignore, list):
|
|
473
|
+
raise CheckError("issues.ignore must be a list")
|
|
474
|
+
return {
|
|
475
|
+
"org": str(org),
|
|
476
|
+
"team": str(config.get("team", "")),
|
|
477
|
+
"name_prefix": str(config.get("name_prefix", "")),
|
|
478
|
+
"include_archived": bool(config.get("include_archived", False)),
|
|
479
|
+
"include_forks": bool(config.get("include_forks", True)),
|
|
480
|
+
"api_url": str(config.get("api_url", GITHUB_API)),
|
|
481
|
+
"rate_limit_safety_factor": int(
|
|
482
|
+
config.get("rate_limit_safety_factor", 4)),
|
|
483
|
+
"expect_min_repos": _positive_int(
|
|
484
|
+
config.get("expect_min_repos", 1), "expect_min_repos"),
|
|
485
|
+
"pr_ignore_prefixes": tuple(str(p) for p in ignore),
|
|
486
|
+
"dependabot_severities": tuple(str(s).lower() for s in severities),
|
|
487
|
+
"advisory_severity_map": advisory_severity_map,
|
|
488
|
+
"code_scanning_severity_map": code_scanning_severity_map,
|
|
489
|
+
"secret_scanning_require_enabled": bool(
|
|
490
|
+
secret_scanning.get("require_enabled", True)),
|
|
491
|
+
"sbom_ignore": tuple(str(r) for r in sbom_ignore),
|
|
492
|
+
"actions_ignore_patterns": action_patterns,
|
|
493
|
+
"actions_all_branches": bool(actions.get("all_branches", False)),
|
|
494
|
+
"actions_show_healthy": bool(actions.get("show_healthy", False)),
|
|
495
|
+
"issues_ignore": tuple(str(r) for r in issues_ignore),
|
|
496
|
+
"subnodes": parse_subnodes(config),
|
|
497
|
+
# `secrets: {token: …}` — required, so two checks of this type can
|
|
498
|
+
# each carry their own team's credential (little-sister ADR-0023).
|
|
499
|
+
"token_ref": parse_secret_refs(config, "token")["token"],
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
def config_summary(self) -> str:
|
|
503
|
+
scope = f"{self.org}/{self.team}" if self.team else self.org
|
|
504
|
+
return config_markdown({
|
|
505
|
+
"scope": scope,
|
|
506
|
+
"name prefix": self.name_prefix or None,
|
|
507
|
+
"include archived": "yes" if self.include_archived else "no",
|
|
508
|
+
"expected repositories": str(self.expect_min_repos),
|
|
509
|
+
"show healthy Actions": "yes" if self.actions_show_healthy else "no",
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
def _subnode_tokens(self) -> dict[str, str]:
|
|
513
|
+
"""Values a `subnodes:` `about` may reference as `{token}` — this check's
|
|
514
|
+
own `org` / `team`, and the shared `{pin_note}` sentence."""
|
|
515
|
+
return {"org": self.org, "team": self.team, "pin_note": PIN_NOTE}
|
|
516
|
+
|
|
517
|
+
def _meta(self, name: str) -> tuple[str, str]:
|
|
518
|
+
"""The (title, about) for aspect `name`: this check type's built-in
|
|
519
|
+
`SUBNODES` text, which the config's `subnodes:` block replaces — or extends,
|
|
520
|
+
where it writes `{default}` into its own text (little-sister ADR-0025).
|
|
521
|
+
Tokens expand in either case."""
|
|
522
|
+
configured = self.subnodes.get(name, {})
|
|
523
|
+
default = SUBNODES.get(name, {})
|
|
524
|
+
tokens = self._subnode_tokens()
|
|
525
|
+
return (resolve_text(configured.get("title", ""),
|
|
526
|
+
default.get("title", ""), tokens),
|
|
527
|
+
resolve_text(configured.get("about", ""),
|
|
528
|
+
default.get("about", ""), tokens))
|
|
529
|
+
|
|
530
|
+
# --- helpers -------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
def _make_client(self, token: str) -> GitHubClient:
|
|
533
|
+
"""Build the API client. Overridden in tests to avoid live calls."""
|
|
534
|
+
return GitHubClient(token, api_url=self.api_url,
|
|
535
|
+
timeout=self.timeout_seconds)
|
|
536
|
+
|
|
537
|
+
def _discover(self, client: GitHubClient) -> list[Repo]:
|
|
538
|
+
"""The in-scope repositories (team-scoped when ``team`` is set), as typed
|
|
539
|
+
:class:`Repo` values — this is the seam where the API's ``Any`` stops."""
|
|
540
|
+
if self.team:
|
|
541
|
+
teams = client.get_paginated(f"/orgs/{self.org}/teams")
|
|
542
|
+
match = next((t for t in teams
|
|
543
|
+
if self.team in (values.text(t, "slug"),
|
|
544
|
+
values.text(t, "name"))), None)
|
|
545
|
+
if match is None:
|
|
546
|
+
raise GitHubError(
|
|
547
|
+
f"team {self.team!r} not found in org {self.org!r}")
|
|
548
|
+
# NOT `slug`: that name holds the imported slug builder, and rebinding
|
|
549
|
+
# it here would shadow the function for the rest of this scope — a trap
|
|
550
|
+
# for the next aspect that needs it.
|
|
551
|
+
team_slug = values.text(match, "slug", required=True, where="team")
|
|
552
|
+
repos = client.get_paginated(
|
|
553
|
+
f"/orgs/{self.org}/teams/{team_slug}/repos")
|
|
554
|
+
else:
|
|
555
|
+
repos = client.get_paginated(f"/orgs/{self.org}/repos")
|
|
556
|
+
kept: list[Repo] = []
|
|
557
|
+
for row in repos:
|
|
558
|
+
repo = Repo.from_api(row)
|
|
559
|
+
if repo.archived and not self.include_archived:
|
|
560
|
+
continue
|
|
561
|
+
if repo.fork and not self.include_forks:
|
|
562
|
+
continue
|
|
563
|
+
if self.name_prefix and not repo.name.startswith(self.name_prefix):
|
|
564
|
+
continue
|
|
565
|
+
kept.append(repo)
|
|
566
|
+
return kept
|
|
567
|
+
|
|
568
|
+
# --- aspects -------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def _pull_requests(self, client: GitHubClient,
|
|
571
|
+
repos: list[Repo]) -> CheckResult:
|
|
572
|
+
"""WARN if any repo has an open pull request (excluding ignored titles)."""
|
|
573
|
+
description = "Open pull requests awaiting attention"
|
|
574
|
+
title, about = self._meta("pull_requests")
|
|
575
|
+
try:
|
|
576
|
+
entries: list[tuple[str, str]] = []
|
|
577
|
+
for repo in repos:
|
|
578
|
+
prs = client.get_paginated(
|
|
579
|
+
f"/repos/{repo.full_name}/pulls", {"state": "open"})
|
|
580
|
+
for pull in prs:
|
|
581
|
+
# NOT `title`: that name holds this leaf's display label, and
|
|
582
|
+
# rebinding it here handed the leaf the last PR's subject.
|
|
583
|
+
subject = values.text(pull, "title")
|
|
584
|
+
if any(subject.upper().startswith(prefix.upper())
|
|
585
|
+
for prefix in self.pr_ignore_prefixes):
|
|
586
|
+
continue
|
|
587
|
+
user = values.text(pull, "user", "login", default="?")
|
|
588
|
+
url = values.text(pull, "html_url")
|
|
589
|
+
number = values.number(pull, "number")
|
|
590
|
+
name = plain(repo.name)
|
|
591
|
+
entries.append((
|
|
592
|
+
_entry_slug(repo, "pr", number, url),
|
|
593
|
+
f"{_link(f'{name}: {plain(subject)}', url)} "
|
|
594
|
+
f"[{plain(user)}]"))
|
|
595
|
+
except GitHubError as error:
|
|
596
|
+
# Prose, not members: one condition (the aspect could not run) rather
|
|
597
|
+
# than a list of independently pinnable findings — so it stays a plain
|
|
598
|
+
# string and the node pin remains its unit of suppression
|
|
599
|
+
# (little-sister ADR-0036).
|
|
600
|
+
return CheckResult(
|
|
601
|
+
StatusCode.ERROR,
|
|
602
|
+
[f"failed to check pull requests: {plain(str(error))}"],
|
|
603
|
+
name="pull_requests", description=description,
|
|
604
|
+
title=title, about=about)
|
|
605
|
+
code = StatusCode.WARN if entries else StatusCode.OK
|
|
606
|
+
return CheckResult(code, entries, name="pull_requests",
|
|
607
|
+
description=description, title=title, about=about)
|
|
608
|
+
|
|
609
|
+
def _collect(self, client: GitHubClient, repos: list[Repo],
|
|
610
|
+
suffix: str) -> tuple[
|
|
611
|
+
list[tuple[Repo, list[Any]]],
|
|
612
|
+
list[tuple[str, str]], list[Repo]]:
|
|
613
|
+
"""Fetch an open-alerts list per repo. A **404** means the feature is not
|
|
614
|
+
enabled for that repo; those repos are returned separately (as
|
|
615
|
+
``not_enabled``) so a caller can skip them quietly *or* flag them. Any
|
|
616
|
+
other failure is surfaced as an error note, so a token/scope problem
|
|
617
|
+
never reads as 'no alerts'.
|
|
618
|
+
|
|
619
|
+
The error notes are keyed like the findings: "this repository could not be
|
|
620
|
+
read" is a condition somebody may well be working on (a missing scope, an
|
|
621
|
+
archived repo), and it should be pinnable without silencing the alerts that
|
|
622
|
+
*did* come back."""
|
|
623
|
+
results: list[tuple[Repo, list[Any]]] = []
|
|
624
|
+
errors: list[tuple[str, str]] = []
|
|
625
|
+
not_enabled: list[Repo] = []
|
|
626
|
+
for repo in repos:
|
|
627
|
+
try:
|
|
628
|
+
alerts = client.get_paginated(
|
|
629
|
+
f"/repos/{repo.full_name}{suffix}", {"state": "open"})
|
|
630
|
+
except GitHubError as error:
|
|
631
|
+
if error.status == 404:
|
|
632
|
+
not_enabled.append(repo)
|
|
633
|
+
continue
|
|
634
|
+
errors.append((_entry_slug(repo, "unreadable"),
|
|
635
|
+
f"{plain(repo.name)}: could not read "
|
|
636
|
+
f"({plain(str(error))})"))
|
|
637
|
+
continue
|
|
638
|
+
results.append((repo, alerts))
|
|
639
|
+
return results, errors, not_enabled
|
|
640
|
+
|
|
641
|
+
def _finalize(self, name: str, description: str,
|
|
642
|
+
entries: list[tuple[str, str]], base_code: StatusCode,
|
|
643
|
+
errors: list[tuple[str, str]]) -> CheckResult:
|
|
644
|
+
"""One aspect leaf from its findings and its read failures.
|
|
645
|
+
|
|
646
|
+
The reason is handed over as ``(slug, text)`` pairs, which declares the
|
|
647
|
+
lines **members** (little-sister ADR-0036): each is an independent
|
|
648
|
+
finding, so an operator
|
|
649
|
+
who opens a ticket for one can pin that line and leave the rest of the
|
|
650
|
+
aspect reporting."""
|
|
651
|
+
reason = [*entries, *errors]
|
|
652
|
+
code = base_code
|
|
653
|
+
if errors and code is StatusCode.OK:
|
|
654
|
+
code = StatusCode.WARN
|
|
655
|
+
title, about = self._meta(name)
|
|
656
|
+
return CheckResult(code, reason, name=name, description=description,
|
|
657
|
+
title=title, about=about)
|
|
658
|
+
|
|
659
|
+
def _severity_bands(
|
|
660
|
+
self, name: str, description: str,
|
|
661
|
+
groups: dict[str, list[tuple[str, str]]],
|
|
662
|
+
severity_map: dict[str, StatusCode],
|
|
663
|
+
errors: list[tuple[str, str]],
|
|
664
|
+
*, order: tuple[str, ...] = SECURITY_SEVERITY_ORDER,
|
|
665
|
+
) -> CheckResult:
|
|
666
|
+
"""One aspect branch with one uncoded leaf per source-severity band.
|
|
667
|
+
|
|
668
|
+
The grouping carries severity onto the node rather than burying it in the
|
|
669
|
+
reason text. Empty configured bands still render as OK, making it visible that
|
|
670
|
+
they were watched. Read failures stay on the aspect container at WARN:
|
|
671
|
+
they have no honest source severity and must not be smuggled into one.
|
|
672
|
+
"""
|
|
673
|
+
seen = set(order)
|
|
674
|
+
band_order = [*order,
|
|
675
|
+
*(severity for severity in severity_map
|
|
676
|
+
if severity not in seen),
|
|
677
|
+
*(severity for severity in groups
|
|
678
|
+
if severity not in seen and severity not in severity_map)]
|
|
679
|
+
children: list[CheckResult] = []
|
|
680
|
+
for severity in band_order:
|
|
681
|
+
if severity not in severity_map and severity not in groups:
|
|
682
|
+
continue
|
|
683
|
+
entries = groups.get(severity, [])
|
|
684
|
+
code = (severity_map.get(severity, StatusCode.WARN) if entries
|
|
685
|
+
else StatusCode.OK)
|
|
686
|
+
children.append(CheckResult(
|
|
687
|
+
code, entries, name=severity,
|
|
688
|
+
description=f"{severity.capitalize()} {description}",
|
|
689
|
+
title=severity.capitalize()))
|
|
690
|
+
title, about = self._meta(name)
|
|
691
|
+
return CheckResult(
|
|
692
|
+
StatusCode.WARN if errors else StatusCode.OK,
|
|
693
|
+
errors,
|
|
694
|
+
name=name,
|
|
695
|
+
description=description,
|
|
696
|
+
children=tuple(children),
|
|
697
|
+
title=title,
|
|
698
|
+
about=about,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
def _security_advisories(self, client: GitHubClient,
|
|
702
|
+
repos: list[Repo]) -> CheckResult:
|
|
703
|
+
"""Open Dependabot alerts, grouped into configured severity bands."""
|
|
704
|
+
results, errors, _ = self._collect(client, repos, "/dependabot/alerts")
|
|
705
|
+
groups: dict[str, list[tuple[str, str]]] = {}
|
|
706
|
+
for repo, alerts in results:
|
|
707
|
+
for alert in alerts:
|
|
708
|
+
severity = values.text(alert, "security_advisory", "severity",
|
|
709
|
+
default="unknown").lower()
|
|
710
|
+
if severity not in self.dependabot_severities:
|
|
711
|
+
continue
|
|
712
|
+
summary = values.text(alert, "security_advisory", "summary")
|
|
713
|
+
url = values.text(alert, "html_url")
|
|
714
|
+
number = values.number(alert, "number")
|
|
715
|
+
name = plain(repo.name)
|
|
716
|
+
groups.setdefault(severity, []).append((
|
|
717
|
+
_entry_slug(repo, "advisory", number, url),
|
|
718
|
+
_link(f"{name}: {plain(summary)}", url)))
|
|
719
|
+
severity_map = {
|
|
720
|
+
severity: self.advisory_severity_map.get(severity, StatusCode.WARN)
|
|
721
|
+
for severity in self.dependabot_severities
|
|
722
|
+
}
|
|
723
|
+
return self._severity_bands(
|
|
724
|
+
"security_advisories", "Dependabot advisories", groups,
|
|
725
|
+
severity_map, errors, order=self.dependabot_severities)
|
|
726
|
+
|
|
727
|
+
def _code_scanning_alerts(self, client: GitHubClient,
|
|
728
|
+
repos: list[Repo]) -> CheckResult:
|
|
729
|
+
"""Open code-scanning alerts, grouped into source-severity bands."""
|
|
730
|
+
results, errors, _ = self._collect(client, repos, "/code-scanning/alerts")
|
|
731
|
+
groups: dict[str, list[tuple[str, str]]] = {}
|
|
732
|
+
for repo, alerts in results:
|
|
733
|
+
for alert in alerts:
|
|
734
|
+
severity = values.text(alert, "rule", "security_severity_level",
|
|
735
|
+
default="none").lower()
|
|
736
|
+
detail = (values.text(alert, "rule", "description")
|
|
737
|
+
or values.text(alert, "rule", "id")
|
|
738
|
+
or "alert")
|
|
739
|
+
url = values.text(alert, "html_url")
|
|
740
|
+
number = values.number(alert, "number")
|
|
741
|
+
name = plain(repo.name)
|
|
742
|
+
groups.setdefault(severity, []).append((
|
|
743
|
+
_entry_slug(repo, "codescan", number, url),
|
|
744
|
+
_link(f"{name}: {plain(detail)}", url)))
|
|
745
|
+
return self._severity_bands(
|
|
746
|
+
"code_scanning_alerts", "code-scanning alerts", groups,
|
|
747
|
+
self.code_scanning_severity_map, errors)
|
|
748
|
+
|
|
749
|
+
def _secret_scanning_alerts(self, client: GitHubClient,
|
|
750
|
+
repos: list[Repo]) -> CheckResult:
|
|
751
|
+
"""Any open secret-scanning alert → ERROR. Unless
|
|
752
|
+
``secret_scanning.require_enabled`` is false, a repo with secret scanning
|
|
753
|
+
**not enabled** is flagged too (also → ERROR): the alerts endpoint 404s
|
|
754
|
+
when scanning is disabled for the repo, which would otherwise read as
|
|
755
|
+
'no alerts'."""
|
|
756
|
+
results, errors, not_enabled = self._collect(
|
|
757
|
+
client, repos, "/secret-scanning/alerts")
|
|
758
|
+
entries: list[tuple[str, str]] = []
|
|
759
|
+
for repo, alerts in results:
|
|
760
|
+
for alert in alerts:
|
|
761
|
+
secret = (values.text(alert, "secret_type_display_name")
|
|
762
|
+
or values.text(alert, "secret_type")
|
|
763
|
+
or "secret")
|
|
764
|
+
created = values.text(alert, "created_at")
|
|
765
|
+
url = values.text(alert, "html_url")
|
|
766
|
+
number = values.number(alert, "number")
|
|
767
|
+
name = plain(repo.name)
|
|
768
|
+
entries.append((
|
|
769
|
+
_entry_slug(repo, "secret", number, url),
|
|
770
|
+
_link(f"{name}: {plain(secret)} detected {plain(created)}",
|
|
771
|
+
url)))
|
|
772
|
+
if self.secret_scanning_require_enabled:
|
|
773
|
+
for repo in not_enabled:
|
|
774
|
+
name = plain(repo.name)
|
|
775
|
+
settings = (f"https://github.com/{repo.full_name}"
|
|
776
|
+
"/settings/security_analysis")
|
|
777
|
+
# A distinct kind, not `secret`: "scanning is off" is a different
|
|
778
|
+
# condition from "an alert fired", and pinning the one must not
|
|
779
|
+
# need the other's number.
|
|
780
|
+
entries.append((
|
|
781
|
+
_entry_slug(repo, "secret-scanning-off"),
|
|
782
|
+
_link(f"{name}: secret scanning not enabled", settings)))
|
|
783
|
+
code = StatusCode.ERROR if entries else StatusCode.OK
|
|
784
|
+
return self._finalize(
|
|
785
|
+
"secret_scanning_alerts",
|
|
786
|
+
"Open secret-scanning alerts (and repos with it disabled)",
|
|
787
|
+
entries, code, errors)
|
|
788
|
+
|
|
789
|
+
def _sbom_check(self, client: GitHubClient,
|
|
790
|
+
repos: list[Repo]) -> CheckResult:
|
|
791
|
+
"""A repo with code but no dependency graph (SBOM) → ERROR. Repos in
|
|
792
|
+
``sbom_ignore`` are skipped; a 404 counts as missing, a permission error
|
|
793
|
+
is surfaced."""
|
|
794
|
+
entries: list[tuple[str, str]] = []
|
|
795
|
+
errors: list[tuple[str, str]] = []
|
|
796
|
+
for repo in repos:
|
|
797
|
+
if repo.name in self.sbom_ignore:
|
|
798
|
+
continue
|
|
799
|
+
name = plain(repo.name)
|
|
800
|
+
network = f"https://github.com/{repo.full_name}/network/dependencies"
|
|
801
|
+
# At most one line per repository, so the repo and the aspect are the
|
|
802
|
+
# whole identity — no number to hang it on and none needed.
|
|
803
|
+
missing = (_entry_slug(repo, "sbom"),
|
|
804
|
+
_link(f"{name}: missing SBOM", network))
|
|
805
|
+
try:
|
|
806
|
+
sbom = client.get(
|
|
807
|
+
f"/repos/{repo.full_name}/dependency-graph/sbom")
|
|
808
|
+
except GitHubError as error:
|
|
809
|
+
if error.status == 404:
|
|
810
|
+
entries.append(missing)
|
|
811
|
+
continue
|
|
812
|
+
errors.append((_entry_slug(repo, "unreadable"),
|
|
813
|
+
f"{name}: could not read ({plain(str(error))})"))
|
|
814
|
+
continue
|
|
815
|
+
if not values.rows(sbom, "sbom", "relationships", where="sbom"):
|
|
816
|
+
entries.append(missing)
|
|
817
|
+
code = StatusCode.ERROR if entries else StatusCode.OK
|
|
818
|
+
return self._finalize("sbom_check",
|
|
819
|
+
"Repositories missing an SBOM (dependency graph)",
|
|
820
|
+
entries, code, errors)
|
|
821
|
+
|
|
822
|
+
#: Workflow-run conclusions that count as a failure.
|
|
823
|
+
_ACTIONS_FAIL = ("failure", "timed_out", "startup_failure")
|
|
824
|
+
#: Empty-conclusion states that positively mean work is in flight. Unknown
|
|
825
|
+
#: states do not default to running (little-sister ADR-0032 rule 7).
|
|
826
|
+
_ACTIONS_RUNNING = ("queued", "in_progress", "pending", "requested")
|
|
827
|
+
|
|
828
|
+
@classmethod
|
|
829
|
+
def _action_verdict(cls, run: object) -> tuple[StatusCode, str] | None:
|
|
830
|
+
"""The completed verdict a run contributes, or none when it contributes
|
|
831
|
+
only an in-flight/neutral fact. Cancelled and skipped runs deliberately do
|
|
832
|
+
not erase the last useful verdict beneath them."""
|
|
833
|
+
status = values.text(run, "status").lower()
|
|
834
|
+
conclusion = values.text(run, "conclusion").lower()
|
|
835
|
+
if conclusion in cls._ACTIONS_FAIL:
|
|
836
|
+
return StatusCode.ERROR, "failed"
|
|
837
|
+
if status == "waiting" or conclusion == "action_required":
|
|
838
|
+
return StatusCode.WARN, "waiting"
|
|
839
|
+
if conclusion == "success":
|
|
840
|
+
return StatusCode.OK, "passed"
|
|
841
|
+
return None
|
|
842
|
+
|
|
843
|
+
@staticmethod
|
|
844
|
+
def _action_text(repo: Repo, workflow: str, branch: str,
|
|
845
|
+
completed: object | None, running: object | None,
|
|
846
|
+
verdict: tuple[StatusCode, str] | None) -> str:
|
|
847
|
+
"""One workflow line carrying its last verdict and current run together."""
|
|
848
|
+
where = (f"{plain(repo.name)} ({plain(branch)}) / "
|
|
849
|
+
f"{plain(workflow)}")
|
|
850
|
+
completed_url = (values.text(completed, "html_url")
|
|
851
|
+
if completed is not None else "")
|
|
852
|
+
running_url = (values.text(running, "html_url")
|
|
853
|
+
if running is not None else "")
|
|
854
|
+
run_number = (values.number(completed, "run_number")
|
|
855
|
+
if completed is not None else 0)
|
|
856
|
+
if verdict is None:
|
|
857
|
+
text = f"{_link(where, running_url)}: no completed run"
|
|
858
|
+
else:
|
|
859
|
+
_code, word = verdict
|
|
860
|
+
number = f" (#{run_number})" if run_number else ""
|
|
861
|
+
text = f"{_link(where, completed_url)}: {word}{number}"
|
|
862
|
+
if running is not None:
|
|
863
|
+
running_number = values.number(running, "run_number")
|
|
864
|
+
label = f"#{running_number} running" if running_number else "running"
|
|
865
|
+
text += f" · {_link(label, running_url)}"
|
|
866
|
+
return text
|
|
867
|
+
|
|
868
|
+
def _actions(self, client: GitHubClient,
|
|
869
|
+
repos: list[Repo]) -> CheckResult:
|
|
870
|
+
"""One coded entry per workflow/branch that has something to say.
|
|
871
|
+
|
|
872
|
+
The entry code is the newest useful completed verdict. A newer in-flight
|
|
873
|
+
run is an additional flag and words on that same stable entry, so a retry
|
|
874
|
+
cannot hide the failure it is trying to fix. Healthy idle workflows are
|
|
875
|
+
optional; a run in flight is always emitted. Only runs of a currently
|
|
876
|
+
existing workflow count.
|
|
877
|
+
"""
|
|
878
|
+
problem_entries: list[Entry] = []
|
|
879
|
+
running_entries: list[Entry] = []
|
|
880
|
+
healthy_entries: list[Entry] = []
|
|
881
|
+
for repo in repos:
|
|
882
|
+
name = plain(repo.name)
|
|
883
|
+
full = repo.full_name
|
|
884
|
+
try:
|
|
885
|
+
existing = self._existing_workflow_ids(client, full)
|
|
886
|
+
except GitHubError as error:
|
|
887
|
+
if error.status == 404:
|
|
888
|
+
continue # Actions not enabled
|
|
889
|
+
problem_entries.append(Entry(
|
|
890
|
+
_entry_slug(repo, "workflows-unreadable"),
|
|
891
|
+
f"{name}: could not read workflows ({plain(str(error))})",
|
|
892
|
+
code=StatusCode.WARN))
|
|
893
|
+
continue
|
|
894
|
+
params: dict[str, Any] = {"per_page": 100}
|
|
895
|
+
if not self.actions_all_branches and repo.default_branch:
|
|
896
|
+
params["branch"] = repo.default_branch
|
|
897
|
+
try:
|
|
898
|
+
data = client.get(f"/repos/{full}/actions/runs", params)
|
|
899
|
+
except GitHubError as error:
|
|
900
|
+
if error.status == 404:
|
|
901
|
+
continue # Actions not enabled
|
|
902
|
+
problem_entries.append(Entry(
|
|
903
|
+
_entry_slug(repo, "runs-unreadable"),
|
|
904
|
+
f"{name}: could not read ({plain(str(error))})",
|
|
905
|
+
code=StatusCode.WARN))
|
|
906
|
+
continue
|
|
907
|
+
# GitHub returns newest first. Keep the first in-flight run and the
|
|
908
|
+
# first useful completed verdict independently for each stable
|
|
909
|
+
# (workflow, branch) identity. The old one-set loop let the former
|
|
910
|
+
# claim the key and made the latter disappear.
|
|
911
|
+
states: dict[
|
|
912
|
+
tuple[int, str],
|
|
913
|
+
tuple[str, object | None, object | None,
|
|
914
|
+
tuple[StatusCode, str] | None],
|
|
915
|
+
] = {}
|
|
916
|
+
for run in values.rows(data, "workflow_runs"):
|
|
917
|
+
workflow_id = values.number(run, "workflow_id")
|
|
918
|
+
if workflow_id not in existing:
|
|
919
|
+
continue # run of a deleted workflow
|
|
920
|
+
workflow = (values.text(run, "name")
|
|
921
|
+
or str(workflow_id) or "workflow")
|
|
922
|
+
branch = values.text(run, "head_branch", default="?")
|
|
923
|
+
key = (workflow_id, branch)
|
|
924
|
+
if any(p.search(workflow) for p in self.actions_ignore_patterns):
|
|
925
|
+
continue
|
|
926
|
+
status = values.text(run, "status").lower()
|
|
927
|
+
current = states.get(key, (workflow, None, None, None))
|
|
928
|
+
current_workflow, completed, running, verdict = current
|
|
929
|
+
if running is None and status in self._ACTIONS_RUNNING:
|
|
930
|
+
running = run
|
|
931
|
+
run_verdict = self._action_verdict(run)
|
|
932
|
+
if completed is None and run_verdict is not None:
|
|
933
|
+
completed, verdict = run, run_verdict
|
|
934
|
+
states[key] = (current_workflow, completed, running, verdict)
|
|
935
|
+
|
|
936
|
+
for (workflow_id, branch), state in states.items():
|
|
937
|
+
workflow, completed, running, verdict = state
|
|
938
|
+
if verdict is None and running is None:
|
|
939
|
+
continue
|
|
940
|
+
entry_code = verdict[0] if verdict else StatusCode.UNDEFINED
|
|
941
|
+
if (entry_code is StatusCode.OK and running is None
|
|
942
|
+
and not self.actions_show_healthy):
|
|
943
|
+
continue
|
|
944
|
+
entry = Entry(
|
|
945
|
+
slug(repo.name, "workflow", workflow_id, branch),
|
|
946
|
+
self._action_text(
|
|
947
|
+
repo, workflow, branch, completed, running, verdict),
|
|
948
|
+
code=entry_code,
|
|
949
|
+
running=running is not None,
|
|
950
|
+
)
|
|
951
|
+
if entry_code in (StatusCode.ERROR, StatusCode.WARN):
|
|
952
|
+
problem_entries.append(entry)
|
|
953
|
+
elif running is not None:
|
|
954
|
+
running_entries.append(entry)
|
|
955
|
+
else:
|
|
956
|
+
healthy_entries.append(entry)
|
|
957
|
+
title, about = self._meta("actions")
|
|
958
|
+
return CheckResult(
|
|
959
|
+
reason=(*problem_entries, *running_entries, *healthy_entries),
|
|
960
|
+
name="actions",
|
|
961
|
+
description="Latest completed and in-flight workflow-run state",
|
|
962
|
+
title=title,
|
|
963
|
+
about=about,
|
|
964
|
+
entries=True,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
@staticmethod
|
|
968
|
+
def _existing_workflow_ids(client: GitHubClient, full_name: str) -> set[int]:
|
|
969
|
+
"""IDs of the repo's workflows that still exist (``state`` != ``deleted``),
|
|
970
|
+
so runs of a deleted workflow can be dropped."""
|
|
971
|
+
data = client.get(f"/repos/{full_name}/actions/workflows",
|
|
972
|
+
{"per_page": 100})
|
|
973
|
+
return {values.number(workflow, "id")
|
|
974
|
+
for workflow in values.rows(data, "workflows")
|
|
975
|
+
if values.text(workflow, "state") != "deleted"}
|
|
976
|
+
|
|
977
|
+
def _issues(self, client: GitHubClient, repos: list[Repo]) -> CheckResult:
|
|
978
|
+
"""An open issue → WARN, one line per issue. Repos listed under
|
|
979
|
+
``issues.ignore`` are skipped.
|
|
980
|
+
|
|
981
|
+
**Pull requests are dropped** (:func:`_is_pull_request`): the issues endpoint
|
|
982
|
+
returns them too, and counting them here would report every open PR twice —
|
|
983
|
+
once here and once under ``pull_requests``. A **404** means issues are turned
|
|
984
|
+
off for that repo, which is worth showing rather than reading as 'none'."""
|
|
985
|
+
entries: list[tuple[str, str]] = []
|
|
986
|
+
errors: list[tuple[str, str]] = []
|
|
987
|
+
for repo in repos:
|
|
988
|
+
if repo.name in self.issues_ignore:
|
|
989
|
+
continue
|
|
990
|
+
name = plain(repo.name)
|
|
991
|
+
issues_url = f"https://github.com/{repo.full_name}/issues"
|
|
992
|
+
try:
|
|
993
|
+
# paginated: the plain endpoint caps at GitHub's default page size,
|
|
994
|
+
# which would silently under-report a busy repository
|
|
995
|
+
rows = client.get_paginated(
|
|
996
|
+
f"/repos/{repo.full_name}/issues", {"state": "open"})
|
|
997
|
+
except GitHubError as error:
|
|
998
|
+
if error.status == 404:
|
|
999
|
+
entries.append((
|
|
1000
|
+
_entry_slug(repo, "issues-off"),
|
|
1001
|
+
_link(f"{name}: issues are disabled", issues_url)))
|
|
1002
|
+
continue
|
|
1003
|
+
errors.append((_entry_slug(repo, "unreadable"),
|
|
1004
|
+
f"{name}: could not read ({plain(str(error))})"))
|
|
1005
|
+
continue
|
|
1006
|
+
for row in rows:
|
|
1007
|
+
if _is_pull_request(row):
|
|
1008
|
+
continue
|
|
1009
|
+
# a real int, so it can be put in the URL — an escaped value could not
|
|
1010
|
+
number = values.number(row, "number")
|
|
1011
|
+
subject = values.text(row, "title", default="unknown")
|
|
1012
|
+
entries.append((
|
|
1013
|
+
_entry_slug(repo, "issue", number),
|
|
1014
|
+
f"{_link(f'{name}: has issue {number}', f'{issues_url}/{number}')}"
|
|
1015
|
+
f": {plain(subject)}"))
|
|
1016
|
+
code = StatusCode.WARN if entries else StatusCode.OK
|
|
1017
|
+
return self._finalize("issues", "Open issues per repository",
|
|
1018
|
+
entries, code, errors)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# --- run -----------------------------------------------------------------
|
|
1022
|
+
|
|
1023
|
+
def _scope_reading(self, repos: list[Repo]) -> tuple[StatusCode, str]:
|
|
1024
|
+
"""The coverage backstop on the check's owned container
|
|
1025
|
+
(little-sister ADR-0043)."""
|
|
1026
|
+
count = len(repos)
|
|
1027
|
+
noun = "repository" if count == 1 else "repositories"
|
|
1028
|
+
if count >= self.expect_min_repos:
|
|
1029
|
+
return StatusCode.OK, f"{count} {noun} in scope"
|
|
1030
|
+
if count:
|
|
1031
|
+
return (StatusCode.WARN,
|
|
1032
|
+
f"{count} {noun} in scope, expected at least "
|
|
1033
|
+
f"{self.expect_min_repos}")
|
|
1034
|
+
filters = [f"org {plain(self.org)}"]
|
|
1035
|
+
if self.team:
|
|
1036
|
+
filters.append(f"team {plain(self.team)}")
|
|
1037
|
+
if self.name_prefix:
|
|
1038
|
+
filters.append(f'prefix "{plain(self.name_prefix)}"')
|
|
1039
|
+
return (StatusCode.WARN,
|
|
1040
|
+
f"no repositories in scope ({', '.join(filters)})")
|
|
1041
|
+
|
|
1042
|
+
@staticmethod
|
|
1043
|
+
def _scope_report(repos: list[Repo]) -> str:
|
|
1044
|
+
"""The discovered roster: presence without a status claim
|
|
1045
|
+
(little-sister ADR-0044)."""
|
|
1046
|
+
return "\n".join(
|
|
1047
|
+
f"- [{plain(repo.name)}](https://github.com/{repo.full_name})"
|
|
1048
|
+
for repo in repos)
|
|
1049
|
+
|
|
1050
|
+
def run(self) -> CheckResult:
|
|
1051
|
+
try:
|
|
1052
|
+
client = self._make_client(self.token)
|
|
1053
|
+
repos = self._discover(client)
|
|
1054
|
+
except GitHubError as error:
|
|
1055
|
+
return CheckResult(StatusCode.ERROR,
|
|
1056
|
+
[f"discovery failed: {plain(str(error))}"])
|
|
1057
|
+
# names only — the payloads are large and one line per run is enough to see
|
|
1058
|
+
# what the discovery filter actually selected
|
|
1059
|
+
logger.info("%s: %d repositories in scope: %s", self.path, len(repos),
|
|
1060
|
+
", ".join(repo.name for repo in repos) or "(none)")
|
|
1061
|
+
scope_code, scope_reason = self._scope_reading(repos)
|
|
1062
|
+
scope_report = self._scope_report(repos)
|
|
1063
|
+
|
|
1064
|
+
try:
|
|
1065
|
+
_limit, remaining, _reset = client.rate_limit()
|
|
1066
|
+
needed = max(1, len(repos)) * len(self.ASPECTS)
|
|
1067
|
+
if remaining < self.rate_limit_safety_factor * needed:
|
|
1068
|
+
return CheckResult(
|
|
1069
|
+
StatusCode.WARN,
|
|
1070
|
+
[f"skipped this run: {remaining} API calls left, need > "
|
|
1071
|
+
f"{self.rate_limit_safety_factor}×{needed} "
|
|
1072
|
+
f"for {len(repos)} repo(s)", scope_reason],
|
|
1073
|
+
report=scope_report)
|
|
1074
|
+
except GitHubError:
|
|
1075
|
+
pass # rate-limit endpoint unavailable — proceed rather than block
|
|
1076
|
+
|
|
1077
|
+
children = (
|
|
1078
|
+
self._pull_requests(client, repos),
|
|
1079
|
+
self._security_advisories(client, repos),
|
|
1080
|
+
self._code_scanning_alerts(client, repos),
|
|
1081
|
+
self._secret_scanning_alerts(client, repos),
|
|
1082
|
+
self._sbom_check(client, repos),
|
|
1083
|
+
self._actions(client, repos),
|
|
1084
|
+
self._issues(client, repos),
|
|
1085
|
+
)
|
|
1086
|
+
return CheckResult(
|
|
1087
|
+
scope_code,
|
|
1088
|
+
[scope_reason],
|
|
1089
|
+
children=children,
|
|
1090
|
+
report=scope_report,
|
|
1091
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: little-sister-github
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GitHub overview check type for little-sister.
|
|
5
|
+
Keywords: monitoring,status,github,little-sister
|
|
6
|
+
Author: Michael Meyling
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: little-sister>=0.3.11
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Project-URL: homepage, https://github.com/m-31/little-sister-github
|
|
18
|
+
Project-URL: repository, https://github.com/m-31/little-sister-github
|
|
19
|
+
Project-URL: issues, https://github.com/m-31/little-sister-github/issues
|
|
20
|
+
Project-URL: changelog, https://github.com/m-31/little-sister-github/blob/main/CHANGELOG.md
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# little-sister-github
|
|
24
|
+
|
|
25
|
+
The **`github`** check type for [little-sister](https://github.com/m-31/little-sister):
|
|
26
|
+
one node per configured team, with a child per aspect — open pull requests,
|
|
27
|
+
Dependabot advisories, code-scanning and secret-scanning alerts, SBOM presence,
|
|
28
|
+
workflow runs and open issues.
|
|
29
|
+
|
|
30
|
+
Every finding is an individually addressable line, so an operator who opens a
|
|
31
|
+
ticket for one alert can put **that line** into maintenance and the rest of the
|
|
32
|
+
aspect keeps reporting.
|
|
33
|
+
|
|
34
|
+
## The contract
|
|
35
|
+
|
|
36
|
+
- **Requires** `little-sister >= 0.3.11` — a floor, never a pin.
|
|
37
|
+
- **Runs on** Python **3.11 or newer** — the library's floor, not a higher
|
|
38
|
+
one of its own.
|
|
39
|
+
- **Registers** one check type: **`github`**.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```toml
|
|
44
|
+
# your deployment's pyproject.toml
|
|
45
|
+
[project]
|
|
46
|
+
dependencies = ["little-sister", "little-sister-github"]
|
|
47
|
+
|
|
48
|
+
# Only while *this* one comes from git: little-sister resolves from the index.
|
|
49
|
+
# Delete the table once this package is on an index too — nothing else changes.
|
|
50
|
+
[tool.uv.sources]
|
|
51
|
+
little-sister-github = { git = "…/little-sister-github.git", tag = "v0.1.0" }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
# wsgi.py — registrations first, the app last. The order is load-bearing:
|
|
56
|
+
# importing little_sister.app builds the engine and loads the check configs, so
|
|
57
|
+
# every check type must already be registered. `isort: off` keeps an import
|
|
58
|
+
# sorter from quietly reversing that.
|
|
59
|
+
# isort: off
|
|
60
|
+
import little_sister_github # noqa: F401 registers the `github` type
|
|
61
|
+
from little_sister.app import app
|
|
62
|
+
# isort: on
|
|
63
|
+
|
|
64
|
+
__all__ = ["app"] # without it, lint calls the app import unused
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configure
|
|
68
|
+
|
|
69
|
+
Copy [`examples/github.yaml`](https://github.com/m-31/little-sister-github/blob/v0.1.0/examples/github.yaml) into your deployment's
|
|
70
|
+
`config/checks/`, set `org`, `team` and the token reference, and you are done —
|
|
71
|
+
one file per team. The credential is a **reference**, never a value:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
type: github
|
|
75
|
+
path: /platform/github
|
|
76
|
+
secrets:
|
|
77
|
+
token: env://PLATFORM_GITHUB_TOKEN
|
|
78
|
+
org: example-org
|
|
79
|
+
team: platform
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The token needs `read:org`, `repo`, `security_events` and dependency-graph read
|
|
83
|
+
access. Each team's check carries its own credential, so a second team is a second
|
|
84
|
+
config file rather than a code change.
|
|
85
|
+
|
|
86
|
+
The per-aspect display text ships **with the type** and expands `{org}` / `{team}`
|
|
87
|
+
from the config, so it is not copied per team. Your deployment's own policy — a
|
|
88
|
+
remediation deadline, who to notify — goes in that config's `subnodes:` block,
|
|
89
|
+
appended to the shipped text with `{default}`.
|
|
90
|
+
|
|
91
|
+
## What it reads
|
|
92
|
+
|
|
93
|
+
| Aspect | Endpoint | Grade |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `pull_requests` | `GET /repos/{r}/pulls?state=open` | any open PR (minus `ignore_title_prefixes`) → **WARN** |
|
|
96
|
+
| `security_advisories` | `GET /repos/{r}/dependabot/alerts?state=open` | one leaf per selected severity, graded by `security_advisories.severity_map` |
|
|
97
|
+
| `code_scanning_alerts` | `GET /repos/{r}/code-scanning/alerts?state=open` | one leaf per severity, graded by `code_scanning_alerts.severity_map` |
|
|
98
|
+
| `secret_scanning_alerts` | `GET /repos/{r}/secret-scanning/alerts?state=open` | any open alert → **ERROR**; scanning disabled → **ERROR** (`secret_scanning.require_enabled`) |
|
|
99
|
+
| `sbom_check` | `GET /repos/{r}/dependency-graph/sbom` | no dependency graph → **ERROR** (`sbom_check.ignore`) |
|
|
100
|
+
| `actions` | `GET /repos/{r}/actions/runs` | coded entries: last completed verdict, plus a newer in-flight run |
|
|
101
|
+
| `issues` | `GET /repos/{r}/issues?state=open` | any open issue → **WARN** (`issues.ignore`); issues disabled → **WARN** |
|
|
102
|
+
|
|
103
|
+
The check's own node carries the discovery coverage reading (`expect_min_repos`)
|
|
104
|
+
and the repository roster, and rolls up worst-of its aspects. Only stdlib
|
|
105
|
+
`urllib` is used — the package has no dependency but little-sister itself.
|
|
106
|
+
|
|
107
|
+
## Develop
|
|
108
|
+
|
|
109
|
+
little-sister is declared as a **floor** — the release that promised the surface
|
|
110
|
+
this package imports — and it resolves **from the index**, like any other
|
|
111
|
+
dependency. There is no `[tool.uv.sources]` table here, and the committed
|
|
112
|
+
`uv.lock` is what a release runs against. To work against a local library
|
|
113
|
+
checkout, add the redirect and **do not commit it**: uv reads the sources table of
|
|
114
|
+
a dependency it resolves from a path or a checkout, so a committed line would
|
|
115
|
+
follow this package into every deployment that installs it.
|
|
116
|
+
|
|
117
|
+
```toml
|
|
118
|
+
# pyproject.toml — locally, never committed
|
|
119
|
+
[tool.uv.sources]
|
|
120
|
+
little-sister = { path = "../little-sister" }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Restore `uv.lock` with it. The next `uv run` — the pre-commit gate is one — rewrites
|
|
124
|
+
the lock to `source = { directory = … }`, so a redirect kept out of `pyproject.toml`
|
|
125
|
+
can still reach a commit through the lock beside it.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv sync
|
|
129
|
+
uv run ruff check
|
|
130
|
+
uv run mypy
|
|
131
|
+
uv run mypy --python-version 3.11 # against the floor, not the interpreter you have
|
|
132
|
+
uv run pytest -q
|
|
133
|
+
# The same gate runs before every commit once the hook is enabled:
|
|
134
|
+
git config core.hooksPath hooks
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The tests are fixture-based; nothing in this repository calls GitHub.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT — see [LICENSE](https://github.com/m-31/little-sister-github/blob/v0.1.0/LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
little_sister_github/__init__.py,sha256=hygXS8DZ44ofmBZQLm7pUiP0fUyCY3x1E9rddDKN5sk,948
|
|
2
|
+
little_sister_github/github.py,sha256=MVM5XqGssSsJkYeIsYx10_tU3HSluy8NHx_vkwOYhwI,51889
|
|
3
|
+
little_sister_github/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
little_sister_github-0.1.0.dist-info/licenses/LICENSE,sha256=vftmudSYxZDfQHzcDV2fmdE4DJlqqyVjOLaeuE4QP2E,1072
|
|
5
|
+
little_sister_github-0.1.0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
|
|
6
|
+
little_sister_github-0.1.0.dist-info/METADATA,sha256=YpNHARJkvvAgxXu-bv3w1mQDz2FUajpz9NhLWgsBVCk,6039
|
|
7
|
+
little_sister_github-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Michael Meyling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|