github-security-report 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {github_security_report-0.6.0 → github_security_report-0.7.0}/PKG-INFO +56 -18
  2. {github_security_report-0.6.0 → github_security_report-0.7.0}/README.md +55 -17
  3. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/_version.py +2 -2
  4. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/categories.py +16 -0
  5. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/classify.py +56 -19
  6. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/cli.py +3 -0
  7. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/client.py +64 -30
  8. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/collect.py +63 -8
  9. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/config.py +16 -1
  10. github_security_report-0.7.0/src/github_security_report/gating.py +189 -0
  11. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/models.py +18 -2
  12. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/render/html.py +20 -1
  13. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/render/markdown.py +11 -0
  14. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/render/slack.py +13 -0
  15. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/render/terminal.py +13 -1
  16. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/report.py +26 -3
  17. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/rulesets.py +15 -0
  18. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/severity.py +7 -7
  19. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/templates/report.html.j2 +4 -0
  20. {github_security_report-0.6.0 → github_security_report-0.7.0}/.gitignore +0 -0
  21. {github_security_report-0.6.0 → github_security_report-0.7.0}/LICENSE +0 -0
  22. {github_security_report-0.6.0 → github_security_report-0.7.0}/LICENSES/Apache-2.0.txt +0 -0
  23. {github_security_report-0.6.0 → github_security_report-0.7.0}/pyproject.toml +0 -0
  24. {github_security_report-0.6.0 → github_security_report-0.7.0}/scripts/README.md +0 -0
  25. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/__init__.py +0 -0
  26. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/gitctx.py +0 -0
  27. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/posture.py +0 -0
  28. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/py.typed +0 -0
  29. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/remediate.py +0 -0
  30. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/render/__init__.py +0 -0
  31. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/runner.py +0 -0
  32. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/scope.py +0 -0
  33. {github_security_report-0.6.0 → github_security_report-0.7.0}/src/github_security_report/templates/index.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-security-report
3
- Version: 0.6.0
3
+ Version: 0.7.0
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
@@ -56,8 +56,8 @@ Description-Content-Type: text/markdown
56
56
 
57
57
  > Security and quality **reporting** (not scanning) across GitHub
58
58
  > organisations. Aggregates existing signals — CodeQL, OpenSSF Scorecard,
59
- > zizmor, Dependabot, and secret scanning — and ranks the worst offenders so
60
- > remediation effort goes where it is needed.
59
+ > zizmor, aislop (AI slop), Dependabot, and secret scanning — and ranks the
60
+ > worst offenders so remediation effort goes where it is needed.
61
61
 
62
62
  ## 🗒️ Published reports
63
63
 
@@ -82,10 +82,21 @@ stay brevity-first; the explanatory per-category description and documentation
82
82
  link are shown only on the richer Markdown and HTML (GitHub Pages) outputs.
83
83
 
84
84
  The single GitHub code-scanning feed is partitioned by `tool.name` into CodeQL,
85
- Scorecard, and zizmor; Scorecard prefers the external aggregate score and falls
86
- back to code-scanning findings. See [`docs/BRIEF.md`](docs/BRIEF.md) and
87
- [`docs/phase0-findings.md`](docs/phase0-findings.md) for the full design and the
88
- API research it is built on.
85
+ Scorecard, zizmor, and aislop; Scorecard prefers the external aggregate score
86
+ and falls back to code-scanning findings. See [`docs/BRIEF.md`](docs/BRIEF.md)
87
+ and [`docs/phase0-findings.md`](docs/phase0-findings.md) for the full design and
88
+ the API research it is built on.
89
+
90
+ The workflow-driven signals (OpenSSF Scorecard, zizmor, aislop) only produce
91
+ data when an organisation has deployed supporting workflows. The tool checks
92
+ for that support cheaply before collecting (**feature gating**): an
93
+ organisation with no evidence of a tool — no ruleset requiring its workflow,
94
+ no alerts, no analyses on a sample of repositories — gets a single
95
+ `⏩ Skipping feature: organisation support missing` line for that section
96
+ instead of a nag list. See the
97
+ [organisation scan setup guide](docs/org-scan-setup.md) for the required
98
+ workflows, and disable the check with `report.gating: false` if you want to
99
+ probe everything regardless.
89
100
 
