github-security-report 0.6.0__tar.gz → 0.7.1__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.1}/PKG-INFO +67 -20
  2. {github_security_report-0.6.0 → github_security_report-0.7.1}/README.md +65 -18
  3. {github_security_report-0.6.0 → github_security_report-0.7.1}/pyproject.toml +2 -2
  4. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/_version.py +2 -2
  5. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/categories.py +15 -0
  6. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/classify.py +121 -38
  7. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/cli.py +167 -59
  8. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/client.py +144 -78
  9. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/collect.py +87 -27
  10. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/config.py +25 -15
  11. github_security_report-0.7.1/src/github_security_report/gating.py +208 -0
  12. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/models.py +20 -8
  13. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/posture.py +1 -4
  14. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/remediate.py +3 -9
  15. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/render/html.py +28 -8
  16. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/render/markdown.py +33 -13
  17. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/render/slack.py +34 -11
  18. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/render/terminal.py +27 -15
  19. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/report.py +27 -9
  20. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/rulesets.py +12 -0
  21. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/severity.py +7 -7
  22. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/templates/report.html.j2 +4 -0
  23. {github_security_report-0.6.0 → github_security_report-0.7.1}/.gitignore +0 -0
  24. {github_security_report-0.6.0 → github_security_report-0.7.1}/LICENSE +0 -0
  25. {github_security_report-0.6.0 → github_security_report-0.7.1}/LICENSES/Apache-2.0.txt +0 -0
  26. {github_security_report-0.6.0 → github_security_report-0.7.1}/scripts/README.md +0 -0
  27. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/__init__.py +0 -0
  28. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/gitctx.py +0 -0
  29. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/py.typed +0 -0
  30. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/render/__init__.py +0 -0
  31. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/runner.py +0 -0
  32. {github_security_report-0.6.0 → github_security_report-0.7.1}/src/github_security_report/scope.py +0 -0
  33. {github_security_report-0.6.0 → github_security_report-0.7.1}/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.1
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
@@ -37,7 +37,7 @@ Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
37
37
  Requires-Dist: pytest==9.1.1; extra == 'dev'
38
38
  Requires-Dist: respx==0.23.1; extra == 'dev'
39
39
  Requires-Dist: ruff==0.15.20; extra == 'dev'
40
- Requires-Dist: syrupy==5.3.4; extra == 'dev'
40
+ Requires-Dist: syrupy==5.5.1; extra == 'dev'
41
41
  Requires-Dist: types-jsonschema==4.26.0.20260518; extra == 'dev'
42
42
  Requires-Dist: types-pyyaml==6.0.12.20250915; extra == 'dev'
43
43
  Description-Content-Type: text/markdown
@@ -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
  {
@@ -422,13 +460,22 @@ The tool is published to PyPI and runs with `uvx`. Inside a Git checkout with a
422
460
  `upstream` remote, then `origin`) and prints a Rich table report:
423
461
 
424
462
  ```bash
425
- export GITHUB_TOKEN=ghp_your_token
463
+ export GITHUB_TOKEN="your-token"
426
464
  uvx github-security-report report
427
465
 
428
466
  # Or org mode locally with a PAT:
429
467
  uvx github-security-report report --org lfreleng-actions
430
468
  ```
431
469
 
470
+ ### GitHub Enterprise Server
471
+
472
+ The API endpoints honour the standard environment variables that GitHub
473
+ Actions exports, so the tool works against GitHub Enterprise Server
474
+ without code changes: set `GITHUB_API_URL` and `GITHUB_GRAPHQL_URL` to
475
+ your enterprise endpoints (Actions sets these automatically on GHES
476
+ runners). `SCORECARD_API_URL` overrides the external OpenSSF Scorecard
477
+ API in the same way.
478
+
432
479
  ## Remediation
433
480
 
434
481
  The `remediate` subcommand is the in-tool counterpart to the report: it runs the
