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,288 @@
|
|
|
1
|
+
"""A portable validation of the local enforcement path on this machine.
|
|
2
|
+
|
|
3
|
+
Designed to run unchanged on Linux, macOS and Windows (GitHub Actions runners
|
|
4
|
+
for the latter two), so the compatibility matrix is built from observed results
|
|
5
|
+
rather than from what should work. Every check records what was expected and
|
|
6
|
+
what was observed; a check that could not run is ``SKIPPED`` with the reason,
|
|
7
|
+
never ``PASS``.
|
|
8
|
+
|
|
9
|
+
The repository lives in a directory whose path contains spaces and non-ASCII
|
|
10
|
+
characters, because path handling is where Git hooks most often break across
|
|
11
|
+
platforms.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import platform as platform_module
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ConfigDict
|
|
22
|
+
|
|
23
|
+
from commitguard.git.hooks import (
|
|
24
|
+
HookState,
|
|
25
|
+
HookStatus,
|
|
26
|
+
HookType,
|
|
27
|
+
hook_status,
|
|
28
|
+
install_hooks,
|
|
29
|
+
repository_hooks_dir,
|
|
30
|
+
uninstall_hooks,
|
|
31
|
+
)
|
|
32
|
+
from commitguard.git.repository import Repository
|
|
33
|
+
from commitguard.research.gitenv import Workspace, workspace
|
|
34
|
+
from commitguard.utils.subprocess import run_command
|
|
35
|
+
|
|
36
|
+
BENCHMARK_VERSION = "1.0.0"
|
|
37
|
+
Status = Literal["PASS", "FAIL", "SKIPPED"]
|
|
38
|
+
|
|
39
|
+
CLEAN = "feat: add session rotation\n\nSigned-off-by: Ada Lovelace <ada@example.com>\n"
|
|
40
|
+
AI = "feat: add payment service\n\nCo-authored-by: Claude <noreply@anthropic.com>\n"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Check(BaseModel):
|
|
44
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
45
|
+
|
|
46
|
+
area: str
|
|
47
|
+
name: str
|
|
48
|
+
status: Status
|
|
49
|
+
expected: str
|
|
50
|
+
observed: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PlatformResult(BaseModel):
|
|
54
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
55
|
+
|
|
56
|
+
benchmark: str = "platform"
|
|
57
|
+
benchmark_version: str = BENCHMARK_VERSION
|
|
58
|
+
system: str
|
|
59
|
+
shell_for_hooks: str
|
|
60
|
+
checks: tuple[Check, ...]
|
|
61
|
+
passed: int
|
|
62
|
+
failed: int
|
|
63
|
+
skipped: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _statuses(repository: Repository) -> list[HookStatus]:
|
|
67
|
+
directory = repository_hooks_dir(repository)
|
|
68
|
+
return [hook_status(directory, hook, expected_python=sys.executable) for hook in HookType]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cli(space: Workspace, cwd: Path, *args: str) -> tuple[int, str]:
|
|
72
|
+
result = run_command(
|
|
73
|
+
[sys.executable, "-P", "-m", "commitguard", *args],
|
|
74
|
+
cwd=cwd,
|
|
75
|
+
env_overrides={**space.env, "PYTHONIOENCODING": "utf-8"},
|
|
76
|
+
timeout=300,
|
|
77
|
+
)
|
|
78
|
+
output = (result.stdout + result.stderr).decode("utf-8", "replace")
|
|
79
|
+
return result.returncode, output[-300:]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_platform() -> PlatformResult:
|
|
83
|
+
checks: list[Check] = []
|
|
84
|
+
|
|
85
|
+
def record(area: str, name: str, expected: str, probe: Callable[[], tuple[bool, str]]) -> bool:
|
|
86
|
+
try:
|
|
87
|
+
ok, observed = probe()
|
|
88
|
+
except Exception as exc: # noqa: BLE001 - a failing probe is a FAIL, not a crash
|
|
89
|
+
ok, observed = False, f"{type(exc).__name__}: {exc}"[:300]
|
|
90
|
+
checks.append(
|
|
91
|
+
Check(
|
|
92
|
+
area=area,
|
|
93
|
+
name=name,
|
|
94
|
+
status="PASS" if ok else "FAIL",
|
|
95
|
+
expected=expected,
|
|
96
|
+
observed=observed,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return ok
|
|
100
|
+
|
|
101
|
+
with workspace() as space:
|
|
102
|
+
record(
|
|
103
|
+
"environment",
|
|
104
|
+
"git is available",
|
|
105
|
+
"git --version succeeds",
|
|
106
|
+
lambda: (
|
|
107
|
+
(r := space.git(space.root, "--version", check=False)).returncode == 0,
|
|
108
|
+
r.stdout.decode("utf-8", "replace").strip(),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
base = space.root / "path with spaces" / "répertoire-ünïcøde"
|
|
112
|
+
base.mkdir(parents=True)
|
|
113
|
+
remote = space.init("remote.git", bare=True)
|
|
114
|
+
space.git(base.parent, "init", "--quiet", str(base))
|
|
115
|
+
path = base
|
|
116
|
+
space.git(path, "remote", "add", "origin", str(remote))
|
|
117
|
+
space.git(path, "commit", "--quiet", "--allow-empty", "--no-verify", "-m", "chore: init")
|
|
118
|
+
space.git(path, "push", "--quiet", "--no-verify", "origin", "main")
|
|
119
|
+
repository = Repository.discover(path)
|
|
120
|
+
|
|
121
|
+
installed = record(
|
|
122
|
+
"hooks",
|
|
123
|
+
"install hooks (path with spaces and non-ASCII characters)",
|
|
124
|
+
"pre-commit, commit-msg and pre-push installed",
|
|
125
|
+
lambda: (
|
|
126
|
+
all(
|
|
127
|
+
r.action.value in ("installed", "updated", "unchanged")
|
|
128
|
+
for r in install_hooks(repository, python=sys.executable)
|
|
129
|
+
),
|
|
130
|
+
", ".join(f"{s.hook.value}={s.state.value}" for s in _statuses(repository)),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
if installed:
|
|
134
|
+
record(
|
|
135
|
+
"hooks",
|
|
136
|
+
"hook integrity",
|
|
137
|
+
"every hook reports installed",
|
|
138
|
+
lambda: (
|
|
139
|
+
all(s.state is HookState.INSTALLED for s in _statuses(repository)),
|
|
140
|
+
", ".join(f"{s.hook.value}={s.state.value}" for s in _statuses(repository)),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def head() -> str:
|
|
145
|
+
return space.git(path, "rev-parse", "HEAD").stdout.decode().strip()
|
|
146
|
+
|
|
147
|
+
def commit_probe(
|
|
148
|
+
message: str, *extra: str, allowed: bool
|
|
149
|
+
) -> Callable[[], tuple[bool, str]]:
|
|
150
|
+
def probe() -> tuple[bool, str]:
|
|
151
|
+
before = head()
|
|
152
|
+
result = space.git(
|
|
153
|
+
path, "commit", "--quiet", "--allow-empty", *extra, "-m", message, check=False
|
|
154
|
+
)
|
|
155
|
+
created = head() != before
|
|
156
|
+
ok = (
|
|
157
|
+
(result.returncode == 0 and created)
|
|
158
|
+
if allowed
|
|
159
|
+
else (result.returncode != 0 and not created)
|
|
160
|
+
)
|
|
161
|
+
return (
|
|
162
|
+
ok,
|
|
163
|
+
f"exit {result.returncode}, commit {'created' if created else 'not created'}",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return probe
|
|
167
|
+
|
|
168
|
+
record(
|
|
169
|
+
"pre-commit / commit-msg",
|
|
170
|
+
"clean commit allowed",
|
|
171
|
+
"exit 0, commit created",
|
|
172
|
+
commit_probe(CLEAN, allowed=True),
|
|
173
|
+
)
|
|
174
|
+
record(
|
|
175
|
+
"pre-commit / commit-msg",
|
|
176
|
+
"AI co-authored commit blocked",
|
|
177
|
+
"non-zero exit, no commit",
|
|
178
|
+
commit_probe(AI, allowed=False),
|
|
179
|
+
)
|
|
180
|
+
record(
|
|
181
|
+
"bypass",
|
|
182
|
+
"git commit --no-verify skips local hooks",
|
|
183
|
+
"exit 0, commit created (documented limitation)",
|
|
184
|
+
commit_probe(AI, "--no-verify", allowed=True),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def push_probe(
|
|
188
|
+
message: str, branch: str, *extra: str, allowed: bool
|
|
189
|
+
) -> Callable[[], tuple[bool, str]]:
|
|
190
|
+
def probe() -> tuple[bool, str]:
|
|
191
|
+
space.git(path, "checkout", "--quiet", "-B", branch, "origin/main")
|
|
192
|
+
space.git(path, "commit", "--quiet", "--allow-empty", "--no-verify", "-m", message)
|
|
193
|
+
result = space.git(path, "push", "--quiet", *extra, "origin", branch, check=False)
|
|
194
|
+
ok = result.returncode == 0 if allowed else result.returncode != 0
|
|
195
|
+
return ok, f"exit {result.returncode}"
|
|
196
|
+
|
|
197
|
+
return probe
|
|
198
|
+
|
|
199
|
+
record("pre-push", "clean push allowed", "exit 0", push_probe(CLEAN, "clean", allowed=True))
|
|
200
|
+
record(
|
|
201
|
+
"pre-push",
|
|
202
|
+
"AI co-authored push blocked",
|
|
203
|
+
"non-zero exit",
|
|
204
|
+
push_probe(AI, "ai", allowed=False),
|
|
205
|
+
)
|
|
206
|
+
record(
|
|
207
|
+
"bypass",
|
|
208
|
+
"git push --no-verify skips pre-push",
|
|
209
|
+
"exit 0 (documented limitation)",
|
|
210
|
+
push_probe(AI, "ai-bypass", "--no-verify", allowed=True),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
space.git(path, "checkout", "--quiet", "clean")
|
|
214
|
+
record(
|
|
215
|
+
"cli",
|
|
216
|
+
"commitguard scan on a clean commit",
|
|
217
|
+
"exit 0",
|
|
218
|
+
lambda: ((r := _cli(space, path, "scan", "HEAD"))[0] == 0, f"exit {r[0]}"),
|
|
219
|
+
)
|
|
220
|
+
space.git(path, "checkout", "--quiet", "ai")
|
|
221
|
+
record(
|
|
222
|
+
"cli",
|
|
223
|
+
"commitguard scan on an AI co-authored commit",
|
|
224
|
+
"exit 1 (blocked)",
|
|
225
|
+
lambda: ((r := _cli(space, path, "scan", "HEAD"))[0] == 1, f"exit {r[0]}"),
|
|
226
|
+
)
|
|
227
|
+
config = path / ".commitguard.yaml"
|
|
228
|
+
config.write_bytes(b"version: 1\r\npolicies:\r\n bot_identity:\r\n action: block\r\n")
|
|
229
|
+
record(
|
|
230
|
+
"configuration",
|
|
231
|
+
"configuration with CRLF line endings",
|
|
232
|
+
"exit 1 (valid configuration, AI commit still blocked)",
|
|
233
|
+
lambda: ((r := _cli(space, path, "scan", "HEAD"))[0] == 1, f"exit {r[0]}"),
|
|
234
|
+
)
|
|
235
|
+
config.write_text("version: 1\npolicies: [not, a, mapping]\n", encoding="utf-8")
|
|
236
|
+
record(
|
|
237
|
+
"configuration",
|
|
238
|
+
"invalid configuration fails closed",
|
|
239
|
+
"exit 2 (error), never exit 0",
|
|
240
|
+
lambda: ((r := _cli(space, path, "scan", "HEAD"))[0] == 2, f"exit {r[0]}"),
|
|
241
|
+
)
|
|
242
|
+
config.unlink()
|
|
243
|
+
record(
|
|
244
|
+
"cli",
|
|
245
|
+
"commitguard check --message-file",
|
|
246
|
+
"exit 1 for an AI co-authored message",
|
|
247
|
+
lambda: _message_file_check(space, path),
|
|
248
|
+
)
|
|
249
|
+
record(
|
|
250
|
+
"hooks",
|
|
251
|
+
"uninstall removes the hooks",
|
|
252
|
+
"hooks absent; an AI commit is no longer blocked locally",
|
|
253
|
+
lambda: _uninstall_probe(space, path, repository),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
passed = sum(1 for c in checks if c.status == "PASS")
|
|
257
|
+
failed = sum(1 for c in checks if c.status == "FAIL")
|
|
258
|
+
return PlatformResult(
|
|
259
|
+
system=(
|
|
260
|
+
f"{platform_module.system()} {platform_module.release()} ({platform_module.machine()})"
|
|
261
|
+
),
|
|
262
|
+
shell_for_hooks="Git's sh (Git for Windows ships one)" if os.name == "nt" else "/bin/sh",
|
|
263
|
+
checks=tuple(checks),
|
|
264
|
+
passed=passed,
|
|
265
|
+
failed=failed,
|
|
266
|
+
skipped=len(checks) - passed - failed,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _message_file_check(space: Workspace, path: Path) -> tuple[bool, str]:
|
|
271
|
+
message = path.parent / "message with spaces.txt"
|
|
272
|
+
message.write_text(AI, encoding="utf-8")
|
|
273
|
+
code, _ = _cli(space, path, "check", "--message-file", str(message))
|
|
274
|
+
return code == 1, f"exit {code}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _uninstall_probe(space: Workspace, path: Path, repository: Repository) -> tuple[bool, str]:
|
|
278
|
+
uninstall_hooks(repository)
|
|
279
|
+
states = {s.hook.value: s.state.value for s in _statuses(repository)}
|
|
280
|
+
before = space.git(path, "rev-parse", "HEAD").stdout
|
|
281
|
+
result = space.git(path, "commit", "--quiet", "--allow-empty", "-m", AI, check=False)
|
|
282
|
+
created = space.git(path, "rev-parse", "HEAD").stdout != before
|
|
283
|
+
ok = all(state == HookState.MISSING.value for state in states.values()) and created
|
|
284
|
+
return (
|
|
285
|
+
ok,
|
|
286
|
+
f"hooks {states}; AI commit exit {result.returncode}, "
|
|
287
|
+
f"{'created' if created else 'not created'}",
|
|
288
|
+
)
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Security and benchmark reports assembled from recorded evidence.
|
|
2
|
+
|
|
3
|
+
Nothing here computes a result: every number is read from a recorded benchmark
|
|
4
|
+
result (``benchmarks/results/raw/``) or from evidence written by the test suites
|
|
5
|
+
(``COMMITGUARD_EVIDENCE_DIR``). Anything that has no evidence is reported as
|
|
6
|
+
``Not tested`` rather than left out, so the report cannot quietly overstate what
|
|
7
|
+
is known.
|
|
8
|
+
|
|
9
|
+
Every statement carries one label:
|
|
10
|
+
|
|
11
|
+
``Measured`` a number from a recorded benchmark run on a stated machine;
|
|
12
|
+
``Tested`` an automated test asserts it (test suites, experiments);
|
|
13
|
+
``Observed`` an experiment recorded what actually happened, including failures
|
|
14
|
+
and documented bypasses;
|
|
15
|
+
``Expected`` design intent that no test covers yet;
|
|
16
|
+
``Not tested`` no evidence.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from commitguard import __version__
|
|
26
|
+
from commitguard.research.results import latest, load_results
|
|
27
|
+
|
|
28
|
+
LABELS = ("Measured", "Tested", "Observed", "Expected", "Not tested")
|
|
29
|
+
BENCHMARKS = ("detection", "performance", "hooks", "repository", "platform")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_json(path: Path) -> Any:
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
except (OSError, ValueError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
40
|
+
entries: list[dict[str, Any]] = []
|
|
41
|
+
try:
|
|
42
|
+
text = path.read_text(encoding="utf-8")
|
|
43
|
+
except OSError:
|
|
44
|
+
return entries
|
|
45
|
+
for line in text.splitlines():
|
|
46
|
+
if line.strip():
|
|
47
|
+
try:
|
|
48
|
+
entries.append(json.loads(line))
|
|
49
|
+
except ValueError:
|
|
50
|
+
continue
|
|
51
|
+
return entries
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _environment(manifest: dict[str, Any]) -> str:
|
|
55
|
+
return (
|
|
56
|
+
f"{manifest.get('operating_system')} {manifest.get('os_release')} "
|
|
57
|
+
f"({manifest.get('machine')}), {manifest.get('cpu_model')}, "
|
|
58
|
+
f"{manifest.get('cpu_count')} CPUs, Python {manifest.get('python_version')}, "
|
|
59
|
+
f"Git {manifest.get('git_version')}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def collect(results_dir: Path, evidence_dir: Path | None) -> dict[str, Any]:
|
|
64
|
+
"""Gather every recorded result and piece of evidence into one document."""
|
|
65
|
+
documents = {name: latest(results_dir, name) for name in BENCHMARKS}
|
|
66
|
+
history = [
|
|
67
|
+
{
|
|
68
|
+
"benchmark": document.get("benchmark"),
|
|
69
|
+
"file": path.relative_to(results_dir).as_posix(),
|
|
70
|
+
"timestamp": document.get("manifest", {}).get("timestamp"),
|
|
71
|
+
"commitguard_version": document.get("manifest", {}).get("commitguard_version"),
|
|
72
|
+
"dataset_version": document.get("manifest", {}).get("dataset_version"),
|
|
73
|
+
"source_revision": document.get("manifest", {}).get("source_revision"),
|
|
74
|
+
}
|
|
75
|
+
for path, document in load_results(results_dir)
|
|
76
|
+
]
|
|
77
|
+
experiments = _read_jsonl(evidence_dir / "experiments.jsonl") if evidence_dir else []
|
|
78
|
+
fuzzing = _read_json(evidence_dir / "fuzzing.json") if evidence_dir else None
|
|
79
|
+
redos = _read_json(evidence_dir / "redos.json") if evidence_dir else None
|
|
80
|
+
return {
|
|
81
|
+
"generated_at": datetime.now(UTC).isoformat(),
|
|
82
|
+
"commitguard_version": __version__,
|
|
83
|
+
"results_directory": str(results_dir),
|
|
84
|
+
"evidence_directory": str(evidence_dir) if evidence_dir else None,
|
|
85
|
+
"latest": {
|
|
86
|
+
name: (
|
|
87
|
+
{"file": path.relative_to(results_dir).as_posix(), **document}
|
|
88
|
+
if (pair := documents[name]) and (path := pair[0]) and (document := pair[1])
|
|
89
|
+
else None
|
|
90
|
+
)
|
|
91
|
+
for name in BENCHMARKS
|
|
92
|
+
},
|
|
93
|
+
"history": history,
|
|
94
|
+
"experiments": experiments,
|
|
95
|
+
"fuzzing": fuzzing,
|
|
96
|
+
"redos": redos,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _detection_section(document: dict[str, Any] | None) -> list[str]:
|
|
101
|
+
if document is None:
|
|
102
|
+
return ["| Detection accuracy | Not tested | no recorded detection run |"]
|
|
103
|
+
result, manifest = document["result"], document["manifest"]
|
|
104
|
+
decision = result["decision"]
|
|
105
|
+
rates = result["decision_rates"]
|
|
106
|
+
|
|
107
|
+
def percent(key: str) -> str:
|
|
108
|
+
value = rates.get(key)
|
|
109
|
+
return "n/a" if value is None else f"{value * 100:.3f}%"
|
|
110
|
+
|
|
111
|
+
return [
|
|
112
|
+
f"| False negatives | Measured | {decision['false_negative']} of {result['positives']} "
|
|
113
|
+
f"cases that must not be allowed (dataset {manifest.get('dataset_version')}) |",
|
|
114
|
+
f"| False positives | Measured | {decision['false_positive']} of {result['negatives']} "
|
|
115
|
+
f"clean cases |",
|
|
116
|
+
f"| Precision / recall | Measured | {percent('precision')} / {percent('recall')} |",
|
|
117
|
+
f"| False positive rate / false negative rate | Measured | {percent('false_positive_rate')}"
|
|
118
|
+
f" / {percent('false_negative_rate')} |",
|
|
119
|
+
f"| Detection latency per commit | Measured | p50 {result['latency']['p50_ms']:.4f} ms, "
|
|
120
|
+
f"p95 {result['latency']['p95_ms']:.4f} ms, p99 {result['latency']['p99_ms']:.4f} ms |",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _performance_section(document: dict[str, Any] | None) -> list[str]:
|
|
125
|
+
if document is None:
|
|
126
|
+
return ["| Performance | Not tested | no recorded performance run |"]
|
|
127
|
+
result = document["result"]
|
|
128
|
+
batches = result.get("batches") or []
|
|
129
|
+
messages = result.get("message_sizes") or []
|
|
130
|
+
lines = []
|
|
131
|
+
if batches:
|
|
132
|
+
biggest = batches[-1]
|
|
133
|
+
lines.append(
|
|
134
|
+
f"| Throughput | Measured | {biggest['commits_per_second']:.1f} commits/s on "
|
|
135
|
+
f"{biggest['commits']} commits "
|
|
136
|
+
f"(p50 {biggest['latency']['p50_ms']:.4f} ms per commit) |"
|
|
137
|
+
)
|
|
138
|
+
if messages:
|
|
139
|
+
biggest = messages[-1]
|
|
140
|
+
lines.append(
|
|
141
|
+
f"| Large commit messages | Measured | {biggest['message_bytes']:,} byte message: "
|
|
142
|
+
f"p50 {biggest['latency']['p50_ms']:.3f} ms |"
|
|
143
|
+
)
|
|
144
|
+
peak = result.get("peak_rss_bytes")
|
|
145
|
+
if peak:
|
|
146
|
+
lines.append(f"| Peak memory | Measured | {peak / 1048576:.1f} MiB for the whole run |")
|
|
147
|
+
return lines or ["| Performance | Not tested | recorded run has no measurements |"]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _hooks_section(document: dict[str, Any] | None) -> list[str]:
|
|
151
|
+
if document is None:
|
|
152
|
+
return ["| Hook overhead | Not tested | no recorded hooks run |"]
|
|
153
|
+
result = document["result"]
|
|
154
|
+
lines = []
|
|
155
|
+
for overhead in result.get("overhead", []):
|
|
156
|
+
lines.append(
|
|
157
|
+
f"| Hook overhead: {overhead['operation']} | Measured | "
|
|
158
|
+
f"+{overhead['overhead_p50_ms']:.0f} ms "
|
|
159
|
+
f"({overhead['without_hooks_p50_ms']:.1f} -> {overhead['with_hooks_p50_ms']:.1f} ms, "
|
|
160
|
+
f"median of {result.get('repetitions', '?')} runs) |"
|
|
161
|
+
)
|
|
162
|
+
for observation in result.get("observations", []):
|
|
163
|
+
lines.append(
|
|
164
|
+
f"| {observation['check']} | Observed | {observation['observed']} "
|
|
165
|
+
f"(expected {observation['expected']}) |"
|
|
166
|
+
)
|
|
167
|
+
return lines or ["| Hook overhead | Not tested | recorded run has no measurements |"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _platform_section(document: dict[str, Any] | None) -> list[str]:
|
|
171
|
+
if document is None:
|
|
172
|
+
return ["| Cross-platform validation | Not tested | no recorded platform run |"]
|
|
173
|
+
result = document["result"]
|
|
174
|
+
return [
|
|
175
|
+
f"| Local enforcement on {result['system']} | Tested | {result['passed']} passed, "
|
|
176
|
+
f"{result['failed']} failed, {result['skipped']} skipped |"
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _repository_section(document: dict[str, Any] | None) -> list[str]:
|
|
181
|
+
if document is None:
|
|
182
|
+
return ["| Repository history scanning | Not tested | no recorded repository run |"]
|
|
183
|
+
histories = document["result"].get("histories") or []
|
|
184
|
+
if not histories:
|
|
185
|
+
return ["| Repository history scanning | Not tested | recorded run has no measurements |"]
|
|
186
|
+
biggest = histories[-1]
|
|
187
|
+
return [
|
|
188
|
+
f"| Repository history scanning | Measured | {biggest['commits']} commits in "
|
|
189
|
+
f"{biggest['scan_total_ms'] / 1000:.1f} s ({biggest['commits_per_second']:.0f} commits/s); "
|
|
190
|
+
f"{biggest['blocked']} of {biggest['seeded_violations']} seeded violations found |"
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _experiment_rows(experiments: Iterable[dict[str, Any]]) -> list[str]:
|
|
195
|
+
rows = []
|
|
196
|
+
for entry in sorted(
|
|
197
|
+
experiments, key=lambda item: (item.get("area", ""), item.get("attack", ""))
|
|
198
|
+
):
|
|
199
|
+
rows.append(
|
|
200
|
+
f"| {entry.get('area', '')} | {entry.get('attack', '')} | "
|
|
201
|
+
f"**{entry.get('outcome', '')}** | {entry.get('observed', '')} | "
|
|
202
|
+
f"{entry.get('mitigation', '')} |"
|
|
203
|
+
)
|
|
204
|
+
return rows
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def render_security_report(data: dict[str, Any]) -> str:
|
|
208
|
+
latest_results = data["latest"]
|
|
209
|
+
detection = latest_results.get("detection")
|
|
210
|
+
lines = [
|
|
211
|
+
"# CommitGuard security report",
|
|
212
|
+
"",
|
|
213
|
+
f"Generated {data['generated_at']} for CommitGuard {data['commitguard_version']}.",
|
|
214
|
+
"",
|
|
215
|
+
"Every row is labelled: **Measured** (a number from a recorded benchmark run),",
|
|
216
|
+
"**Tested** (an automated test asserts it), **Observed** (an experiment recorded what",
|
|
217
|
+
"happened, including bypasses), **Expected** (design intent, not yet covered by a test)",
|
|
218
|
+
"or **Not tested**. Nothing in this report is estimated.",
|
|
219
|
+
"",
|
|
220
|
+
]
|
|
221
|
+
if detection:
|
|
222
|
+
lines += [
|
|
223
|
+
"## Environment of the latest runs",
|
|
224
|
+
"",
|
|
225
|
+
f"- Detection: {_environment(detection['manifest'])}",
|
|
226
|
+
f"- Source revision: `{detection['manifest'].get('source_revision')}`"
|
|
227
|
+
f" (dirty: {detection['manifest'].get('source_dirty')})",
|
|
228
|
+
"",
|
|
229
|
+
]
|
|
230
|
+
lines += [
|
|
231
|
+
"## Detection",
|
|
232
|
+
"",
|
|
233
|
+
"| Statement | Label | Evidence |",
|
|
234
|
+
"|---|---|---|",
|
|
235
|
+
*_detection_section(detection),
|
|
236
|
+
"",
|
|
237
|
+
"## Performance",
|
|
238
|
+
"",
|
|
239
|
+
"| Statement | Label | Evidence |",
|
|
240
|
+
"|---|---|---|",
|
|
241
|
+
*_performance_section(latest_results.get("performance")),
|
|
242
|
+
*_repository_section(latest_results.get("repository")),
|
|
243
|
+
*_hooks_section(latest_results.get("hooks")),
|
|
244
|
+
"",
|
|
245
|
+
"## Cross-platform validation",
|
|
246
|
+
"",
|
|
247
|
+
"| Statement | Label | Evidence |",
|
|
248
|
+
"|---|---|---|",
|
|
249
|
+
*_platform_section(latest_results.get("platform")),
|
|
250
|
+
"",
|
|
251
|
+
]
|
|
252
|
+
experiments = data.get("experiments") or []
|
|
253
|
+
lines += ["## Security experiments", ""]
|
|
254
|
+
if experiments:
|
|
255
|
+
outcomes: dict[str, int] = {}
|
|
256
|
+
for entry in experiments:
|
|
257
|
+
outcome = str(entry.get("outcome", "unknown"))
|
|
258
|
+
outcomes[outcome] = outcomes.get(outcome, 0) + 1
|
|
259
|
+
lines += [
|
|
260
|
+
f"{len(experiments)} recorded experiments: "
|
|
261
|
+
+ ", ".join(f"{count} {name}" for name, count in sorted(outcomes.items()))
|
|
262
|
+
+ ".",
|
|
263
|
+
"",
|
|
264
|
+
"| Area | Attack | Outcome | Observed | Mitigation |",
|
|
265
|
+
"|---|---|---|---|---|",
|
|
266
|
+
*_experiment_rows(experiments),
|
|
267
|
+
"",
|
|
268
|
+
]
|
|
269
|
+
else:
|
|
270
|
+
lines += [
|
|
271
|
+
"Not tested here: no experiment evidence was found. Run",
|
|
272
|
+
"`commitguard reproduce security --evidence-dir <dir>` first.",
|
|
273
|
+
"",
|
|
274
|
+
]
|
|
275
|
+
fuzzing, redos = data.get("fuzzing"), data.get("redos")
|
|
276
|
+
lines += ["## Fuzzing and ReDoS", ""]
|
|
277
|
+
if fuzzing:
|
|
278
|
+
lines += [
|
|
279
|
+
f"- **Tested**: {fuzzing['total_executed']} property-based examples across "
|
|
280
|
+
f"{len(fuzzing['executed'])} properties "
|
|
281
|
+
f"({'derandomized' if fuzzing.get('derandomized') else 'randomized'}).",
|
|
282
|
+
]
|
|
283
|
+
else:
|
|
284
|
+
lines.append("- **Not tested**: no fuzzing evidence found.")
|
|
285
|
+
if redos:
|
|
286
|
+
worst = max((item["worst_seconds"] for item in redos["patterns"].values()), default=0.0)
|
|
287
|
+
lines += [
|
|
288
|
+
f"- **Measured**: {len(redos['patterns'])} regular expressions run against "
|
|
289
|
+
f"{redos['adversarial_inputs_per_pattern']} adversarial inputs of "
|
|
290
|
+
f"{redos['input_characters']} characters; worst case {worst * 1000:.1f} ms "
|
|
291
|
+
f"(budget {redos['budget_seconds'] * 1000:.0f} ms).",
|
|
292
|
+
]
|
|
293
|
+
else:
|
|
294
|
+
lines.append("- **Not tested**: no ReDoS evidence found.")
|
|
295
|
+
lines += [
|
|
296
|
+
"",
|
|
297
|
+
"## Recorded runs",
|
|
298
|
+
"",
|
|
299
|
+
"Results are immutable: a new run is a new file, and earlier results are kept.",
|
|
300
|
+
"",
|
|
301
|
+
"| Benchmark | Runs |",
|
|
302
|
+
"|---|---|",
|
|
303
|
+
]
|
|
304
|
+
counts: dict[str, int] = {}
|
|
305
|
+
for entry in data["history"]:
|
|
306
|
+
name = str(entry.get("benchmark"))
|
|
307
|
+
counts[name] = counts.get(name, 0) + 1
|
|
308
|
+
for name in sorted(counts):
|
|
309
|
+
lines.append(f"| {name} | {counts[name]} |")
|
|
310
|
+
lines += [
|
|
311
|
+
"",
|
|
312
|
+
"## What this report does not say",
|
|
313
|
+
"",
|
|
314
|
+
"- It does not claim CommitGuard cannot be bypassed: local hooks are bypassable by the",
|
|
315
|
+
" person running them, and the experiments record exactly that.",
|
|
316
|
+
"- Results labelled Measured come from the machine named above, not from a controlled",
|
|
317
|
+
" laboratory, and were not repeated across machines.",
|
|
318
|
+
"- No external party has reproduced these results yet.",
|
|
319
|
+
"",
|
|
320
|
+
]
|
|
321
|
+
return "\n".join(lines) + "\n"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def render_benchmark_report(data: dict[str, Any]) -> str:
|
|
325
|
+
lines = [
|
|
326
|
+
"# CommitGuard benchmark report",
|
|
327
|
+
"",
|
|
328
|
+
f"Generated {data['generated_at']} for CommitGuard {data['commitguard_version']}.",
|
|
329
|
+
"",
|
|
330
|
+
"Every number below is read from a recorded result file; none is recomputed here.",
|
|
331
|
+
"",
|
|
332
|
+
"| Benchmark | Latest run | Version | Environment |",
|
|
333
|
+
"|---|---|---|---|",
|
|
334
|
+
]
|
|
335
|
+
for name in BENCHMARKS:
|
|
336
|
+
document = data["latest"].get(name)
|
|
337
|
+
if document is None:
|
|
338
|
+
lines.append(f"| {name} | not recorded | - | - |")
|
|
339
|
+
continue
|
|
340
|
+
manifest = document["manifest"]
|
|
341
|
+
lines.append(
|
|
342
|
+
f"| {name} | `{document['file']}` | {manifest.get('commitguard_version')} | "
|
|
343
|
+
f"{manifest.get('operating_system')} {manifest.get('os_release')}, "
|
|
344
|
+
f"Python {manifest.get('python_version')} |"
|
|
345
|
+
)
|
|
346
|
+
lines += [
|
|
347
|
+
"",
|
|
348
|
+
"## All recorded runs",
|
|
349
|
+
"",
|
|
350
|
+
"| Benchmark | File | Timestamp | Dataset |",
|
|
351
|
+
"|---|---|---|---|",
|
|
352
|
+
]
|
|
353
|
+
for entry in data["history"]:
|
|
354
|
+
lines.append(
|
|
355
|
+
f"| {entry['benchmark']} | `{entry['file']}` | {entry['timestamp']} | "
|
|
356
|
+
f"{entry.get('dataset_version') or '-'} |"
|
|
357
|
+
)
|
|
358
|
+
return "\n".join(lines) + "\n"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def write_reports(data: dict[str, Any], output_dir: Path) -> list[Path]:
|
|
362
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
363
|
+
written = []
|
|
364
|
+
for name, content in (
|
|
365
|
+
("security-report.json", json.dumps(data, indent=2, sort_keys=True) + "\n"),
|
|
366
|
+
("security-report.md", render_security_report(data)),
|
|
367
|
+
("benchmark-report.md", render_benchmark_report(data)),
|
|
368
|
+
):
|
|
369
|
+
target = output_dir / name
|
|
370
|
+
target.write_text(content, encoding="utf-8")
|
|
371
|
+
written.append(target)
|
|
372
|
+
return written
|