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,147 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""CLI support: mode resolution, fail-threshold, and GitHub Actions I/O.
|
|
4
|
+
|
|
5
|
+
Pure helpers kept out of the Typer command so they can be unit-tested. See
|
|
6
|
+
``docs/BRIEF.md`` sections 9-12.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
from github_security_report.models import RepoSignal
|
|
18
|
+
from github_security_report.severity import Severity
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# GitHub Actions output names are alphanumeric plus '-'/'_'. Validating the key
|
|
23
|
+
# (not just the value) stops a non-identifier key -- e.g. one containing a
|
|
24
|
+
# newline, '=' or '<<' -- from corrupting $GITHUB_OUTPUT or injecting outputs.
|
|
25
|
+
_OUTPUT_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Mode(str, Enum):
|
|
29
|
+
ORG = "org"
|
|
30
|
+
REPO = "repo"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ModeError(RuntimeError):
|
|
34
|
+
"""Raised when an operating mode cannot be resolved."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_mode(
|
|
38
|
+
requested: str,
|
|
39
|
+
*,
|
|
40
|
+
has_org_config: bool,
|
|
41
|
+
detected_repo: tuple[str, str] | None,
|
|
42
|
+
) -> Mode:
|
|
43
|
+
"""Resolve the operating mode, logging the outcome loudly.
|
|
44
|
+
|
|
45
|
+
Precedence: an explicit ``org``/``repo`` request wins; ``auto`` resolves to
|
|
46
|
+
org when org config is present, else repo when a repository was detected.
|
|
47
|
+
"""
|
|
48
|
+
requested = requested.lower()
|
|
49
|
+
if requested == "org":
|
|
50
|
+
if not has_org_config:
|
|
51
|
+
raise ModeError("scope 'org' requires organisation configuration")
|
|
52
|
+
mode = Mode.ORG
|
|
53
|
+
elif requested == "repo":
|
|
54
|
+
if detected_repo is None:
|
|
55
|
+
raise ModeError("scope 'repo' requires a detected/--specified repository")
|
|
56
|
+
mode = Mode.REPO
|
|
57
|
+
elif requested == "auto":
|
|
58
|
+
if has_org_config:
|
|
59
|
+
mode = Mode.ORG
|
|
60
|
+
elif detected_repo is not None:
|
|
61
|
+
mode = Mode.REPO
|
|
62
|
+
else:
|
|
63
|
+
raise ModeError(
|
|
64
|
+
"cannot resolve scope: provide config (org mode) or run inside a "
|
|
65
|
+
"GitHub checkout (repo mode)"
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
raise ModeError(f"unknown scope: {requested!r}")
|
|
69
|
+
log.info("resolved operating mode: %s", mode.value)
|
|
70
|
+
return mode
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_THRESHOLDS = {
|
|
74
|
+
"none": None,
|
|
75
|
+
"low": Severity.LOW,
|
|
76
|
+
"medium": Severity.MEDIUM,
|
|
77
|
+
"high": Severity.HIGH,
|
|
78
|
+
"critical": Severity.CRITICAL,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _max_severity(sig: RepoSignal) -> Severity | None:
|
|
83
|
+
c = sig.counts
|
|
84
|
+
if c.critical:
|
|
85
|
+
return Severity.CRITICAL
|
|
86
|
+
if c.high:
|
|
87
|
+
return Severity.HIGH
|
|
88
|
+
if c.medium:
|
|
89
|
+
return Severity.MEDIUM
|
|
90
|
+
if c.low:
|
|
91
|
+
return Severity.LOW
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def should_fail(signals: list[RepoSignal], threshold: str) -> bool:
|
|
96
|
+
"""Whether the run should fail given a severity threshold (repo-mode gate).
|
|
97
|
+
|
|
98
|
+
``none`` never fails; ``any`` fails on any offender; a severity name fails
|
|
99
|
+
when any offending signal has a finding at or above that severity.
|
|
100
|
+
"""
|
|
101
|
+
threshold = threshold.lower()
|
|
102
|
+
if threshold == "none":
|
|
103
|
+
return False
|
|
104
|
+
if threshold == "any":
|
|
105
|
+
return any(s.is_offender for s in signals)
|
|
106
|
+
if threshold not in _THRESHOLDS:
|
|
107
|
+
raise ModeError(f"unknown fail threshold: {threshold!r}")
|
|
108
|
+
floor = _THRESHOLDS[threshold]
|
|
109
|
+
assert floor is not None
|
|
110
|
+
return any(
|
|
111
|
+
s.is_offender and (sev := _max_severity(s)) is not None and sev >= floor
|
|
112
|
+
for s in signals
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_github_output(values: dict[str, str], path: str | None = None) -> None:
|
|
117
|
+
"""Append ``key=value`` pairs to ``$GITHUB_OUTPUT`` (multiline-safe).
|
|
118
|
+
|
|
119
|
+
Multiline values use a unique random delimiter per value, regenerated if it
|
|
120
|
+
ever collides with the value, to prevent output-file injection.
|
|
121
|
+
"""
|
|
122
|
+
target = path or os.environ.get("GITHUB_OUTPUT")
|
|
123
|
+
if not target:
|
|
124
|
+
return
|
|
125
|
+
with open(target, "a", encoding="utf-8") as handle:
|
|
126
|
+
for key, value in values.items():
|
|
127
|
+
if not _OUTPUT_KEY.match(key):
|
|
128
|
+
# A non-identifier key would corrupt the output file; skip it
|
|
129
|
+
# loudly rather than write something injectable.
|
|
130
|
+
log.error("refusing to write unsafe GITHUB_OUTPUT key: %r", key)
|
|
131
|
+
continue
|
|
132
|
+
if "\n" in value:
|
|
133
|
+
delimiter = f"ghadelim_{secrets.token_hex(16)}"
|
|
134
|
+
while delimiter in value:
|
|
135
|
+
delimiter = f"ghadelim_{secrets.token_hex(16)}"
|
|
136
|
+
handle.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
|
137
|
+
else:
|
|
138
|
+
handle.write(f"{key}={value}\n")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def append_step_summary(markdown: str, path: str | None = None) -> None:
|
|
142
|
+
"""Append Markdown to ``$GITHUB_STEP_SUMMARY`` if available."""
|
|
143
|
+
target = path or os.environ.get("GITHUB_STEP_SUMMARY")
|
|
144
|
+
if not target:
|
|
145
|
+
return
|
|
146
|
+
with open(target, "a", encoding="utf-8") as handle:
|
|
147
|
+
handle.write(markdown.rstrip() + "\n")
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Repository scoping and exclusions.
|
|
4
|
+
|
|
5
|
+
Default in-scope set: non-archived, non-fork, non-template source repos. Test
|
|
6
|
+
repositories are excluded by **token-delimited** matching (a ``test``/``tests``
|
|
7
|
+
segment after splitting on ``-_./``), never a raw substring -- so ``latest``,
|
|
8
|
+
``attestation`` and ``contest`` are not dropped. Archived and test repos never
|
|
9
|
+
appear in nag lists. Every exclusion is logged with its reason so nothing is
|
|
10
|
+
dropped silently. See ``docs/BRIEF.md`` section 7.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from github_security_report.models import Repo
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_SEGMENT_SPLIT = re.compile(r"[-_./]+")
|
|
24
|
+
_TEST_SEGMENTS = {"test", "tests"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_test_named(name: str) -> bool:
|
|
28
|
+
"""True when a name segment is exactly ``test`` or ``tests``.
|
|
29
|
+
|
|
30
|
+
Token-delimited, not substring: ``test-action`` and ``foo_test`` match;
|
|
31
|
+
``latest-tag``, ``attestation`` and ``contest`` do not.
|
|
32
|
+
"""
|
|
33
|
+
segments = {seg.lower() for seg in _SEGMENT_SPLIT.split(name) if seg}
|
|
34
|
+
return bool(segments & _TEST_SEGMENTS)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ScopeDecision:
|
|
39
|
+
repo: Repo
|
|
40
|
+
included: bool
|
|
41
|
+
reason: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def decide(
|
|
45
|
+
repo: Repo,
|
|
46
|
+
*,
|
|
47
|
+
include_archived: bool = False,
|
|
48
|
+
include_test: bool = False,
|
|
49
|
+
exclude: frozenset[str] | set[str] | tuple[str, ...] = (),
|
|
50
|
+
) -> ScopeDecision:
|
|
51
|
+
"""Decide whether a single repository is in scope, with a reason."""
|
|
52
|
+
if repo.name in exclude:
|
|
53
|
+
return ScopeDecision(repo, False, "explicitly excluded")
|
|
54
|
+
if repo.fork:
|
|
55
|
+
return ScopeDecision(repo, False, "fork")
|
|
56
|
+
if repo.is_template:
|
|
57
|
+
return ScopeDecision(repo, False, "template")
|
|
58
|
+
if repo.archived and not include_archived:
|
|
59
|
+
return ScopeDecision(repo, False, "archived")
|
|
60
|
+
if is_test_named(repo.name) and not include_test:
|
|
61
|
+
return ScopeDecision(repo, False, "test repository")
|
|
62
|
+
return ScopeDecision(repo, True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def filter_repos(
|
|
66
|
+
repos: list[Repo],
|
|
67
|
+
*,
|
|
68
|
+
include_archived: bool = False,
|
|
69
|
+
include_test: bool = False,
|
|
70
|
+
exclude: tuple[str, ...] = (),
|
|
71
|
+
) -> list[Repo]:
|
|
72
|
+
"""Return the in-scope repositories, logging every exclusion and reason."""
|
|
73
|
+
exclude_set = set(exclude)
|
|
74
|
+
kept: list[Repo] = []
|
|
75
|
+
for repo in repos:
|
|
76
|
+
decision = decide(
|
|
77
|
+
repo,
|
|
78
|
+
include_archived=include_archived,
|
|
79
|
+
include_test=include_test,
|
|
80
|
+
exclude=exclude_set,
|
|
81
|
+
)
|
|
82
|
+
if decision.included:
|
|
83
|
+
kept.append(repo)
|
|
84
|
+
else:
|
|
85
|
+
log.info("excluding %s: %s", repo.full_name, decision.reason)
|
|
86
|
+
log.info("%d repositories in scope (of %d)", len(kept), len(repos))
|
|
87
|
+
return kept
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def in_nag_scope(repo: Repo) -> bool:
|
|
91
|
+
"""Whether a repo may appear in nag lists.
|
|
92
|
+
|
|
93
|
+
Archived and test repos are **never** nagged -- you cannot, or would not,
|
|
94
|
+
enable tooling on them -- even when they are otherwise reported (e.g. under
|
|
95
|
+
``include_archived``/``include_test``).
|
|
96
|
+
"""
|
|
97
|
+
return not (repo.archived or is_test_named(repo.name))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Severity scale and normalisation.
|
|
4
|
+
|
|
5
|
+
Phase 0 (see ``docs/phase0-findings.md``) established two severity vocabularies
|
|
6
|
+
in the code-scanning feed:
|
|
7
|
+
|
|
8
|
+
- ``rule.security_severity_level`` -- critical / high / medium / low -- used by
|
|
9
|
+
CodeQL and Scorecard, and the primary ranking key.
|
|
10
|
+
- ``rule.severity`` -- error / warning / note -- the SARIF level, the only axis
|
|
11
|
+
zizmor populates.
|
|
12
|
+
|
|
13
|
+
To present a single, uniform set of severity columns across every table (as the
|
|
14
|
+
design requires), the SARIF level is normalised onto the security scale when no
|
|
15
|
+
security severity is present: error -> high, warning -> medium, note -> low.
|
|
16
|
+
Dependabot's ``security_advisory.severity`` maps directly.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from enum import IntEnum
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Severity(IntEnum):
|
|
25
|
+
"""Ordered severity. Higher value == more severe (worst-first sorting)."""
|
|
26
|
+
|
|
27
|
+
LOW = 1
|
|
28
|
+
MEDIUM = 2
|
|
29
|
+
HIGH = 3
|
|
30
|
+
CRITICAL = 4
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def label(self) -> str:
|
|
34
|
+
return self.name.lower()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Direct names on the security-severity scale (CodeQL, Scorecard, Dependabot).
|
|
38
|
+
_SECURITY_NAMES: dict[str, Severity] = {
|
|
39
|
+
"critical": Severity.CRITICAL,
|
|
40
|
+
"high": Severity.HIGH,
|
|
41
|
+
"medium": Severity.MEDIUM,
|
|
42
|
+
"moderate": Severity.MEDIUM, # Dependabot uses "moderate"
|
|
43
|
+
"low": Severity.LOW,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# SARIF level -> security scale, used only as a fallback (zizmor).
|
|
47
|
+
_SARIF_LEVEL_NAMES: dict[str, Severity] = {
|
|
48
|
+
"error": Severity.HIGH,
|
|
49
|
+
"warning": Severity.MEDIUM,
|
|
50
|
+
"note": Severity.LOW,
|
|
51
|
+
"none": Severity.LOW,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def from_name(value: str | None) -> Severity | None:
|
|
56
|
+
"""Parse a security-severity name (critical/high/medium/low/moderate)."""
|
|
57
|
+
if not value:
|
|
58
|
+
return None
|
|
59
|
+
return _SECURITY_NAMES.get(value.strip().lower())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def from_sarif_level(value: str | None) -> Severity | None:
|
|
63
|
+
"""Parse a SARIF level (error/warning/note) onto the security scale."""
|
|
64
|
+
if not value:
|
|
65
|
+
return None
|
|
66
|
+
return _SARIF_LEVEL_NAMES.get(value.strip().lower())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def from_code_scanning(
|
|
70
|
+
security_severity_level: str | None,
|
|
71
|
+
sarif_severity: str | None,
|
|
72
|
+
) -> Severity:
|
|
73
|
+
"""Resolve a code-scanning alert's severity.
|
|
74
|
+
|
|
75
|
+
Prefers ``security_severity_level``; falls back to the SARIF ``severity``
|
|
76
|
+
(the zizmor case). Defaults to ``LOW`` when neither is recognised so a
|
|
77
|
+
finding is never silently dropped from ranking.
|
|
78
|
+
"""
|
|
79
|
+
return (
|
|
80
|
+
from_name(security_severity_level)
|
|
81
|
+
or from_sarif_level(sarif_severity)
|
|
82
|
+
or Severity.LOW
|
|
83
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Security reports</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
|
11
|
+
line-height: 1.6; color: #333;
|
|
12
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
13
|
+
min-height: 100vh; padding: 2rem 1rem;
|
|
14
|
+
}
|
|
15
|
+
.container { max-width: 1100px; margin: 0 auto; }
|
|
16
|
+
header { background: #fff; border-radius: 12px; padding: 2rem; margin-bottom: 2rem;
|
|
17
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
|
18
|
+
header h1 { font-size: 2.25rem; color: #2d3748; }
|
|
19
|
+
header .subtitle { color: #718096; margin-top: 0.25rem; }
|
|
20
|
+
header .meta { color: #718096; font-size: 0.9rem; margin-top: 1rem;
|
|
21
|
+
padding-top: 1rem; border-top: 1px solid #e2e8f0; }
|
|
22
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
|
|
23
|
+
.card { background: #fff; border-radius: 12px; padding: 1.5rem; box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
24
|
+
transition: transform 0.2s ease, box-shadow 0.2s ease; }
|
|
25
|
+
.card:hover { transform: translateY(-4px); box-shadow: 0 8px 16px rgba(102,126,234,0.25); }
|
|
26
|
+
.card h2 { font-size: 1.25rem; color: #2d3748; }
|
|
27
|
+
.card .count { color: #718096; font-size: 0.9rem; margin: 0.5rem 0 1rem; }
|
|
28
|
+
.btn { display: inline-block; background: #667eea; color: #fff; padding: 0.5rem 1rem;
|
|
29
|
+
border-radius: 6px; text-decoration: none; font-weight: 500; }
|
|
30
|
+
.btn:hover { background: #5568d3; }
|
|
31
|
+
footer { text-align: center; color: #fff; padding: 2rem; font-size: 0.85rem; }
|
|
32
|
+
footer a { color: #fff; }
|
|
33
|
+
</style>
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<div class="container">
|
|
37
|
+
<header>
|
|
38
|
+
<h1><span aria-hidden="true">🔐</span> Security reports</h1>
|
|
39
|
+
<p class="subtitle">Security and quality posture across GitHub organisations</p>
|
|
40
|
+
<div class="meta">Generated {{ generated_at }} · {{ orgs | length }} organisation(s)</div>
|
|
41
|
+
</header>
|
|
42
|
+
<div class="grid">
|
|
43
|
+
{% for org in orgs %}
|
|
44
|
+
<div class="card">
|
|
45
|
+
<h2>{{ org.name }}</h2>
|
|
46
|
+
<p class="count">{{ org.repo_count }} repositories</p>
|
|
47
|
+
<a class="btn" href="{{ org.slug }}/report.html">View report →</a>
|
|
48
|
+
</div>
|
|
49
|
+
{% endfor %}
|
|
50
|
+
</div>
|
|
51
|
+
<footer>
|
|
52
|
+
Generated by
|
|
53
|
+
<a href="https://github.com/lfreleng-actions/github-security-report-action">github-security-report</a>
|
|
54
|
+
</footer>
|
|
55
|
+
</div>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Security report: {{ org }}</title>
|
|
7
|
+
{#- Simple-DataTables: version-pinned with Subresource Integrity, so the
|
|
8
|
+
browser rejects a tampered/substituted CDN asset. -#}
|
|
9
|
+
<link rel="stylesheet"
|
|
10
|
+
href="https://cdn.jsdelivr.net/npm/simple-datatables@{{ datatables_version }}/dist/style.css"
|
|
11
|
+
integrity="{{ datatables_css_sri }}"
|
|
12
|
+
crossorigin="anonymous">
|
|
13
|
+
<style>
|
|
14
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
15
|
+
body {
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
|
17
|
+
line-height: 1.6; color: #1a202c; background: #f7fafc; padding: 2rem 1rem;
|
|
18
|
+
}
|
|
19
|
+
.container { max-width: 1100px; margin: 0 auto; }
|
|
20
|
+
header {
|
|
21
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
22
|
+
color: #fff; border-radius: 12px; padding: 2rem; margin-bottom: 2rem;
|
|
23
|
+
}
|
|
24
|
+
header h1 { font-size: 2rem; }
|
|
25
|
+
header .meta { opacity: 0.9; font-size: 0.9rem; margin-top: 0.5rem; }
|
|
26
|
+
section { background: #fff; border-radius: 12px; padding: 1.5rem 2rem; margin-bottom: 1.5rem;
|
|
27
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
28
|
+
section h2 { color: #2d3748; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; }
|
|
29
|
+
section h3 { color: #2d3748; margin-top: 1.5rem; font-size: 1.05rem; }
|
|
30
|
+
.note { color: #718096; font-size: 0.85rem; margin-top: 0.5rem; font-style: italic; }
|
|
31
|
+
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
32
|
+
th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid #edf2f7; }
|
|
33
|
+
th { background: #f7fafc; }
|
|
34
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
35
|
+
.clean { color: #2f855a; margin-top: 0.75rem; }
|
|
36
|
+
.nag-title { margin-top: 1rem; font-weight: 600; color: #b7791f; }
|
|
37
|
+
.nag { margin: 0.25rem 0 0 1.25rem; }
|
|
38
|
+
.unknown { color: #718096; font-size: 0.9rem; margin-top: 0.75rem; }
|
|
39
|
+
.empty { color: #718096; font-style: italic; margin-top: 0.5rem; }
|
|
40
|
+
.crit { color: #c53030; font-weight: 600; }
|
|
41
|
+
.partial-banner { background: #fffaf0; border: 1px solid #dd6b20; color: #9c4221;
|
|
42
|
+
border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; }
|
|
43
|
+
.excluded-banner { background: #ebf8ff; border: 1px solid #4299e1; color: #2c5282;
|
|
44
|
+
border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; }
|
|
45
|
+
footer { text-align: center; color: #718096; font-size: 0.85rem; padding: 1rem; }
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<div class="container">
|
|
50
|
+
<header>
|
|
51
|
+
<h1><span aria-hidden="true">🔐</span> Security report: {{ org }}</h1>
|
|
52
|
+
<div class="meta">{{ repo_count }} repositories analysed · generated {{ generated_at }}</div>
|
|
53
|
+
</header>
|
|
54
|
+
|
|
55
|
+
{% if partial %}
|
|
56
|
+
<div class="partial-banner" role="alert">
|
|
57
|
+
<span aria-hidden="true">⚠️</span> <strong>Incomplete:</strong> the repository
|
|
58
|
+
listing could not be fully read, so some repositories may be missing from
|
|
59
|
+
this report.
|
|
60
|
+
</div>
|
|
61
|
+
{% endif %}
|
|
62
|
+
|
|
63
|
+
{% if excluded %}
|
|
64
|
+
<div class="excluded-banner">
|
|
65
|
+
<span aria-hidden="true">⏩</span> <strong>Excluded from analysis ({{ excluded_total }}):</strong>
|
|
66
|
+
{% for r in excluded %}<a href="{{ r.url }}">{{ r.name }}</a>{% if not loop.last %}, {% endif %}{% endfor %}{% if excluded_hidden %} … and {{ excluded_hidden }} more{% endif %}
|
|
67
|
+
</div>
|
|
68
|
+
{% endif %}
|
|
69
|
+
|
|
70
|
+
{% for s in sections %}
|
|
71
|
+
<section>
|
|
72
|
+
<h2>{{ s.title }}</h2>
|
|
73
|
+
{% if s.rows %}
|
|
74
|
+
<table class="dt-enabled">
|
|
75
|
+
<thead>
|
|
76
|
+
<tr>{% for col in s.columns %}<th class="{{ 'num' if not loop.first else '' }}">{{ col }}</th>{% endfor %}</tr>
|
|
77
|
+
</thead>
|
|
78
|
+
<tbody>
|
|
79
|
+
{% for row in s.rows %}
|
|
80
|
+
<tr>
|
|
81
|
+
<td><a href="{{ row.url }}">{{ row.name }}</a></td>
|
|
82
|
+
{% for cell in row.cells %}<td class="num">{{ cell }}</td>{% endfor %}
|
|
83
|
+
</tr>
|
|
84
|
+
{% endfor %}
|
|
85
|
+
</tbody>
|
|
86
|
+
</table>
|
|
87
|
+
{% if s.hidden %}<p class="note">… and {{ s.hidden }} more</p>{% endif %}
|
|
88
|
+
{% endif %}
|
|
89
|
+
{% if s.clean_count %}<p class="clean"><span aria-hidden="true">✅</span> {{ s.clean_count }} repositories clean</p>{% endif %}
|
|
90
|
+
{% if s.nag %}
|
|
91
|
+
<p class="nag-title">Not enabled — enable to appear in future reports:</p>
|
|
92
|
+
<ul class="nag">{% for r in s.nag %}<li><a href="{{ r.url }}">{{ r.name }}</a></li>{% endfor %}{% if s.nag_hidden %}<li>… and {{ s.nag_hidden }} more</li>{% endif %}</ul>
|
|
93
|
+
{% endif %}
|
|
94
|
+
{% if s.unknown_count %}<p class="unknown"><span aria-hidden="true">ℹ️</span> {{ s.unknown_count }} repositories with unknown status (insufficient permission or a transient read failure)</p>{% endif %}
|
|
95
|
+
{% if not (s.rows or s.clean_count or s.nag or s.unknown_count) %}<p class="empty">No data available.</p>{% endif %}
|
|
96
|
+
{% for t in s.extra_tables or [] %}
|
|
97
|
+
<h3>{{ t.title }}</h3>
|
|
98
|
+
{% if t.rows %}
|
|
99
|
+
<table class="dt-enabled">
|
|
100
|
+
<thead>
|
|
101
|
+
<tr>{% for col in t.columns %}<th>{{ col }}</th>{% endfor %}</tr>
|
|
102
|
+
</thead>
|
|
103
|
+
<tbody>
|
|
104
|
+
{% for row in t.rows %}
|
|
105
|
+
<tr>
|
|
106
|
+
<td><a href="{{ row.url }}">{{ row.name }}</a></td>
|
|
107
|
+
{% for cell in row.cells %}<td>{{ cell }}</td>{% endfor %}
|
|
108
|
+
</tr>
|
|
109
|
+
{% endfor %}
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
{% if t.hidden %}<p class="note">… and {{ t.hidden }} more</p>{% endif %}
|
|
113
|
+
{% elif t.empty_note %}<p class="clean"><span aria-hidden="true">✅</span> {{ t.empty_note }}</p>{% endif %}
|
|
114
|
+
{% if t.rows and t.note %}<p class="note">{{ t.note }}</p>{% endif %}
|
|
115
|
+
{% endfor %}
|
|
116
|
+
</section>
|
|
117
|
+
{% endfor %}
|
|
118
|
+
|
|
119
|
+
{% if releases %}
|
|
120
|
+
<section>
|
|
121
|
+
<h2>{{ releases.title }}</h2>
|
|
122
|
+
{% if releases.rows %}
|
|
123
|
+
<table class="dt-enabled">
|
|
124
|
+
<thead>
|
|
125
|
+
<tr>{% for col in releases.columns %}<th>{{ col }}</th>{% endfor %}</tr>
|
|
126
|
+
</thead>
|
|
127
|
+
<tbody>
|
|
128
|
+
{% for row in releases.rows %}
|
|
129
|
+
<tr>
|
|
130
|
+
<td><a href="{{ row.url }}">{{ row.name }}</a></td>
|
|
131
|
+
{% for cell in row.cells %}<td>{{ cell }}</td>{% endfor %}
|
|
132
|
+
</tr>
|
|
133
|
+
{% endfor %}
|
|
134
|
+
</tbody>
|
|
135
|
+
</table>
|
|
136
|
+
{% if releases.hidden %}<p class="note">… and {{ releases.hidden }} more</p>{% endif %}
|
|
137
|
+
{% elif releases.empty_note %}<p class="clean"><span aria-hidden="true">✅</span> {{ releases.empty_note }}</p>{% endif %}
|
|
138
|
+
{% if releases.rows and releases.note %}<p class="note">{{ releases.note }}</p>{% endif %}
|
|
139
|
+
</section>
|
|
140
|
+
{% endif %}
|
|
141
|
+
|
|
142
|
+
<footer>
|
|
143
|
+
Generated by
|
|
144
|
+
<a href="https://github.com/lfreleng-actions/github-security-report-action">github-security-report</a>
|
|
145
|
+
</footer>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@{{ datatables_version }}/dist/umd/simple-datatables.min.js"
|
|
149
|
+
integrity="{{ datatables_js_sri }}"
|
|
150
|
+
crossorigin="anonymous"
|
|
151
|
+
type="text/javascript"></script>
|
|
152
|
+
<script>
|
|
153
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
154
|
+
document.querySelectorAll('table.dt-enabled').forEach(function (table) {
|
|
155
|
+
if (table.querySelectorAll('tbody tr').length < 3) { return; }
|
|
156
|
+
try { new simpleDatatables.DataTable(table, { perPage: 25 }); }
|
|
157
|
+
catch (e) { console.error('DataTable init failed', e); }
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
</script>
|
|
161
|
+
</body>
|
|
162
|
+
</html>
|