90
101
  Further sections report **configuration posture** and **freshness** as plain
91
102
  tables (org mode):
@@ -153,7 +164,7 @@ organisation and **Repository access** set to *All repositories*, then grant:
153
164
  | Metadata | Mandatory baseline; listing organisation repositories |
154
165
  | Contents | `.github/dependabot.yml`, latest release, and tag dates |
155
166
  | Dependabot alerts | Open Dependabot vulnerability alerts |
156
- | Code scanning alerts | CodeQL / Scorecard / zizmor findings |
167
+ | Code scanning alerts | CodeQL / Scorecard / zizmor / aislop findings |
157
168
  | Secret scanning alerts | Open secret-scanning alerts |
158
169
  | Administration | Dependabot enablement + security-updates status, and effective branch rules |
159
170
 
@@ -311,7 +322,7 @@ both true.
311
322
  The example above hides Zizmor on every surface, and keeps Releases / Tagging
312
323
  out of the terminal and Slack while still publishing it to the Markdown and HTML
313
324
  Pages output. The valid category keys are: `codeql`, `scorecard`, `zizmor`,
314
- `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
325
+ `aislop`, `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
315
326
  `dependabot_updates_enabled`, `dependabot_cooldown`, `releases`,
316
327
  `mutable_releases`, `private_vulnerability_reporting`. Like the other `report`
317
328
  settings, `categories` can be set
@@ -328,22 +339,49 @@ unless every org sharing that channel also disables it (this mirrors the
328
339
  most-generous `top_n` rule applied to the same grouping). The terminal, Markdown
329
340
  and HTML surfaces are per-org and are not affected by this union.
330
341
 
342
+ ### Organisation feature gating
343
+
344
+ The workflow-driven signals (OpenSSF Scorecard, zizmor, aislop) need
345
+ organisation-deployed workflows before they produce any data (see the
346
+ [organisation scan setup guide](docs/org-scan-setup.md)). By default the tool
347
+ runs a cheap support check per organisation before collecting each of them:
348
+ evidence is an org ruleset requiring the tool's workflow, existing
349
+ code-scanning alerts from the tool, analyses on a sample of repositories, or
350
+ (for Scorecard) an external scorecard.dev score. A signal with no evidence is
351
+ **skipped** — not probed per repository, not classified — and its section
352
+ shows a single `⏩ Skipping feature: organisation support missing` line
353
+ linking the setup guide, on every output surface. Set `report.gating` to
354
+ `false` (globally or per organisation) to always probe everything:
355
+
356
+ ```json
357
+ {
358
+ "report": { "gating": false },
359
+ "organizations": [{ "name": "lfreleng-actions" }]
360
+ }
361
+ ```
362
+
363
+ Gating decides **collection**; the per-category render toggles above decide
364
+ **presentation**. A skipped section still renders (as the one-line notice)
365
+ unless its category is also disabled.
366
+
331
367
  ### Pass/fail severity cutoff
332
368
 
333
- The severity-ranked signals (CodeQL, Scorecard, Zizmor, Dependabot alerts) use a
334
- `fail_severity` cutoff to decide when a repository counts as a failure. A
335
- repository is flagged as an offender only when it carries a finding **at or
336
- above** the cutoff; findings below it fold into the clean count. Severities run
337
- (lowest to highest) `informational`, `low`, `medium`, `high`, `critical` —
338
- `informational` being the sub-low rung for SARIF `none` findings and
339
- unclassifiable alerts. Zizmor's SARIF `note` findings normalise to `low`
369
+ The severity-ranked signals (CodeQL, Scorecard, Zizmor, aislop, Dependabot
370
+ alerts) use a `fail_severity` cutoff to decide when a repository counts as a
371
+ failure. A repository is flagged as an offender only when it carries a finding
372
+ **at or above** the cutoff; findings below it fold into the clean count.
373
+ Severities run (lowest to highest) `informational`, `low`, `medium`, `high`,
374
+ `critical` — `informational` being the sub-low rung for SARIF `none` findings
375
+ and unclassifiable alerts. Zizmor's SARIF `note` findings normalise to `low`
340
376
  (zizmor emits its Low findings at `note`, and the organisation scan pipeline's
341
377
  `--min-severity low` floor keeps informational findings out of the uploaded
342
378
  SARIF), matching the ruleset-enforced PR gate that blocks on note-and-above.
