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,367 @@
|
|
|
1
|
+
"""Rendering of :class:`ScanReport` objects as text or JSON.
|
|
2
|
+
|
|
3
|
+
* ``scan`` text: detailed, human-oriented explanation of every finding.
|
|
4
|
+
* ``check`` text: one tab-separated line per finding plus a ``result=`` line.
|
|
5
|
+
* JSON: the full report model, ASCII-only (all non-ASCII and control
|
|
6
|
+
characters escaped, so it is terminal-safe), schema_version 1.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from commitguard.cli.output import supports_unicode
|
|
12
|
+
from commitguard.core.decision import Action
|
|
13
|
+
from commitguard.core.result import Evidence, MatchReason
|
|
14
|
+
from commitguard.security.sanitization import sanitize_for_terminal
|
|
15
|
+
from commitguard.services.reports import CommitReport, EvaluatedFinding, ScanReport
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _s(text: str, limit: int = 300) -> str:
|
|
19
|
+
return sanitize_for_terminal(text, max_length=limit)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_json(report: ScanReport) -> str:
|
|
23
|
+
return json.dumps(report.model_dump(mode="json"), indent=2, ensure_ascii=True, sort_keys=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _symbols() -> tuple[str, str, str]:
|
|
27
|
+
return ("✓", "✗", "!") if supports_unicode() else ("OK", "X", "!")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _reason(reason: MatchReason) -> str:
|
|
31
|
+
return f'{reason.kind.value} "{_s(reason.value, 120)}" ({_s(reason.rule, 120)})'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _evidence_lines(evidence: Evidence) -> list[str]:
|
|
35
|
+
source = evidence.source.label
|
|
36
|
+
if evidence.line_number is not None:
|
|
37
|
+
source += f", line {evidence.line_number}"
|
|
38
|
+
lines = [f" Evidence: {_s(evidence.value)}", f" Source: {source}"]
|
|
39
|
+
for index, reason in enumerate(evidence.matched):
|
|
40
|
+
lines.append(f" {'Matched:' if index == 0 else '':<12} {_reason(reason)}")
|
|
41
|
+
if evidence.notes:
|
|
42
|
+
lines.append(f" Notes: {_s('; '.join(evidence.notes))}")
|
|
43
|
+
return lines
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _finding_block(item: EvaluatedFinding, commit: CommitReport) -> list[str]:
|
|
47
|
+
finding = item.finding
|
|
48
|
+
policy = f" (policy {item.policy_id})" if item.policy_id else ""
|
|
49
|
+
lines = [
|
|
50
|
+
_s(finding.title, 120),
|
|
51
|
+
f" Commit: {commit.short_sha}",
|
|
52
|
+
f" Detector: {finding.detector}",
|
|
53
|
+
f" Rule: {finding.rule_id}",
|
|
54
|
+
f" Severity: {finding.severity.value}",
|
|
55
|
+
f" Confidence: {finding.confidence.value}",
|
|
56
|
+
f" Action: {item.action.value}{policy}",
|
|
57
|
+
f" Message: {_s(finding.message)}",
|
|
58
|
+
]
|
|
59
|
+
for evidence in finding.evidence:
|
|
60
|
+
lines.extend(_evidence_lines(evidence))
|
|
61
|
+
lines.append(f" Remediation: {_s(finding.remediation)}")
|
|
62
|
+
return lines
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_scan_text(report: ScanReport) -> str:
|
|
66
|
+
ok, cross, bang = _symbols()
|
|
67
|
+
lines = ["CommitGuard"]
|
|
68
|
+
if report.repository:
|
|
69
|
+
lines.append(f"Repository: {_s(report.repository)}")
|
|
70
|
+
count = len(report.commits)
|
|
71
|
+
lines.append(
|
|
72
|
+
f"Target: {_s(report.target, 120)} ({count} commit{'s' if count != 1 else ''})"
|
|
73
|
+
)
|
|
74
|
+
lines.append("Config: " + " < ".join(_s(source, 200) for source in report.config_sources))
|
|
75
|
+
lines.append("")
|
|
76
|
+
|
|
77
|
+
ordered = [(item, commit) for commit in report.commits for item in commit.findings]
|
|
78
|
+
ordered.sort(key=lambda pair: -pair[0].action.rank) # stable: commit order kept per action
|
|
79
|
+
failures = [(failure, commit) for commit in report.commits for failure in commit.failures]
|
|
80
|
+
|
|
81
|
+
if not ordered and not failures:
|
|
82
|
+
lines.append(f"{ok} Repository scanned")
|
|
83
|
+
lines.append(f"{ok} No policy violations detected")
|
|
84
|
+
else:
|
|
85
|
+
headline = {
|
|
86
|
+
Action.BLOCK: f"{cross} BLOCKED: policy violation detected",
|
|
87
|
+
Action.WARN: f"{bang} WARNING: policy warnings detected",
|
|
88
|
+
Action.ALLOW: f"{ok} Findings present, all allowed by policy",
|
|
89
|
+
}[report.action]
|
|
90
|
+
lines.append(headline)
|
|
91
|
+
for failure, commit in failures:
|
|
92
|
+
lines.append("")
|
|
93
|
+
lines.append(f"{cross} Detector failure (scan incomplete)")
|
|
94
|
+
lines.append(f" Commit: {commit.short_sha}")
|
|
95
|
+
lines.append(f" Detector: {failure.failure.detector}")
|
|
96
|
+
lines.append(
|
|
97
|
+
f" Error: {_s(failure.failure.error_type)}: {_s(failure.failure.message)}"
|
|
98
|
+
)
|
|
99
|
+
lines.append(f" Action: {failure.action.value} ({_s(failure.reason)})")
|
|
100
|
+
for item, commit in ordered:
|
|
101
|
+
lines.append("")
|
|
102
|
+
lines.extend(_finding_block(item, commit))
|
|
103
|
+
|
|
104
|
+
summary = report.summary
|
|
105
|
+
lines.append("")
|
|
106
|
+
lines.append(
|
|
107
|
+
f"Summary: {summary['commits']} commit(s), {summary['block']} blocking, "
|
|
108
|
+
f"{summary['warn']} warning(s), {summary['allow']} allowed finding(s)"
|
|
109
|
+
)
|
|
110
|
+
lines.append(f"Result: {report.action.value.upper()}")
|
|
111
|
+
return "\n".join(lines)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_check_text(report: ScanReport) -> str:
|
|
115
|
+
"""Stable, grep/cut-friendly output: ``ACTION<TAB>sha<TAB>detector<TAB>rule<TAB>evidence``."""
|
|
116
|
+
lines = []
|
|
117
|
+
for commit in report.commits:
|
|
118
|
+
for failure in commit.failures:
|
|
119
|
+
lines.append(
|
|
120
|
+
"\t".join(
|
|
121
|
+
[
|
|
122
|
+
failure.action.value.upper(),
|
|
123
|
+
commit.short_sha,
|
|
124
|
+
failure.failure.detector,
|
|
125
|
+
"detector_failure",
|
|
126
|
+
_s(failure.failure.message, 200),
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
for item in commit.findings:
|
|
131
|
+
evidence = item.finding.evidence[0].value
|
|
132
|
+
lines.append(
|
|
133
|
+
"\t".join(
|
|
134
|
+
[
|
|
135
|
+
item.action.value.upper(),
|
|
136
|
+
commit.short_sha,
|
|
137
|
+
item.finding.detector,
|
|
138
|
+
item.finding.rule_id,
|
|
139
|
+
_s(evidence, 200),
|
|
140
|
+
]
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
summary = report.summary
|
|
144
|
+
lines.append(
|
|
145
|
+
f"result={report.action.value.upper()} commits={summary['commits']} "
|
|
146
|
+
f"block={summary['block']} warn={summary['warn']} allow={summary['allow']}"
|
|
147
|
+
)
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --------------------------------------------------------------------------- #
|
|
152
|
+
# Hook output (written to stderr by the hook commands)
|
|
153
|
+
# --------------------------------------------------------------------------- #
|
|
154
|
+
_PUSH_REMEDIATION = [
|
|
155
|
+
"How to fix:",
|
|
156
|
+
" CommitGuard never modifies commits. Rewrite the blocked commits so they no",
|
|
157
|
+
" longer carry the attribution, then push again:",
|
|
158
|
+
" - latest commit only: git commit --amend",
|
|
159
|
+
" (replaces that one local commit with an edited copy)",
|
|
160
|
+
" - older commits: git rebase -i <commit>^ and mark them 'reword'",
|
|
161
|
+
" (recreates the selected commits and every commit after them)",
|
|
162
|
+
" Only rewrite commits that have not already been shared with others.",
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _short_finding_lines(item: EvaluatedFinding) -> list[str]:
|
|
167
|
+
finding = item.finding
|
|
168
|
+
evidence = finding.evidence[0]
|
|
169
|
+
where = evidence.source.label + (
|
|
170
|
+
f", line {evidence.line_number}" if evidence.line_number is not None else ""
|
|
171
|
+
)
|
|
172
|
+
return [
|
|
173
|
+
f" {_s(finding.title, 120)} [{finding.rule_id}, {finding.severity.value}]",
|
|
174
|
+
f" Evidence: {_s(evidence.value, 160)} ({where})",
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _commit_marker(action: Action) -> str:
|
|
179
|
+
ok, cross, bang = _symbols()
|
|
180
|
+
return {Action.BLOCK: cross, Action.WARN: bang, Action.ALLOW: ok}[action]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def render_push_text(report: ScanReport, *, remote: str | None, verbose: bool) -> str:
|
|
184
|
+
ok, cross, bang = _symbols()
|
|
185
|
+
commits = report.commits
|
|
186
|
+
counts = {action: sum(1 for c in commits if c.action is action) for action in Action}
|
|
187
|
+
lines = ["CommitGuard"]
|
|
188
|
+
if not commits:
|
|
189
|
+
lines.append(f"{ok} No new commits to check for this push.")
|
|
190
|
+
return "\n".join(lines)
|
|
191
|
+
|
|
192
|
+
if report.action is Action.ALLOW and not any(c.findings or c.failures for c in commits):
|
|
193
|
+
count = len(commits)
|
|
194
|
+
return (
|
|
195
|
+
f"CommitGuard: {ok} {count} outgoing commit{'s' if count != 1 else ''} checked, "
|
|
196
|
+
"no policy violations"
|
|
197
|
+
)
|
|
198
|
+
if report.action is Action.BLOCK:
|
|
199
|
+
lines.append(f"{cross} PUSH BLOCKED")
|
|
200
|
+
elif report.action is Action.WARN:
|
|
201
|
+
lines.append(f"{bang} PUSH ALLOWED WITH WARNINGS")
|
|
202
|
+
else:
|
|
203
|
+
lines.append(f"{ok} Push allowed: no policy violations")
|
|
204
|
+
lines += [
|
|
205
|
+
f"Remote: {_s(remote or '?', 200)}",
|
|
206
|
+
f"Commits checked: {len(commits)}",
|
|
207
|
+
f"Violations: {counts[Action.BLOCK]}",
|
|
208
|
+
f"Warnings: {counts[Action.WARN]}",
|
|
209
|
+
f"Allowed: {counts[Action.ALLOW]}",
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
flagged = [c for c in commits if c.findings or c.failures]
|
|
213
|
+
for commit in sorted(flagged, key=lambda c: -c.action.rank):
|
|
214
|
+
lines.append("")
|
|
215
|
+
lines.append(
|
|
216
|
+
f"{_commit_marker(commit.action)} {commit.short_sha} {_s(commit.subject, 72)}"
|
|
217
|
+
)
|
|
218
|
+
for failure in commit.failures:
|
|
219
|
+
lines.append(
|
|
220
|
+
f" Detector failure: {failure.failure.detector} "
|
|
221
|
+
f"({_s(failure.failure.message, 160)}); analysis incomplete"
|
|
222
|
+
)
|
|
223
|
+
for item in commit.findings:
|
|
224
|
+
if verbose:
|
|
225
|
+
lines.extend(" " + line for line in _finding_block(item, commit))
|
|
226
|
+
else:
|
|
227
|
+
lines.extend(_short_finding_lines(item))
|
|
228
|
+
if item.action is not Action.BLOCK:
|
|
229
|
+
lines.append(f" Action: {item.action.value}")
|
|
230
|
+
|
|
231
|
+
if report.action is Action.BLOCK:
|
|
232
|
+
lines += ["", *_PUSH_REMEDIATION, "", "No changes were pushed to the remote repository."]
|
|
233
|
+
if not verbose:
|
|
234
|
+
lines.append("Full evidence: commitguard scan <sha> (for each blocked commit)")
|
|
235
|
+
return "\n".join(lines)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def render_commit_hook_text(report: ScanReport, *, stage: str, verbose: bool) -> str:
|
|
239
|
+
ok, cross, bang = _symbols()
|
|
240
|
+
(commit,) = report.commits
|
|
241
|
+
if not commit.findings and not commit.failures:
|
|
242
|
+
return ""
|
|
243
|
+
lines = ["CommitGuard"]
|
|
244
|
+
if report.action is Action.BLOCK:
|
|
245
|
+
lines.append(f"{cross} COMMIT BLOCKED ({stage})")
|
|
246
|
+
elif report.action is Action.WARN:
|
|
247
|
+
lines.append(f"{bang} Commit allowed with warnings ({stage})")
|
|
248
|
+
else:
|
|
249
|
+
lines.append(f"{ok} Findings allowed by policy ({stage})")
|
|
250
|
+
for failure in commit.failures:
|
|
251
|
+
lines.append(
|
|
252
|
+
f" Detector failure: {failure.failure.detector} ({_s(failure.failure.message, 160)})"
|
|
253
|
+
)
|
|
254
|
+
for item in commit.findings:
|
|
255
|
+
lines.append("")
|
|
256
|
+
if verbose:
|
|
257
|
+
lines.extend(_finding_block(item, commit))
|
|
258
|
+
else:
|
|
259
|
+
lines.extend(line[2:] for line in _short_finding_lines(item))
|
|
260
|
+
lines.append(f" Action: {item.action.value}")
|
|
261
|
+
if report.action is Action.BLOCK:
|
|
262
|
+
lines.append("")
|
|
263
|
+
lines.append("Nothing was committed. Your staged changes are unchanged.")
|
|
264
|
+
if stage == "commit-msg":
|
|
265
|
+
lines.append("Remove the attribution from the message and commit again.")
|
|
266
|
+
lines.append(
|
|
267
|
+
"Your message is still in .git/COMMIT_EDITMSG; to reuse it: "
|
|
268
|
+
"git commit -e -F .git/COMMIT_EDITMSG"
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
lines.append(
|
|
272
|
+
"Commit under the responsible human contributor's identity "
|
|
273
|
+
"(git config user.name / user.email, or --author)."
|
|
274
|
+
)
|
|
275
|
+
return "\n".join(lines)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# --------------------------------------------------------------------------- #
|
|
279
|
+
# CI output
|
|
280
|
+
# --------------------------------------------------------------------------- #
|
|
281
|
+
MAX_CI_FINDINGS = 50
|
|
282
|
+
_CI_REMEDIATION = (
|
|
283
|
+
"Please update the listed commits so that they comply with the repository's "
|
|
284
|
+
"contribution policy, then push the updated branch. CommitGuard does not "
|
|
285
|
+
"automatically rewrite Git history."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def render_ci_text(report: ScanReport, *, failed: bool) -> str:
|
|
290
|
+
ok, cross, bang = _symbols()
|
|
291
|
+
rule = "━" * 40 if supports_unicode() else "-" * 40
|
|
292
|
+
ci = report.ci
|
|
293
|
+
commits = report.commits
|
|
294
|
+
counts = {action: sum(1 for c in commits if c.action is action) for action in Action}
|
|
295
|
+
lines = ["CommitGuard", rule]
|
|
296
|
+
if ci is not None:
|
|
297
|
+
if ci.repository:
|
|
298
|
+
lines.append(f"Repository: {_s(ci.repository, 200)}")
|
|
299
|
+
event = _s(ci.event, 60)
|
|
300
|
+
if ci.pull_request_number:
|
|
301
|
+
event += f" (PR #{ci.pull_request_number}{', from a fork' if ci.from_fork else ''})"
|
|
302
|
+
lines.append(f"Event: {event}")
|
|
303
|
+
if ci.head_sha:
|
|
304
|
+
base = ci.base_sha[:12] if ci.base_sha else "(none)"
|
|
305
|
+
lines.append(f"Range: {base}..{ci.head_sha[:12]}")
|
|
306
|
+
lines.append(f"Policy: {_s(ci.policy_source, 200)}")
|
|
307
|
+
lines += [
|
|
308
|
+
f"{ok} policy loaded",
|
|
309
|
+
f"{ok} commits scanned: {len(commits)}",
|
|
310
|
+
f"{ok if not any(c.failures for c in commits) else cross} detection completed",
|
|
311
|
+
]
|
|
312
|
+
by_rule: dict[str, int] = {}
|
|
313
|
+
for commit in commits:
|
|
314
|
+
for item in commit.findings:
|
|
315
|
+
by_rule[item.finding.rule_id] = by_rule.get(item.finding.rule_id, 0) + 1
|
|
316
|
+
if by_rule:
|
|
317
|
+
lines.append("Findings:")
|
|
318
|
+
lines.extend(f" {rule_id}: {count}" for rule_id, count in sorted(by_rule.items()))
|
|
319
|
+
else:
|
|
320
|
+
lines.append("Findings: 0")
|
|
321
|
+
lines += [
|
|
322
|
+
f"Violations: {counts[Action.BLOCK]}",
|
|
323
|
+
f"Warnings: {counts[Action.WARN]}",
|
|
324
|
+
f"Allowed: {counts[Action.ALLOW]}",
|
|
325
|
+
]
|
|
326
|
+
if ci is not None:
|
|
327
|
+
for notice in ci.notices:
|
|
328
|
+
lines.append(f"{bang} {_s(notice, 400)}")
|
|
329
|
+
|
|
330
|
+
shown = 0
|
|
331
|
+
omitted = 0
|
|
332
|
+
for commit in sorted(commits, key=lambda c: -c.action.rank):
|
|
333
|
+
for failure in commit.failures:
|
|
334
|
+
lines += [
|
|
335
|
+
"",
|
|
336
|
+
f"{cross} Detector failure (analysis incomplete)",
|
|
337
|
+
f" Commit: {commit.short_sha}",
|
|
338
|
+
f" Detector: {failure.failure.detector}",
|
|
339
|
+
f" Error: {_s(failure.failure.message, 200)}",
|
|
340
|
+
]
|
|
341
|
+
for item in commit.findings:
|
|
342
|
+
if shown >= MAX_CI_FINDINGS:
|
|
343
|
+
omitted += 1
|
|
344
|
+
continue
|
|
345
|
+
shown += 1
|
|
346
|
+
finding = item.finding
|
|
347
|
+
evidence = finding.evidence[0]
|
|
348
|
+
lines += [
|
|
349
|
+
"",
|
|
350
|
+
f"{_commit_marker(item.action)} {_s(finding.title, 120)}",
|
|
351
|
+
f" Commit: {commit.short_sha} {_s(commit.subject, 72)}",
|
|
352
|
+
f" Evidence: {_s(evidence.value, 200)} ({evidence.source.label})",
|
|
353
|
+
f" Rule: {finding.rule_id}",
|
|
354
|
+
f" Severity: {finding.severity.value}",
|
|
355
|
+
f" Action: {item.action.value}",
|
|
356
|
+
]
|
|
357
|
+
if omitted:
|
|
358
|
+
lines.append(f"\n... {omitted} more finding(s) omitted; use --format json for all")
|
|
359
|
+
|
|
360
|
+
if failed:
|
|
361
|
+
result = "BLOCK" if report.action is Action.BLOCK else "FAILED (fail-on: warn)"
|
|
362
|
+
else:
|
|
363
|
+
result = "PASS (with warnings)" if report.action is Action.WARN else "PASS"
|
|
364
|
+
lines += ["", f"Result: {result}"]
|
|
365
|
+
if report.action is Action.BLOCK:
|
|
366
|
+
lines += ["", f"Remediation: {_CI_REMEDIATION}"]
|
|
367
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Repository configuration (``.commitguard.yaml``).
|
|
2
|
+
|
|
3
|
+
Configuration is security-relevant, so it is validated strictly: unknown keys,
|
|
4
|
+
unknown policy IDs, wrong types, duplicate YAML keys and YAML tags that
|
|
5
|
+
construct Python objects are all rejected rather than ignored.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Configuration file locations, limits and the ``commitguard init`` template."""
|
|
2
|
+
|
|
3
|
+
CONFIG_FILENAMES: tuple[str, ...] = (".commitguard.yaml", ".commitguard.yml")
|
|
4
|
+
DEFAULT_CONFIG_FILENAME = CONFIG_FILENAMES[0]
|
|
5
|
+
GLOBAL_CONFIG_DIRNAME = "commitguard"
|
|
6
|
+
GLOBAL_CONFIG_FILENAME = "config.yaml"
|
|
7
|
+
CURRENT_CONFIG_VERSION = 1
|
|
8
|
+
|
|
9
|
+
#: Refuse to parse configuration files larger than this (defence against
|
|
10
|
+
#: resource exhaustion via YAML alias expansion or huge files).
|
|
11
|
+
MAX_CONFIG_BYTES = 64 * 1024
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG_TEMPLATE = """\
|
|
14
|
+
# CommitGuard repository configuration.
|
|
15
|
+
# Docs: docs/configuration.md
|
|
16
|
+
#
|
|
17
|
+
# Policies not listed here keep their built-in secure defaults.
|
|
18
|
+
# Valid actions: allow | warn | block
|
|
19
|
+
|
|
20
|
+
version: 1
|
|
21
|
+
|
|
22
|
+
policies:
|
|
23
|
+
ai_coauthor:
|
|
24
|
+
enabled: true
|
|
25
|
+
action: block
|
|
26
|
+
ai_identity:
|
|
27
|
+
enabled: true
|
|
28
|
+
action: block
|
|
29
|
+
ai_trailer:
|
|
30
|
+
enabled: true
|
|
31
|
+
action: block
|
|
32
|
+
malformed_trailer:
|
|
33
|
+
enabled: true
|
|
34
|
+
action: warn
|
|
35
|
+
bot_identity:
|
|
36
|
+
enabled: true
|
|
37
|
+
action: warn
|
|
38
|
+
|
|
39
|
+
# Which Git hooks enforce the policies above (after `commitguard install`).
|
|
40
|
+
# Disabling a hook is visible in `commitguard doctor`.
|
|
41
|
+
enforcement:
|
|
42
|
+
pre_commit: true
|
|
43
|
+
commit_msg: true
|
|
44
|
+
pre_push: true
|
|
45
|
+
|
|
46
|
+
# What to do about a violation. By default CommitGuard blocks and leaves the
|
|
47
|
+
# message to you. Set auto_remove: true and the commit-msg hook deletes the
|
|
48
|
+
# offending lines instead, reports what it removed, and lets the commit through.
|
|
49
|
+
# Attribution in the author or committer identity still blocks: no edit to the
|
|
50
|
+
# message can fix that. `commitguard doctor` shows when this is on.
|
|
51
|
+
# remediation:
|
|
52
|
+
# auto_remove: true
|
|
53
|
+
"""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Effective hook enforcement and remediation settings, merged across layers."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from commitguard.config.schema import CommitGuardConfig
|
|
6
|
+
|
|
7
|
+
DEFAULT_MAX_PUSH_COMMITS = 10_000
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Enforcement(BaseModel):
|
|
11
|
+
"""Resolved enforcement settings. Secure default: every hook enforces."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
14
|
+
|
|
15
|
+
pre_commit: bool = True
|
|
16
|
+
commit_msg: bool = True
|
|
17
|
+
pre_push: bool = True
|
|
18
|
+
max_push_commits: int = DEFAULT_MAX_PUSH_COMMITS
|
|
19
|
+
|
|
20
|
+
def enabled(self, hook: str) -> bool:
|
|
21
|
+
return bool(getattr(self, hook.replace("-", "_")))
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def complete(self) -> bool:
|
|
25
|
+
return self.pre_commit and self.commit_msg and self.pre_push
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_enforcement(*configs: CommitGuardConfig) -> Enforcement:
|
|
29
|
+
"""Merge enforcement settings from configuration layers (lowest first)."""
|
|
30
|
+
effective = Enforcement()
|
|
31
|
+
for config in configs:
|
|
32
|
+
updates = config.enforcement.model_dump(exclude_unset=True)
|
|
33
|
+
if updates:
|
|
34
|
+
effective = effective.model_copy(update=updates)
|
|
35
|
+
return effective
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Remediation(BaseModel):
|
|
39
|
+
"""Resolved remediation settings. Secure default: change nothing, just block."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
42
|
+
|
|
43
|
+
auto_remove: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_remediation(*configs: CommitGuardConfig) -> Remediation:
|
|
47
|
+
"""Merge remediation settings from configuration layers (lowest first)."""
|
|
48
|
+
effective = Remediation()
|
|
49
|
+
for config in configs:
|
|
50
|
+
updates = config.remediation.model_dump(exclude_unset=True)
|
|
51
|
+
if updates:
|
|
52
|
+
effective = effective.model_copy(update=updates)
|
|
53
|
+
return effective
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Locate, parse, validate and layer configuration files.
|
|
2
|
+
|
|
3
|
+
Precedence (later layers override earlier ones, field by field)::
|
|
4
|
+
|
|
5
|
+
1. built-in defaults commitguard.policies.defaults
|
|
6
|
+
2. global configuration $XDG_CONFIG_HOME/commitguard/config.yaml
|
|
7
|
+
(default ~/.config/commitguard/config.yaml)
|
|
8
|
+
3. repository configuration <repo root>/.commitguard.yaml (or .yml)
|
|
9
|
+
4. explicit configuration --config PATH
|
|
10
|
+
|
|
11
|
+
A layer only overrides the policy fields it sets; omitted policies and fields
|
|
12
|
+
keep the value from the layer below, ultimately the secure built-in default.
|
|
13
|
+
|
|
14
|
+
Security properties:
|
|
15
|
+
|
|
16
|
+
* strict safe YAML (no object construction, no duplicate keys, no aliases);
|
|
17
|
+
* files are size-limited and must be regular files;
|
|
18
|
+
* repository configuration is read only from the repository root - never from
|
|
19
|
+
parent directories, which may be controlled by someone else.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from enum import StrEnum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
28
|
+
|
|
29
|
+
from commitguard.config.defaults import (
|
|
30
|
+
CONFIG_FILENAMES,
|
|
31
|
+
GLOBAL_CONFIG_DIRNAME,
|
|
32
|
+
GLOBAL_CONFIG_FILENAME,
|
|
33
|
+
MAX_CONFIG_BYTES,
|
|
34
|
+
)
|
|
35
|
+
from commitguard.config.schema import CommitGuardConfig
|
|
36
|
+
from commitguard.exceptions.base import UnsafeInputError
|
|
37
|
+
from commitguard.exceptions.configuration import ConfigurationError
|
|
38
|
+
from commitguard.security.safe_yaml import load_yaml
|
|
39
|
+
from commitguard.utils.filesystem import read_text_limited
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConfigLayer(StrEnum):
|
|
43
|
+
BUILTIN = "builtin"
|
|
44
|
+
GLOBAL = "global"
|
|
45
|
+
REPOSITORY = "repository"
|
|
46
|
+
EXPLICIT = "explicit"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConfigSource(BaseModel):
|
|
50
|
+
"""Where one configuration layer came from."""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
53
|
+
|
|
54
|
+
layer: ConfigLayer
|
|
55
|
+
path: Path | None = None
|
|
56
|
+
revision: str | None = None # set when the file was read from a commit, not the work tree
|
|
57
|
+
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
if self.path is None:
|
|
60
|
+
return self.layer.value
|
|
61
|
+
if self.revision is not None:
|
|
62
|
+
return f"{self.layer.value}: {self.path.as_posix()} @ {self.revision[:12]}"
|
|
63
|
+
return f"{self.layer.value}: {self.path}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LoadedConfig(BaseModel):
|
|
67
|
+
"""All configuration layers in precedence order (lowest first)."""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
70
|
+
|
|
71
|
+
layers: tuple[tuple[ConfigSource, CommitGuardConfig], ...]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def configs(self) -> tuple[CommitGuardConfig, ...]:
|
|
75
|
+
return tuple(config for _, config in self.layers)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def sources(self) -> tuple[ConfigSource, ...]:
|
|
79
|
+
return tuple(source for source, _ in self.layers)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_config(repository_root: Path) -> Path | None:
|
|
83
|
+
"""Return the configuration file at ``repository_root``, if any.
|
|
84
|
+
|
|
85
|
+
Having both ``.commitguard.yaml`` and ``.commitguard.yml`` is ambiguous and
|
|
86
|
+
rejected.
|
|
87
|
+
"""
|
|
88
|
+
candidates = [repository_root / name for name in CONFIG_FILENAMES]
|
|
89
|
+
present = [path for path in candidates if path.exists() or path.is_symlink()]
|
|
90
|
+
if len(present) > 1:
|
|
91
|
+
raise ConfigurationError(
|
|
92
|
+
"multiple configuration files found: " + ", ".join(p.name for p in present)
|
|
93
|
+
)
|
|
94
|
+
return present[0] if present else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def global_config_path() -> Path:
|
|
98
|
+
"""Path of the per-user global configuration file (may not exist)."""
|
|
99
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
100
|
+
root = Path(base) if base and Path(base).is_absolute() else Path.home() / ".config"
|
|
101
|
+
return root / GLOBAL_CONFIG_DIRNAME / GLOBAL_CONFIG_FILENAME
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_config(text: str, *, path: Path | None = None) -> CommitGuardConfig:
|
|
105
|
+
"""Parse and validate configuration from YAML text."""
|
|
106
|
+
try:
|
|
107
|
+
document = load_yaml(text)
|
|
108
|
+
except yaml.YAMLError as exc:
|
|
109
|
+
raise ConfigurationError(f"invalid YAML: {exc}", path=path) from exc
|
|
110
|
+
|
|
111
|
+
if not isinstance(document, dict):
|
|
112
|
+
raise ConfigurationError("configuration must be a YAML mapping", path=path)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
return CommitGuardConfig.model_validate(document)
|
|
116
|
+
except ValidationError as exc:
|
|
117
|
+
raise ConfigurationError(_format_validation_error(exc), path=path) from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_config(path: Path) -> CommitGuardConfig:
|
|
121
|
+
"""Read, parse and validate a configuration file."""
|
|
122
|
+
try:
|
|
123
|
+
text = read_text_limited(path, max_bytes=MAX_CONFIG_BYTES)
|
|
124
|
+
except FileNotFoundError as exc:
|
|
125
|
+
raise ConfigurationError("configuration file not found", path=path) from exc
|
|
126
|
+
except (OSError, UnsafeInputError) as exc:
|
|
127
|
+
raise ConfigurationError(str(exc), path=path) from exc
|
|
128
|
+
return parse_config(text, path=path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_repository_config(repository_root: Path) -> tuple[CommitGuardConfig, Path | None]:
|
|
132
|
+
"""Load only the repository layer, or an empty config if it has none."""
|
|
133
|
+
path = find_config(repository_root)
|
|
134
|
+
if path is None:
|
|
135
|
+
return CommitGuardConfig(version=1), None
|
|
136
|
+
return load_config(path), path
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_effective_config(
|
|
140
|
+
repository_root: Path | None,
|
|
141
|
+
*,
|
|
142
|
+
explicit_path: Path | None = None,
|
|
143
|
+
include_global: bool = True,
|
|
144
|
+
) -> LoadedConfig:
|
|
145
|
+
"""Load every applicable configuration layer in precedence order."""
|
|
146
|
+
layers: list[tuple[ConfigSource, CommitGuardConfig]] = [
|
|
147
|
+
(ConfigSource(layer=ConfigLayer.BUILTIN), CommitGuardConfig(version=1))
|
|
148
|
+
]
|
|
149
|
+
if include_global:
|
|
150
|
+
path = global_config_path()
|
|
151
|
+
if path.exists() or path.is_symlink():
|
|
152
|
+
layers.append((ConfigSource(layer=ConfigLayer.GLOBAL, path=path), load_config(path)))
|
|
153
|
+
if repository_root is not None:
|
|
154
|
+
repo_path = find_config(repository_root)
|
|
155
|
+
if repo_path is not None:
|
|
156
|
+
layers.append(
|
|
157
|
+
(ConfigSource(layer=ConfigLayer.REPOSITORY, path=repo_path), load_config(repo_path))
|
|
158
|
+
)
|
|
159
|
+
if explicit_path is not None:
|
|
160
|
+
layers.append(
|
|
161
|
+
(
|
|
162
|
+
ConfigSource(layer=ConfigLayer.EXPLICIT, path=explicit_path),
|
|
163
|
+
load_config(explicit_path),
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
return LoadedConfig(layers=tuple(layers))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _format_validation_error(exc: ValidationError) -> str:
|
|
170
|
+
lines = ["invalid configuration:"]
|
|
171
|
+
for error in exc.errors(include_url=False, include_input=False):
|
|
172
|
+
location = ".".join(str(part) for part in error["loc"]) or "<root>"
|
|
173
|
+
lines.append(f" - {location}: {error['msg']}")
|
|
174
|
+
return "\n".join(lines)
|