@@ -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
  {
@@ -378,13 +416,22 @@ The tool is published to PyPI and runs with `uvx`. Inside a Git checkout with a
378
416
  `upstream` remote, then `origin`) and prints a Rich table report:
379
417
 
380
418
  ```bash
381
- export GITHUB_TOKEN=ghp_your_token
419
+ export GITHUB_TOKEN="your-token"
382
420
  uvx github-security-report report
383
421
 
384
422
  # Or org mode locally with a PAT:
385
423
  uvx github-security-report report --org lfreleng-actions
386
424
  ```
387
425
 
426
+ ### GitHub Enterprise Server
427
+
428
+ The API endpoints honour the standard environment variables that GitHub
429
+ Actions exports, so the tool works against GitHub Enterprise Server
430
+ without code changes: set `GITHUB_API_URL` and `GITHUB_GRAPHQL_URL` to
431
+ your enterprise endpoints (Actions sets these automatically on GHES
432
+ runners). `SCORECARD_API_URL` overrides the external OpenSSF Scorecard
433
+ API in the same way.
434
+
388
435
  ## Remediation
389
436
 
390
437
  The `remediate` subcommand is the in-tool counterpart to the report: it runs the
@@ -63,7 +63,7 @@ dev = [
63
63
  "pytest-asyncio==1.4.0",
64
64
  "pytest-cov==7.1.0",
65
65
  "respx==0.23.1",
66
- "syrupy==5.3.4",
66
+ "syrupy==5.5.1",
67
67
  "mypy==2.1.0",
68
68
  "ruff==0.15.20",
69
69
  "types-jsonschema==4.26.0.20260518",
@@ -95,7 +95,7 @@ dev = [
95
95
  "pytest-asyncio==1.4.0",
96
96
  "pytest-cov==7.1.0",
97
97
  "respx==0.23.1",
98
- "syrupy==5.3.4",
98
+ "syrupy==5.5.1",
99
99
  "mypy==2.1.0",
100
100
  "ruff==0.15.20",
101
101
  "types-jsonschema==4.26.0.20260518",
@@ -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.1'
22
+ __version_tuple__ = version_tuple = (0, 7, 1)
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,20 @@ _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 severity."
122
+ ),
123
+ # aislop, like zizmor, populates only the SARIF level axis
124
+ # (error/warning/note); "note" normalises to LOW (see severity.py), so
125
+ # any aislop finding fails -- matching the ruleset-enforced PR gate.
126
+ fail_severity=Severity.LOW,
127
+ ),
113
128
  CategoryKey.DEPENDABOT_ALERTS: CategoryMeta(
114
129
  key=CategoryKey.DEPENDABOT_ALERTS,
115
130
  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,
@@ -36,9 +37,7 @@ from github_security_report.severity import Severity
36
37
  FailSeverities = Mapping[SignalType, Severity]
37
38
 
38
39
 
39
- def _cutoff(
40
- signal: SignalType, fail_severities: FailSeverities | None
41
- ) -> Severity:
40
+ def _cutoff(signal: SignalType, fail_severities: FailSeverities | None) -> Severity:
42
41
  """The fail-severity cutoff for ``signal`` (config override or default)."""
43
42
  if fail_severities is not None and signal in fail_severities:
44
43
  return fail_severities[signal]
@@ -50,7 +49,7 @@ class RepoFacts:
50
49
  """Raw per-repository facts gathered by the client, pre-classification."""
51
50
 
52
51
  repo: Repo
53
- # Code scanning (covers CodeQL, Scorecard, zizmor).
52
+ # Code scanning (covers CodeQL, Scorecard, zizmor, aislop).
54
53
  code_scanning_status: int = 200 # 200 ok, 404 disabled, 403 forbidden
55
54
  code_scanning_tools: set[str] = field(default_factory=set) # analyses tool names
56
55
  code_scanning_alerts: list[dict] = field(default_factory=list) # all tools
@@ -130,13 +129,19 @@ def _code_scanning_tool_signal(
130
129
  facts.code_scanning_status == 200 and tool_name in facts.code_scanning_tools
131
130
  )
132
131
  if not enabled:
133
- detail = "code scanning disabled" if facts.code_scanning_status == 404 else f"{tool_name} not enabled"
132
+ detail = (
133
+ "code scanning disabled"
134
+ if facts.code_scanning_status == 404
135
+ else f"{tool_name} not enabled"
136
+ )
134
137
  return RepoSignal(repo, signal, RepoState.NAG, detail=detail)
135
138
  counts = count_code_scanning(facts.code_scanning_alerts, tool_name)
136
139
  # Enabled with no findings is only "clean" when the alert read succeeded;
137
140
  # an unreadable sweep (e.g. org-bulk 403/5xx) must not masquerade as clean.
138
141
  if counts.total == 0 and facts.code_scanning_alerts_status != 200:
139
- return RepoSignal(repo, signal, RepoState.UNKNOWN, detail="alert data unavailable")
142
+ return RepoSignal(
143
+ repo, signal, RepoState.UNKNOWN, detail="alert data unavailable"
144
+ )
140
145
  # Only findings at or above the category's fail-severity cutoff make the
141
146
  # repo an offender; sub-threshold findings (e.g. informational) fold into
142
147
  # the clean count.
@@ -149,7 +154,10 @@ def classify_codeql(
149
154
  facts: RepoFacts, fail_severities: FailSeverities | None = None
150
155
  ) -> RepoSignal:
151
156
  return _code_scanning_tool_signal(
152
- facts, SignalType.CODEQL, "CodeQL", fail_severities
157
+ facts,
158
+ SignalType.CODEQL,
159
+ CODE_SCANNING_TOOLS[SignalType.CODEQL],
160
+ fail_severities,
153
161
  )
154
162
 
155
163
 
@@ -157,7 +165,21 @@ def classify_zizmor(
157
165
  facts: RepoFacts, fail_severities: FailSeverities | None = None
158
166
  ) -> RepoSignal:
159
167
  return _code_scanning_tool_signal(
160
- facts, SignalType.ZIZMOR, "zizmor", fail_severities
168
+ facts,
169
+ SignalType.ZIZMOR,
170
+ CODE_SCANNING_TOOLS[SignalType.ZIZMOR],
171
+ fail_severities,
172
+ )
173
+
174
+
175
+ def classify_aislop(
176
+ facts: RepoFacts, fail_severities: FailSeverities | None = None
177
+ ) -> RepoSignal:
178
+ return _code_scanning_tool_signal(
179
+ facts,
180
+ SignalType.AISLOP,
181
+ CODE_SCANNING_TOOLS[SignalType.AISLOP],
182
+ fail_severities,
161
183
  )
162
184
 
163
185
 
@@ -166,8 +188,13 @@ def classify_scorecard(
166
188
  ) -> RepoSignal:
167
189
  """Scorecard: prefer the external aggregate score, else code-scanning findings."""
168
190
  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
191
+ counts = count_code_scanning(
192
+ facts.code_scanning_alerts, CODE_SCANNING_TOOLS[SignalType.SCORECARD]
193
+ )
194
+ has_cs = (
195
+ CODE_SCANNING_TOOLS[SignalType.SCORECARD] in facts.code_scanning_tools
196
+ and facts.code_scanning_status == 200
197
+ )
171
198
  cutoff = _cutoff(SignalType.SCORECARD, fail_severities)
172
199
 
173
200
  if facts.scorecard_status == 200 and facts.scorecard_score is not None:
@@ -178,21 +205,34 @@ def classify_scorecard(
178
205
  # the code-scanning cutoff logic used elsewhere in this file.
179
206
  clean = facts.scorecard_score >= 10.0 and not counts.at_or_above(cutoff)
180
207
  state = RepoState.CLEAN if clean else RepoState.OFFENDER
181
- return RepoSignal(repo, SignalType.SCORECARD, state, counts=counts, score=facts.scorecard_score)
208
+ return RepoSignal(
209
+ repo,
210
+ SignalType.SCORECARD,
211
+ state,
212
+ counts=counts,
213
+ score=facts.scorecard_score,
214
+ )
182
215
  if has_cs:
183
216
  if counts.total == 0 and facts.code_scanning_alerts_status != 200:
184
- return RepoSignal(repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="alert data unavailable")
185
- state = (
186
- RepoState.OFFENDER if counts.at_or_above(cutoff) else RepoState.CLEAN
187
- )
217
+ return RepoSignal(
218
+ repo,
219
+ SignalType.SCORECARD,
220
+ RepoState.UNKNOWN,
221
+ detail="alert data unavailable",
222
+ )
223
+ state = RepoState.OFFENDER if counts.at_or_above(cutoff) else RepoState.CLEAN
188
224
  return RepoSignal(repo, SignalType.SCORECARD, state, counts=counts)
189
225
  # An indeterminate external request (a 403 forbidden/blocked, a 5xx
190
226
  # including the synthetic 503 a transport failure produces, or a forbidden
191
227
  # code-scanning probe) is unknown, not a definitive nag. Only a clean 404
192
228
  # with no code-scanning Scorecard data means "no results".
193
229
  if facts.code_scanning_status == 403 or facts.scorecard_status not in (200, 404):
194
- return RepoSignal(repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="indeterminate")
195
- return RepoSignal(repo, SignalType.SCORECARD, RepoState.NAG, detail="no Scorecard results")
230
+ return RepoSignal(
231
+ repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="indeterminate"
232
+ )
233
+ return RepoSignal(
234
+ repo, SignalType.SCORECARD, RepoState.NAG, detail="no Scorecard results"
235
+ )
196
236
 
197
237
 
198
238
  def classify_secret_scanning(
@@ -203,9 +243,19 @@ def classify_secret_scanning(
203
243
  # other classifiers (they are dispatched positionally) and is unused here.
204
244
  repo = facts.repo
205
245
  if facts.secret_scanning_status == 403:
206
- return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.UNKNOWN, detail="insufficient permission")
246
+ return RepoSignal(
247
+ repo,
248
+ SignalType.SECRET_SCANNING,
249
+ RepoState.UNKNOWN,
250
+ detail="insufficient permission",
251
+ )
207
252
  if facts.secret_scanning_status == 404:
208
- return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.NAG, detail="secret scanning disabled")
253
+ return RepoSignal(
254
+ repo,
255
+ SignalType.SECRET_SCANNING,
256
+ RepoState.NAG,
257
+ detail="secret scanning disabled",
258
+ )
209
259
  # Flat open count; stored as HIGH so ranking (more == worse) works and the
210
260
  # repo-mode gate treats leaked secrets as serious, without conflating them
211
261
  # with CRITICAL code findings (which would make --fail-threshold critical
@@ -215,12 +265,19 @@ def classify_secret_scanning(
215
265
  # Positive evidence of leaked secrets is actionable even when the read was
216
266
  # incomplete (a later page failed) or the enablement probe was indeterminate.
217
267
  if facts.secret_scanning_open:
218
- return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.OFFENDER, counts=counts)
268
+ return RepoSignal(
269
+ repo, SignalType.SECRET_SCANNING, RepoState.OFFENDER, counts=counts
270
+ )
219
271
  # A zero count is only "clean" when both the enablement probe and the
220
272
  # open-count read succeeded; an indeterminate status (5xx) or an unreadable
221
273
  # sweep cannot confirm the repo is clean.
222
274
  if facts.secret_scanning_status != 200 or facts.secret_scanning_open_status != 200:
223
- return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.UNKNOWN, detail="alert data unavailable")
275
+ return RepoSignal(
276
+ repo,
277
+ SignalType.SECRET_SCANNING,
278
+ RepoState.UNKNOWN,
279
+ detail="alert data unavailable",
280
+ )
224
281
  return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.CLEAN, counts=counts)
