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.
- {github_security_report-0.5.0 → github_security_report-0.6.0}/PKG-INFO +59 -6
- {github_security_report-0.5.0 → github_security_report-0.6.0}/README.md +56 -3
- {github_security_report-0.5.0 → github_security_report-0.6.0}/pyproject.toml +3 -3
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/_version.py +2 -2
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/categories.py +3 -2
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/cli.py +109 -1
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/client.py +123 -0
- github_security_report-0.6.0/src/github_security_report/remediate.py +248 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/terminal.py +75 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/severity.py +21 -12
- {github_security_report-0.5.0 → github_security_report-0.6.0}/.gitignore +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/LICENSE +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/LICENSES/Apache-2.0.txt +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/scripts/README.md +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/__init__.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/classify.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/collect.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/config.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/gitctx.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/models.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/posture.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/py.typed +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/__init__.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/html.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/markdown.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/render/slack.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/report.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/rulesets.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/runner.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/scope.py +0 -0
- {github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/templates/index.html.j2 +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
339
|
-
|
|
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
|
|
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
|
|
295
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/_version.py
RENAMED
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (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
|
-
#
|
|
109
|
-
#
|
|
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(
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/cli.py
RENAMED
|
@@ -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()
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/client.py
RENAMED
|
@@ -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
|
+
)
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/severity.py
RENAMED
|
@@ -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,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 ``
|
|
30
|
-
findings
|
|
31
|
-
|
|
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).
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
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.
|
|
70
|
+
"note": Severity.LOW,
|
|
62
71
|
"none": Severity.INFORMATIONAL,
|
|
63
72
|
}
|
|
64
73
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/__init__.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/classify.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/collect.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/config.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/gitctx.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/models.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/posture.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/report.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/rulesets.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/runner.py
RENAMED
|
File without changes
|
{github_security_report-0.5.0 → github_security_report-0.6.0}/src/github_security_report/scope.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|