github-security-report 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github_security_report/__init__.py +13 -0
- github_security_report/_version.py +24 -0
- github_security_report/classify.py +208 -0
- github_security_report/cli.py +448 -0
- github_security_report/client.py +493 -0
- github_security_report/collect.py +376 -0
- github_security_report/config.py +343 -0
- github_security_report/gitctx.py +66 -0
- github_security_report/models.py +172 -0
- github_security_report/posture.py +264 -0
- github_security_report/py.typed +1 -0
- github_security_report/render/__init__.py +3 -0
- github_security_report/render/html.py +142 -0
- github_security_report/render/markdown.py +172 -0
- github_security_report/render/slack.py +215 -0
- github_security_report/render/terminal.py +163 -0
- github_security_report/report.py +173 -0
- github_security_report/rulesets.py +137 -0
- github_security_report/runner.py +147 -0
- github_security_report/scope.py +97 -0
- github_security_report/severity.py +83 -0
- github_security_report/templates/index.html.j2 +57 -0
- github_security_report/templates/report.html.j2 +162 -0
- github_security_report-0.1.0.dist-info/METADATA +318 -0
- github_security_report-0.1.0.dist-info/RECORD +28 -0
- github_security_report-0.1.0.dist-info/WHEEL +4 -0
- github_security_report-0.1.0.dist-info/entry_points.txt +2 -0
- github_security_report-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Security and quality reporting across GitHub organisations."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
try: # pragma: no cover - resolved at build time by hatch-vcs
|
|
8
|
+
from github_security_report._version import __version__
|
|
9
|
+
except ImportError: # pragma: no cover - editable/source checkout without build
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Classification: raw API facts -> four-state RepoSignal results.
|
|
4
|
+
|
|
5
|
+
Pure, transport-free logic encoding every Phase 0 finding
|
|
6
|
+
(``docs/phase0-findings.md``):
|
|
7
|
+
|
|
8
|
+
- the single code-scanning feed is partitioned by ``tool.name`` into CodeQL,
|
|
9
|
+
Scorecard and zizmor -- counts are filtered per tool;
|
|
10
|
+
- CodeQL/Scorecard/zizmor enablement is the presence of that tool in
|
|
11
|
+
``code-scanning/analyses`` (not ``default-setup``); a 404 on code scanning
|
|
12
|
+
means it is disabled entirely;
|
|
13
|
+
- secret scanning 404 = disabled, 200 [] = enabled-clean;
|
|
14
|
+
- Dependabot ``hasVulnerabilityAlertsEnabled == false`` = disabled;
|
|
15
|
+
- Scorecard prefers the external aggregate score, else code-scanning findings;
|
|
16
|
+
- 403 / indeterminate -> the unknown bucket, never clean or nag.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from github_security_report import severity
|
|
24
|
+
from github_security_report.models import (
|
|
25
|
+
Repo,
|
|
26
|
+
RepoSignal,
|
|
27
|
+
RepoState,
|
|
28
|
+
SeverityCounts,
|
|
29
|
+
SignalType,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RepoFacts:
|
|
35
|
+
"""Raw per-repository facts gathered by the client, pre-classification."""
|
|
36
|
+
|
|
37
|
+
repo: Repo
|
|
38
|
+
# Code scanning (covers CodeQL, Scorecard, zizmor).
|
|
39
|
+
code_scanning_status: int = 200 # 200 ok, 404 disabled, 403 forbidden
|
|
40
|
+
code_scanning_tools: set[str] = field(default_factory=set) # analyses tool names
|
|
41
|
+
code_scanning_alerts: list[dict] = field(default_factory=list) # all tools
|
|
42
|
+
code_scanning_alerts_status: int = 200 # status of the alert read (200 ok)
|
|
43
|
+
# Secret scanning.
|
|
44
|
+
secret_scanning_status: int = 200 # 200 enabled, 404 disabled, 403 forbidden
|
|
45
|
+
secret_scanning_open: int = 0
|
|
46
|
+
secret_scanning_open_status: int = 200 # status of the open-count read
|
|
47
|
+
# Dependabot.
|
|
48
|
+
dependabot_enabled: bool | None = None # None = indeterminate
|
|
49
|
+
dependabot_alerts: list[dict] = field(default_factory=list)
|
|
50
|
+
dependabot_alerts_status: int = 200 # status of the alert read
|
|
51
|
+
# Scorecard (external API).
|
|
52
|
+
scorecard_status: int = 404 # 200 has score, 404 none, 403 forbidden
|
|
53
|
+
scorecard_score: float | None = None
|
|
54
|
+
# Signals (by value) enforced for this repo via an org/branch ruleset
|
|
55
|
+
# (e.g. zizmor required by a central workflow), even with no per-repo file.
|
|
56
|
+
ruleset_signals: set[str] = field(default_factory=set)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --------------------------------------------------------------------------- #
|
|
60
|
+
# Counting helpers
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
def count_code_scanning(alerts: list[dict], tool_name: str) -> SeverityCounts:
|
|
63
|
+
"""Sum open code-scanning alerts for a single tool, by severity."""
|
|
64
|
+
counts = SeverityCounts()
|
|
65
|
+
for alert in alerts:
|
|
66
|
+
if (alert.get("tool") or {}).get("name") != tool_name:
|
|
67
|
+
continue
|
|
68
|
+
rule = alert.get("rule") or {}
|
|
69
|
+
sev = severity.from_code_scanning(
|
|
70
|
+
rule.get("security_severity_level"), rule.get("severity")
|
|
71
|
+
)
|
|
72
|
+
counts.add(sev)
|
|
73
|
+
return counts
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def count_dependabot(alerts: list[dict]) -> SeverityCounts:
|
|
77
|
+
"""Sum open Dependabot alerts by advisory severity."""
|
|
78
|
+
counts = SeverityCounts()
|
|
79
|
+
for alert in alerts:
|
|
80
|
+
advisory = alert.get("security_advisory") or {}
|
|
81
|
+
vuln = alert.get("security_vulnerability") or {}
|
|
82
|
+
sev = severity.from_name(advisory.get("severity") or vuln.get("severity"))
|
|
83
|
+
counts.add(sev or severity.Severity.LOW)
|
|
84
|
+
return counts
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
# Per-signal classification
|
|
89
|
+
# --------------------------------------------------------------------------- #
|
|
90
|
+
def _code_scanning_tool_signal(
|
|
91
|
+
facts: RepoFacts, signal: SignalType, tool_name: str
|
|
92
|
+
) -> RepoSignal:
|
|
93
|
+
"""Shared four-state logic for the code-scanning-derived signals.
|
|
94
|
+
|
|
95
|
+
A tool is enabled when its analyses are present, OR when an org/branch
|
|
96
|
+
ruleset enforces it for this repo (a central required workflow) -- the
|
|
97
|
+
latter prevents falsely nagging repos whose tool runs from a ruleset.
|
|
98
|
+
"""
|
|
99
|
+
repo = facts.repo
|
|
100
|
+
covered = signal.value in facts.ruleset_signals
|
|
101
|
+
# An indeterminate code-scanning probe (403 forbidden, a 5xx, or a synthetic
|
|
102
|
+
# 0 from a transport failure) is unknown, not a nag -- unless a ruleset
|
|
103
|
+
# already proves the tool is enabled for this repo.
|
|
104
|
+
if not covered and facts.code_scanning_status not in (200, 404):
|
|
105
|
+
detail = (
|
|
106
|
+
"insufficient permission"
|
|
107
|
+
if facts.code_scanning_status == 403
|
|
108
|
+
else "indeterminate"
|
|
109
|
+
)
|
|
110
|
+
return RepoSignal(repo, signal, RepoState.UNKNOWN, detail=detail)
|
|
111
|
+
enabled = covered or (
|
|
112
|
+
facts.code_scanning_status == 200 and tool_name in facts.code_scanning_tools
|
|
113
|
+
)
|
|
114
|
+
if not enabled:
|
|
115
|
+
detail = "code scanning disabled" if facts.code_scanning_status == 404 else f"{tool_name} not enabled"
|
|
116
|
+
return RepoSignal(repo, signal, RepoState.NAG, detail=detail)
|
|
117
|
+
counts = count_code_scanning(facts.code_scanning_alerts, tool_name)
|
|
118
|
+
# Enabled with no findings is only "clean" when the alert read succeeded;
|
|
119
|
+
# an unreadable sweep (e.g. org-bulk 403/5xx) must not masquerade as clean.
|
|
120
|
+
if counts.total == 0 and facts.code_scanning_alerts_status != 200:
|
|
121
|
+
return RepoSignal(repo, signal, RepoState.UNKNOWN, detail="alert data unavailable")
|
|
122
|
+
state = RepoState.OFFENDER if counts.total else RepoState.CLEAN
|
|
123
|
+
return RepoSignal(repo, signal, state, counts=counts)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def classify_codeql(facts: RepoFacts) -> RepoSignal:
|
|
127
|
+
return _code_scanning_tool_signal(facts, SignalType.CODEQL, "CodeQL")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def classify_zizmor(facts: RepoFacts) -> RepoSignal:
|
|
131
|
+
return _code_scanning_tool_signal(facts, SignalType.ZIZMOR, "zizmor")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def classify_scorecard(facts: RepoFacts) -> RepoSignal:
|
|
135
|
+
"""Scorecard: prefer the external aggregate score, else code-scanning findings."""
|
|
136
|
+
repo = facts.repo
|
|
137
|
+
counts = count_code_scanning(facts.code_scanning_alerts, "Scorecard")
|
|
138
|
+
has_cs = "Scorecard" in facts.code_scanning_tools and facts.code_scanning_status == 200
|
|
139
|
+
|
|
140
|
+
if facts.scorecard_status == 200 and facts.scorecard_score is not None:
|
|
141
|
+
# A perfect 10 with no findings is clean; anything else is an offender.
|
|
142
|
+
clean = facts.scorecard_score >= 10.0 and counts.total == 0
|
|
143
|
+
state = RepoState.CLEAN if clean else RepoState.OFFENDER
|
|
144
|
+
return RepoSignal(repo, SignalType.SCORECARD, state, counts=counts, score=facts.scorecard_score)
|
|
145
|
+
if has_cs:
|
|
146
|
+
if counts.total == 0 and facts.code_scanning_alerts_status != 200:
|
|
147
|
+
return RepoSignal(repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="alert data unavailable")
|
|
148
|
+
state = RepoState.OFFENDER if counts.total else RepoState.CLEAN
|
|
149
|
+
return RepoSignal(repo, SignalType.SCORECARD, state, counts=counts)
|
|
150
|
+
# An indeterminate external request (a 403 forbidden/blocked, a 5xx
|
|
151
|
+
# including the synthetic 503 a transport failure produces, or a forbidden
|
|
152
|
+
# code-scanning probe) is unknown, not a definitive nag. Only a clean 404
|
|
153
|
+
# with no code-scanning Scorecard data means "no results".
|
|
154
|
+
if facts.code_scanning_status == 403 or facts.scorecard_status not in (200, 404):
|
|
155
|
+
return RepoSignal(repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="indeterminate")
|
|
156
|
+
return RepoSignal(repo, SignalType.SCORECARD, RepoState.NAG, detail="no Scorecard results")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def classify_secret_scanning(facts: RepoFacts) -> RepoSignal:
|
|
160
|
+
repo = facts.repo
|
|
161
|
+
if facts.secret_scanning_status == 403:
|
|
162
|
+
return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.UNKNOWN, detail="insufficient permission")
|
|
163
|
+
if facts.secret_scanning_status == 404:
|
|
164
|
+
return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.NAG, detail="secret scanning disabled")
|
|
165
|
+
# Flat open count; stored as HIGH so ranking (more == worse) works and the
|
|
166
|
+
# repo-mode gate treats leaked secrets as serious, without conflating them
|
|
167
|
+
# with CRITICAL code findings (which would make --fail-threshold critical
|
|
168
|
+
# trip on any secret alert). Rendered as a single total because
|
|
169
|
+
# SignalType.uses_severity_columns is False for secret scanning.
|
|
170
|
+
counts = SeverityCounts(high=facts.secret_scanning_open)
|
|
171
|
+
# Positive evidence of leaked secrets is actionable even when the read was
|
|
172
|
+
# incomplete (a later page failed) or the enablement probe was indeterminate.
|
|
173
|
+
if facts.secret_scanning_open:
|
|
174
|
+
return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.OFFENDER, counts=counts)
|
|
175
|
+
# A zero count is only "clean" when both the enablement probe and the
|
|
176
|
+
# open-count read succeeded; an indeterminate status (5xx) or an unreadable
|
|
177
|
+
# sweep cannot confirm the repo is clean.
|
|
178
|
+
if facts.secret_scanning_status != 200 or facts.secret_scanning_open_status != 200:
|
|
179
|
+
return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.UNKNOWN, detail="alert data unavailable")
|
|
180
|
+
return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.CLEAN, counts=counts)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def classify_dependabot(facts: RepoFacts) -> RepoSignal:
|
|
184
|
+
repo = facts.repo
|
|
185
|
+
if facts.dependabot_enabled is None:
|
|
186
|
+
return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="indeterminate")
|
|
187
|
+
if facts.dependabot_enabled is False:
|
|
188
|
+
return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.NAG, detail="Dependabot alerts disabled")
|
|
189
|
+
counts = count_dependabot(facts.dependabot_alerts)
|
|
190
|
+
# Enabled with no alerts is only "clean" when the alert read succeeded.
|
|
191
|
+
if counts.total == 0 and facts.dependabot_alerts_status != 200:
|
|
192
|
+
return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="alert data unavailable")
|
|
193
|
+
state = RepoState.OFFENDER if counts.total else RepoState.CLEAN
|
|
194
|
+
return RepoSignal(repo, SignalType.DEPENDABOT, state, counts=counts)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_CLASSIFIERS = (
|
|
198
|
+
classify_codeql,
|
|
199
|
+
classify_scorecard,
|
|
200
|
+
classify_zizmor,
|
|
201
|
+
classify_dependabot,
|
|
202
|
+
classify_secret_scanning,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def classify_repo(facts: RepoFacts) -> list[RepoSignal]:
|
|
207
|
+
"""Classify a repository across all five signals."""
|
|
208
|
+
return [classifier(facts) for classifier in _CLASSIFIERS]
|