225
282
 
226
283
 
@@ -229,34 +286,60 @@ def classify_dependabot(
229
286
  ) -> RepoSignal:
230
287
  repo = facts.repo
231
288
  if facts.dependabot_enabled is None:
232
- return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="indeterminate")
289
+ return RepoSignal(
290
+ repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="indeterminate"
291
+ )
233
292
  if facts.dependabot_enabled is False:
234
- return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.NAG, detail="Dependabot alerts disabled")
293
+ return RepoSignal(
294
+ repo,
295
+ SignalType.DEPENDABOT,
296
+ RepoState.NAG,
297
+ detail="Dependabot alerts disabled",
298
+ )
235
299
  counts = count_dependabot(facts.dependabot_alerts)
236
300
  # Enabled with no alerts is only "clean" when the alert read succeeded.
237
301
  if counts.total == 0 and facts.dependabot_alerts_status != 200:
238
- return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="alert data unavailable")
302
+ return RepoSignal(
303
+ repo,
304
+ SignalType.DEPENDABOT,
305
+ RepoState.UNKNOWN,
306
+ detail="alert data unavailable",
307
+ )
239
308
  failing = counts.at_or_above(_cutoff(SignalType.DEPENDABOT, fail_severities))
240
309
  state = RepoState.OFFENDER if failing else RepoState.CLEAN
