commitguardian 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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""``commitguard github``: GitHub Actions guidance and GitHub App operations.
|
|
2
|
+
|
|
3
|
+
* ``setup`` - local, read-only Actions workflow guidance (no API access);
|
|
4
|
+
* ``validate`` - check GitHub App configuration and, unless ``--offline``,
|
|
5
|
+
authenticate to GitHub and verify permissions and installations;
|
|
6
|
+
* ``webhook-test`` - verify and normalise a webhook payload locally (no network,
|
|
7
|
+
no scan, no repository code);
|
|
8
|
+
* ``serve`` - run the GitHub App webhook service (development server).
|
|
9
|
+
|
|
10
|
+
No command configures or verifies branch protection, and no command prints the
|
|
11
|
+
private key, the webhook secret, JWTs or installation tokens.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Annotated
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from commitguard.cli.output import ExitCode, handled_errors, info, supports_unicode
|
|
22
|
+
from commitguard.config.sources import load_mandatory_policy
|
|
23
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
24
|
+
from commitguard.git.repository import Repository
|
|
25
|
+
from commitguard.github.errors import WebhookValidationError, safe_text
|
|
26
|
+
from commitguard.github.events import (
|
|
27
|
+
CheckRunRerequestedEvent,
|
|
28
|
+
CheckSuiteRerequestedEvent,
|
|
29
|
+
IgnoredEvent,
|
|
30
|
+
InstallationEvent,
|
|
31
|
+
InstallationRepositoriesEvent,
|
|
32
|
+
MergeGroupEvent,
|
|
33
|
+
PullRequestEvent,
|
|
34
|
+
PushEvent,
|
|
35
|
+
normalize_webhook,
|
|
36
|
+
)
|
|
37
|
+
from commitguard.github.permissions import (
|
|
38
|
+
OPTIONAL_PERMISSIONS,
|
|
39
|
+
OPTIONAL_WEBHOOK_EVENTS,
|
|
40
|
+
REQUIRED_PERMISSIONS,
|
|
41
|
+
WEBHOOK_EVENTS,
|
|
42
|
+
excessive_permissions,
|
|
43
|
+
missing_permissions,
|
|
44
|
+
)
|
|
45
|
+
from commitguard.github.pull_requests import disposition
|
|
46
|
+
from commitguard.github.settings import (
|
|
47
|
+
ENV_DATA_DIR,
|
|
48
|
+
ENV_MANDATORY_POLICY_FILE,
|
|
49
|
+
private_key_file_too_open,
|
|
50
|
+
read_app_id,
|
|
51
|
+
read_private_key,
|
|
52
|
+
read_webhook_secret,
|
|
53
|
+
)
|
|
54
|
+
from commitguard.github.webhooks import MAX_WEBHOOK_BYTES, parse_json_object, verify_signature
|
|
55
|
+
from commitguard.github.workflow import (
|
|
56
|
+
CHECK_NAME,
|
|
57
|
+
WORKFLOW_FILE,
|
|
58
|
+
WorkflowIssueLevel,
|
|
59
|
+
inspect_repository_workflows,
|
|
60
|
+
)
|
|
61
|
+
from commitguard.observability.logging import configure_json_logging
|
|
62
|
+
from commitguard.security.sanitization import sanitize_for_terminal
|
|
63
|
+
from commitguard.utils.filesystem import read_bytes_limited
|
|
64
|
+
|
|
65
|
+
if TYPE_CHECKING: # the App needs the optional 'app' extra (cryptography) and HTTP modules
|
|
66
|
+
from commitguard.github.auth import AppCredentials
|
|
67
|
+
from commitguard.github.client import Transport
|
|
68
|
+
|
|
69
|
+
github_app = typer.Typer(
|
|
70
|
+
help="GitHub enforcement: Actions setup guidance and the CommitGuard GitHub App.",
|
|
71
|
+
no_args_is_help=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Replaced in tests; None means HTTPS to api.github.com via the standard library.
|
|
75
|
+
transport_factory: "Callable[[], Transport] | None" = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _app_modules_available() -> None:
|
|
79
|
+
try:
|
|
80
|
+
import cryptography # noqa: F401
|
|
81
|
+
except ImportError:
|
|
82
|
+
raise CommitGuardError(
|
|
83
|
+
"the GitHub App needs the optional dependencies. CommitGuard is not on PyPI "
|
|
84
|
+
"(that name belongs to an unrelated project); install from source, e.g. "
|
|
85
|
+
'python -m pip install "commitguard[app] @ '
|
|
86
|
+
'git+https://github.com/oyinlola-tech/commitguard@<commit-sha>"'
|
|
87
|
+
) from None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
GUIDANCE = """\
|
|
91
|
+
To make the check authoritative (repository Settings -> Rules / Branches):
|
|
92
|
+
|
|
93
|
+
1. Protect the target branch (e.g. main) with a ruleset or branch protection rule.
|
|
94
|
+
2. Require a pull request before merging; do not allow bypassing, and
|
|
95
|
+
block direct pushes (restrict who can push / "Restrict updates").
|
|
96
|
+
3. Require status checks to pass, and add the check "{check}".
|
|
97
|
+
GitHub lists it under that job name after the workflow has run once.
|
|
98
|
+
4. If you use a merge queue, keep the merge_group trigger in the workflow.
|
|
99
|
+
5. Protect the enforcement files: require code owner review for
|
|
100
|
+
.github/workflows/ and .commitguard.yaml (CODEOWNERS), because a pull
|
|
101
|
+
request can edit the workflow that checks it. Organisations can instead
|
|
102
|
+
require the workflow from a separate repository with a ruleset.
|
|
103
|
+
|
|
104
|
+
Without these settings the workflow only reports; it does not prevent merges.
|
|
105
|
+
A workflow triggered by push runs after the commits are already on GitHub.
|
|
106
|
+
CommitGuard cannot verify these settings locally."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def setup_command() -> None:
|
|
110
|
+
"""Show GitHub workflow status, the required check name and setup steps."""
|
|
111
|
+
ok, cross = ("✓", "✗") if supports_unicode() else ("OK", "X")
|
|
112
|
+
bang = "!"
|
|
113
|
+
symbol = {
|
|
114
|
+
WorkflowIssueLevel.OK: "-",
|
|
115
|
+
WorkflowIssueLevel.WARN: bang,
|
|
116
|
+
WorkflowIssueLevel.FAIL: cross,
|
|
117
|
+
}
|
|
118
|
+
with handled_errors():
|
|
119
|
+
repository = Repository.discover()
|
|
120
|
+
inspections = inspect_repository_workflows(repository.root)
|
|
121
|
+
|
|
122
|
+
info("CommitGuard GitHub setup")
|
|
123
|
+
info("")
|
|
124
|
+
if not inspections:
|
|
125
|
+
info(f"{cross} No workflow runs CommitGuard.")
|
|
126
|
+
info(
|
|
127
|
+
f" Create {WORKFLOW_FILE.as_posix()} with: commitguard init --github "
|
|
128
|
+
"--action-repository OWNER/REPO --action-ref <commit sha>"
|
|
129
|
+
)
|
|
130
|
+
raise typer.Exit(code=int(ExitCode.ERROR))
|
|
131
|
+
failed = False
|
|
132
|
+
for inspection in inspections:
|
|
133
|
+
path = inspection.path.relative_to(repository.root).as_posix()
|
|
134
|
+
info(f"{ok} {sanitize_for_terminal(path)}")
|
|
135
|
+
for name in inspection.check_names:
|
|
136
|
+
info(f" Required check name: {sanitize_for_terminal(name)}")
|
|
137
|
+
for issue in inspection.issues:
|
|
138
|
+
failed = failed or issue.level is WorkflowIssueLevel.FAIL
|
|
139
|
+
info(
|
|
140
|
+
f" {symbol[issue.level]} {sanitize_for_terminal(issue.message, max_length=300)}"
|
|
141
|
+
)
|
|
142
|
+
info("")
|
|
143
|
+
info(GUIDANCE.format(check=CHECK_NAME))
|
|
144
|
+
if failed:
|
|
145
|
+
raise typer.Exit(code=int(ExitCode.ERROR))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
github_app.command("setup")(setup_command)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _Checklist:
|
|
152
|
+
def __init__(self) -> None:
|
|
153
|
+
self.ok, self.cross = ("✓", "✗") if supports_unicode() else ("OK", "X")
|
|
154
|
+
self.bang = "!"
|
|
155
|
+
self.failed = False
|
|
156
|
+
|
|
157
|
+
def passed(self, label: str, detail: str = "") -> None:
|
|
158
|
+
info(f"{self.ok} {label}" + (f": {detail}" if detail else ""))
|
|
159
|
+
|
|
160
|
+
def fail(self, label: str, detail: str) -> None:
|
|
161
|
+
self.failed = True
|
|
162
|
+
info(f"{self.cross} {label}: {safe_text(detail, 400)}")
|
|
163
|
+
|
|
164
|
+
def warn(self, label: str, detail: str) -> None:
|
|
165
|
+
info(f"{self.bang} {label}: {safe_text(detail, 400)}")
|
|
166
|
+
|
|
167
|
+
def skip(self, label: str, detail: str) -> None:
|
|
168
|
+
info(f"- {label}: {detail}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@github_app.command("validate")
|
|
172
|
+
def validate_command(
|
|
173
|
+
installation_id: Annotated[
|
|
174
|
+
int | None,
|
|
175
|
+
typer.Option("--installation-id", min=1, help="Also mint a token for this installation."),
|
|
176
|
+
] = None,
|
|
177
|
+
offline: Annotated[
|
|
178
|
+
bool,
|
|
179
|
+
typer.Option("--offline", help="Check local configuration only; do not contact GitHub."),
|
|
180
|
+
] = False,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Validate GitHub App configuration, authentication, installations and permissions."""
|
|
183
|
+
env = os.environ
|
|
184
|
+
check = _Checklist()
|
|
185
|
+
info("CommitGuard GitHub Configuration")
|
|
186
|
+
info("")
|
|
187
|
+
|
|
188
|
+
app_id = None
|
|
189
|
+
try:
|
|
190
|
+
app_id = read_app_id(env)
|
|
191
|
+
check.passed("App ID")
|
|
192
|
+
except CommitGuardError as exc:
|
|
193
|
+
check.fail("App ID", str(exc))
|
|
194
|
+
|
|
195
|
+
credentials = None
|
|
196
|
+
try:
|
|
197
|
+
_app_modules_available()
|
|
198
|
+
from commitguard.github.auth import AppCredentials
|
|
199
|
+
|
|
200
|
+
credentials = AppCredentials(app_id or 1, read_private_key(env))
|
|
201
|
+
check.passed("Private key")
|
|
202
|
+
if private_key_file_too_open(env):
|
|
203
|
+
check.warn("Private key", "the key file is readable by other users (chmod 600)")
|
|
204
|
+
except CommitGuardError as exc:
|
|
205
|
+
check.fail("Private key", str(exc))
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
read_webhook_secret(env)
|
|
209
|
+
check.passed("Webhook secret")
|
|
210
|
+
except CommitGuardError as exc:
|
|
211
|
+
check.fail("Webhook secret", str(exc))
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
from commitguard.github.repositories import require_mirror_git
|
|
215
|
+
|
|
216
|
+
require_mirror_git()
|
|
217
|
+
check.passed("Git version")
|
|
218
|
+
except CommitGuardError as exc:
|
|
219
|
+
check.fail("Git version", str(exc))
|
|
220
|
+
|
|
221
|
+
data_dir = env.get(ENV_DATA_DIR, "")
|
|
222
|
+
if data_dir and Path(data_dir).is_absolute():
|
|
223
|
+
check.passed("Data directory")
|
|
224
|
+
else:
|
|
225
|
+
check.fail("Data directory", f"{ENV_DATA_DIR} must be set to an absolute path")
|
|
226
|
+
|
|
227
|
+
policy_file = env.get(ENV_MANDATORY_POLICY_FILE)
|
|
228
|
+
if policy_file:
|
|
229
|
+
try:
|
|
230
|
+
load_mandatory_policy(Path(policy_file))
|
|
231
|
+
check.passed("Mandatory policy")
|
|
232
|
+
except CommitGuardError as exc:
|
|
233
|
+
check.fail("Mandatory policy", str(exc))
|
|
234
|
+
|
|
235
|
+
if offline:
|
|
236
|
+
check.skip("GitHub authentication", "skipped (--offline)")
|
|
237
|
+
info("")
|
|
238
|
+
info("Status:")
|
|
239
|
+
info("NOT READY" if check.failed else "CONFIGURATION VALID (GitHub not contacted)")
|
|
240
|
+
raise typer.Exit(code=int(ExitCode.ERROR if check.failed else ExitCode.OK))
|
|
241
|
+
|
|
242
|
+
if credentials is None or app_id is None:
|
|
243
|
+
check.skip("GitHub authentication", "not attempted (fix the configuration above)")
|
|
244
|
+
else:
|
|
245
|
+
_validate_online(check, credentials, installation_id)
|
|
246
|
+
|
|
247
|
+
info("")
|
|
248
|
+
info("Status:")
|
|
249
|
+
info("NOT READY" if check.failed else "READY")
|
|
250
|
+
raise typer.Exit(code=int(ExitCode.ERROR if check.failed else ExitCode.OK))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _validate_online(
|
|
254
|
+
check: _Checklist, credentials: "AppCredentials", installation_id: int | None
|
|
255
|
+
) -> None:
|
|
256
|
+
from commitguard.github.auth import InstallationTokenProvider
|
|
257
|
+
from commitguard.github.client import GitHubClient
|
|
258
|
+
|
|
259
|
+
client = GitHubClient(transport_factory() if transport_factory else None)
|
|
260
|
+
try:
|
|
261
|
+
app = client.get_app(credentials.create_jwt())
|
|
262
|
+
check.passed("GitHub authentication", f"App {safe_text(app.slug, 80)}")
|
|
263
|
+
except CommitGuardError as exc:
|
|
264
|
+
check.fail("GitHub authentication", str(exc))
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
missing = missing_permissions(app.permissions)
|
|
268
|
+
if missing:
|
|
269
|
+
check.fail(
|
|
270
|
+
"Required permissions",
|
|
271
|
+
"missing " + ", ".join(f"{k}: {v}" for k, v in missing.items()),
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
check.passed(
|
|
275
|
+
"Required permissions", ", ".join(f"{k}: {v}" for k, v in REQUIRED_PERMISSIONS.items())
|
|
276
|
+
)
|
|
277
|
+
extra = excessive_permissions(app.permissions)
|
|
278
|
+
if extra:
|
|
279
|
+
check.warn(
|
|
280
|
+
"Least privilege",
|
|
281
|
+
"not needed by CommitGuard: " + ", ".join(f"{k}: {v}" for k, v in extra.items()),
|
|
282
|
+
)
|
|
283
|
+
always_sent = {"installation", "installation_repositories"}
|
|
284
|
+
missing_events = sorted(set(WEBHOOK_EVENTS) - set(app.events) - always_sent)
|
|
285
|
+
if missing_events:
|
|
286
|
+
# GitHub always delivers installation events to Apps; they are not listed.
|
|
287
|
+
check.fail("Webhook events", "not subscribed: " + ", ".join(missing_events))
|
|
288
|
+
else:
|
|
289
|
+
check.passed("Webhook events")
|
|
290
|
+
optional_events = sorted(set(OPTIONAL_WEBHOOK_EVENTS) - set(app.events))
|
|
291
|
+
optional_permissions = missing_permissions(app.permissions, OPTIONAL_PERMISSIONS)
|
|
292
|
+
if optional_events or optional_permissions:
|
|
293
|
+
details = []
|
|
294
|
+
if optional_events:
|
|
295
|
+
details.append("not subscribed: " + ", ".join(optional_events))
|
|
296
|
+
if optional_permissions:
|
|
297
|
+
details.append(
|
|
298
|
+
"missing " + ", ".join(f"{k}: {v}" for k, v in optional_permissions.items())
|
|
299
|
+
)
|
|
300
|
+
check.warn(
|
|
301
|
+
"Re-runs and merge queue",
|
|
302
|
+
"; ".join(details) + " (GitHub re-run requests and merge queue validation are off)",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
installations = client.list_app_installations(credentials.create_jwt())
|
|
307
|
+
except CommitGuardError as exc:
|
|
308
|
+
check.fail("Installation access", str(exc))
|
|
309
|
+
return
|
|
310
|
+
if not installations:
|
|
311
|
+
check.fail("Installation access", "the App is not installed on any account")
|
|
312
|
+
return
|
|
313
|
+
selected = [i for i in installations if installation_id in (None, i.id)]
|
|
314
|
+
if installation_id is not None and not selected:
|
|
315
|
+
check.fail("Installation access", f"installation {installation_id} not found")
|
|
316
|
+
return
|
|
317
|
+
for item in selected:
|
|
318
|
+
suspended = " (suspended)" if item.suspended_at else ""
|
|
319
|
+
label = (
|
|
320
|
+
f"installation {item.id} ({item.account.type.value} {item.account.login}){suspended}"
|
|
321
|
+
)
|
|
322
|
+
installation_missing = missing_permissions(item.permissions)
|
|
323
|
+
if installation_missing:
|
|
324
|
+
check.fail(
|
|
325
|
+
"Installation permissions",
|
|
326
|
+
f"{label} has not granted " + ", ".join(installation_missing),
|
|
327
|
+
)
|
|
328
|
+
if installation_id is None:
|
|
329
|
+
check.passed("Installation access", f"{len(installations)} installation(s)")
|
|
330
|
+
return
|
|
331
|
+
try:
|
|
332
|
+
provider = InstallationTokenProvider(credentials, client)
|
|
333
|
+
token = provider.token(installation_id, None)
|
|
334
|
+
repositories = client.list_installation_repositories(token.token)
|
|
335
|
+
check.passed(
|
|
336
|
+
"Installation access",
|
|
337
|
+
f"installation {installation_id}: {len(repositories)} repository(ies) accessible",
|
|
338
|
+
)
|
|
339
|
+
provider.invalidate(installation_id)
|
|
340
|
+
except CommitGuardError as exc:
|
|
341
|
+
check.fail("Installation access", str(exc))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@github_app.command("webhook-test")
|
|
345
|
+
def webhook_test_command(
|
|
346
|
+
payload: Annotated[Path, typer.Argument(help="Webhook payload JSON file.", dir_okay=False)],
|
|
347
|
+
event: Annotated[str, typer.Option("--event", help="X-GitHub-Event value, e.g. push.")],
|
|
348
|
+
signature: Annotated[
|
|
349
|
+
str | None,
|
|
350
|
+
typer.Option(
|
|
351
|
+
"--signature",
|
|
352
|
+
help="X-Hub-Signature-256 value to verify with COMMITGUARD_GITHUB_WEBHOOK_SECRET.",
|
|
353
|
+
),
|
|
354
|
+
] = None,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Verify and normalise a webhook payload locally (no network, no scan)."""
|
|
357
|
+
check = _Checklist()
|
|
358
|
+
info("CommitGuard webhook test")
|
|
359
|
+
info("")
|
|
360
|
+
try:
|
|
361
|
+
body = read_bytes_limited(payload, max_bytes=MAX_WEBHOOK_BYTES)
|
|
362
|
+
except (OSError, CommitGuardError) as exc:
|
|
363
|
+
check.fail("Payload", str(exc) if isinstance(exc, CommitGuardError) else "cannot be read")
|
|
364
|
+
raise typer.Exit(code=int(ExitCode.ERROR)) from None
|
|
365
|
+
if signature is None:
|
|
366
|
+
check.skip("Signature", "not checked (pass --signature to verify)")
|
|
367
|
+
else:
|
|
368
|
+
try:
|
|
369
|
+
verify_signature(read_webhook_secret(os.environ), body, signature)
|
|
370
|
+
check.passed("Signature")
|
|
371
|
+
except CommitGuardError as exc:
|
|
372
|
+
check.fail("Signature", str(exc))
|
|
373
|
+
try:
|
|
374
|
+
normalized = normalize_webhook(event, parse_json_object(body))
|
|
375
|
+
check.passed("Payload", f"{event} event is well-formed")
|
|
376
|
+
except WebhookValidationError as exc:
|
|
377
|
+
check.fail("Payload", str(exc))
|
|
378
|
+
raise typer.Exit(code=int(ExitCode.ERROR)) from None
|
|
379
|
+
|
|
380
|
+
if isinstance(normalized, IgnoredEvent):
|
|
381
|
+
info(f" Result: ignored ({safe_text(normalized.reason, 100)})")
|
|
382
|
+
elif isinstance(normalized, InstallationEvent):
|
|
383
|
+
info(f" Installation: {normalized.installation_id} ({normalized.account.login})")
|
|
384
|
+
info(f" Action: {normalized.action.value}")
|
|
385
|
+
elif isinstance(normalized, InstallationRepositoriesEvent):
|
|
386
|
+
info(f" Installation: {normalized.installation_id} ({normalized.account.login})")
|
|
387
|
+
info(f" Repositories added: {len(normalized.added)}, removed: {len(normalized.removed)}")
|
|
388
|
+
elif isinstance(normalized, CheckRunRerequestedEvent | CheckSuiteRerequestedEvent):
|
|
389
|
+
info(f" Installation: {normalized.installation_id}")
|
|
390
|
+
info(f" Repository: {normalized.repository.full_name} (id {normalized.repository.id})")
|
|
391
|
+
info(f" Commit: {normalized.head_sha[:12]}")
|
|
392
|
+
info(" Result: re-run of the stored CommitGuard scan for this commit (if one exists)")
|
|
393
|
+
elif isinstance(normalized, MergeGroupEvent):
|
|
394
|
+
info(f" Installation: {normalized.installation_id}")
|
|
395
|
+
info(f" Repository: {normalized.repository.full_name} (id {normalized.repository.id})")
|
|
396
|
+
info(f" Merge group: {normalized.base_sha[:12]}..{normalized.head_sha[:12]}")
|
|
397
|
+
info(f" Action: {normalized.action.value}")
|
|
398
|
+
info(
|
|
399
|
+
" Result: "
|
|
400
|
+
+ ("scan the merge group commit" if normalized.reason is None else "merge group ended")
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
info(f" Installation: {normalized.installation_id}")
|
|
404
|
+
info(f" Repository: {normalized.repository.full_name} (id {normalized.repository.id})")
|
|
405
|
+
ctx = normalized.context
|
|
406
|
+
if isinstance(normalized, PullRequestEvent):
|
|
407
|
+
info(f" Pull request: #{normalized.number} ({normalized.action})")
|
|
408
|
+
info(f" Range: {(ctx.base_sha or '')[:12]}..{(ctx.head_sha or '')[:12]}")
|
|
409
|
+
info(f" Result: {disposition(normalized).value}")
|
|
410
|
+
elif isinstance(normalized, PushEvent):
|
|
411
|
+
info(f" Ref: {safe_text(ctx.ref or '', 200)}")
|
|
412
|
+
if ctx.ref_deleted:
|
|
413
|
+
info(" Result: ignored (branch deleted)")
|
|
414
|
+
else:
|
|
415
|
+
info(
|
|
416
|
+
f" Commits: {(ctx.before_sha or 'new ref')[:12]}..{(ctx.after_sha or '')[:12]}"
|
|
417
|
+
)
|
|
418
|
+
info(" Result: scan")
|
|
419
|
+
raise typer.Exit(code=int(ExitCode.ERROR if check.failed else ExitCode.OK))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@github_app.command("serve")
|
|
423
|
+
def serve_command(
|
|
424
|
+
host: Annotated[str, typer.Option("--host", help="Bind address.")] = "127.0.0.1",
|
|
425
|
+
port: Annotated[int, typer.Option("--port", min=1, max=65535)] = 8080,
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Run the GitHub App service: webhooks, and the dashboard when configured.
|
|
428
|
+
|
|
429
|
+
Put a TLS-terminating reverse proxy in front of it.
|
|
430
|
+
"""
|
|
431
|
+
with handled_errors():
|
|
432
|
+
_app_modules_available()
|
|
433
|
+
from commitguard.api.hosting import build_dashboard, create_server_app
|
|
434
|
+
from commitguard.api.settings import dashboard_enabled, load_dashboard_settings
|
|
435
|
+
from commitguard.github.app import service_from_environment
|
|
436
|
+
from commitguard.github.server import serve
|
|
437
|
+
|
|
438
|
+
configure_json_logging()
|
|
439
|
+
service = service_from_environment()
|
|
440
|
+
dashboard = (
|
|
441
|
+
build_dashboard(service, load_dashboard_settings()) if dashboard_enabled() else None
|
|
442
|
+
)
|
|
443
|
+
service.start()
|
|
444
|
+
try:
|
|
445
|
+
serve(create_server_app(service, dashboard), host=host, port=port)
|
|
446
|
+
except KeyboardInterrupt:
|
|
447
|
+
pass
|
|
448
|
+
finally:
|
|
449
|
+
service.stop()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""``commitguard hook <name>``: the stable interface called by installed Git hooks.
|
|
2
|
+
|
|
3
|
+
The hook scripts in ``.git/hooks`` only locate CommitGuard and call these
|
|
4
|
+
commands; all analysis happens in :mod:`commitguard.services.hooks`.
|
|
5
|
+
|
|
6
|
+
Exit codes: 0 allow (including warnings), 1 block, 2 error. Any error blocks
|
|
7
|
+
the Git operation (fail closed) with instructions to run ``commitguard doctor``.
|
|
8
|
+
All output goes to stderr, as Git expects from hooks.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from commitguard.cli.common import ConfigOption
|
|
20
|
+
from commitguard.cli.output import ExitCode
|
|
21
|
+
from commitguard.cli.render import render_commit_hook_text, render_push_text
|
|
22
|
+
from commitguard.core.decision import Action
|
|
23
|
+
from commitguard.git.repository import Repository
|
|
24
|
+
from commitguard.security.sanitization import sanitize_block
|
|
25
|
+
from commitguard.services.hooks import (
|
|
26
|
+
HookRun,
|
|
27
|
+
run_commit_msg,
|
|
28
|
+
run_pre_commit,
|
|
29
|
+
run_pre_push,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
MAX_PRE_PUSH_STDIN_BYTES = 8 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
hook_app = typer.Typer(
|
|
35
|
+
help="Entry points used by installed Git hooks (stable interface).",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
VerboseOption = Annotated[
|
|
40
|
+
bool, typer.Option("--verbose", "-v", help="Show full evidence for every finding.")
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _err(text: str) -> None:
|
|
45
|
+
if text:
|
|
46
|
+
typer.echo(text, err=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def _fail_closed(operation: str) -> Iterator[None]:
|
|
51
|
+
"""Any failure blocks the Git operation with an actionable message (exit 2)."""
|
|
52
|
+
try:
|
|
53
|
+
yield
|
|
54
|
+
except typer.Exit:
|
|
55
|
+
raise
|
|
56
|
+
except Exception as exc: # noqa: BLE001 - every failure must block, never allow
|
|
57
|
+
reason = sanitize_block(str(exc) or type(exc).__name__, max_length=2000)
|
|
58
|
+
_err(
|
|
59
|
+
"CommitGuard could not verify repository policy.\n"
|
|
60
|
+
f"Reason: {reason}\n"
|
|
61
|
+
f"{operation} blocked because the security check could not be completed.\n"
|
|
62
|
+
"Run: commitguard doctor"
|
|
63
|
+
)
|
|
64
|
+
raise typer.Exit(code=int(ExitCode.ERROR)) from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _removal_notice(run: HookRun) -> str:
|
|
68
|
+
"""What ``remediation.auto_remove`` deleted. Never silent: the developer
|
|
69
|
+
wrote those lines and must see that they are gone."""
|
|
70
|
+
lines = [
|
|
71
|
+
"CommitGuard",
|
|
72
|
+
f"! REMOVED prohibited attribution from the commit message "
|
|
73
|
+
f"({len(run.removed)} line{'s' if len(run.removed) > 1 else ''})",
|
|
74
|
+
"",
|
|
75
|
+
]
|
|
76
|
+
lines += [
|
|
77
|
+
f" line {removed.line_number}: {sanitize_block(removed.text, max_length=200)}"
|
|
78
|
+
f" [{removed.rule_id}]"
|
|
79
|
+
for removed in run.removed
|
|
80
|
+
]
|
|
81
|
+
lines += [
|
|
82
|
+
"",
|
|
83
|
+
" The commit was created without them.",
|
|
84
|
+
" Turn this off with `remediation: {auto_remove: false}` to block instead.",
|
|
85
|
+
]
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _finish(run: HookRun, text: str) -> None:
|
|
90
|
+
if not run.enabled:
|
|
91
|
+
_err(
|
|
92
|
+
f"CommitGuard: {run.hook.value} enforcement is disabled by configuration; "
|
|
93
|
+
"no checks were run (see `commitguard doctor`)."
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
if run.removed:
|
|
97
|
+
_err(_removal_notice(run))
|
|
98
|
+
_err(text)
|
|
99
|
+
if run.report is not None and run.report.action is Action.BLOCK:
|
|
100
|
+
raise typer.Exit(code=int(ExitCode.BLOCKED))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@hook_app.command("pre-commit")
|
|
104
|
+
def pre_commit_command(config: ConfigOption = None, verbose: VerboseOption = False) -> None:
|
|
105
|
+
"""Check the pending commit's author/committer identity."""
|
|
106
|
+
with _fail_closed("Commit"):
|
|
107
|
+
run = run_pre_commit(Repository.discover(), config_path=config)
|
|
108
|
+
text = (
|
|
109
|
+
render_commit_hook_text(run.report, stage="pre-commit", verbose=verbose)
|
|
110
|
+
if run.report
|
|
111
|
+
else ""
|
|
112
|
+
)
|
|
113
|
+
_finish(run, text)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@hook_app.command("commit-msg")
|
|
117
|
+
def commit_msg_command(
|
|
118
|
+
message_file: Annotated[Path, typer.Argument(help="Message file passed by Git.")],
|
|
119
|
+
config: ConfigOption = None,
|
|
120
|
+
verbose: VerboseOption = False,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Check the commit message (after Git's default cleanup) and pending identity."""
|
|
123
|
+
with _fail_closed("Commit"):
|
|
124
|
+
run = run_commit_msg(Repository.discover(), message_file, config_path=config)
|
|
125
|
+
text = (
|
|
126
|
+
render_commit_hook_text(run.report, stage="commit-msg", verbose=verbose)
|
|
127
|
+
if run.report
|
|
128
|
+
else ""
|
|
129
|
+
)
|
|
130
|
+
_finish(run, text)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@hook_app.command("pre-push")
|
|
134
|
+
def pre_push_command(
|
|
135
|
+
remote: Annotated[str, typer.Argument(help="Remote name passed by Git.")] = "",
|
|
136
|
+
url: Annotated[str, typer.Argument(help="Remote URL passed by Git.")] = "",
|
|
137
|
+
config: ConfigOption = None,
|
|
138
|
+
verbose: VerboseOption = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Check every commit a push would introduce (ref updates are read from stdin)."""
|
|
141
|
+
with _fail_closed("Push"):
|
|
142
|
+
data = sys.stdin.buffer.read(MAX_PRE_PUSH_STDIN_BYTES + 1)
|
|
143
|
+
if len(data) > MAX_PRE_PUSH_STDIN_BYTES:
|
|
144
|
+
raise ValueError("pre-push input is too large")
|
|
145
|
+
run = run_pre_push(
|
|
146
|
+
Repository.discover(),
|
|
147
|
+
remote or url,
|
|
148
|
+
data.decode("utf-8", errors="replace"),
|
|
149
|
+
config_path=config,
|
|
150
|
+
)
|
|
151
|
+
text = (
|
|
152
|
+
render_push_text(run.report, remote=remote or url, verbose=verbose)
|
|
153
|
+
if run.report
|
|
154
|
+
else ""
|
|
155
|
+
)
|
|
156
|
+
_finish(run, text)
|