379
+ aislop populates the same SARIF level axis and normalises identically.
343
380
 
344
381
  The global default cutoff is `medium`, so `low` and `informational` findings
345
- pass. Zizmor defaults to `low` (only `informational` passes). Override the
346
- cutoff per category under `report.categories.<key>.fail_severity`:
382
+ pass. Zizmor and aislop default to `low` (only `informational` passes).
383
+ Override the cutoff per category under
384
+ `report.categories.<key>.fail_severity`:
347
385
 
348
386
  ```json
349
387
  {
@@ -12,8 +12,8 @@
12
12
 
13
13
  > Security and quality **reporting** (not scanning) across GitHub
14
14
  > organisations. Aggregates existing signals — CodeQL, OpenSSF Scorecard,
15
- > zizmor, Dependabot, and secret scanning — and ranks the worst offenders so
16
- > remediation effort goes where it is needed.
15
+ > zizmor, aislop (AI slop), Dependabot, and secret scanning — and ranks the
16
+ > worst offenders so remediation effort goes where it is needed.
17
17
 
18
18
  ## 🗒️ Published reports
19
19
 
@@ -38,10 +38,21 @@ stay brevity-first; the explanatory per-category description and documentation
38
38
  link are shown only on the richer Markdown and HTML (GitHub Pages) outputs.
39
39
 
40
40
  The single GitHub code-scanning feed is partitioned by `tool.name` into CodeQL,
41
- Scorecard, and zizmor; Scorecard prefers the external aggregate score and falls
42
- back to code-scanning findings. See [`docs/BRIEF.md`](docs/BRIEF.md) and
43
- [`docs/phase0-findings.md`](docs/phase0-findings.md) for the full design and the
44
- API research it is built on.
41
+ Scorecard, zizmor, and aislop; Scorecard prefers the external aggregate score
42
+ and falls back to code-scanning findings. See [`docs/BRIEF.md`](docs/BRIEF.md)
43
+ and [`docs/phase0-findings.md`](docs/phase0-findings.md) for the full design and
44
+ the API research it is built on.
45
+
46
+ The workflow-driven signals (OpenSSF Scorecard, zizmor, aislop) only produce
47
+ data when an organisation has deployed supporting workflows. The tool checks
48
+ for that support cheaply before collecting (**feature gating**): an
49
+ organisation with no evidence of a tool — no ruleset requiring its workflow,
50
+ no alerts, no analyses on a sample of repositories — gets a single
51
+ `⏩ Skipping feature: organisation support missing` line for that section
52
+ instead of a nag list. See the
53
+ [organisation scan setup guide](docs/org-scan-setup.md) for the required
54
+ workflows, and disable the check with `report.gating: false` if you want to
55
+ probe everything regardless.
45
56
 
46
57
  Further sections report **configuration posture** and **freshness** as plain
47
58
  tables (org mode):
@@ -109,7 +120,7 @@ organisation and **Repository access** set to *All repositories*, then grant:
109
120
  | Metadata | Mandatory baseline; listing organisation repositories |
110
121
  | Contents | `.github/dependabot.yml`, latest release, and tag dates |
111
122
  | Dependabot alerts | Open Dependabot vulnerability alerts |
112
- | Code scanning alerts | CodeQL / Scorecard / zizmor findings |
123
+ | Code scanning alerts | CodeQL / Scorecard / zizmor / aislop findings |
113
124
  | Secret scanning alerts | Open secret-scanning alerts |
114
125
  | Administration | Dependabot enablement + security-updates status, and effective branch rules |
115
126
 
@@ -267,7 +278,7 @@ both true.
267
278
  The example above hides Zizmor on every surface, and keeps Releases / Tagging
268
279
  out of the terminal and Slack while still publishing it to the Markdown and HTML
269
280
  Pages output. The valid category keys are: `codeql`, `scorecard`, `zizmor`,
270
- `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
281
+ `aislop`, `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
271
282
  `dependabot_updates_enabled`, `dependabot_cooldown`, `releases`,
272
283
  `mutable_releases`, `private_vulnerability_reporting`. Like the other `report`
273
284
  settings, `categories` can be set
@@ -284,22 +295,49 @@ unless every org sharing that channel also disables it (this mirrors the
284
295
  most-generous `top_n` rule applied to the same grouping). The terminal, Markdown
285
296
  and HTML surfaces are per-org and are not affected by this union.
286
297
 
298
+ ### Organisation feature gating
299
+
300
+ The workflow-driven signals (OpenSSF Scorecard, zizmor, aislop) need
301
+ organisation-deployed workflows before they produce any data (see the
302
+ [organisation scan setup guide](docs/org-scan-setup.md)). By default the tool
303
+ runs a cheap support check per organisation before collecting each of them:
304
+ evidence is an org ruleset requiring the tool's workflow, existing
305
+ code-scanning alerts from the tool, analyses on a sample of repositories, or
306
+ (for Scorecard) an external scorecard.dev score. A signal with no evidence is
307
+ **skipped** — not probed per repository, not classified — and its section
308
+ shows a single `⏩ Skipping feature: organisation support missing` line
309
+ linking the setup guide, on every output surface. Set `report.gating` to
310
+ `false` (globally or per organisation) to always probe everything:
311
+
312
+ ```json
313
+ {
314
+ "report": { "gating": false },
315
+ "organizations": [{ "name": "lfreleng-actions" }]
316
+ }
317
+ ```
318
+
319
+ Gating decides **collection**; the per-category render toggles above decide
320
+ **presentation**. A skipped section still renders (as the one-line notice)
321
+ unless its category is also disabled.
322
+
287
323
  ### Pass/fail severity cutoff
288
324
 
289
- The severity-ranked signals (CodeQL, Scorecard, Zizmor, Dependabot alerts) use a
290
- `fail_severity` cutoff to decide when a repository counts as a failure. A
291
- repository is flagged as an offender only when it carries a finding **at or
292
- above** the cutoff; findings below it fold into the clean count. Severities run
293
- (lowest to highest) `informational`, `low`, `medium`, `high`, `critical` —
294
- `informational` being the sub-low rung for SARIF `none` findings and
295
- unclassifiable alerts. Zizmor's SARIF `note` findings normalise to `low`
325
+ The severity-ranked signals (CodeQL, Scorecard, Zizmor, aislop, Dependabot
326
+ alerts) use a `fail_severity` cutoff to decide when a repository counts as a
327
+ failure. A repository is flagged as an offender only when it carries a finding
328
+ **at or above** the cutoff; findings below it fold into the clean count.
329
+ Severities run (lowest to highest) `informational`, `low`, `medium`, `high`,
330
+ `critical` — `informational` being the sub-low rung for SARIF `none` findings
331
+ and unclassifiable alerts. Zizmor's SARIF `note` findings normalise to `low`
296
332
  (zizmor emits its Low findings at `note`, and the organisation scan pipeline's
297
333
  `--min-severity low` floor keeps informational findings out of the uploaded
298
334
  SARIF), matching the ruleset-enforced PR gate that blocks on note-and-above.
335
+ aislop populates the same SARIF level axis and normalises identically.
299
336
 
300
337
  The global default cutoff is `medium`, so `low` and `informational` findings
301
- pass. Zizmor defaults to `low` (only `informational` passes). Override the
302
- cutoff per category under `report.categories.<key>.fail_severity`:
338
+ pass. Zizmor and aislop default to `low` (only `informational` passes).
339
+ Override the cutoff per category under
340
+ `report.categories.<key>.fail_severity`:
303
341
 
304
342
  ```json