241
310
  return RepoSignal(repo, SignalType.DEPENDABOT, state, counts=counts)
242
311
 
243
312
 
244
- _CLASSIFIERS = (
245
- classify_codeql,
246
- classify_scorecard,
247
- classify_zizmor,
248
- classify_dependabot,
249
- classify_secret_scanning,
250
- )
313
+ # One classifier per signal, keyed so orchestration can skip gated-out signals.
314
+ _Classifier = Callable[["RepoFacts", "FailSeverities | None"], RepoSignal]
315
+
316
+ _CLASSIFIERS: dict[SignalType, _Classifier] = {
317
+ SignalType.CODEQL: classify_codeql,
318
+ SignalType.SCORECARD: classify_scorecard,
319
+ SignalType.ZIZMOR: classify_zizmor,
320
+ SignalType.AISLOP: classify_aislop,
321
+ SignalType.DEPENDABOT: classify_dependabot,
322
+ SignalType.SECRET_SCANNING: classify_secret_scanning,
323
+ }
251
324
 
252
325
 
253
326
  def classify_repo(
254
- facts: RepoFacts, fail_severities: FailSeverities | None = None
327
+ facts: RepoFacts,
328
+ fail_severities: FailSeverities | None = None,
329
+ *,
330
+ skip: Set[SignalType] = frozenset(),
255
331
  ) -> list[RepoSignal]:
256
- """Classify a repository across all five signals.
332
+ """Classify a repository across all signals (minus any in ``skip``).
257
333
 
258
334
  ``fail_severities`` optionally overrides the per-signal fail-severity cutoff
259
335
  (otherwise each signal uses its category default); it governs which findings
260
- are severe enough to mark a repository as an offender.
336
+ are severe enough to mark a repository as an offender. ``skip`` names
337
+ signals to leave unclassified entirely -- used when organisation feature
338
+ gating (:mod:`gating`) found no support for a workflow-driven tool, so the
339
+ repository is neither nagged nor counted for that signal.
261
340
  """
262
- return [classifier(facts, fail_severities) for classifier in _CLASSIFIERS]
341
+ return [
342
+ classifier(facts, fail_severities)
343
+ for signal, classifier in _CLASSIFIERS.items()
344
+ if signal not in skip
345
+ ]