github-security-report 0.5.0__tar.gz → 0.6.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 (32) hide show
  1. {github_security_report-0.5.0 → github_security_report-0.6.0}/PKG-INFO +59 -6
  2. {github_security_report-0.5.0 → github_security_report-0.6.0}/README.md +56 -3
  3. {github_security_report-0.5.0 → github_security_report-0.6.0}/pyproject.toml +3 -3
  4. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/_version.py +2 -2
  5. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/categories.py +3 -2
  6. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/cli.py +109 -1
  7. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/client.py +123 -0
  8. github_security_report-0.6.0/src/github_security_report/remediate.py +248 -0
  9. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/terminal.py +75 -0
  10. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/severity.py +21 -12
  11. {github_security_report-0.5.0 → github_security_report-0.6.0}/.gitignore +0 -0
  12. {github_security_report-0.5.0 → github_security_report-0.6.0}/LICENSE +0 -0
  13. {github_security_report-0.5.0 → github_security_report-0.6.0}/LICENSES/Apache-2.0.txt +0 -0
  14. {github_security_report-0.5.0 → github_security_report-0.6.0}/scripts/README.md +0 -0
  15. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/__init__.py +0 -0
  16. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/classify.py +0 -0
  17. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/collect.py +0 -0
  18. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/config.py +0 -0
  19. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/gitctx.py +0 -0
  20. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/models.py +0 -0
  21. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/posture.py +0 -0
  22. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/py.typed +0 -0
  23. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/__init__.py +0 -0
  24. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/html.py +0 -0
  25. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/markdown.py +0 -0
  26. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/slack.py +0 -0
  27. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/report.py +0 -0
  28. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/rulesets.py +0 -0
  29. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/runner.py +0 -0
  30. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/scope.py +0 -0
  31. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/templates/index.html.j2 +0 -0
  32. {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/templates/report.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-security-report
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -29,7 +29,7 @@ Requires-Dist: jinja2==3.1.6
29
29
  Requires-Dist: jsonschema==4.26.0
30
30
  Requires-Dist: pyyaml==6.0.3
31
31
  Requires-Dist: rich==15.0.0
32
- Requires-Dist: typer==0.26.7
32
+ Requires-Dist: typer==0.26.8
33
33
  Provides-Extra: dev
34
34
  Requires-Dist: mypy==2.1.0; extra == 'dev'
35
35
  Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
@@ -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.3; extra == 'dev'
40
+ Requires-Dist: syrupy==5.3.4; 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
@@ -335,8 +335,11 @@ The severity-ranked signals (CodeQL, Scorecard, Zizmor, Dependabot alerts) use a
335
335
  repository is flagged as an offender only when it carries a finding **at or
336
336
  above** the cutoff; findings below it fold into the clean count. Severities run
337
337
  (lowest to highest) `informational`, `low`, `medium`, `high`, `critical` —
338
- `informational` being the new sub-low rung that SARIF `note`/`none` findings
339
- (the bulk of a tool like Zizmor) normalise to.
338
+ `informational` being the sub-low rung for SARIF `none` findings and
339
+ unclassifiable alerts. Zizmor's SARIF `note` findings normalise to `low`
340
+ (zizmor emits its Low findings at `note`, and the organisation scan pipeline's
341
+ `--min-severity low` floor keeps informational findings out of the uploaded
342
+ SARIF), matching the ruleset-enforced PR gate that blocks on note-and-above.
340
343
 
341
344
  The global default cutoff is `medium`, so `low` and `informational` findings
342
345
  pass. Zizmor defaults to `low` (only `informational` passes). Override the
@@ -426,8 +429,57 @@ uvx github-security-report report
426
429
  uvx github-security-report report --org lfreleng-actions
427
430
  ```
428
431
 
432
+ ## Remediation
433
+
434
+ The `remediate` subcommand is the in-tool counterpart to the report: it runs the
435
+ same collection, then switches on each selected security feature wherever a
436
+ repository has it **confirmed off**. Only the offenders the report already
437
+ surfaces are acted on — repositories whose state could not be read are counted
438
+ as *unknown* and are never written to, so remediation never blind-writes.
439
+
440
+ It is **dry run by default** (these are privileged writes); pass `--apply` to
441
+ make changes. A single **write-capable** org-admin token (from `--token-env`,
442
+ default `GITHUB_TOKEN`) drives both the read and the writes across every
443
+ configured organisation, so it bypasses the per-org read-only `token_env` in the
444
+ config.
445
+
446
+ ```bash
447
+ # An org-admin token is required: a classic PAT with the `repo` scope
448
+ # (administers repository security settings) plus `read:org` to enumerate repos.
449
+ source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
450
+
451
+ # Dry run (default): preview every change, touch nothing.
452
+ uvx github-security-report remediate --org lfreleng-actions
453
+
454
+ # Apply: enable every remediable feature that is off, across all configured orgs.
455
+ uvx github-security-report remediate \
456
+ --config ~/.config/github-security-report/config.json --apply
457
+
458
+ # Limit to specific categories (repeatable).
459
+ uvx github-security-report remediate --org lfreleng-actions \
460
+ --category codeql --category private_vulnerability_reporting --apply
461
+ ```
462
+
463
+ The remediable categories are the simple on/off features with a documented
464
+ enablement endpoint:
465
+
466
+ | `--category` | Enables |
467
+ |---|---|
468
+ | `codeql` | CodeQL default setup (provisioned asynchronously) |
469
+ | `secret_scanning` | Secret scanning |
470
+ | `dependabot_alerts_enabled` | Dependabot vulnerability alerts |
471
+ | `dependabot_updates_enabled` | Dependabot security updates (plus alerts) |
472
+ | `private_vulnerability_reporting` | Private vulnerability reporting |
473
+
474
+ Qualitative findings (Scorecard, zizmor, open Dependabot alerts, cooldown,
475
+ release freshness/mutability) are reported but not auto-remediated. Remediation
476
+ is organisation-scoped (`--scope org`, the default and only supported scope).
477
+
429
478
  ## Bulk Remediation Scripts
430
479
 
480
+ The standalone scripts below predate the `remediate` subcommand and remain for
481
+ ad-hoc, single-feature runs. For most workflows, prefer `remediate` above.
482
+
431
483
  The report ends with **nag lists** — repositories where a supported feature is
432
484
  switched off. Where GitHub exposes the relevant toggle through its REST API,
433
485
  the [`scripts/`](scripts/) directory ships standalone helpers that clear a whole
@@ -449,7 +501,8 @@ the current state of each repository, enables the feature where it is off, and
449
501
  verifies the result.
450
502
 
451
503
  ```bash
452
- # An org-admin token is required (classic PAT with repo admin / admin:org).
504
+ # An org-admin token is required: a classic PAT with the `repo` scope
505
+ # (administers repository security settings) plus `read:org` to enumerate repos.
453
506
  source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
454
507
 
455
508
  # Dry run (default): preview every change, touch nothing.
@@ -291,8 +291,11 @@ The severity-ranked signals (CodeQL, Scorecard, Zizmor, Dependabot alerts) use a
291
291
  repository is flagged as an offender only when it carries a finding **at or
292
292
  above** the cutoff; findings below it fold into the clean count. Severities run
293
293
  (lowest to highest) `informational`, `low`, `medium`, `high`, `critical` —
294
- `informational` being the new sub-low rung that SARIF `note`/`none` findings
295
- (the bulk of a tool like Zizmor) normalise to.
294
+ `informational` being the sub-low rung for SARIF `none` findings and
295
+ unclassifiable alerts. Zizmor's SARIF `note` findings normalise to `low`
296
+ (zizmor emits its Low findings at `note`, and the organisation scan pipeline's
297
+ `--min-severity low` floor keeps informational findings out of the uploaded
298
+ SARIF), matching the ruleset-enforced PR gate that blocks on note-and-above.
296
299
 
297
300
  The global default cutoff is `medium`, so `low` and `informational` findings
298
301
  pass. Zizmor defaults to `low` (only `informational` passes). Override the
@@ -382,8 +385,57 @@ uvx github-security-report report
382
385
  uvx github-security-report report --org lfreleng-actions
383
386
  ```
384
387
 
388
+ ## Remediation
389
+
390
+ The `remediate` subcommand is the in-tool counterpart to the report: it runs the
391
+ same collection, then switches on each selected security feature wherever a
392
+ repository has it **confirmed off**. Only the offenders the report already
393
+ surfaces are acted on — repositories whose state could not be read are counted
394
+ as *unknown* and are never written to, so remediation never blind-writes.
395
+
396
+ It is **dry run by default** (these are privileged writes); pass `--apply` to
397
+ make changes. A single **write-capable** org-admin token (from `--token-env`,
398
+ default `GITHUB_TOKEN`) drives both the read and the writes across every
399
+ configured organisation, so it bypasses the per-org read-only `token_env` in the
400
+ config.
401
+
402
+ ```bash
403
+ # An org-admin token is required: a classic PAT with the `repo` scope
404
+ # (administers repository security settings) plus `read:org` to enumerate repos.
405
+ source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
406
+
407
+ # Dry run (default): preview every change, touch nothing.
408
+ uvx github-security-report remediate --org lfreleng-actions
409
+
410
+ # Apply: enable every remediable feature that is off, across all configured orgs.
411
+ uvx github-security-report remediate \
412
+ --config ~/.config/github-security-report/config.json --apply
413
+
414
+ # Limit to specific categories (repeatable).
415
+ uvx github-security-report remediate --org lfreleng-actions \
416
+ --category codeql --category private_vulnerability_reporting --apply
417
+ ```
418
+
419
+ The remediable categories are the simple on/off features with a documented
420
+ enablement endpoint:
421
+
422
+ | `--category` | Enables |
423
+ |---|---|
424
+ | `codeql` | CodeQL default setup (provisioned asynchronously) |
425
+ | `secret_scanning` | Secret scanning |
426
+ | `dependabot_alerts_enabled` | Dependabot vulnerability alerts |
427
+ | `dependabot_updates_enabled` | Dependabot security updates (plus alerts) |
428
+ | `private_vulnerability_reporting` | Private vulnerability reporting |
429
+
430
+ Qualitative findings (Scorecard, zizmor, open Dependabot alerts, cooldown,
431
+ release freshness/mutability) are reported but not auto-remediated. Remediation
432
+ is organisation-scoped (`--scope org`, the default and only supported scope).
433
+
385
434
  ## Bulk Remediation Scripts
386
435
 
436
+ The standalone scripts below predate the `remediate` subcommand and remain for
437
+ ad-hoc, single-feature runs. For most workflows, prefer `remediate` above.
438
+
387
439
  The report ends with **nag lists** — repositories where a supported feature is
388
440
  switched off. Where GitHub exposes the relevant toggle through its REST API,
389
441
  the [`scripts/`](scripts/) directory ships standalone helpers that clear a whole
@@ -405,7 +457,8 @@ the current state of each repository, enables the feature where it is off, and
405
457
  verifies the result.
406
458
 
407
459
  ```bash
408
- # An org-admin token is required (classic PAT with repo admin / admin:org).
460
+ # An org-admin token is required: a classic PAT with the `repo` scope
461
+ # (administers repository security settings) plus `read:org` to enumerate repos.
409
462
  source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
410
463
 
411
464
  # Dry run (default): preview every change, touch nothing.
@@ -45,7 +45,7 @@ keywords = [
45
45
  ]
46
46
  dependencies = [
47
47
  "httpx[http2]==0.28.1",
48
- "typer==0.26.7",
48
+ "typer==0.26.8",
49
49
  "rich==15.0.0",
50
50
  "jinja2==3.1.6",
51
51
  "jsonschema==4.26.0",
@@ -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.3",
66
+ "syrupy==5.3.4",
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.3",
98
+ "syrupy==5.3.4",
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.5.0'
22
- __version_tuple__ = version_tuple = (0, 5, 0)
21
+ __version__ = version = '0.6.0'
22
+ __version_tuple__ = version_tuple = (0, 6, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -105,8 +105,9 @@ _CATEGORIES: dict[CategoryKey, CategoryMeta] = {
105
105
  "Zizmor static analysis of GitHub Actions workflows, ranked "
106
106
  "worst-first by severity."
107
107
  ),
108
- # Zizmor's advisory "note" findings normalise to informational; only
109
- # those pass. A low-or-higher zizmor finding is treated as a failure.
108
+ # zizmor emits its Low findings at SARIF level "note", which
109
+ # normalises to LOW (see severity.py), so any zizmor finding fails --
110
+ # matching the ruleset-enforced PR gate that blocks on note-and-above.
110
111
  fail_severity=Severity.LOW,
111
112
  ),
112
113
  CategoryKey.DEPENDABOT_ALERTS: CategoryMeta(
@@ -17,7 +17,7 @@ import logging
17
17
  import os
18
18
  import re
19
19
  import sys
20
- from collections.abc import Callable, Mapping
20
+ from collections.abc import Callable, Mapping, Sequence
21
21
  from dataclasses import replace
22
22
  from pathlib import Path
23
23
  from typing import NoReturn
@@ -26,6 +26,7 @@ import typer
26
26
  from rich.console import Console
27
27
 
28
28
  from github_security_report import __version__, collect, config, gitctx, runner
29
+ from github_security_report import remediate as remediate_mod
29
30
  from github_security_report.categories import CategoryKey
30
31
  from github_security_report.client import GitHubClient, NetworkError
31
32
  from github_security_report.config import Config, OrgConfig, ReportConfig
@@ -423,6 +424,43 @@ async def _run_repo(owner: str, repo_name: str, *, token_env: str, console: Cons
423
424
  return 0
424
425
 
425
426
 
427
+ async def _run_remediate(
428
+ cfg: Config,
429
+ *,
430
+ console: Console,
431
+ token: str,
432
+ categories: Sequence[CategoryKey],
433
+ apply: bool,
434
+ ) -> int:
435
+ """Collect each org's posture and enable (or preview enabling) features.
436
+
437
+ A single write-capable token drives both the read (collection) and the
438
+ writes for every configured org, so the per-org read ``token_env`` in the
439
+ config is intentionally bypassed. Returns 1 when any enable failed, else 0.
440
+ """
441
+ now = dt.datetime.now(dt.timezone.utc)
442
+ failures = 0
443
+ async with GitHubClient(token) as client:
444
+ for org_cfg in cfg.organizations:
445
+ report = await collect.collect_org(
446
+ client, org_cfg, org_cfg.report, generated_at=now
447
+ )
448
+ results = await remediate_mod.remediate_org(
449
+ client, report, categories=categories, apply=apply
450
+ )
451
+ # Honour the org's configured terminal offender limit, the same
452
+ # cap the report's CLI output uses, so large orgs stay readable.
453
+ term_render.render_remediation(
454
+ report.org,
455
+ results,
456
+ console,
457
+ apply=apply,
458
+ top_n=org_cfg.report.cli_top_n,
459
+ )
460
+ failures += sum(result.failures for result in results)
461
+ return 1 if failures else 0
462
+
463
+
426
464
  def _repo_outputs(signals: list[RepoSignal], fail_threshold: str) -> dict[str, str]:
427
465
  outputs = {s.signal.value + "_open": str(s.counts.total) for s in signals}
428
466
  outputs["failed"] = "true" if runner.should_fail(signals, fail_threshold) else "false"
@@ -540,5 +578,75 @@ def report(
540
578
  raise typer.Exit(code)
541
579
 
542
580
 
581
+ @app.command()
582
+ def remediate(
583
+ config_file: str | None = typer.Option(None, "--config", "-c", help="Path to a JSON config file."),
584
+ config_data: str | None = typer.Option(None, "--config-data", help="Raw or base64 JSON config (vars/secrets)."),
585
+ org: str | None = typer.Option(None, "--org", help="Single organisation (shorthand for org mode)."),
586
+ scope: str = typer.Option("org", "--scope", help="Only 'org' is supported; remediation is organisation-scoped."),
587
+ category: list[str] | None = typer.Option(None, "--category", help="Remediable category to act on (repeatable; default: all). One of: codeql, secret_scanning, dependabot_alerts_enabled, dependabot_updates_enabled, private_vulnerability_reporting."),
588
+ token_env: str = typer.Option("GITHUB_TOKEN", "--token-env", help="Env var holding a WRITE-capable org-admin PAT. Used for both reading posture and enabling features across every configured org."),
589
+ apply: bool = typer.Option(False, "--apply", help="Perform the writes. Without this flag remediate only previews (dry run)."),
590
+ no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
591
+ ) -> None:
592
+ """Enable security features on repositories that lack them.
593
+
594
+ Runs the same collection the report uses, then switches on each selected
595
+ remediable feature wherever a repository has it confirmed off. Dry run by
596
+ default: pass --apply to make changes. Requires a write-capable token
597
+ (org admin), distinct from the read-only reporting PAT.
598
+ """
599
+ plain = no_color or bool(os.environ.get("CI")) or not sys.stdout.isatty()
600
+ console = Console(no_color=plain, highlight=False)
601
+
602
+ if scope != "org":
603
+ console.print("[red]remediate supports only --scope org[/red]")
604
+ raise typer.Exit(2)
605
+
606
+ keys, unknown = remediate_mod.parse_categories(category or [])
607
+ if unknown:
608
+ valid = ", ".join(key.value for key in remediate_mod.REMEDIABLE)
609
+ # markup=False: the user-supplied --category values are printed
610
+ # literally, so bracketed input cannot be interpreted as Rich markup.
611
+ console.print(
612
+ f"Unknown --category: {', '.join(unknown)}. Valid values: {valid}",
613
+ style="red",
614
+ markup=False,
615
+ )
616
+ raise typer.Exit(2)
617
+ categories = keys or list(remediate_mod.REMEDIABLE)
618
+
619
+ cfg = _load_config(config_file, config_data, org, token_env, console=console)
620
+ if cfg is None:
621
+ console.print(
622
+ "[red]No configuration: provide --config, --config-data or --org.[/red]"
623
+ )
624
+ raise typer.Exit(2)
625
+
626
+ token = os.environ.get(token_env, "").strip()
627
+ if not token:
628
+ # markup=False guards the user-supplied --token-env value.
629
+ console.print(
630
+ f"No token in ${token_env} (a write-capable org-admin PAT is required).",
631
+ style="red",
632
+ markup=False,
633
+ )
634
+ raise typer.Exit(2)
635
+
636
+ try:
637
+ code = asyncio.run(
638
+ _run_remediate(
639
+ cfg,
640
+ console=console,
641
+ token=token,
642
+ categories=categories,
643
+ apply=apply,
644
+ )
645
+ )
646
+ except NetworkError as exc:
647
+ _abort_network(console, exc)
648
+ raise typer.Exit(code)
649
+
650
+
543
651
  if __name__ == "__main__": # pragma: no cover
544
652
  app()
@@ -782,6 +782,129 @@ class GitHubClient:
782
782
  await resp.aclose() # release the connection once the body is read
783
783
  return bool(data.get("enabled"))
784
784
 
785
+ # ------------------------------------------------------------------ #
786
+ # Remediation writes (enable a feature on one repository)
787
+ # ------------------------------------------------------------------ #
788
+ @staticmethod
789
+ def _write_note(resp: httpx.Response) -> str:
790
+ """A short ``"<status> <body>"`` note for a failed write (truncated).
791
+
792
+ Only the first slice of the body is processed: the body is capped at 80
793
+ characters (after a short numeric status prefix), so normalising the
794
+ whole body -- which may be a large HTML or JSON error page -- would
795
+ allocate for output that is discarded anyway.
796
+ """
797
+ body = " ".join(resp.text[:200].split())
798
+ return f"{resp.status_code} {body[:80]}".strip()
799
+
800
+ async def enable_dependabot_alerts(
801
+ self, org: str, repo: str
802
+ ) -> tuple[bool, str]:
803
+ """Enable Dependabot vulnerability alerts. Returns ``(ok, note)``.
804
+
805
+ ``PUT .../vulnerability-alerts`` is idempotent and returns ``204``; any
806
+ other status is a failure whose note carries the status and body.
807
+ """
808
+ resp = await self._request(
809
+ "PUT", f"{self._api_url}/repos/{org}/{repo}/vulnerability-alerts"
810
+ )
811
+ ok = resp.status_code == 204
812
+ note = "" if ok else self._write_note(resp)
813
+ await resp.aclose()
814
+ return ok, note
815
+
816
+ async def enable_dependabot_security_updates(
817
+ self, org: str, repo: str
818
+ ) -> tuple[bool, str]:
819
+ """Enable Dependabot security updates. Returns ``(ok, note)``.
820
+
821
+ Alerts are the prerequisite, so they are enabled first (idempotent
822
+ ``204``); then ``PUT .../automated-security-fixes`` (``204``). Either
823
+ step failing aborts and returns that step's status note.
824
+ """
825
+ ok, note = await self.enable_dependabot_alerts(org, repo)
826
+ if not ok:
827
+ return False, f"vulnerability-alerts -> {note}"
828
+ resp = await self._request(
829
+ "PUT", f"{self._api_url}/repos/{org}/{repo}/automated-security-fixes"
830
+ )
831
+ fixed = resp.status_code == 204
832
+ fnote = (
833
+ ""
834
+ if fixed
835
+ else f"automated-security-fixes -> {self._write_note(resp)}"
836
+ )
837
+ await resp.aclose()
838
+ return fixed, fnote
839
+
840
+ async def enable_private_vulnerability_reporting(
841
+ self, org: str, repo: str
842
+ ) -> tuple[bool, str]:
843
+ """Enable private vulnerability reporting (``PUT``, ``204``).
844
+
845
+ Returns ``(ok, note)``; a classic PAT needs the ``repo`` scope (write).
846
+ """
847
+ resp = await self._request(
848
+ "PUT",
849
+ f"{self._api_url}/repos/{org}/{repo}/private-vulnerability-reporting",
850
+ )
851
+ ok = resp.status_code == 204
852
+ note = "" if ok else self._write_note(resp)
853
+ await resp.aclose()
854
+ return ok, note
855
+
856
+ async def enable_codeql_default_setup(
857
+ self, org: str, repo: str
858
+ ) -> tuple[bool, str]:
859
+ """Enable CodeQL default setup. Returns ``(ok, note)``.
860
+
861
+ ``PATCH .../code-scanning/default-setup`` with ``{"state":
862
+ "configured"}`` usually provisions a scan asynchronously and returns
863
+ ``202`` with a run URL, but can also return ``200`` when the update is
864
+ applied synchronously; both are treated as success. A ``202`` means
865
+ "accepted", not "already scanning", so it keeps an async hint.
866
+ Repositories with no CodeQL-supported languages, or with Actions
867
+ disabled, return a 4xx; those are reported as failures but are
868
+ non-fatal to the rest of the run.
869
+ """
870
+ resp = await self._request(
871
+ "PATCH",
872
+ f"{self._api_url}/repos/{org}/{repo}/code-scanning/default-setup",
873
+ json={"state": "configured"},
874
+ )
875
+ ok = resp.status_code in (200, 202)
876
+ if not ok:
877
+ note = self._write_note(resp)
878
+ elif resp.status_code == 202:
879
+ note = "accepted (async)"
880
+ else:
881
+ note = ""
882
+ await resp.aclose()
883
+ return ok, note
884
+
885
+ async def enable_secret_scanning(
886
+ self, org: str, repo: str
887
+ ) -> tuple[bool, str]:
888
+ """Enable secret scanning. Returns ``(ok, note)``.
889
+
890
+ ``PATCH /repos/{o}/{r}`` with the repository's ``security_and_analysis``
891
+ block returns the updated repository (``200``); any other status is a
892
+ failure whose note carries the status and body.
893
+ """
894
+ resp = await self._request(
895
+ "PATCH",
896
+ f"{self._api_url}/repos/{org}/{repo}",
897
+ json={
898
+ "security_and_analysis": {
899
+ "secret_scanning": {"status": "enabled"},
900
+ },
901
+ },
902
+ )
903
+ ok = resp.status_code == 200
904
+ note = "" if ok else self._write_note(resp)
905
+ await resp.aclose()
906
+ return ok, note
907
+
785
908
  async def repo_graph_batch(
786
909
  self, org: str, names: list[str]
787
910
  ) -> dict[str, RepoGraphData]:
@@ -0,0 +1,248 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Remediation: switch on security features that repositories lack.
4
+
5
+ The report identifies repositories where a remediable security feature is off.
6
+ This module turns those features on via the GitHub REST API, acting only on the
7
+ confirmed-off, in-scope offenders the report already surfaced -- never on
8
+ repositories whose state could not be read (those are counted as *unknown* and
9
+ never appear as offenders, so the collection step doubles as the "read state"
10
+ that the never-blind-write rule requires). It is dry-run oriented: the CLI
11
+ previews the work by default and writes only when asked to apply.
12
+
13
+ The set of remediable categories is deliberately narrower than the report. Only
14
+ categories that are a simple on/off feature with a documented enablement
15
+ endpoint are here; qualitative findings (Scorecard, zizmor, open alerts,
16
+ cooldown, release freshness/mutability) are reported but not auto-remediated.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
22
+ from dataclasses import dataclass
23
+ from typing import Protocol
24
+
25
+ from github_security_report.categories import (
26
+ CategoryKey,
27
+ CategoryMeta,
28
+ category_meta,
29
+ )
30
+ from github_security_report.models import Repo, SignalType
31
+ from github_security_report.report import OrgReport, TableSection
32
+
33
+
34
+ class RemediationClient(Protocol):
35
+ """The write surface a remediator needs (a subset of ``GitHubClient``).
36
+
37
+ Each method enables one feature on one repository and returns
38
+ ``(ok, note)``: ``ok`` is whether the write succeeded, and ``note`` carries
39
+ a short diagnostic (an error status/body on failure, or a hint such as
40
+ ``"accepted (async)"`` on success). Tests supply an in-memory fake.
41
+ """
42
+
43
+ async def enable_dependabot_alerts(
44
+ self, org: str, repo: str
45
+ ) -> tuple[bool, str]: ...
46
+
47
+ async def enable_dependabot_security_updates(
48
+ self, org: str, repo: str
49
+ ) -> tuple[bool, str]: ...
50
+
51
+ async def enable_private_vulnerability_reporting(
52
+ self, org: str, repo: str
53
+ ) -> tuple[bool, str]: ...
54
+
55
+ async def enable_codeql_default_setup(
56
+ self, org: str, repo: str
57
+ ) -> tuple[bool, str]: ...
58
+
59
+ async def enable_secret_scanning(
60
+ self, org: str, repo: str
61
+ ) -> tuple[bool, str]: ...
62
+
63
+
64
+ # Actions a repository outcome can carry. "would enable" is the dry-run preview;
65
+ # "enabled" and "FAILED" are the two terminal states after an apply.
66
+ _WOULD_ENABLE = "would enable"
67
+ _ENABLED = "enabled"
68
+ _FAILED = "FAILED"
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class RepoOutcome:
73
+ """The result of (planning to) enable one feature on one repository."""
74
+
75
+ name: str
76
+ action: str # "would enable" | "enabled" | "FAILED"
77
+ note: str = ""
78
+
79
+ @property
80
+ def failed(self) -> bool:
81
+ return self.action == _FAILED
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class CategoryRemediation:
86
+ """Every repository outcome for one remediated category."""
87
+
88
+ category: CategoryMeta
89
+ outcomes: tuple[RepoOutcome, ...]
90
+
91
+ @property
92
+ def failures(self) -> int:
93
+ return sum(1 for o in self.outcomes if o.failed)
94
+
95
+
96
+ # --------------------------------------------------------------------------- #
97
+ # Offender extraction
98
+ # --------------------------------------------------------------------------- #
99
+ def _nag_offenders(signal: SignalType) -> Callable[[OrgReport], list[Repo]]:
100
+ """Offenders for a signal category: its NAG (feature-disabled) repos."""
101
+
102
+ def _get(report: OrgReport) -> list[Repo]:
103
+ return [
104
+ repo
105
+ for section in report.sections
106
+ if section.signal is signal
107
+ for repo in section.nag_repos
108
+ ]
109
+
110
+ return _get
111
+
112
+
113
+ def _find_table(report: OrgReport, key: CategoryKey) -> TableSection | None:
114
+ """The posture table for ``key`` (Dependabot sub-tables or the PVR table)."""
115
+ candidates = list(report.dependabot_tables)
116
+ if report.private_vulnerability_reporting is not None:
117
+ candidates.append(report.private_vulnerability_reporting)
118
+ for table in candidates:
119
+ if table.category.key is key:
120
+ return table
121
+ return None
122
+
123
+
124
+ def _table_offenders(key: CategoryKey) -> Callable[[OrgReport], list[Repo]]:
125
+ """Offenders for a posture-table category: the table's listed repos."""
126
+
127
+ def _get(report: OrgReport) -> list[Repo]:
128
+ table = _find_table(report, key)
129
+ return [row.repo for row in table.rows] if table is not None else []
130
+
131
+ return _get
132
+
133
+
134
+ # --------------------------------------------------------------------------- #
135
+ # Registry
136
+ # --------------------------------------------------------------------------- #
137
+ @dataclass(frozen=True)
138
+ class _Remediator:
139
+ key: CategoryKey
140
+ offenders: Callable[[OrgReport], list[Repo]]
141
+ enable: Callable[[RemediationClient, str, str], Awaitable[tuple[bool, str]]]
142
+
143
+
144
+ _REMEDIATORS: tuple[_Remediator, ...] = (
145
+ _Remediator(
146
+ CategoryKey.CODEQL,
147
+ _nag_offenders(SignalType.CODEQL),
148
+ lambda c, o, r: c.enable_codeql_default_setup(o, r),
149
+ ),
150
+ _Remediator(
151
+ CategoryKey.SECRET_SCANNING,
152
+ _nag_offenders(SignalType.SECRET_SCANNING),
153
+ lambda c, o, r: c.enable_secret_scanning(o, r),
154
+ ),
155
+ _Remediator(
156
+ CategoryKey.DEPENDABOT_ALERTS_ENABLED,
157
+ _table_offenders(CategoryKey.DEPENDABOT_ALERTS_ENABLED),
158
+ lambda c, o, r: c.enable_dependabot_alerts(o, r),
159
+ ),
160
+ _Remediator(
161
+ CategoryKey.DEPENDABOT_UPDATES_ENABLED,
162
+ _table_offenders(CategoryKey.DEPENDABOT_UPDATES_ENABLED),
163
+ lambda c, o, r: c.enable_dependabot_security_updates(o, r),
164
+ ),
165
+ _Remediator(
166
+ CategoryKey.PRIVATE_VULNERABILITY_REPORTING,
167
+ _table_offenders(CategoryKey.PRIVATE_VULNERABILITY_REPORTING),
168
+ lambda c, o, r: c.enable_private_vulnerability_reporting(o, r),
169
+ ),
170
+ )
171
+
172
+ _BY_KEY: dict[CategoryKey, _Remediator] = {r.key: r for r in _REMEDIATORS}
173
+
174
+ # The remediable category keys, in the order they are acted on and rendered.
175
+ REMEDIABLE: tuple[CategoryKey, ...] = tuple(r.key for r in _REMEDIATORS)
176
+
177
+
178
+ def parse_categories(values: Iterable[str]) -> tuple[list[CategoryKey], list[str]]:
179
+ """Map user-supplied category strings to keys.
180
+
181
+ Returns ``(keys, unknown)``: the resolved remediable keys (de-duplicated,
182
+ input order preserved) and any values that are not remediable category
183
+ names. The caller reports ``unknown`` and, when it is empty, acts on
184
+ ``keys`` (or every remediable category when the user selected none).
185
+ """
186
+ valid = {key.value: key for key in REMEDIABLE}
187
+ keys: list[CategoryKey] = []
188
+ unknown: list[str] = []
189
+ for value in values:
190
+ key = valid.get(value)
191
+ if key is None:
192
+ unknown.append(value)
193
+ elif key not in keys:
194
+ keys.append(key)
195
+ return keys, unknown
196
+
197
+
198
+ async def remediate_org(
199
+ client: RemediationClient,
200
+ report: OrgReport,
201
+ *,
202
+ categories: Sequence[CategoryKey] | None = None,
203
+ apply: bool,
204
+ ) -> list[CategoryRemediation]:
205
+ """Enable (or, in dry run, preview enabling) features across one org report.
206
+
207
+ Acts on every selected category (defaulting to all remediable categories),
208
+ in the canonical :data:`REMEDIABLE` order. In dry run every offender yields
209
+ a ``"would enable"`` outcome and no write is issued; with ``apply`` each
210
+ offender is written and yields ``"enabled"`` or ``"FAILED"`` with the
211
+ write's diagnostic note. Categories are always represented (with an empty
212
+ outcome list when they have no offenders) so the renderer can show that a
213
+ selected category had nothing to do.
214
+
215
+ Raises :class:`ValueError` if ``categories`` contains a key that is not
216
+ remediable, rather than failing later with an opaque ``KeyError``.
217
+ Duplicate keys are collapsed so a feature is never enabled twice in a run.
218
+ """
219
+ requested = list(categories) if categories is not None else list(REMEDIABLE)
220
+ invalid = [key for key in requested if key not in _BY_KEY]
221
+ if invalid:
222
+ names = ", ".join(key.value for key in invalid)
223
+ raise ValueError(f"not remediable: {names}")
224
+ # De-duplicate (a caller may repeat a key) while preserving first-seen
225
+ # order; the canonical sort below then fixes the acting/rendering order.
226
+ selected: list[CategoryKey] = []
227
+ for key in requested:
228
+ if key not in selected:
229
+ selected.append(key)
230
+ order = {rem.key: i for i, rem in enumerate(_REMEDIATORS)}
231
+ results: list[CategoryRemediation] = []
232
+ for key in sorted(selected, key=lambda k: order[k]):
233
+ rem = _BY_KEY[key]
234
+ outcomes: list[RepoOutcome] = []
235
+ for repo in rem.offenders(report):
236
+ if not apply:
237
+ outcomes.append(RepoOutcome(repo.name, _WOULD_ENABLE))
238
+ continue
239
+ ok, note = await rem.enable(client, report.org, repo.name)
240
+ outcomes.append(
241
+ RepoOutcome(repo.name, _ENABLED if ok else _FAILED, note)
242
+ )
243
+ results.append(
244
+ CategoryRemediation(
245
+ category=category_meta(key), outcomes=tuple(outcomes)
246
+ )
247
+ )
248
+ return results
@@ -14,11 +14,13 @@ from collections.abc import Callable, Sequence
14
14
  from dataclasses import replace
15
15
 
16
16
  from rich.console import Console
17
+ from rich.markup import escape
17
18
  from rich.table import Table
18
19
 
19
20
  from github_security_report.categories import CategoryKey
20
21
  from github_security_report.models import Repo, RepoSignal, SignalType
21
22
  from github_security_report.render import markdown
23
+ from github_security_report.remediate import CategoryRemediation
22
24
  from github_security_report.report import (
23
25
  SUMMARY_EMOJI,
24
26
  OrgReport,
@@ -260,3 +262,76 @@ def render_orgs(
260
262
  ) -> None:
261
263
  for org in orgs:
262
264
  render_org(org, console, top_n=top_n)
265
+
266
+
267
+ def render_remediation(
268
+ org: str,
269
+ results: Sequence[CategoryRemediation],
270
+ console: Console,
271
+ *,
272
+ apply: bool,
273
+ top_n: int | None = None,
274
+ ) -> None:
275
+ """Render a remediation run: one block per category, with a trailing summary.
276
+
277
+ Mirrors the report's inline style rather than a table: each category names
278
+ the repositories it would enable / enabled (honouring ``top_n``) and lists
279
+ any failures one per line with their diagnostic. Dry run prints a leading
280
+ notice; apply mode prints none (the writes finish before this renders), and
281
+ a trailing summary totals the work across categories.
282
+ """
283
+ console.rule(f"[bold]Remediation: {escape(org)}[/bold]")
284
+ # In apply mode the writes have already happened by the time this renders,
285
+ # so a pre-amble banner would be misleading; only the dry-run notice (shown
286
+ # before nothing is changed) is useful.
287
+ if not apply:
288
+ console.print(
289
+ "[bold yellow]DRY RUN[/bold yellow] — no changes made. Re-run with "
290
+ "[bold]--apply[/bold] to enable features.\n"
291
+ )
292
+
293
+ planned = 0
294
+ changed = 0
295
+ failed = 0
296
+ for result in results:
297
+ console.print(f"[bold]{result.category.title}[/bold]")
298
+ # Classify by run mode and each outcome's own failed flag rather than
299
+ # by the action string, so the renderer owns no copy of the action
300
+ # vocabulary defined in remediate.py.
301
+ failures = [o for o in result.outcomes if o.failed]
302
+ succeeded = [o for o in result.outcomes if not o.failed]
303
+ would = succeeded if not apply else []
304
+ enabled = succeeded if apply else []
305
+ if not result.outcomes:
306
+ console.print(" [green]Nothing to remediate[/green]")
307
+ if would:
308
+ names = _truncated_names([o.name for o in would], top_n)
309
+ console.print(
310
+ f" [yellow]→[/yellow] {len(would)} would enable: {escape(names)}"
311
+ )
312
+ if enabled:
313
+ names = _truncated_names([o.name for o in enabled], top_n)
314
+ console.print(
315
+ f" [green]{SUMMARY_EMOJI['pass']}[/green] {len(enabled)} enabled: "
316
+ f"{escape(names)}"
317
+ )
318
+ for outcome in failures:
319
+ detail = f": {escape(outcome.note)}" if outcome.note else ""
320
+ console.print(
321
+ f" [red]{SUMMARY_EMOJI['fail']}[/red] {escape(outcome.name)} "
322
+ f"failed{detail}"
323
+ )
324
+ planned += len(would)
325
+ changed += len(enabled)
326
+ failed += len(failures)
327
+ console.print()
328
+
329
+ if apply:
330
+ console.print(
331
+ f"[bold]Summary:[/bold] {changed} enabled, {failed} failed."
332
+ )
333
+ else:
334
+ console.print(
335
+ f"[bold]Summary:[/bold] {planned} to enable (dry run). Re-run with "
336
+ "[bold]--apply[/bold] to make changes."
337
+ )
@@ -12,10 +12,17 @@ in the code-scanning feed:
12
12
 
13
13
  To present a single, uniform set of severity columns across every table (as the
14
14
  design requires), the SARIF level is normalised onto the security scale when no
15
- security severity is present: error -> high, warning -> medium, and note/none ->
16
- informational (the sub-low rung, so a category's ``fail_severity`` cutoff can
17
- treat these advisory findings as non-actionable). Dependabot's
18
- ``security_advisory.severity`` maps directly.
15
+ security severity is present: error -> high, warning -> medium, note -> low,
16
+ and none -> informational. Dependabot's ``security_advisory.severity`` maps
17
+ directly.
18
+
19
+ The ``note`` mapping mirrors zizmor's own SARIF encoder, which emits both its
20
+ Low and Informational findings at SARIF level ``note`` (Medium -> ``warning``,
21
+ High -> ``error``). The organisation scan pipeline runs zizmor with
22
+ ``--min-severity low``, so informational findings never reach the uploaded
23
+ SARIF: every ``note`` alert in code scanning is a genuine Low finding, and the
24
+ ruleset-enforced PR gate blocks on it. Mapping ``note`` below LOW would
25
+ (and previously did) under-state the estate's posture relative to that gate.
19
26
  """
20
27
 
21
28
  from __future__ import annotations
@@ -26,9 +33,9 @@ from enum import IntEnum
26
33
  class Severity(IntEnum):
27
34
  """Ordered severity. Higher value == more severe (worst-first sorting).
28
35
 
29
- ``INFORMATIONAL`` is the lowest rung (below ``LOW``): SARIF ``note``/``none``
30
- findings -- the bulk of a tool like zizmor -- normalise here, so a category
31
- can choose to treat them as non-actionable via its ``fail_severity`` cutoff.
36
+ ``INFORMATIONAL`` is the lowest rung (below ``LOW``): SARIF ``none``
37
+ findings and unclassifiable alerts normalise here, so a category can
38
+ choose to treat them as non-actionable via its ``fail_severity`` cutoff.
32
39
  """
33
40
 
34
41
  INFORMATIONAL = 0
@@ -51,14 +58,16 @@ _SECURITY_NAMES: dict[str, Severity] = {
51
58
  "low": Severity.LOW,
52
59
  }
53
60
 
54
- # SARIF level -> security scale, used only as a fallback (zizmor). The SARIF
55
- # vocabulary has no distinct "low": ``note`` (and the rare ``none``) carry the
56
- # advisory, non-actionable findings, so they normalise to INFORMATIONAL -- below
57
- # LOW -- letting a category's fail_severity cutoff exclude them.
61
+ # SARIF level -> security scale, used only as a fallback (zizmor). zizmor's
62
+ # SARIF encoder emits Low AND Informational findings as ``note``, but the scan
63
+ # pipeline's --min-severity low floor keeps informational findings out of the
64
+ # SARIF entirely, so a ``note`` alert is a genuine Low finding (matching the
65
+ # ruleset-enforced PR gate, which blocks on note-and-above). The rare ``none``
66
+ # level stays at INFORMATIONAL.
58
67
  _SARIF_LEVEL_NAMES: dict[str, Severity] = {
59
68
  "error": Severity.HIGH,
60
69
  "warning": Severity.MEDIUM,
61
- "note": Severity.INFORMATIONAL,
70
+ "note": Severity.LOW,
62
71
  "none": Severity.INFORMATIONAL,
63
72
  }
64
73