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,447 @@
|
|
|
1
|
+
"""Repository discovery and read-only repository queries.
|
|
2
|
+
|
|
3
|
+
Nothing in this module writes to the repository.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import secrets
|
|
7
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from commitguard.exceptions.base import UnsafeInputError
|
|
14
|
+
from commitguard.exceptions.git import (
|
|
15
|
+
GitCommandError,
|
|
16
|
+
GitError,
|
|
17
|
+
MalformedGitOutputError,
|
|
18
|
+
NotAGitRepositoryError,
|
|
19
|
+
)
|
|
20
|
+
from commitguard.git.commands import run_git
|
|
21
|
+
from commitguard.git.commit import Commit
|
|
22
|
+
from commitguard.provenance.author import Identity
|
|
23
|
+
from commitguard.security.validation import (
|
|
24
|
+
is_git_sha,
|
|
25
|
+
validate_git_config_key,
|
|
26
|
+
validate_repository_path,
|
|
27
|
+
validate_revision,
|
|
28
|
+
)
|
|
29
|
+
from commitguard.utils.subprocess import CommandResult
|
|
30
|
+
|
|
31
|
+
# Fields are NUL-separated; the free-form message (%B) comes last so that any
|
|
32
|
+
# NUL bytes inside it cannot shift the positions of the structured fields.
|
|
33
|
+
_COMMIT_FIELDS = "%x00".join(["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%B"])
|
|
34
|
+
_COMMIT_FIELD_COUNT = 9
|
|
35
|
+
# Commits are read in batches; records are separated by a random per-call
|
|
36
|
+
# boundary that commit messages cannot predict.
|
|
37
|
+
_READ_BATCH_SIZE = 256
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Repository:
|
|
41
|
+
"""A discovered Git repository with a work tree."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, root: Path, git_dir: Path, *, git_env: Mapping[str, str] | None = None
|
|
45
|
+
) -> None:
|
|
46
|
+
self.root = root
|
|
47
|
+
self.git_dir = git_dir
|
|
48
|
+
# Extra environment for every git invocation on this repository (e.g. a
|
|
49
|
+
# server-side mirror disables lazy object fetching). Never holds secrets.
|
|
50
|
+
self._git_env = dict(git_env) if git_env else None
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return f"Repository(root={self.root!s})"
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------ #
|
|
56
|
+
# Discovery
|
|
57
|
+
# ------------------------------------------------------------------ #
|
|
58
|
+
@classmethod
|
|
59
|
+
def discover(cls, path: Path | None = None) -> "Repository":
|
|
60
|
+
"""Locate the repository containing ``path`` (default: CWD).
|
|
61
|
+
|
|
62
|
+
Raises :class:`NotAGitRepositoryError` if ``path`` is not inside a work
|
|
63
|
+
tree. Bare repositories are not supported yet.
|
|
64
|
+
"""
|
|
65
|
+
start = (path or Path.cwd()).resolve()
|
|
66
|
+
try:
|
|
67
|
+
result = run_git(
|
|
68
|
+
[
|
|
69
|
+
"rev-parse",
|
|
70
|
+
"--path-format=absolute",
|
|
71
|
+
"--show-toplevel",
|
|
72
|
+
"--absolute-git-dir",
|
|
73
|
+
],
|
|
74
|
+
cwd=start,
|
|
75
|
+
)
|
|
76
|
+
except GitCommandError as exc:
|
|
77
|
+
raise NotAGitRepositoryError(f"not inside a Git work tree: {start}") from exc
|
|
78
|
+
|
|
79
|
+
lines = result.stdout.decode("utf-8", errors="surrogateescape").splitlines()
|
|
80
|
+
if len(lines) != 2 or not all(lines):
|
|
81
|
+
raise NotAGitRepositoryError(f"not inside a Git work tree: {start}")
|
|
82
|
+
return cls(root=Path(lines[0]), git_dir=Path(lines[1]))
|
|
83
|
+
|
|
84
|
+
def _git(
|
|
85
|
+
self,
|
|
86
|
+
args: Sequence[str],
|
|
87
|
+
*,
|
|
88
|
+
check: bool = True,
|
|
89
|
+
input_bytes: bytes | None = None,
|
|
90
|
+
) -> CommandResult:
|
|
91
|
+
return run_git(
|
|
92
|
+
args, cwd=self.root, check=check, input_bytes=input_bytes, extra_env=self._git_env
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------ #
|
|
96
|
+
# Queries
|
|
97
|
+
# ------------------------------------------------------------------ #
|
|
98
|
+
def resolve_commit(self, revision: str) -> str:
|
|
99
|
+
"""Resolve ``revision`` to a full commit object ID."""
|
|
100
|
+
validate_revision(revision)
|
|
101
|
+
result = self._git(
|
|
102
|
+
["rev-parse", "--verify", "--quiet", "--end-of-options", f"{revision}^{{commit}}"],
|
|
103
|
+
check=False,
|
|
104
|
+
)
|
|
105
|
+
sha = result.stdout.decode("ascii", errors="replace").strip()
|
|
106
|
+
if not result.ok or not is_git_sha(sha):
|
|
107
|
+
raise GitError("revision does not resolve to a commit")
|
|
108
|
+
return sha
|
|
109
|
+
|
|
110
|
+
def read_commit(self, revision: str = "HEAD") -> Commit:
|
|
111
|
+
"""Read one commit's metadata into a normalised :class:`Commit`."""
|
|
112
|
+
return self.read_commits([self.resolve_commit(revision)])[0]
|
|
113
|
+
|
|
114
|
+
def list_commits(self, revision_range: str, *, max_count: int) -> list[str]:
|
|
115
|
+
"""Return commit IDs selected by ``revision_range``, oldest first.
|
|
116
|
+
|
|
117
|
+
A plain revision (``HEAD``, a SHA, a branch) selects that single commit.
|
|
118
|
+
A range containing ``..`` (``origin/main..HEAD``) selects every commit in
|
|
119
|
+
the range, as ``git rev-list`` does. More than ``max_count`` commits is an
|
|
120
|
+
error rather than a silent truncation.
|
|
121
|
+
"""
|
|
122
|
+
validate_revision(revision_range)
|
|
123
|
+
if ".." not in revision_range:
|
|
124
|
+
return [self.resolve_commit(revision_range)]
|
|
125
|
+
result = self._git(
|
|
126
|
+
[
|
|
127
|
+
"rev-list",
|
|
128
|
+
f"--max-count={max_count + 1}",
|
|
129
|
+
"--end-of-options",
|
|
130
|
+
revision_range,
|
|
131
|
+
"--",
|
|
132
|
+
],
|
|
133
|
+
check=False,
|
|
134
|
+
)
|
|
135
|
+
if not result.ok:
|
|
136
|
+
raise GitError("revision range could not be resolved")
|
|
137
|
+
shas = result.stdout.decode("ascii", errors="replace").split()
|
|
138
|
+
if not all(is_git_sha(sha) for sha in shas):
|
|
139
|
+
raise MalformedGitOutputError("unexpected rev-list output")
|
|
140
|
+
if len(shas) > max_count:
|
|
141
|
+
raise GitError(f"revision range selects more than {max_count} commits")
|
|
142
|
+
shas.reverse()
|
|
143
|
+
return shas
|
|
144
|
+
|
|
145
|
+
def read_commits(self, shas: Sequence[str]) -> list[Commit]:
|
|
146
|
+
"""Read metadata for full commit IDs, preserving order.
|
|
147
|
+
|
|
148
|
+
Mailmap rewriting, notes, signature display and replace refs are all
|
|
149
|
+
disabled so that the metadata inspected is exactly what the commit
|
|
150
|
+
objects record.
|
|
151
|
+
"""
|
|
152
|
+
for sha in shas:
|
|
153
|
+
if not is_git_sha(sha):
|
|
154
|
+
raise UnsafeInputError("read_commits requires full commit ids")
|
|
155
|
+
commits: list[Commit] = []
|
|
156
|
+
for start in range(0, len(shas), _READ_BATCH_SIZE):
|
|
157
|
+
commits.extend(self._read_batch(shas[start : start + _READ_BATCH_SIZE]))
|
|
158
|
+
return commits
|
|
159
|
+
|
|
160
|
+
def iter_commits(self, shas: Sequence[str]) -> Iterator[Commit]:
|
|
161
|
+
"""Like :meth:`read_commits`, but reads one batch at a time (bounded memory)."""
|
|
162
|
+
for sha in shas:
|
|
163
|
+
if not is_git_sha(sha):
|
|
164
|
+
raise UnsafeInputError("iter_commits requires full commit ids")
|
|
165
|
+
for start in range(0, len(shas), _READ_BATCH_SIZE):
|
|
166
|
+
yield from self._read_batch(shas[start : start + _READ_BATCH_SIZE])
|
|
167
|
+
|
|
168
|
+
def _read_batch(self, shas: Sequence[str]) -> list[Commit]:
|
|
169
|
+
boundary = secrets.token_hex(16)
|
|
170
|
+
result = self._git(
|
|
171
|
+
[
|
|
172
|
+
"-c",
|
|
173
|
+
"log.showSignature=false",
|
|
174
|
+
"-c",
|
|
175
|
+
"i18n.logOutputEncoding=UTF-8",
|
|
176
|
+
"log",
|
|
177
|
+
"--no-walk=unsorted",
|
|
178
|
+
"--no-use-mailmap",
|
|
179
|
+
"--no-notes",
|
|
180
|
+
"--no-color",
|
|
181
|
+
f"--pretty=format:%x00{boundary}%x00{_COMMIT_FIELDS}",
|
|
182
|
+
"--end-of-options",
|
|
183
|
+
*shas,
|
|
184
|
+
"--",
|
|
185
|
+
],
|
|
186
|
+
)
|
|
187
|
+
records = result.stdout.split(f"\x00{boundary}\x00".encode("ascii"))
|
|
188
|
+
if records[0].strip():
|
|
189
|
+
raise MalformedGitOutputError("unexpected data before first commit record")
|
|
190
|
+
records = records[1:]
|
|
191
|
+
if len(records) != len(shas):
|
|
192
|
+
raise MalformedGitOutputError("commit record count does not match request")
|
|
193
|
+
return [
|
|
194
|
+
# git separates records with a newline, which is not part of the message
|
|
195
|
+
_parse_commit_record(
|
|
196
|
+
record.removesuffix(b"\n") if index < len(records) - 1 else record, expected_sha=sha
|
|
197
|
+
)
|
|
198
|
+
for index, (record, sha) in enumerate(zip(records, shas, strict=True))
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
def pending_identities(self) -> tuple[Identity, Identity]:
|
|
202
|
+
"""Author and committer Git would use for a new commit (``git var``)."""
|
|
203
|
+
return self._git_var_identity("GIT_AUTHOR_IDENT"), self._git_var_identity(
|
|
204
|
+
"GIT_COMMITTER_IDENT"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _git_var_identity(self, variable: str) -> Identity:
|
|
208
|
+
result = self._git(["var", variable], check=False)
|
|
209
|
+
if not result.ok:
|
|
210
|
+
raise GitError(f"git could not determine {variable} (is user.name/user.email set?)")
|
|
211
|
+
line = result.stdout.decode("utf-8", errors="replace").rstrip("\n")
|
|
212
|
+
# "Name <email> 1700000000 +0100"
|
|
213
|
+
ident, _, _timezone = line.rpartition(" ")
|
|
214
|
+
ident, _, _timestamp = ident.rpartition(" ")
|
|
215
|
+
open_index, close_index = ident.rfind("<"), ident.rfind(">")
|
|
216
|
+
if open_index < 0 or close_index < open_index or close_index != len(ident) - 1:
|
|
217
|
+
raise MalformedGitOutputError(f"unexpected {variable} format")
|
|
218
|
+
return Identity(name=ident[:open_index].strip(), email=ident[open_index + 1 : close_index])
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def common_dir(self) -> Path:
|
|
222
|
+
"""The Git directory shared by all worktrees (where hooks normally live)."""
|
|
223
|
+
result = self._git(["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
|
224
|
+
return Path(result.stdout.decode("utf-8", errors="surrogateescape").strip())
|
|
225
|
+
|
|
226
|
+
def peel_to_commit(self, oid: str) -> str | None:
|
|
227
|
+
"""Return the commit an object (commit or annotated tag) refers to.
|
|
228
|
+
|
|
229
|
+
Returns None if the object does not exist locally or peels to a
|
|
230
|
+
non-commit object (e.g. a tag pointing at a tree or blob).
|
|
231
|
+
"""
|
|
232
|
+
if not is_git_sha(oid):
|
|
233
|
+
raise UnsafeInputError("peel_to_commit requires a full object id")
|
|
234
|
+
result = self._git(
|
|
235
|
+
["rev-parse", "--verify", "--quiet", "--end-of-options", f"{oid}^{{commit}}"],
|
|
236
|
+
check=False,
|
|
237
|
+
)
|
|
238
|
+
sha = result.stdout.decode("ascii", errors="replace").strip()
|
|
239
|
+
return sha if result.ok and is_git_sha(sha) else None
|
|
240
|
+
|
|
241
|
+
def object_exists(self, oid: str) -> bool:
|
|
242
|
+
if not is_git_sha(oid):
|
|
243
|
+
raise UnsafeInputError("object_exists requires a full object id")
|
|
244
|
+
return self._git(["cat-file", "-e", oid], check=False).ok
|
|
245
|
+
|
|
246
|
+
def has_commit(self, oid: str) -> bool:
|
|
247
|
+
return is_git_sha(oid) and self.peel_to_commit(oid) == oid
|
|
248
|
+
|
|
249
|
+
def is_ancestor(self, ancestor: str, descendant: str) -> bool | None:
|
|
250
|
+
"""Whether ``ancestor`` is reachable from ``descendant`` (a commit is its own ancestor).
|
|
251
|
+
|
|
252
|
+
Returns None when either commit is not available locally, so callers can
|
|
253
|
+
tell "not an ancestor" apart from "cannot tell".
|
|
254
|
+
"""
|
|
255
|
+
if not (is_git_sha(ancestor) and is_git_sha(descendant)):
|
|
256
|
+
raise UnsafeInputError("is_ancestor requires full object ids")
|
|
257
|
+
if not (self.has_commit(ancestor) and self.has_commit(descendant)):
|
|
258
|
+
return None
|
|
259
|
+
result = self._git(["merge-base", "--is-ancestor", ancestor, descendant], check=False)
|
|
260
|
+
if result.returncode in (0, 1):
|
|
261
|
+
return result.returncode == 0
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def remote_exists(self, name: str) -> bool:
|
|
265
|
+
if not _is_simple_remote_name(name):
|
|
266
|
+
return False
|
|
267
|
+
return self.config_get(f"remote.{name}.url") is not None
|
|
268
|
+
|
|
269
|
+
def remote_tracking_tips(self, remote: str) -> list[str]:
|
|
270
|
+
"""Commit IDs at the tips of ``refs/remotes/<remote>/*`` (what the remote had)."""
|
|
271
|
+
if not self.remote_exists(remote):
|
|
272
|
+
return []
|
|
273
|
+
# One call: object type/name and, for tags, the peeled type/name.
|
|
274
|
+
result = self._git(
|
|
275
|
+
[
|
|
276
|
+
"for-each-ref",
|
|
277
|
+
"--format=%(objecttype) %(objectname) %(*objecttype) %(*objectname)",
|
|
278
|
+
"--",
|
|
279
|
+
f"refs/remotes/{remote}/",
|
|
280
|
+
],
|
|
281
|
+
)
|
|
282
|
+
tips = []
|
|
283
|
+
for line in result.stdout.decode("ascii", errors="replace").splitlines():
|
|
284
|
+
fields = line.split()
|
|
285
|
+
if len(fields) >= 2 and fields[0] == "commit" and is_git_sha(fields[1]):
|
|
286
|
+
tips.append(fields[1])
|
|
287
|
+
elif len(fields) == 4 and fields[2] == "commit" and is_git_sha(fields[3]):
|
|
288
|
+
tips.append(fields[3])
|
|
289
|
+
return sorted(set(tips))
|
|
290
|
+
|
|
291
|
+
def rev_list(
|
|
292
|
+
self,
|
|
293
|
+
include: Sequence[str],
|
|
294
|
+
exclude: Sequence[str] = (),
|
|
295
|
+
*,
|
|
296
|
+
max_count: int,
|
|
297
|
+
) -> list[str]:
|
|
298
|
+
"""Commits reachable from ``include`` but not from ``exclude``, oldest first.
|
|
299
|
+
|
|
300
|
+
All inputs must be full object IDs; they are passed on stdin (no argument
|
|
301
|
+
length limits, no option parsing). More than ``max_count`` results is an
|
|
302
|
+
error rather than a silent truncation.
|
|
303
|
+
"""
|
|
304
|
+
for oid in (*include, *exclude):
|
|
305
|
+
if not is_git_sha(oid):
|
|
306
|
+
raise UnsafeInputError("rev_list requires full object ids")
|
|
307
|
+
if not include:
|
|
308
|
+
return []
|
|
309
|
+
stdin = "".join(f"{oid}\n" for oid in include) + "".join(f"^{oid}\n" for oid in exclude)
|
|
310
|
+
result = self._git(
|
|
311
|
+
["rev-list", f"--max-count={max_count + 1}", "--stdin"],
|
|
312
|
+
input_bytes=stdin.encode("ascii"),
|
|
313
|
+
)
|
|
314
|
+
shas = result.stdout.decode("ascii", errors="replace").split()
|
|
315
|
+
if not all(is_git_sha(sha) for sha in shas):
|
|
316
|
+
raise MalformedGitOutputError("unexpected rev-list output")
|
|
317
|
+
if len(shas) > max_count:
|
|
318
|
+
raise GitError(f"more than {max_count} commits would need to be analysed")
|
|
319
|
+
shas.reverse()
|
|
320
|
+
return shas
|
|
321
|
+
|
|
322
|
+
def cleanup_message(self, message: str) -> str:
|
|
323
|
+
"""Approximate the cleanup ``git commit`` applies before storing a message.
|
|
324
|
+
|
|
325
|
+
A commit-msg hook cannot see whether an editor was used or which
|
|
326
|
+
``--cleanup`` option was given, so this errs towards *keeping* text:
|
|
327
|
+
|
|
328
|
+
* comment lines are stripped only when ``commit.cleanup=strip`` is
|
|
329
|
+
configured (with ``-m``/``-F``, Git's default keeps ``#`` lines, so
|
|
330
|
+
stripping them would hide attribution that really gets stored);
|
|
331
|
+
* everything from Git's scissors line is dropped unless the mode is
|
|
332
|
+
``verbatim``/``whitespace`` (the diff ``git commit -v`` appends there
|
|
333
|
+
is never part of the message);
|
|
334
|
+
* whitespace is normalised with ``git stripspace`` unless ``verbatim``.
|
|
335
|
+
|
|
336
|
+
pre-push analyses the real commit objects and is the authoritative check.
|
|
337
|
+
"""
|
|
338
|
+
mode = (self.config_get("commit.cleanup") or "default").lower()
|
|
339
|
+
if mode == "verbatim":
|
|
340
|
+
return message
|
|
341
|
+
if mode in ("default", "strip", "scissors"):
|
|
342
|
+
message = _cut_at_scissors(message)
|
|
343
|
+
args = ["stripspace"]
|
|
344
|
+
if mode == "strip":
|
|
345
|
+
args.append("--strip-comments")
|
|
346
|
+
result = self._git(args, input_bytes=message.encode("utf-8", "surrogatepass"))
|
|
347
|
+
return result.stdout.decode("utf-8", errors="replace")
|
|
348
|
+
|
|
349
|
+
def ref_commit(self, refname: str) -> str | None:
|
|
350
|
+
"""The commit a fully qualified ref (``refs/...``) points to, if it exists."""
|
|
351
|
+
if not refname.startswith("refs/") or any(ord(c) < 0x20 for c in refname):
|
|
352
|
+
raise UnsafeInputError("ref_commit requires a fully qualified ref name")
|
|
353
|
+
result = self._git(
|
|
354
|
+
["rev-parse", "--verify", "--quiet", "--end-of-options", f"{refname}^{{commit}}"],
|
|
355
|
+
check=False,
|
|
356
|
+
)
|
|
357
|
+
sha = result.stdout.decode("ascii", errors="replace").strip()
|
|
358
|
+
return sha if result.ok and is_git_sha(sha) else None
|
|
359
|
+
|
|
360
|
+
def read_blob_at(self, commit: str, path: str, *, max_bytes: int) -> bytes | None:
|
|
361
|
+
"""Read a regular file from a commit's tree (not from the work tree).
|
|
362
|
+
|
|
363
|
+
Returns None if the path does not exist at that commit. Raises
|
|
364
|
+
:class:`UnsafeInputError` for non-regular entries (symlinks,
|
|
365
|
+
submodules, directories) or files larger than ``max_bytes``.
|
|
366
|
+
"""
|
|
367
|
+
if not is_git_sha(commit):
|
|
368
|
+
raise UnsafeInputError("read_blob_at requires a full commit id")
|
|
369
|
+
validate_repository_path(path)
|
|
370
|
+
result = self._git(["ls-tree", "-z", "--end-of-options", commit, "--", path])
|
|
371
|
+
entries = [e for e in result.stdout.split(b"\x00") if e]
|
|
372
|
+
match = None
|
|
373
|
+
for entry in entries:
|
|
374
|
+
meta, _, name = entry.partition(b"\t")
|
|
375
|
+
if name.decode("utf-8", errors="surrogateescape") == path:
|
|
376
|
+
match = meta.decode("ascii", errors="replace").split()
|
|
377
|
+
if match is None:
|
|
378
|
+
return None
|
|
379
|
+
if len(match) != 3 or match[1] != "blob" or match[0] not in ("100644", "100755"):
|
|
380
|
+
raise UnsafeInputError(f"{path} at {commit[:12]} is not a regular file")
|
|
381
|
+
oid = match[2]
|
|
382
|
+
if not is_git_sha(oid):
|
|
383
|
+
raise MalformedGitOutputError("unexpected ls-tree output")
|
|
384
|
+
size = self._git(["cat-file", "-s", oid]).stdout.decode("ascii").strip()
|
|
385
|
+
if not size.isdigit() or int(size) > max_bytes:
|
|
386
|
+
raise UnsafeInputError(f"{path} at {commit[:12]} is larger than {max_bytes} bytes")
|
|
387
|
+
return self._git(["cat-file", "blob", oid]).stdout
|
|
388
|
+
|
|
389
|
+
def config_get(self, key: str) -> str | None:
|
|
390
|
+
"""Return a Git configuration value, or None if it is unset."""
|
|
391
|
+
validate_git_config_key(key)
|
|
392
|
+
result = self._git(["config", "--get", "--end-of-options", key], check=False)
|
|
393
|
+
if result.returncode == 1: # key not set
|
|
394
|
+
return None
|
|
395
|
+
if not result.ok:
|
|
396
|
+
raise GitCommandError(result.args, result.returncode, "git config failed")
|
|
397
|
+
return result.stdout.decode("utf-8", errors="replace").rstrip("\n")
|
|
398
|
+
|
|
399
|
+
def hooks_dir(self) -> Path:
|
|
400
|
+
"""Return the effective hooks directory (honours ``core.hooksPath``)."""
|
|
401
|
+
result = self._git(["rev-parse", "--path-format=absolute", "--git-path", "hooks"])
|
|
402
|
+
return Path(result.stdout.decode("utf-8", errors="surrogateescape").strip())
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _parse_commit_record(raw: bytes, *, expected_sha: str) -> Commit:
|
|
406
|
+
"""Parse the NUL-separated output of :data:`_COMMIT_FIELDS` defensively."""
|
|
407
|
+
fields = raw.decode("utf-8", errors="replace").split("\x00", _COMMIT_FIELD_COUNT - 1)
|
|
408
|
+
if len(fields) != _COMMIT_FIELD_COUNT:
|
|
409
|
+
raise MalformedGitOutputError("unexpected commit record shape")
|
|
410
|
+
|
|
411
|
+
sha, parents, a_name, a_email, a_date, c_name, c_email, c_date, message = fields
|
|
412
|
+
if sha != expected_sha:
|
|
413
|
+
raise MalformedGitOutputError("commit record does not match requested object")
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
return Commit(
|
|
417
|
+
sha=sha,
|
|
418
|
+
parents=tuple(parents.split()),
|
|
419
|
+
author=Identity(name=a_name, email=a_email),
|
|
420
|
+
committer=Identity(name=c_name, email=c_email),
|
|
421
|
+
authored_at=datetime.fromisoformat(a_date),
|
|
422
|
+
committed_at=datetime.fromisoformat(c_date),
|
|
423
|
+
message=message,
|
|
424
|
+
)
|
|
425
|
+
except (ValueError, UnsafeInputError, ValidationError) as exc:
|
|
426
|
+
raise MalformedGitOutputError(f"invalid commit metadata: {type(exc).__name__}") from exc
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
_SCISSORS = "------------------------ >8 ------------------------"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _cut_at_scissors(message: str) -> str:
|
|
433
|
+
lines = message.split("\n")
|
|
434
|
+
for index, line in enumerate(lines):
|
|
435
|
+
stripped = line.strip()
|
|
436
|
+
if stripped.endswith(_SCISSORS) and len(stripped) <= len(_SCISSORS) + 4:
|
|
437
|
+
return "\n".join(lines[:index]) + ("\n" if index else "")
|
|
438
|
+
return message
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _is_simple_remote_name(name: str) -> bool:
|
|
442
|
+
"""A configured remote name usable in ref patterns (no globs, paths or URLs)."""
|
|
443
|
+
return (
|
|
444
|
+
0 < len(name) <= 200
|
|
445
|
+
and not name.startswith(("-", "."))
|
|
446
|
+
and all(ch.isalnum() or ch in "._-" for ch in name)
|
|
447
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""GitHub integration: GitHub Actions (Phase 4) and the CommitGuard GitHub App (Phase 5).
|
|
2
|
+
|
|
3
|
+
Both are adapters around the same core: they turn GitHub events into a
|
|
4
|
+
:class:`~commitguard.ci.context.CIContext`, run
|
|
5
|
+
:class:`~commitguard.services.scan.ScanService` (the detection engine and policy
|
|
6
|
+
evaluator shared with the CLI and Git hooks) and translate the result into
|
|
7
|
+
GitHub output. Nothing in this package detects anything or decides policy.
|
|
8
|
+
|
|
9
|
+
GitHub Actions (no network, no token):
|
|
10
|
+
|
|
11
|
+
* :mod:`~commitguard.github.events` - event payloads -> ``CIContext``;
|
|
12
|
+
* :mod:`~commitguard.github.actions` - workflow commands, job summary, outputs;
|
|
13
|
+
* :mod:`~commitguard.github.checks` - check output model and conclusions;
|
|
14
|
+
* :mod:`~commitguard.github.workflow` - workflow template and static inspection.
|
|
15
|
+
|
|
16
|
+
GitHub App (webhooks, Checks API; needs the optional ``app`` extra):
|
|
17
|
+
|
|
18
|
+
* :mod:`~commitguard.github.settings` - environment configuration;
|
|
19
|
+
* :mod:`~commitguard.github.auth` - App JWT, down-scoped installation tokens;
|
|
20
|
+
* :mod:`~commitguard.github.client` - small REST client (retries, rate limits);
|
|
21
|
+
* :mod:`~commitguard.github.webhooks` - signature, header, size and JSON validation;
|
|
22
|
+
* :mod:`~commitguard.github.events` - webhook normalisation (typed events);
|
|
23
|
+
* :mod:`~commitguard.github.installations` - installation lifecycle and authorization;
|
|
24
|
+
* :mod:`~commitguard.github.repositories` - metadata-only Git mirrors (no checkout);
|
|
25
|
+
* :mod:`~commitguard.github.storage` - deliveries, installations, jobs, audit (SQLite);
|
|
26
|
+
* :mod:`~commitguard.github.queue` - event queue abstraction;
|
|
27
|
+
* :mod:`~commitguard.github.worker` - scan worker and Check Run lifecycle;
|
|
28
|
+
* :mod:`~commitguard.github.check_runs` - Check Run content;
|
|
29
|
+
* :mod:`~commitguard.github.app` - service wiring and WSGI endpoint;
|
|
30
|
+
* :mod:`~commitguard.github.server` - development HTTP server.
|
|
31
|
+
|
|
32
|
+
A failing check blocks merges only when branch protection or a ruleset
|
|
33
|
+
requires it; CommitGuard does not configure or verify those settings.
|
|
34
|
+
"""
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""GitHub Actions runtime integration: workflow commands, job summary, outputs.
|
|
2
|
+
|
|
3
|
+
Everything printed in a GitHub Actions log can be interpreted as a workflow
|
|
4
|
+
command (``::set-env``, ``::add-mask::`` ...). Commit messages, author names and
|
|
5
|
+
branch names are attacker-controlled, so:
|
|
6
|
+
|
|
7
|
+
* human-readable output containing untrusted text is wrapped in
|
|
8
|
+
``::stop-commands::<random token>`` ... ``::<token>::``;
|
|
9
|
+
* annotation messages and properties are escaped exactly as ``@actions/core``
|
|
10
|
+
does, after control characters are made visible;
|
|
11
|
+
* the job summary is Markdown with every untrusted value escaped;
|
|
12
|
+
* ``$GITHUB_OUTPUT`` only ever receives enum values and integers.
|
|
13
|
+
|
|
14
|
+
No GitHub API calls and no token are needed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import secrets
|
|
19
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from commitguard.core.decision import Action
|
|
24
|
+
from commitguard.github.checks import AnnotationLevel, CheckOutput
|
|
25
|
+
from commitguard.github.markdown import escape_markdown
|
|
26
|
+
from commitguard.security.sanitization import sanitize_for_terminal
|
|
27
|
+
from commitguard.services.reports import ScanReport
|
|
28
|
+
|
|
29
|
+
MAX_SUMMARY_FINDINGS = 50
|
|
30
|
+
_COMMAND = {
|
|
31
|
+
AnnotationLevel.FAILURE: "error",
|
|
32
|
+
AnnotationLevel.WARNING: "warning",
|
|
33
|
+
AnnotationLevel.NOTICE: "notice",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def running_in_github_actions(environ: Mapping[str, str] | None = None) -> bool:
|
|
38
|
+
return (environ if environ is not None else os.environ).get("GITHUB_ACTIONS") == "true"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def escape_data(value: str) -> str:
|
|
42
|
+
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def escape_property(value: str) -> str:
|
|
46
|
+
return escape_data(value).replace(":", "%3A").replace(",", "%2C")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def workflow_command(command: str, message: str, **properties: str) -> str:
|
|
50
|
+
safe_message = escape_data(sanitize_for_terminal(message, max_length=1000))
|
|
51
|
+
props = ",".join(
|
|
52
|
+
f"{key}={escape_property(sanitize_for_terminal(value, max_length=200))}"
|
|
53
|
+
for key, value in properties.items()
|
|
54
|
+
)
|
|
55
|
+
return f"::{command}{' ' + props if props else ''}::{safe_message}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def annotation_commands(output: CheckOutput) -> list[str]:
|
|
59
|
+
lines = [
|
|
60
|
+
workflow_command(_COMMAND[a.level], a.message, title=a.title) for a in output.annotations
|
|
61
|
+
]
|
|
62
|
+
if output.omitted_annotations:
|
|
63
|
+
lines.append(
|
|
64
|
+
workflow_command(
|
|
65
|
+
"notice",
|
|
66
|
+
f"{output.omitted_annotations} more finding(s) not annotated; see the job summary",
|
|
67
|
+
title="CommitGuard",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return lines
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@contextmanager
|
|
74
|
+
def commands_stopped(emit: Callable[[str], None]) -> Iterator[None]:
|
|
75
|
+
"""Disable workflow command processing while untrusted text is printed."""
|
|
76
|
+
token = secrets.token_hex(16)
|
|
77
|
+
emit(f"::stop-commands::{token}")
|
|
78
|
+
try:
|
|
79
|
+
yield
|
|
80
|
+
finally:
|
|
81
|
+
emit(f"::{token}::")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_md = escape_markdown
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_step_summary(report: ScanReport, output: CheckOutput) -> str:
|
|
88
|
+
ci = report.ci
|
|
89
|
+
icon = {"failure": "FAILED", "success": "PASSED"}[output.conclusion]
|
|
90
|
+
lines = [f"## {icon} {_md(output.title)}", ""]
|
|
91
|
+
if ci is not None:
|
|
92
|
+
lines += [
|
|
93
|
+
"| | |",
|
|
94
|
+
"|---|---|",
|
|
95
|
+
f"| Event | {_md(ci.event)}"
|
|
96
|
+
+ (f" (PR \\#{ci.pull_request_number})" if ci.pull_request_number else "")
|
|
97
|
+
+ (" from a fork" if ci.from_fork else "")
|
|
98
|
+
+ " |",
|
|
99
|
+
f"| Range | {_md((ci.base_sha or '')[:12])}..{_md((ci.head_sha or '')[:12])} |",
|
|
100
|
+
f"| Policy source | {_md(ci.policy_source)} |",
|
|
101
|
+
]
|
|
102
|
+
counts = output.counts
|
|
103
|
+
lines += [
|
|
104
|
+
f"| Commits scanned | {counts['commits']} |",
|
|
105
|
+
f"| Violations | {counts['block']} |",
|
|
106
|
+
f"| Warnings | {counts['warn']} |",
|
|
107
|
+
f"| Allowed | {counts['allow']} |",
|
|
108
|
+
f"| Result | **{report.action.value.upper()}** |",
|
|
109
|
+
"",
|
|
110
|
+
]
|
|
111
|
+
if ci is not None and ci.notices:
|
|
112
|
+
lines += ["### Notices", ""] + [f"- {_md(n, 400)}" for n in ci.notices] + [""]
|
|
113
|
+
|
|
114
|
+
rows = []
|
|
115
|
+
for commit in sorted(report.commits, key=lambda c: -c.action.rank):
|
|
116
|
+
for failure in commit.failures:
|
|
117
|
+
rows.append(
|
|
118
|
+
f"| {_md(commit.short_sha)} | detector failure | {_md(failure.failure.detector)} "
|
|
119
|
+
f"| - | {_md(failure.failure.message)} | {failure.action.value} |"
|
|
120
|
+
)
|
|
121
|
+
for item in commit.findings:
|
|
122
|
+
rows.append(
|
|
123
|
+
f"| {_md(commit.short_sha)} | {_md(item.finding.title)} | {item.finding.rule_id} "
|
|
124
|
+
f"| {item.finding.severity.value} | {_md(item.finding.evidence[0].value)} "
|
|
125
|
+
f"| {item.action.value} |"
|
|
126
|
+
)
|
|
127
|
+
if rows:
|
|
128
|
+
lines += [
|
|
129
|
+
"### Findings",
|
|
130
|
+
"",
|
|
131
|
+
"| Commit | Finding | Rule | Severity | Evidence | Action |",
|
|
132
|
+
"|---|---|---|---|---|---|",
|
|
133
|
+
*rows[:MAX_SUMMARY_FINDINGS],
|
|
134
|
+
]
|
|
135
|
+
if len(rows) > MAX_SUMMARY_FINDINGS:
|
|
136
|
+
lines.append(f"\n{len(rows) - MAX_SUMMARY_FINDINGS} more finding(s) omitted.")
|
|
137
|
+
lines.append("")
|
|
138
|
+
if report.action is Action.BLOCK:
|
|
139
|
+
lines += [
|
|
140
|
+
"### Remediation",
|
|
141
|
+
"",
|
|
142
|
+
"Update the listed commits so that they comply with the repository's contribution "
|
|
143
|
+
"policy, then push the updated branch. CommitGuard does not rewrite Git history.",
|
|
144
|
+
"",
|
|
145
|
+
]
|
|
146
|
+
return "\n".join(lines) + "\n"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def append_file(path: Path, text: str) -> None:
|
|
150
|
+
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
151
|
+
handle.write(text)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def step_outputs(report: ScanReport, output: CheckOutput) -> str:
|
|
155
|
+
counts = output.counts
|
|
156
|
+
values = {
|
|
157
|
+
"result": report.action.value,
|
|
158
|
+
"conclusion": output.conclusion,
|
|
159
|
+
"commits": counts["commits"],
|
|
160
|
+
"violations": counts["block"],
|
|
161
|
+
"warnings": counts["warn"],
|
|
162
|
+
}
|
|
163
|
+
return "".join(f"{key}={value}\n" for key, value in values.items())
|