305
343
  {
@@ -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.6.0'
22
- __version_tuple__ = version_tuple = (0, 6, 0)
21
+ __version__ = version = '0.7.0'
22
+ __version_tuple__ = version_tuple = (0, 7, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -31,6 +31,7 @@ class CategoryKey(str, Enum):
31
31
  CODEQL = "codeql"
32
32
  SCORECARD = "scorecard"
33
33
  ZIZMOR = "zizmor"
34
+ AISLOP = "aislop"
34
35
  DEPENDABOT_ALERTS = "dependabot_alerts"
35
36
  SECRET_SCANNING = "secret_scanning"
36
37
  DEPENDABOT_ALERTS_ENABLED = "dependabot_alerts_enabled"
@@ -110,6 +111,21 @@ _CATEGORIES: dict[CategoryKey, CategoryMeta] = {
110
111
  # matching the ruleset-enforced PR gate that blocks on note-and-above.
111
112
  fail_severity=Severity.LOW,
112
113
  ),
114
+ CategoryKey.AISLOP: CategoryMeta(
115
+ key=CategoryKey.AISLOP,
116
+ title="AI Slop Analysis",
117
+ pass_label="Clean",
118
+ fail_label=None,
119
+ url="https://github.com/scanaislop/aislop",
120
+ description=(
121
+ "aislop AI-slop / code-quality findings, ranked worst-first by "
122
+ "severity."
123
+ ),
124
+ # aislop, like zizmor, populates only the SARIF level axis
125
+ # (error/warning/note); "note" normalises to LOW (see severity.py), so
126
+ # any aislop finding fails -- matching the ruleset-enforced PR gate.
127
+ fail_severity=Severity.LOW,
128
+ ),
113
129
  CategoryKey.DEPENDABOT_ALERTS: CategoryMeta(
114
130
  key=CategoryKey.DEPENDABOT_ALERTS,
115
131
  title="Dependabot: Security Alerts",
@@ -6,8 +6,8 @@ Pure, transport-free logic encoding every Phase 0 finding
6
6
  (``docs/phase0-findings.md``):
7
7
 
8
8
  - the single code-scanning feed is partitioned by ``tool.name`` into CodeQL,
9
- Scorecard and zizmor -- counts are filtered per tool;
10
- - CodeQL/Scorecard/zizmor enablement is the presence of that tool in
9
+ Scorecard, zizmor and aislop -- counts are filtered per tool;
10
+ - CodeQL/Scorecard/zizmor/aislop enablement is the presence of that tool in
11
11
  ``code-scanning/analyses`` (not ``default-setup``); a 404 on code scanning
12
12
  means it is disabled entirely;
13
13
  - secret scanning 404 = disabled, 200 [] = enabled-clean;
@@ -18,11 +18,12 @@ Pure, transport-free logic encoding every Phase 0 finding
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
- from collections.abc import Mapping
21
+ from collections.abc import Callable, Mapping, Set
22
22
  from dataclasses import dataclass, field
23
23
 
24
24
  from github_security_report import severity
25
25
  from github_security_report.models import (
26
+ CODE_SCANNING_TOOLS,
26
27
  Repo,
27
28
  RepoSignal,
28
29
  RepoState,
@@ -50,7 +51,7 @@ class RepoFacts:
50
51
  """Raw per-repository facts gathered by the client, pre-classification."""
51
52
 
52
53
  repo: Repo
53
- # Code scanning (covers CodeQL, Scorecard, zizmor).
54
+ # Code scanning (covers CodeQL, Scorecard, zizmor, aislop).
54
55
  code_scanning_status: int = 200 # 200 ok, 404 disabled, 403 forbidden
55
56
  code_scanning_tools: set[str] = field(default_factory=set) # analyses tool names
56
57
  code_scanning_alerts: list[dict] = field(default_factory=list) # all tools
@@ -149,7 +150,10 @@ def classify_codeql(
149
150
  facts: RepoFacts, fail_severities: FailSeverities | None = None
150
151
  ) -> RepoSignal:
151
152
  return _code_scanning_tool_signal(
152
- facts, SignalType.CODEQL, "CodeQL", fail_severities
153
+ facts,
154
+ SignalType.CODEQL,
155
+ CODE_SCANNING_TOOLS[SignalType.CODEQL],
156
+ fail_severities,
153
157
  )
154
158
 
155
159
 
@@ -157,7 +161,21 @@ def classify_zizmor(
157
161
  facts: RepoFacts, fail_severities: FailSeverities | None = None
158
162
  ) -> RepoSignal:
159
163
  return _code_scanning_tool_signal(
160
- facts, SignalType.ZIZMOR, "zizmor", fail_severities
164
+ facts,
165
+ SignalType.ZIZMOR,
166
+ CODE_SCANNING_TOOLS[SignalType.ZIZMOR],
167
+ fail_severities,
168
+ )
169
+
170
+
171
+ def classify_aislop(
172
+ facts: RepoFacts, fail_severities: FailSeverities | None = None
173
+ ) -> RepoSignal:
174
+ return _code_scanning_tool_signal(
175
+ facts,
176
+ SignalType.AISLOP,
177
+ CODE_SCANNING_TOOLS[SignalType.AISLOP],
178
+ fail_severities,
161
179
  )
162
180
 
163
181
 
@@ -166,8 +184,13 @@ def classify_scorecard(
166
184
  ) -> RepoSignal:
167
185
  """Scorecard: prefer the external aggregate score, else code-scanning findings."""
168
186
  repo = facts.repo
169
- counts = count_code_scanning(facts.code_scanning_alerts, "Scorecard")
170
- has_cs = "Scorecard" in facts.code_scanning_tools and facts.code_scanning_status == 200
187
+ counts = count_code_scanning(
188
+ facts.code_scanning_alerts, CODE_SCANNING_TOOLS[SignalType.SCORECARD]
189
+ )
190
+ has_cs = (
191
+ CODE_SCANNING_TOOLS[SignalType.SCORECARD] in facts.code_scanning_tools
192
+ and facts.code_scanning_status == 200
193
+ )
171
194
  cutoff = _cutoff(SignalType.SCORECARD, fail_severities)
172
195
 
173
196
  if facts.scorecard_status == 200 and facts.scorecard_score is not None:
@@ -241,22 +264,36 @@ def classify_dependabot(
241
264
  return RepoSignal(repo, SignalType.DEPENDABOT, state, counts=counts)
242
265
 
243
266
 
244
- _CLASSIFIERS = (
245
- classify_codeql,
246
- classify_scorecard,
247
- classify_zizmor,
248
- classify_dependabot,
249
- classify_secret_scanning,
250
- )
267
+ # One classifier per signal, keyed so orchestration can skip gated-out signals.
268
+ _Classifier = Callable[["RepoFacts", "FailSeverities | None"], RepoSignal]
269
+
270
+ _CLASSIFIERS: dict[SignalType, _Classifier] = {
271
+ SignalType.CODEQL: classify_codeql,
272
+ SignalType.SCORECARD: classify_scorecard,
273
+ SignalType.ZIZMOR: classify_zizmor,
274
+ SignalType.AISLOP: classify_aislop,
275
+ SignalType.DEPENDABOT: classify_dependabot,
276
+ SignalType.SECRET_SCANNING: classify_secret_scanning,
277
+ }
251
278
 
252
279
 
253
280
  def classify_repo(
254
- facts: RepoFacts, fail_severities: FailSeverities | None = None
281
+ facts: RepoFacts,
282
+ fail_severities: FailSeverities | None = None,
283
+ *,
284
+ skip: Set[SignalType] = frozenset(),
255
285
  ) -> list[RepoSignal]:
256
- """Classify a repository across all five signals.
286
+ """Classify a repository across all signals (minus any in ``skip``).
257
287
 
258
288
  ``fail_severities`` optionally overrides the per-signal fail-severity cutoff
259
289
  (otherwise each signal uses its category default); it governs which findings
260
- are severe enough to mark a repository as an offender.
290
+ are severe enough to mark a repository as an offender. ``skip`` names
291
+ signals to leave unclassified entirely -- used when organisation feature
292
+ gating (:mod:`gating`) found no support for a workflow-driven tool, so the
293
+ repository is neither nagged nor counted for that signal.
261
294
  """
262
- return [classifier(facts, fail_severities) for classifier in _CLASSIFIERS]
295
+ return [
296
+ classifier(facts, fail_severities)
297
+ for signal, classifier in _CLASSIFIERS.items()
298
+ if signal not in skip
299
+ ]
@@ -128,6 +128,9 @@ def _org_to_dict(org: OrgReport) -> dict:
128
128
  "clean_count": s.clean_count,
129
129
  "nag": [r.full_name for r in s.nag_repos],
130
130
  "unknown_count": s.unknown_count,
131
+ # True when organisation feature gating skipped this signal
132
+ # (no supporting workflows found), so nothing was collected.
133
+ "skipped": s.skipped,
131
134
  }
132
135
  for s in org.sections
133
136
  ],
@@ -21,7 +21,12 @@ from typing import cast
21
21
 
22
22
  import httpx
23
23
 
24
- from github_security_report.models import ReleaseRef, Repo, RepoGraphData
24
+ from github_security_report.models import (
25
+ CODE_SCANNING_TOOLS,
26
+ ReleaseRef,
27
+ Repo,
28
+ RepoGraphData,
29
+ )
25
30
 
26
31
  log = logging.getLogger(__name__)
27
32
 
@@ -122,8 +127,9 @@ query($owner: String!, $name: String!) {
122
127
  # The code-scanning-derived signal tools whose enablement we probe per repo.
123
128
  # Each is checked via the analyses ``tool_name`` filter (a definitive presence
124
129
  # test) rather than scanning the analysis history, which a busy repo could push
125
- # a low-frequency tool out of.
126
- _CODE_SCANNING_SIGNAL_TOOLS = ("CodeQL", "Scorecard", "zizmor")
130
+ # a low-frequency tool out of. Derived from the shared signal->tool mapping so
131
+ # adding a SARIF-uploading tool needs no client change.
132
+ _CODE_SCANNING_SIGNAL_TOOLS = tuple(CODE_SCANNING_TOOLS.values())
127
133
 
128
134
  # Batched per-repo prefetch. One aliased query fetches, for many repositories
129
135
  # at once: Dependabot-alerts enablement, the most-recent tag's commit date
@@ -558,48 +564,76 @@ class GitHubClient:
558
564
  # ------------------------------------------------------------------ #
559
565
  # Per-repo enabled-probes
560
566
  # ------------------------------------------------------------------ #
561
- async def code_scanning_tools(self, org: str, repo: str) -> tuple[int, set[str]]:
562
- """Return (status, enabled signal tool names) from code-scanning analyses.
567
+ async def _analyses_tool_probe(
568
+ self, org: str, repo: str, tool: str
569
+ ) -> tuple[int, bool]:
570
+ """Probe a repo's code-scanning analyses for one tool (status, present).
563
571
 
564
- Each tool in ``_CODE_SCANNING_SIGNAL_TOOLS`` is probed with the analyses
565
- ``tool_name`` filter, a definitive presence test that does not depend on
566
- how many analyses a busy repo has accumulated (the previous page-by-page
567
- scan could miss a low-frequency tool past its page cap and wrongly nag
568
- it). The first probe's status is authoritative for the endpoint (404 =
569
- code scanning disabled, 403 = forbidden, 5xx/0 = indeterminate); a later
570
- per-tool probe that fails is skipped (its tool goes undetected for this
571
- run) rather than discarding the whole result.
572
+ A ``per_page=1`` + ``tool_name`` filtered read of the analyses endpoint:
573
+ a definitive, single-request presence test for a SARIF-uploading tool.
572
574
  """
573
575
  url = f"{self._api_url}/repos/{org}/{repo}/code-scanning/analyses"
576
+ resp = await self._request(
577
+ "GET", url, params={"per_page": 1, "tool_name": tool}
578
+ )
579
+ status = resp.status_code
580
+ if status != 200:
581
+ await resp.aclose() # unread body would leak a pooled connection
582
+ return status, False
583
+ has_analyses = bool(resp.json())
584
+ await resp.aclose() # release the connection once the body is read
585
+ return 200, has_analyses
574
586
 
575
- async def probe(tool: str) -> tuple[int, bool]:
576
- resp = await self._request(
577
- "GET", url, params={"per_page": 1, "tool_name": tool}
578
- )
579
- status = resp.status_code
580
- if status != 200:
581
- await resp.aclose() # unread body would leak a pooled connection
582
- return status, False
583
- has_analyses = bool(resp.json())
584
- await resp.aclose() # release the connection once the body is read
585
- return 200, has_analyses
587
+ async def code_scanning_tool_present(
588
+ self, org: str, repo: str, tool: str
589
+ ) -> bool:
590
+ """Whether ``tool`` has uploaded code-scanning analyses to this repo.
586
591
 
587
- first_tool, *rest = _CODE_SCANNING_SIGNAL_TOOLS
588
- status, has = await probe(first_tool)
592
+ The lightweight organisation-gating probe: any non-200 (disabled,
593
+ forbidden, indeterminate) simply reports the tool as not present here,
594
+ so a gating sweep degrades to "no evidence" rather than erroring.
595
+ """
596
+ status, present = await self._analyses_tool_probe(org, repo, tool)
597
+ return status == 200 and present
598
+
599
+ async def code_scanning_tools(
600
+ self, org: str, repo: str, tools: tuple[str, ...] | None = None
601
+ ) -> tuple[int, set[str]]:
602
+ """Return (status, enabled signal tool names) from code-scanning analyses.
603
+
604
+ Each tool in ``tools`` (default: every signal tool) is probed with the
605
+ analyses ``tool_name`` filter, a definitive presence test that does not
606
+ depend on how many analyses a busy repo has accumulated (the previous
607
+ page-by-page scan could miss a low-frequency tool past its page cap and
608
+ wrongly nag it). The first probe's status is authoritative for the
609
+ endpoint (404 = code scanning disabled, 403 = forbidden, 5xx/0 =
610
+ indeterminate); a later per-tool probe that fails is skipped (its tool
611
+ goes undetected for this run) rather than discarding the whole result.
612
+ Callers pass a subset when organisation gating has already ruled some
613
+ tools out, saving one request per repo per skipped tool.
614
+ """
615
+ probe_tools = tools if tools is not None else _CODE_SCANNING_SIGNAL_TOOLS
616
+ if not probe_tools:
617
+ return 200, set()
618
+
619
+ first_tool, *rest = probe_tools
620
+ status, has = await self._analyses_tool_probe(org, repo, first_tool)
589
621
  if status != 200:
590
622
  # The first probe's status is authoritative for the endpoint
591
623
  # (404 = disabled, 403 = forbidden, 5xx/0 = indeterminate).
592
624
  return status, set()
593
- tools: set[str] = {first_tool} if has else set()
625
+ tools_found: set[str] = {first_tool} if has else set()
594
626
  # The endpoint is reachable; probe the remaining tools concurrently. A
595
627
  # later probe that fails just leaves its tool undetected for this run.
596
- results = await asyncio.gather(*(probe(tool) for tool in rest))
597
- tools.update(
628
+ results = await asyncio.gather(
629
+ *(self._analyses_tool_probe(org, repo, tool) for tool in rest)
630
+ )
631
+ tools_found.update(
598
632
  tool
599
633
  for tool, (st, hit) in zip(rest, results, strict=True)
600
634
  if st == 200 and hit
601
635
  )
602
- return 200, tools
636
+ return 200, tools_found
603
637
 
604
638
  async def secret_scanning_status(self, org: str, repo: str) -> int:
605
639
  """HTTP status of the secret-scanning alerts endpoint (404 = disabled)."""