github-security-report 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,376 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Org-mode orchestration: gather, classify, and aggregate.
4
+
5
+ Ties the transport (:mod:`client`), scoping (:mod:`scope`), classification
6
+ (:mod:`classify`) and aggregation (:mod:`report`) together for a single
7
+ organisation, following the Phase 0 strategy: one org-bulk sweep per signal,
8
+ then bounded per-repo enabled-probes. Accepts any object satisfying the client
9
+ protocol so it is testable without a live network.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import datetime as dt
16
+ import logging
17
+ from collections import defaultdict
18
+ from collections.abc import Mapping
19
+ from typing import Protocol
20
+
21
+ from github_security_report import posture, rulesets, scope
22
+ from github_security_report.classify import RepoFacts, classify_repo
23
+ from github_security_report.config import (
24
+ DEFAULT_RULESET_WORKFLOWS,
25
+ OrgConfig,
26
+ ReportConfig,
27
+ )
28
+ from github_security_report.models import Repo, RepoSignal, SignalType
29
+ from github_security_report.posture import RepoPosture
30
+ from github_security_report.report import OrgReport, build_org_report
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ # Per-repo probe tasks are created in batches of this size so very large orgs
35
+ # do not allocate every task at once (HTTP concurrency is bounded separately by
36
+ # the client semaphore).
37
+ _REPO_BATCH = 50
38
+
39
+
40
+ class ClientProtocol(Protocol):
41
+ """The subset of :class:`client.GitHubClient` that orchestration needs."""
42
+
43
+ async def list_org_repos(self, org: str) -> tuple[int, list[Repo]]: ...
44
+ async def org_bulk_alerts(self, org: str, kind: str) -> tuple[int, list[dict]]: ...
45
+ async def org_workflow_rulesets(self, org: str) -> tuple[int, list[dict]]: ...
46
+ async def code_scanning_tools(self, org: str, repo: str) -> tuple[int, set[str]]: ...
47
+ async def secret_scanning_status(self, org: str, repo: str) -> int: ...
48
+ async def dependabot_enabled(self, org: str, repo: str) -> bool | None: ...
49
+ async def scorecard_score(self, org: str, repo: str) -> tuple[int, float | None]: ...
50
+ async def automated_security_fixes(self, org: str, repo: str) -> bool | None: ...
51
+ async def dependabot_config(self, org: str, repo: str) -> tuple[int, str]: ...
52
+ async def latest_release_at(self, org: str, repo: str) -> dt.datetime | None: ...
53
+ async def latest_tag_at(self, org: str, repo: str) -> dt.datetime | None: ...
54
+
55
+
56
+ class RepoClientProtocol(Protocol):
57
+ """Extra per-repo methods needed for repo mode."""
58
+
59
+ async def get_repo(self, org: str, repo: str) -> Repo | None: ...
60
+ async def code_scanning_tools(self, org: str, repo: str) -> tuple[int, set[str]]: ...
61
+ async def repo_code_scanning_alerts(self, org: str, repo: str) -> tuple[int, list[dict]]: ...
62
+ async def repo_secret_scanning(self, org: str, repo: str) -> tuple[int, int]: ...
63
+ async def dependabot_enabled(self, org: str, repo: str) -> bool | None: ...
64
+ async def repo_dependabot_alerts(self, org: str, repo: str) -> tuple[int, list[dict]]: ...
65
+ async def repo_branch_rules(self, org: str, repo: str, branch: str) -> tuple[int, list[dict]]: ...
66
+ async def scorecard_score(self, org: str, repo: str) -> tuple[int, float | None]: ...
67
+
68
+
69
+ def _group_by_repo(alerts: list[dict]) -> dict[str, list[dict]]:
70
+ """Group org-bulk alerts by repository name (each carries ``repository``)."""
71
+ grouped: dict[str, list[dict]] = defaultdict(list)
72
+ for alert in alerts:
73
+ name = (alert.get("repository") or {}).get("name")
74
+ if name:
75
+ grouped[name].append(alert)
76
+ return grouped
77
+
78
+
79
+ async def _facts_for_repo(
80
+ client: ClientProtocol,
81
+ org: str,
82
+ repo: Repo,
83
+ code_scanning: dict[str, list[dict]],
84
+ dependabot: dict[str, list[dict]],
85
+ secret: dict[str, list[dict]],
86
+ ruleset_signals: set[str],
87
+ sweep_status: dict[str, int],
88
+ ) -> RepoFacts:
89
+ # These per-repo probes are independent; gather them so each repo's reads
90
+ # overlap. Real HTTP concurrency stays bounded by the client semaphore.
91
+ (cs_status, cs_tools), secret_status, dependabot_on, (scorecard_status, score) = (
92
+ await asyncio.gather(
93
+ client.code_scanning_tools(org, repo.name),
94
+ client.secret_scanning_status(org, repo.name),
95
+ client.dependabot_enabled(org, repo.name),
96
+ client.scorecard_score(org, repo.name),
97
+ )
98
+ )
99
+ return RepoFacts(
100
+ repo=repo,
101
+ code_scanning_status=cs_status,
102
+ code_scanning_tools=cs_tools,
103
+ code_scanning_alerts=code_scanning.get(repo.name, []),
104
+ code_scanning_alerts_status=sweep_status["code-scanning"],
105
+ secret_scanning_status=secret_status,
106
+ secret_scanning_open=len(secret.get(repo.name, [])),
107
+ secret_scanning_open_status=sweep_status["secret-scanning"],
108
+ dependabot_enabled=dependabot_on,
109
+ dependabot_alerts=dependabot.get(repo.name, []),
110
+ dependabot_alerts_status=sweep_status["dependabot"],
111
+ scorecard_status=scorecard_status,
112
+ scorecard_score=score,
113
+ ruleset_signals=ruleset_signals,
114
+ )
115
+
116
+
117
+ async def _posture_for_repo(
118
+ client: ClientProtocol,
119
+ org: str,
120
+ repo: Repo,
121
+ *,
122
+ dependabot_alerts: bool | None,
123
+ skip_release_probes: bool,
124
+ ) -> RepoPosture:
125
+ """Probe one repo's Dependabot posture and release/tag freshness.
126
+
127
+ ``dependabot_alerts`` is reused from the signal sweep (the GraphQL
128
+ ``hasVulnerabilityAlertsEnabled`` read) rather than re-fetched. Release and
129
+ tag probes are skipped for repositories excluded from the Releases/Tagging
130
+ table (young or opted out), which then carry no release/tag timestamps.
131
+ """
132
+ # The enablement and config reads are independent; gather them (the client
133
+ # semaphore still bounds real HTTP concurrency).
134
+ security_updates, (cfg_status, cfg_text) = await asyncio.gather(
135
+ client.automated_security_fixes(org, repo.name),
136
+ client.dependabot_config(org, repo.name),
137
+ )
138
+ has_config = cfg_status == 200
139
+ cooldown_missing = (
140
+ posture.cooldown_missing_ecosystems(cfg_text) if has_config else ()
141
+ )
142
+ latest_release_at: dt.datetime | None = None
143
+ latest_tag_at: dt.datetime | None = None
144
+ if not skip_release_probes:
145
+ # Release and tag probes are independent too; gather them.
146
+ latest_release_at, latest_tag_at = await asyncio.gather(
147
+ client.latest_release_at(org, repo.name),
148
+ client.latest_tag_at(org, repo.name),
149
+ )
150
+ return RepoPosture(
151
+ repo=repo,
152
+ dependabot_alerts=dependabot_alerts,
153
+ security_updates=security_updates,
154
+ cooldown_missing=cooldown_missing,
155
+ has_dependabot_config=has_config,
156
+ latest_release_at=latest_release_at,
157
+ latest_tag_at=latest_tag_at,
158
+ )
159
+
160
+
161
+ async def collect_org(
162
+ client: ClientProtocol,
163
+ org_cfg: OrgConfig,
164
+ report_cfg: ReportConfig,
165
+ *,
166
+ generated_at: dt.datetime | None = None,
167
+ ) -> OrgReport:
168
+ """Collect and build the report for one organisation."""
169
+ org = org_cfg.name
170
+ log.info("collecting %s", org)
171
+ repos_status, repos = await client.list_org_repos(org)
172
+ if repos_status != 200:
173
+ log.warning(
174
+ "repository listing for org %s is incomplete (status %s); the "
175
+ "report may omit repositories and their findings",
176
+ org,
177
+ repos_status,
178
+ )
179
+ in_scope = scope.filter_repos(
180
+ repos,
181
+ include_archived=report_cfg.include_archived,
182
+ include_test=report_cfg.include_test,
183
+ exclude=org_cfg.exclude,
184
+ )
185
+ # Repositories removed specifically by the per-org exclude list (not by
186
+ # fork/template/archived/test filtering) are tracked so the report can show
187
+ # them as explicitly excluded rather than silently dropping them.
188
+ exclude_names = set(org_cfg.exclude)
189
+ excluded_repos = [repo for repo in repos if repo.name in exclude_names]
190
+
191
+ # One org-bulk sweep per signal, plus the workflow-driven ruleset coverage
192
+ # (concurrent). Each sweep returns its HTTP status so an unreadable sweep
193
+ # (e.g. 403/5xx) degrades affected signals to UNKNOWN rather than CLEAN.
194
+ # Ruleset coverage degrades gracefully if the token cannot read org
195
+ # rulesets (e.g. 403): repos then fall back to per-repo evidence.
196
+ (
197
+ (cs_status, cs_alerts),
198
+ (dep_status, dep_alerts),
199
+ (secret_status, secret_alerts),
200
+ ), (rs_status, rs_details) = await asyncio.gather(
201
+ asyncio.gather(
202
+ client.org_bulk_alerts(org, "code-scanning"),
203
+ client.org_bulk_alerts(org, "dependabot"),
204
+ client.org_bulk_alerts(org, "secret-scanning"),
205
+ ),
206
+ client.org_workflow_rulesets(org),
207
+ )
208
+ sweep_status = {
209
+ "code-scanning": cs_status,
210
+ "dependabot": dep_status,
211
+ "secret-scanning": secret_status,
212
+ }
213
+ for kind, status in sweep_status.items():
214
+ if status != 200:
215
+ log.warning(
216
+ "%s alert sweep for org %s unavailable (status %s); affected "
217
+ "signals reported as unknown rather than clean",
218
+ kind,
219
+ org,
220
+ status,
221
+ )
222
+ code_scanning = _group_by_repo(cs_alerts)
223
+ dependabot = _group_by_repo(dep_alerts)
224
+ secret = _group_by_repo(secret_alerts)
225
+
226
+ workflow_rulesets = rulesets.parse_workflow_rulesets(rs_details)
227
+ if rs_status != 200:
228
+ log.warning(
229
+ "org rulesets unavailable for %s (status %s); ruleset-based tool "
230
+ "coverage disabled",
231
+ org,
232
+ rs_status,
233
+ )
234
+ coverage = {
235
+ repo.name: rulesets.signals_covered(
236
+ repo.name, workflow_rulesets, report_cfg.ruleset_workflows
237
+ )
238
+ for repo in in_scope
239
+ }
240
+
241
+ # Bounded per-repo probes. The client semaphore caps real HTTP concurrency;
242
+ # chunking the gather also bounds task creation so very large orgs
243
+ # (hundreds/thousands of repos) do not allocate every task at once.
244
+ facts: list[RepoFacts] = []
245
+ for start in range(0, len(in_scope), _REPO_BATCH):
246
+ batch = in_scope[start : start + _REPO_BATCH]
247
+ facts.extend(
248
+ await asyncio.gather(
249
+ *(
250
+ _facts_for_repo(
251
+ client, org, repo, code_scanning, dependabot, secret,
252
+ coverage.get(repo.name, set()), sweep_status,
253
+ )
254
+ for repo in batch
255
+ )
256
+ )
257
+ )
258
+
259
+ signals = [sig for repo_facts in facts for sig in classify_repo(repo_facts)]
260
+ report = build_org_report(
261
+ org,
262
+ signals,
263
+ repo_count=len(in_scope),
264
+ generated_at=generated_at,
265
+ partial=repos_status != 200,
266
+ excluded_repos=excluded_repos,
267
+ )
268
+
269
+ # Extra reporting categories (outside the four-state model): Dependabot
270
+ # configuration posture and release/tag freshness. The Dependabot alerts
271
+ # enablement flag is reused from the per-repo facts rather than re-probed;
272
+ # release/tag probes are skipped for repositories the Releases/Tagging table
273
+ # would exclude anyway (young or opted out), saving two HTTP calls each.
274
+ when = report.generated_at
275
+ dependabot_on = {f.repo.name: f.dependabot_enabled for f in facts}
276
+ postures: list[RepoPosture] = []
277
+ for start in range(0, len(in_scope), _REPO_BATCH):
278
+ batch = in_scope[start : start + _REPO_BATCH]
279
+ postures.extend(
280
+ await asyncio.gather(
281
+ *(
282
+ _posture_for_repo(
283
+ client, org, repo,
284
+ dependabot_alerts=dependabot_on.get(repo.name),
285
+ skip_release_probes=posture.is_release_excluded(
286
+ repo,
287
+ generated_at=when,
288
+ min_age_days=report_cfg.release_min_age_days,
289
+ exclude=org_cfg.releases_exclude,
290
+ ),
291
+ )
292
+ for repo in batch
293
+ )
294
+ )
295
+ )
296
+
297
+ report.dependabot_tables = posture.build_dependabot_tables(postures)
298
+ report.releases = posture.build_releases_table(
299
+ postures,
300
+ generated_at=when,
301
+ min_age_days=report_cfg.release_min_age_days,
302
+ exclude=org_cfg.releases_exclude,
303
+ )
304
+ # The "Alerts Not Enabled" sub-table carries the repositories with Dependabot
305
+ # alerts disabled, so drop them from the Dependabot signal section's nag list
306
+ # to avoid listing the same repositories twice under the one heading.
307
+ for section in report.sections:
308
+ if section.signal is SignalType.DEPENDABOT:
309
+ section.nag_repos = []
310
+ return report
311
+
312
+
313
+ async def collect_repo(
314
+ client: RepoClientProtocol,
315
+ owner: str,
316
+ repo_name: str,
317
+ *,
318
+ ruleset_workflows: Mapping[str, str] | None = None,
319
+ ) -> tuple[Repo | None, list[RepoSignal]]:
320
+ """Collect and classify a single repository (repo mode, ``GITHUB_TOKEN``).
321
+
322
+ Uses only per-repo endpoints -- no org-bulk sweep and no org-level scope.
323
+ Returns the repository identity (None if unreadable) and its classified
324
+ signals.
325
+ """
326
+ repo = await client.get_repo(owner, repo_name)
327
+ if repo is None:
328
+ log.error("cannot read %s/%s (check token and permissions)", owner, repo_name)
329
+ return None, []
330
+ cs_status, cs_tools = await client.code_scanning_tools(owner, repo_name)
331
+ # Skip the alerts call when code scanning is disabled/indeterminate.
332
+ cs_alerts: list[dict] = []
333
+ cs_alerts_status = 200
334
+ if cs_status == 200:
335
+ cs_alerts_status, cs_alerts = await client.repo_code_scanning_alerts(
336
+ owner, repo_name
337
+ )
338
+ secret_status, secret_open = await client.repo_secret_scanning(owner, repo_name)
339
+ dependabot_on = await client.dependabot_enabled(owner, repo_name)
340
+ # Only fetch Dependabot alerts when the feature is enabled.
341
+ dependabot_alerts: list[dict] = []
342
+ dependabot_alerts_status = 200
343
+ if dependabot_on:
344
+ dependabot_alerts_status, dependabot_alerts = await client.repo_dependabot_alerts(
345
+ owner, repo_name
346
+ )
347
+ scorecard_status, score = await client.scorecard_score(owner, repo_name)
348
+ # Ruleset coverage from the repo's effective branch rules (includes
349
+ # inherited org rulesets); repo-scoped tokens can read this endpoint.
350
+ rs_status, branch_rules = await client.repo_branch_rules(
351
+ owner, repo_name, repo.default_branch
352
+ )
353
+ ruleset_signals = (
354
+ rulesets.signals_from_branch_rules(
355
+ branch_rules, ruleset_workflows or DEFAULT_RULESET_WORKFLOWS
356
+ )
357
+ if rs_status == 200
358
+ else set()
359
+ )
360
+ facts = RepoFacts(
361
+ repo=repo,
362
+ code_scanning_status=cs_status,
363
+ code_scanning_tools=cs_tools,
364
+ code_scanning_alerts=cs_alerts,
365
+ code_scanning_alerts_status=cs_alerts_status,
366
+ secret_scanning_status=secret_status,
367
+ secret_scanning_open=secret_open,
368
+ secret_scanning_open_status=secret_status,
369
+ dependabot_enabled=dependabot_on,
370
+ dependabot_alerts=dependabot_alerts,
371
+ dependabot_alerts_status=dependabot_alerts_status,
372
+ scorecard_status=scorecard_status,
373
+ scorecard_score=score,
374
+ ruleset_signals=ruleset_signals,
375
+ )
376
+ return repo, classify_repo(facts)