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,254 @@
|
|
|
1
|
+
"""Git hook runtime: what ``commitguard hook <name>`` does.
|
|
2
|
+
|
|
3
|
+
Hooks are enforcement points only. Every stage builds commits from Git data
|
|
4
|
+
and runs the same :class:`~commitguard.services.analysis.Analyzer` used by
|
|
5
|
+
``scan``/``check``.
|
|
6
|
+
|
|
7
|
+
============= =========================================== ==================
|
|
8
|
+
Stage Input What is analysed
|
|
9
|
+
============= =========================================== ==================
|
|
10
|
+
pre-commit pending author/committer (``git var``) identity rules
|
|
11
|
+
commit-msg message file after Git's default cleanup message + identity
|
|
12
|
+
plus pending author/committer
|
|
13
|
+
pre-push ref updates on stdin every outgoing commit
|
|
14
|
+
============= =========================================== ==================
|
|
15
|
+
|
|
16
|
+
commit-msg sees the message *before* Git creates the commit object; options
|
|
17
|
+
such as ``--cleanup=verbatim`` are invisible to it. pre-push analyses the real
|
|
18
|
+
commit objects and is the authoritative local check.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ConfigDict
|
|
26
|
+
|
|
27
|
+
from commitguard.config.enforcement import (
|
|
28
|
+
Enforcement,
|
|
29
|
+
Remediation,
|
|
30
|
+
build_enforcement,
|
|
31
|
+
build_remediation,
|
|
32
|
+
)
|
|
33
|
+
from commitguard.config.loader import LoadedConfig
|
|
34
|
+
from commitguard.core.context import ScanTrigger
|
|
35
|
+
from commitguard.core.decision import Action
|
|
36
|
+
from commitguard.exceptions.git import GitError
|
|
37
|
+
from commitguard.git.push import PushUpdate, parse_pre_push_input
|
|
38
|
+
from commitguard.git.ranges import resolve_commit_range
|
|
39
|
+
from commitguard.git.repository import Repository
|
|
40
|
+
from commitguard.services.analysis import (
|
|
41
|
+
MAX_MESSAGE_FILE_BYTES,
|
|
42
|
+
Analyzer,
|
|
43
|
+
build_report,
|
|
44
|
+
load_analyzer,
|
|
45
|
+
pending_commit,
|
|
46
|
+
)
|
|
47
|
+
from commitguard.services.remediation import (
|
|
48
|
+
RemovedLine,
|
|
49
|
+
removable_lines,
|
|
50
|
+
strip_lines,
|
|
51
|
+
with_actual_text,
|
|
52
|
+
)
|
|
53
|
+
from commitguard.services.reports import ScanReport
|
|
54
|
+
from commitguard.utils.filesystem import read_bytes_limited
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HookName(StrEnum):
|
|
58
|
+
PRE_COMMIT = "pre-commit"
|
|
59
|
+
COMMIT_MSG = "commit-msg"
|
|
60
|
+
PRE_PUSH = "pre-push"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UpdateDisposition(StrEnum):
|
|
64
|
+
SCANNED = "scanned"
|
|
65
|
+
DELETED = "deleted" # nothing to analyse
|
|
66
|
+
NON_COMMIT = "non_commit" # e.g. tag pointing at a tree or blob
|
|
67
|
+
UP_TO_DATE = "up_to_date" # no commits the remote does not already have
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UpdatePlan(BaseModel):
|
|
71
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
72
|
+
|
|
73
|
+
update: PushUpdate
|
|
74
|
+
disposition: UpdateDisposition
|
|
75
|
+
commits: tuple[str, ...] = ()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class HookRun(BaseModel):
|
|
79
|
+
"""Outcome of one hook invocation."""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
82
|
+
|
|
83
|
+
hook: HookName
|
|
84
|
+
enabled: bool
|
|
85
|
+
report: ScanReport | None = None
|
|
86
|
+
updates: tuple[UpdatePlan, ...] = ()
|
|
87
|
+
remote: str | None = None
|
|
88
|
+
#: Lines deleted from the pending message by ``remediation.auto_remove``.
|
|
89
|
+
#: Non-empty only when the rewritten message then analysed clean.
|
|
90
|
+
removed: tuple[RemovedLine, ...] = ()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _setup(
|
|
94
|
+
repository: Repository, config_path: Path | None
|
|
95
|
+
) -> tuple[Analyzer, LoadedConfig, Enforcement, Remediation]:
|
|
96
|
+
analyzer, loaded = load_analyzer(repository, config_path=config_path)
|
|
97
|
+
return (
|
|
98
|
+
analyzer,
|
|
99
|
+
loaded,
|
|
100
|
+
build_enforcement(*loaded.configs),
|
|
101
|
+
build_remediation(*loaded.configs),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_pre_commit(repository: Repository, *, config_path: Path | None = None) -> HookRun:
|
|
106
|
+
analyzer, loaded, enforcement, _ = _setup(repository, config_path)
|
|
107
|
+
if not enforcement.pre_commit:
|
|
108
|
+
return HookRun(hook=HookName.PRE_COMMIT, enabled=False)
|
|
109
|
+
author, committer = repository.pending_identities()
|
|
110
|
+
# No message exists yet: only identity-based rules can match.
|
|
111
|
+
report = analyzer.analyze(pending_commit("", author, committer), ScanTrigger.PRE_COMMIT)
|
|
112
|
+
return HookRun(
|
|
113
|
+
hook=HookName.PRE_COMMIT,
|
|
114
|
+
enabled=True,
|
|
115
|
+
report=build_report(
|
|
116
|
+
[report],
|
|
117
|
+
repository=repository,
|
|
118
|
+
target="pending commit",
|
|
119
|
+
trigger=ScanTrigger.PRE_COMMIT,
|
|
120
|
+
config=loaded,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run_commit_msg(
|
|
126
|
+
repository: Repository, message_file: Path, *, config_path: Path | None = None
|
|
127
|
+
) -> HookRun:
|
|
128
|
+
analyzer, loaded, enforcement, remediation = _setup(repository, config_path)
|
|
129
|
+
if not enforcement.commit_msg:
|
|
130
|
+
return HookRun(hook=HookName.COMMIT_MSG, enabled=False)
|
|
131
|
+
raw = read_bytes_limited(message_file, max_bytes=MAX_MESSAGE_FILE_BYTES)
|
|
132
|
+
message = repository.cleanup_message(raw.decode("utf-8", errors="replace"))
|
|
133
|
+
author, committer = repository.pending_identities()
|
|
134
|
+
|
|
135
|
+
def analyse(text: str) -> ScanReport:
|
|
136
|
+
commit = analyzer.analyze(pending_commit(text, author, committer), ScanTrigger.COMMIT_MSG)
|
|
137
|
+
return build_report(
|
|
138
|
+
[commit],
|
|
139
|
+
repository=repository,
|
|
140
|
+
target="pending commit message",
|
|
141
|
+
trigger=ScanTrigger.COMMIT_MSG,
|
|
142
|
+
config=loaded,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
report = analyse(message)
|
|
146
|
+
removed: tuple[RemovedLine, ...] = ()
|
|
147
|
+
if remediation.auto_remove and report.action is Action.BLOCK:
|
|
148
|
+
fixed = _auto_remove(message_file, message, report, analyse)
|
|
149
|
+
if fixed is not None:
|
|
150
|
+
report, removed = fixed
|
|
151
|
+
return HookRun(hook=HookName.COMMIT_MSG, enabled=True, report=report, removed=removed)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _auto_remove(
|
|
155
|
+
message_file: Path,
|
|
156
|
+
message: str,
|
|
157
|
+
report: ScanReport,
|
|
158
|
+
analyse: Callable[[str], ScanReport],
|
|
159
|
+
) -> tuple[ScanReport, tuple[RemovedLine, ...]] | None:
|
|
160
|
+
"""Delete the offending lines, if that genuinely clears the block.
|
|
161
|
+
|
|
162
|
+
Returns the report for the rewritten message and what was removed, or
|
|
163
|
+
``None`` to leave the original block in place. Every path that is not
|
|
164
|
+
provably clean returns ``None``: the caller then blocks as usual.
|
|
165
|
+
"""
|
|
166
|
+
removals = removable_lines(report.commits[0]) if report.commits else None
|
|
167
|
+
if removals is None:
|
|
168
|
+
return None
|
|
169
|
+
rewritten = strip_lines(message, frozenset(r.line_number for r in removals))
|
|
170
|
+
if not rewritten.strip():
|
|
171
|
+
return None # nothing would be left; refuse rather than empty the message
|
|
172
|
+
after = analyse(rewritten)
|
|
173
|
+
if after.action is Action.BLOCK:
|
|
174
|
+
return None # the removal did not fix it: never allow on assumption
|
|
175
|
+
message_file.write_text(rewritten, encoding="utf-8")
|
|
176
|
+
return after, with_actual_text(message, removals)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def plan_push(
|
|
180
|
+
repository: Repository,
|
|
181
|
+
updates: Sequence[PushUpdate],
|
|
182
|
+
remote: str,
|
|
183
|
+
*,
|
|
184
|
+
max_commits: int,
|
|
185
|
+
) -> tuple[list[UpdatePlan], list[str]]:
|
|
186
|
+
"""Work out which commits a push would introduce, deduplicated, oldest first.
|
|
187
|
+
|
|
188
|
+
For each update the commits reachable from the local object but not from
|
|
189
|
+
the remote's current object (when present locally) or from any of the
|
|
190
|
+
remote's tracking refs are selected. That covers new branches (all-zero
|
|
191
|
+
remote object) without rescanning history the remote already has, and
|
|
192
|
+
force pushes (only commits the remote lacks). Deletions have no outgoing
|
|
193
|
+
commits; tags are peeled to the commit they reference.
|
|
194
|
+
"""
|
|
195
|
+
known_remote = repository.remote_tracking_tips(remote)
|
|
196
|
+
plans: list[UpdatePlan] = []
|
|
197
|
+
ordered: dict[str, None] = {}
|
|
198
|
+
for update in updates:
|
|
199
|
+
if update.is_delete:
|
|
200
|
+
plans.append(UpdatePlan(update=update, disposition=UpdateDisposition.DELETED))
|
|
201
|
+
continue
|
|
202
|
+
commit = repository.peel_to_commit(update.local_oid)
|
|
203
|
+
if commit is None:
|
|
204
|
+
plans.append(UpdatePlan(update=update, disposition=UpdateDisposition.NON_COMMIT))
|
|
205
|
+
continue
|
|
206
|
+
exclude = set(known_remote)
|
|
207
|
+
remote_commit = None if update.is_new_ref else repository.peel_to_commit(update.remote_oid)
|
|
208
|
+
if remote_commit is not None:
|
|
209
|
+
exclude.add(remote_commit)
|
|
210
|
+
commits = resolve_commit_range(
|
|
211
|
+
repository, commit, exclude, base=remote_commit, max_count=max_commits
|
|
212
|
+
).commits
|
|
213
|
+
for sha in commits:
|
|
214
|
+
ordered.setdefault(sha, None)
|
|
215
|
+
if len(ordered) > max_commits:
|
|
216
|
+
raise GitError(f"push would introduce more than {max_commits} commits")
|
|
217
|
+
plans.append(
|
|
218
|
+
UpdatePlan(
|
|
219
|
+
update=update,
|
|
220
|
+
disposition=UpdateDisposition.SCANNED if commits else UpdateDisposition.UP_TO_DATE,
|
|
221
|
+
commits=tuple(commits),
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
return plans, list(ordered)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def run_pre_push(
|
|
228
|
+
repository: Repository,
|
|
229
|
+
remote: str,
|
|
230
|
+
stdin_text: str,
|
|
231
|
+
*,
|
|
232
|
+
config_path: Path | None = None,
|
|
233
|
+
) -> HookRun:
|
|
234
|
+
analyzer, loaded, enforcement, _ = _setup(repository, config_path)
|
|
235
|
+
if not enforcement.pre_push:
|
|
236
|
+
return HookRun(hook=HookName.PRE_PUSH, enabled=False, remote=remote)
|
|
237
|
+
updates = parse_pre_push_input(stdin_text)
|
|
238
|
+
plans, shas = plan_push(repository, updates, remote, max_commits=enforcement.max_push_commits)
|
|
239
|
+
reports = [
|
|
240
|
+
analyzer.analyze(commit, ScanTrigger.PRE_PUSH) for commit in repository.read_commits(shas)
|
|
241
|
+
]
|
|
242
|
+
return HookRun(
|
|
243
|
+
hook=HookName.PRE_PUSH,
|
|
244
|
+
enabled=True,
|
|
245
|
+
remote=remote,
|
|
246
|
+
updates=tuple(plans),
|
|
247
|
+
report=build_report(
|
|
248
|
+
reports,
|
|
249
|
+
repository=repository,
|
|
250
|
+
target=f"push to {remote}",
|
|
251
|
+
trigger=ScanTrigger.PRE_PUSH,
|
|
252
|
+
config=loaded,
|
|
253
|
+
),
|
|
254
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Removing prohibited attribution from a pending commit message.
|
|
2
|
+
|
|
3
|
+
Only the ``commit-msg`` hook can do this safely. It runs *before* Git creates
|
|
4
|
+
the commit object, so deleting a line rewrites nothing: there is no history to
|
|
5
|
+
rewrite yet. By the time ``pre-push`` runs, the commits exist and correcting
|
|
6
|
+
them means rewriting history, which CommitGuard never does on its own.
|
|
7
|
+
|
|
8
|
+
The rules are deliberately narrow, because this feature turns a block into a
|
|
9
|
+
commit:
|
|
10
|
+
|
|
11
|
+
* it applies only when **every** blocking finding points at a message line.
|
|
12
|
+
Attribution carried by the author or committer identity cannot be fixed by
|
|
13
|
+
editing text, so it still blocks;
|
|
14
|
+
* a detector failure always blocks - a message that could not be fully analysed
|
|
15
|
+
is never "fixed";
|
|
16
|
+
* the stripped message is **re-analysed**, and the commit proceeds only if the
|
|
17
|
+
result is clean. Nothing is ever allowed on the assumption that the removal
|
|
18
|
+
worked;
|
|
19
|
+
* if nothing but blank lines would be left, the commit blocks instead, rather
|
|
20
|
+
than producing an empty message.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ConfigDict
|
|
24
|
+
|
|
25
|
+
from commitguard.core.decision import Action
|
|
26
|
+
from commitguard.core.result import EvidenceSource
|
|
27
|
+
from commitguard.services.reports import CommitReport
|
|
28
|
+
|
|
29
|
+
#: Evidence that lives on a line of the message, and so can be deleted.
|
|
30
|
+
REMOVABLE_SOURCES = frozenset(
|
|
31
|
+
{EvidenceSource.COAUTHOR_TRAILER, EvidenceSource.TRAILER, EvidenceSource.MESSAGE}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RemovedLine(BaseModel):
|
|
36
|
+
"""One line deleted from a pending commit message."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
39
|
+
|
|
40
|
+
line_number: int
|
|
41
|
+
text: str
|
|
42
|
+
rule_id: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def removable_lines(report: CommitReport) -> tuple[RemovedLine, ...] | None:
|
|
46
|
+
"""The message lines whose deletion would clear every block, or ``None``.
|
|
47
|
+
|
|
48
|
+
``None`` means "editing the message cannot fix this": an identity-based
|
|
49
|
+
finding, a detector failure, or evidence with no line number.
|
|
50
|
+
"""
|
|
51
|
+
if any(failure.action is Action.BLOCK for failure in report.failures):
|
|
52
|
+
return None
|
|
53
|
+
removals: dict[int, RemovedLine] = {}
|
|
54
|
+
for evaluated in report.findings:
|
|
55
|
+
if evaluated.action is not Action.BLOCK:
|
|
56
|
+
continue # warnings do not block, so nothing has to be removed for them
|
|
57
|
+
for evidence in evaluated.finding.evidence:
|
|
58
|
+
if evidence.source not in REMOVABLE_SOURCES or evidence.line_number is None:
|
|
59
|
+
return None
|
|
60
|
+
removals.setdefault(
|
|
61
|
+
evidence.line_number,
|
|
62
|
+
RemovedLine(
|
|
63
|
+
line_number=evidence.line_number,
|
|
64
|
+
text=evidence.value,
|
|
65
|
+
rule_id=evaluated.finding.rule_id,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
return tuple(removals[number] for number in sorted(removals)) or None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def with_actual_text(message: str, removals: tuple[RemovedLine, ...]) -> tuple[RemovedLine, ...]:
|
|
72
|
+
"""Replace each removal's evidence value with the message line itself.
|
|
73
|
+
|
|
74
|
+
Evidence carries the matched value (``Claude <noreply@anthropic.com>``);
|
|
75
|
+
the developer needs to see the whole line that disappeared.
|
|
76
|
+
"""
|
|
77
|
+
lines = message.splitlines()
|
|
78
|
+
return tuple(
|
|
79
|
+
removed.model_copy(update={"text": lines[removed.line_number - 1].strip()})
|
|
80
|
+
if 0 < removed.line_number <= len(lines)
|
|
81
|
+
else removed
|
|
82
|
+
for removed in removals
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def strip_lines(message: str, line_numbers: frozenset[int]) -> str:
|
|
87
|
+
"""Return ``message`` without the given 1-based lines.
|
|
88
|
+
|
|
89
|
+
Blank lines left stranded at the end are trimmed, so deleting a trailing
|
|
90
|
+
trailer block does not leave the message ending in blank lines.
|
|
91
|
+
"""
|
|
92
|
+
kept = [
|
|
93
|
+
line
|
|
94
|
+
for number, line in enumerate(message.splitlines(), start=1)
|
|
95
|
+
if number not in line_numbers
|
|
96
|
+
]
|
|
97
|
+
while kept and not kept[-1].strip():
|
|
98
|
+
kept.pop()
|
|
99
|
+
return "\n".join(kept) + "\n" if kept else ""
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Structured, serialisable analysis reports.
|
|
2
|
+
|
|
3
|
+
Reports are the stable output contract for the CLI's JSON format and the
|
|
4
|
+
future audit log. They contain commit IDs, findings, evidence (concise
|
|
5
|
+
metadata only) and decisions - never file contents or full commit messages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
12
|
+
|
|
13
|
+
from commitguard.core.decision import Action, Decision
|
|
14
|
+
from commitguard.core.result import DetectionResult, DetectorFailure, Finding
|
|
15
|
+
|
|
16
|
+
REPORT_SCHEMA_VERSION: Literal[1] = 1
|
|
17
|
+
MAX_SUBJECT_CHARS = 120
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EvaluatedFinding(BaseModel):
|
|
21
|
+
"""A finding together with the policy outcome applied to it."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
24
|
+
|
|
25
|
+
finding: Finding
|
|
26
|
+
action: Action
|
|
27
|
+
policy_id: str | None
|
|
28
|
+
reason: str
|
|
29
|
+
|
|
30
|
+
@computed_field # type: ignore[prop-decorator]
|
|
31
|
+
@property
|
|
32
|
+
def fingerprint(self) -> str:
|
|
33
|
+
return self.finding.fingerprint
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EvaluatedFailure(BaseModel):
|
|
37
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
38
|
+
|
|
39
|
+
failure: DetectorFailure
|
|
40
|
+
action: Action
|
|
41
|
+
reason: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CommitReport(BaseModel):
|
|
45
|
+
"""Detection + decision for one commit."""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
48
|
+
|
|
49
|
+
commit_sha: str | None
|
|
50
|
+
short_sha: str
|
|
51
|
+
subject: str = Field(default="", description="First line of the message, truncated")
|
|
52
|
+
action: Action
|
|
53
|
+
findings: tuple[EvaluatedFinding, ...] = ()
|
|
54
|
+
failures: tuple[EvaluatedFailure, ...] = ()
|
|
55
|
+
detectors_run: tuple[str, ...] = ()
|
|
56
|
+
detectors_skipped: tuple[str, ...] = ()
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def build(
|
|
60
|
+
cls,
|
|
61
|
+
short_sha: str,
|
|
62
|
+
detection: DetectionResult,
|
|
63
|
+
decision: Decision,
|
|
64
|
+
*,
|
|
65
|
+
subject: str = "",
|
|
66
|
+
) -> "CommitReport":
|
|
67
|
+
findings: list[EvaluatedFinding] = []
|
|
68
|
+
failures: list[EvaluatedFailure] = []
|
|
69
|
+
for explanation in decision.explanations:
|
|
70
|
+
if explanation.finding is not None:
|
|
71
|
+
findings.append(
|
|
72
|
+
EvaluatedFinding(
|
|
73
|
+
finding=explanation.finding,
|
|
74
|
+
action=explanation.action,
|
|
75
|
+
policy_id=explanation.policy_id,
|
|
76
|
+
reason=explanation.reason,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
elif explanation.failure is not None:
|
|
80
|
+
failures.append(
|
|
81
|
+
EvaluatedFailure(
|
|
82
|
+
failure=explanation.failure,
|
|
83
|
+
action=explanation.action,
|
|
84
|
+
reason=explanation.reason,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return cls(
|
|
88
|
+
commit_sha=detection.commit_sha,
|
|
89
|
+
short_sha=short_sha,
|
|
90
|
+
subject=subject[:MAX_SUBJECT_CHARS],
|
|
91
|
+
action=decision.action,
|
|
92
|
+
findings=tuple(findings),
|
|
93
|
+
failures=tuple(failures),
|
|
94
|
+
detectors_run=detection.detectors_run,
|
|
95
|
+
detectors_skipped=detection.detectors_skipped,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CIReport(BaseModel):
|
|
100
|
+
"""CI context attached to reports produced by ``commitguard ci``."""
|
|
101
|
+
|
|
102
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
103
|
+
|
|
104
|
+
provider: str
|
|
105
|
+
event: str
|
|
106
|
+
repository: str | None = None
|
|
107
|
+
ref: str | None = None
|
|
108
|
+
pull_request_number: int | None = None
|
|
109
|
+
from_fork: bool = False
|
|
110
|
+
base_sha: str | None = None
|
|
111
|
+
head_sha: str | None = None
|
|
112
|
+
policy_source: str
|
|
113
|
+
config_changes: tuple[str, ...] = Field(
|
|
114
|
+
default=(), description="Config files changed by the evaluated commits (not applied)"
|
|
115
|
+
)
|
|
116
|
+
policy_weakenings: tuple[str, ...] = Field(
|
|
117
|
+
default=(), description="Policies the evaluated commits' config would weaken (not applied)"
|
|
118
|
+
)
|
|
119
|
+
notices: tuple[str, ...] = ()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ScanReport(BaseModel):
|
|
123
|
+
"""Result of analysing one or more commits."""
|
|
124
|
+
|
|
125
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
126
|
+
|
|
127
|
+
schema_version: Literal[1] = REPORT_SCHEMA_VERSION
|
|
128
|
+
tool_version: str
|
|
129
|
+
generated_at: datetime
|
|
130
|
+
repository: str | None
|
|
131
|
+
target: str
|
|
132
|
+
trigger: str
|
|
133
|
+
config_sources: tuple[str, ...]
|
|
134
|
+
action: Action
|
|
135
|
+
commits: tuple[CommitReport, ...]
|
|
136
|
+
ci: CIReport | None = None
|
|
137
|
+
|
|
138
|
+
@computed_field # type: ignore[prop-decorator]
|
|
139
|
+
@property
|
|
140
|
+
def summary(self) -> dict[str, int]:
|
|
141
|
+
counts = {action.value: 0 for action in Action}
|
|
142
|
+
for commit in self.commits:
|
|
143
|
+
actions = [f.action for f in commit.findings] + [f.action for f in commit.failures]
|
|
144
|
+
for action in actions:
|
|
145
|
+
counts[action.value] += 1
|
|
146
|
+
return {"commits": len(self.commits), **counts}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""ScanService: one entry point for "analyse these commits under this policy".
|
|
2
|
+
|
|
3
|
+
Every server-side adapter (``commitguard ci github`` in GitHub Actions and the
|
|
4
|
+
GitHub App worker) builds a :class:`ScanRequest` and gets a :class:`ScanResult`
|
|
5
|
+
containing the same :class:`~commitguard.services.reports.ScanReport` that
|
|
6
|
+
``commitguard scan --format json`` produces - there is no platform-specific
|
|
7
|
+
result format and no platform-specific detection.
|
|
8
|
+
|
|
9
|
+
A scan is deterministic: the same commits, trusted policy, mandatory policy,
|
|
10
|
+
rules and CommitGuard version produce the same result and the same
|
|
11
|
+
:attr:`ScanMetadata.scan_id`, which makes retries, duplicate webhooks and
|
|
12
|
+
Action/App comparisons checkable.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from commitguard import __version__
|
|
18
|
+
from commitguard.ci.context import CIContext
|
|
19
|
+
from commitguard.config.sources import MandatoryPolicy
|
|
20
|
+
from commitguard.core.decision import Action
|
|
21
|
+
from commitguard.git.repository import Repository
|
|
22
|
+
from commitguard.policies.governance import EffectivePolicy, GovernanceInputs
|
|
23
|
+
from commitguard.policies.model import Policy
|
|
24
|
+
from commitguard.rules.loader import builtin_rules_fingerprint
|
|
25
|
+
from commitguard.rules.matcher import CompiledRules
|
|
26
|
+
from commitguard.security.hashing import fingerprint
|
|
27
|
+
from commitguard.security.validation import validate_repository_path
|
|
28
|
+
from commitguard.services.ci import DEFAULT_CI_MAX_COMMITS, CIPlan, execute_ci_plan, plan_ci
|
|
29
|
+
from commitguard.services.enforcement import EnforcementDecision, EnforcementService
|
|
30
|
+
from commitguard.services.reports import ScanReport
|
|
31
|
+
|
|
32
|
+
SCAN_ID_LENGTH = 32
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ScanRequest(BaseModel):
|
|
36
|
+
model_config = ConfigDict(frozen=True, extra="forbid", arbitrary_types_allowed=True)
|
|
37
|
+
|
|
38
|
+
repository: Repository
|
|
39
|
+
context: CIContext
|
|
40
|
+
config_path: str | None = None
|
|
41
|
+
max_commits: int = Field(default=DEFAULT_CI_MAX_COMMITS, ge=1)
|
|
42
|
+
fail_on: Action = Action.BLOCK
|
|
43
|
+
mandatory_policy: MandatoryPolicy | None = None
|
|
44
|
+
#: Organization governance (GitHub App); replaces ``mandatory_policy`` when set.
|
|
45
|
+
governance: GovernanceInputs | None = None
|
|
46
|
+
rules: CompiledRules | None = None # None: the bundled, trusted rules
|
|
47
|
+
#: Version label of ``rules`` when they are not the bundled rules.
|
|
48
|
+
rules_version: str | None = None
|
|
49
|
+
|
|
50
|
+
def model_post_init(self, __context: object) -> None:
|
|
51
|
+
if self.config_path is not None:
|
|
52
|
+
validate_repository_path(self.config_path)
|
|
53
|
+
if self.fail_on is Action.ALLOW:
|
|
54
|
+
raise ValueError("fail_on must be block or warn")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ScanStatistics(BaseModel):
|
|
58
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
59
|
+
|
|
60
|
+
commits_scanned: int
|
|
61
|
+
violations: int # commits whose result is BLOCK
|
|
62
|
+
warnings: int # commits whose result is WARN
|
|
63
|
+
allowed: int
|
|
64
|
+
findings: int
|
|
65
|
+
detector_failures: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ScanMetadata(BaseModel):
|
|
69
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
70
|
+
|
|
71
|
+
scan_id: str
|
|
72
|
+
tool_version: str
|
|
73
|
+
rules_version: str
|
|
74
|
+
policy_version: str
|
|
75
|
+
policy_source: str
|
|
76
|
+
provider: str
|
|
77
|
+
event: str
|
|
78
|
+
base_sha: str | None
|
|
79
|
+
head_sha: str | None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ScanResult(BaseModel):
|
|
83
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
84
|
+
|
|
85
|
+
report: ScanReport
|
|
86
|
+
plan: CIPlan
|
|
87
|
+
statistics: ScanStatistics
|
|
88
|
+
metadata: ScanMetadata
|
|
89
|
+
enforcement: EnforcementDecision
|
|
90
|
+
policies: tuple[Policy, ...] = () # effective policies, for reproducible history
|
|
91
|
+
#: Fields the repository configuration set per rule (before organization governance).
|
|
92
|
+
repository_overrides: dict[str, dict[str, str | bool]] = {}
|
|
93
|
+
effective: EffectivePolicy | None = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def action(self) -> Action:
|
|
97
|
+
return self.report.action
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def statistics_for(report: ScanReport) -> ScanStatistics:
|
|
101
|
+
commits = report.commits
|
|
102
|
+
return ScanStatistics(
|
|
103
|
+
commits_scanned=len(commits),
|
|
104
|
+
violations=sum(1 for c in commits if c.action is Action.BLOCK),
|
|
105
|
+
warnings=sum(1 for c in commits if c.action is Action.WARN),
|
|
106
|
+
allowed=sum(1 for c in commits if c.action is Action.ALLOW),
|
|
107
|
+
findings=sum(len(c.findings) for c in commits),
|
|
108
|
+
detector_failures=sum(len(c.failures) for c in commits),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ScanService:
|
|
113
|
+
def plan(self, request: ScanRequest) -> CIPlan:
|
|
114
|
+
return plan_ci(
|
|
115
|
+
request.repository,
|
|
116
|
+
request.context,
|
|
117
|
+
config_path=request.config_path,
|
|
118
|
+
max_commits=request.max_commits,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def execute(self, request: ScanRequest, plan: CIPlan) -> ScanResult:
|
|
122
|
+
run = execute_ci_plan(
|
|
123
|
+
request.repository,
|
|
124
|
+
request.context,
|
|
125
|
+
plan,
|
|
126
|
+
rules=request.rules,
|
|
127
|
+
mandatory=request.mandatory_policy,
|
|
128
|
+
governance=request.governance,
|
|
129
|
+
)
|
|
130
|
+
if request.rules is None:
|
|
131
|
+
rules_version = builtin_rules_fingerprint()
|
|
132
|
+
else:
|
|
133
|
+
rules_version = request.rules_version or "custom"
|
|
134
|
+
context = request.context
|
|
135
|
+
scan_id = fingerprint(
|
|
136
|
+
[
|
|
137
|
+
__version__,
|
|
138
|
+
rules_version,
|
|
139
|
+
run.policy_fingerprint,
|
|
140
|
+
context.provider.value,
|
|
141
|
+
context.event.value,
|
|
142
|
+
context.repository or "",
|
|
143
|
+
str(context.pull_request_number or ""),
|
|
144
|
+
plan.range.base or "",
|
|
145
|
+
plan.range.head or "",
|
|
146
|
+
",".join(plan.range.exclude),
|
|
147
|
+
]
|
|
148
|
+
)[:SCAN_ID_LENGTH]
|
|
149
|
+
metadata = ScanMetadata(
|
|
150
|
+
scan_id=scan_id,
|
|
151
|
+
tool_version=__version__,
|
|
152
|
+
rules_version=rules_version,
|
|
153
|
+
policy_version=run.policy_fingerprint,
|
|
154
|
+
policy_source=run.report.ci.policy_source if run.report.ci else "",
|
|
155
|
+
provider=context.provider.value,
|
|
156
|
+
event=context.event_name,
|
|
157
|
+
base_sha=plan.range.base,
|
|
158
|
+
head_sha=plan.range.head,
|
|
159
|
+
)
|
|
160
|
+
return ScanResult(
|
|
161
|
+
report=run.report,
|
|
162
|
+
plan=plan,
|
|
163
|
+
statistics=statistics_for(run.report),
|
|
164
|
+
metadata=metadata,
|
|
165
|
+
enforcement=EnforcementService(request.fail_on).decide(run.report),
|
|
166
|
+
policies=run.policies,
|
|
167
|
+
repository_overrides=run.repository_overrides,
|
|
168
|
+
effective=run.effective,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def run(self, request: ScanRequest) -> ScanResult:
|
|
172
|
+
return self.execute(request, self.plan(request))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Low-level helpers (subprocess, filesystem, platform) with no domain knowledge."""
|