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,72 @@
|
|
|
1
|
+
"""Detector registry.
|
|
2
|
+
|
|
3
|
+
The registry is an explicit, in-process list. There is intentionally no
|
|
4
|
+
dynamic plugin discovery (entry points, import-by-name from configuration):
|
|
5
|
+
configuration must never cause arbitrary code to be imported or executed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from commitguard.detectors.base import Detector
|
|
9
|
+
from commitguard.exceptions.base import UnsafeInputError
|
|
10
|
+
from commitguard.exceptions.detection import DetectorRegistrationError
|
|
11
|
+
from commitguard.rules.matcher import CompiledRules
|
|
12
|
+
from commitguard.security.validation import validate_identifier
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DetectorRegistry:
|
|
16
|
+
"""An ordered collection of uniquely named detectors."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._detectors: dict[str, Detector] = {}
|
|
20
|
+
|
|
21
|
+
def register(self, detector: Detector) -> None:
|
|
22
|
+
if not isinstance(detector, Detector):
|
|
23
|
+
raise DetectorRegistrationError(f"{detector!r} is not a Detector")
|
|
24
|
+
try:
|
|
25
|
+
validate_identifier(detector.name, kind="detector name")
|
|
26
|
+
for rule in detector.rules:
|
|
27
|
+
validate_identifier(rule, kind="rule id")
|
|
28
|
+
except (AttributeError, UnsafeInputError) as exc:
|
|
29
|
+
raise DetectorRegistrationError(str(exc)) from exc
|
|
30
|
+
if not detector.rules:
|
|
31
|
+
raise DetectorRegistrationError(f"detector {detector.name!r} declares no rules")
|
|
32
|
+
if detector.name in self._detectors:
|
|
33
|
+
raise DetectorRegistrationError(f"detector {detector.name!r} is already registered")
|
|
34
|
+
self._detectors[detector.name] = detector
|
|
35
|
+
|
|
36
|
+
def get(self, name: str) -> Detector:
|
|
37
|
+
try:
|
|
38
|
+
return self._detectors[name]
|
|
39
|
+
except KeyError:
|
|
40
|
+
raise DetectorRegistrationError(f"no detector named {name!r}") from None
|
|
41
|
+
|
|
42
|
+
def all(self) -> tuple[Detector, ...]:
|
|
43
|
+
"""All detectors, sorted by name for deterministic execution."""
|
|
44
|
+
return tuple(self._detectors[name] for name in sorted(self._detectors))
|
|
45
|
+
|
|
46
|
+
def rules(self) -> frozenset[str]:
|
|
47
|
+
"""Every rule ID declared by a registered detector."""
|
|
48
|
+
return frozenset(rule for detector in self._detectors.values() for rule in detector.rules)
|
|
49
|
+
|
|
50
|
+
def __len__(self) -> int:
|
|
51
|
+
return len(self._detectors)
|
|
52
|
+
|
|
53
|
+
def __contains__(self, name: object) -> bool:
|
|
54
|
+
return name in self._detectors
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def builtin_registry(rules: CompiledRules) -> DetectorRegistry:
|
|
58
|
+
"""Return a registry of CommitGuard's built-in detectors using ``rules``."""
|
|
59
|
+
from commitguard.detectors.bot import BotDetector
|
|
60
|
+
from commitguard.detectors.coauthor import CoauthorDetector
|
|
61
|
+
from commitguard.detectors.identity import IdentityDetector
|
|
62
|
+
from commitguard.detectors.trailer import TrailerDetector
|
|
63
|
+
|
|
64
|
+
registry = DetectorRegistry()
|
|
65
|
+
for detector in (
|
|
66
|
+
CoauthorDetector(rules),
|
|
67
|
+
IdentityDetector(rules),
|
|
68
|
+
TrailerDetector(rules),
|
|
69
|
+
BotDetector(rules),
|
|
70
|
+
):
|
|
71
|
+
registry.register(detector)
|
|
72
|
+
return registry
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Trailer detector: configured attribution trailers, malformed trailers and
|
|
2
|
+
exact tool attribution footers.
|
|
3
|
+
|
|
4
|
+
Nothing here is flagged merely for existing. ``Reviewed-by`` or
|
|
5
|
+
``Signed-off-by`` only produce ``ai_trailer`` when a configured rule says so
|
|
6
|
+
(e.g. the value is an AI identity); ``malformed_trailer`` only applies to keys
|
|
7
|
+
listed in ``malformed_trailer_checks``. ``Co-authored-by`` belongs to the
|
|
8
|
+
co-author detector and is not reported here as AI attribution.
|
|
9
|
+
|
|
10
|
+
Message markers are exact whole-line matches of tool-inserted footers such as
|
|
11
|
+
``Generated with [Claude Code](https://claude.com/claude-code)``, which tools
|
|
12
|
+
usually prefix with a pictographic symbol (U+1F916 ROBOT FACE). There is no
|
|
13
|
+
free-text inference: "use AI service for recommendations" is not evidence.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import unicodedata
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from typing import ClassVar
|
|
19
|
+
|
|
20
|
+
from commitguard.core.context import CommitContext
|
|
21
|
+
from commitguard.core.result import (
|
|
22
|
+
Confidence,
|
|
23
|
+
Evidence,
|
|
24
|
+
EvidenceSource,
|
|
25
|
+
Finding,
|
|
26
|
+
MatchKind,
|
|
27
|
+
MatchReason,
|
|
28
|
+
Severity,
|
|
29
|
+
)
|
|
30
|
+
from commitguard.detectors.base import Detector, require_complete_trailers
|
|
31
|
+
from commitguard.provenance.normalization import normalize_text
|
|
32
|
+
from commitguard.provenance.trailers import Trailer, TrailerIssue
|
|
33
|
+
from commitguard.rules.matcher import CompiledRules
|
|
34
|
+
from commitguard.rules.models import PATTERNS_FILE, MalformedTrailerCheck, TrailerRule
|
|
35
|
+
|
|
36
|
+
RULE_AI_TRAILER = "ai_trailer"
|
|
37
|
+
RULE_MALFORMED_TRAILER = "malformed_trailer"
|
|
38
|
+
|
|
39
|
+
MAX_MARKER_LINE_CHARS = 300
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _marker_key(line: str) -> str:
|
|
43
|
+
"""Normalised line with any leading pictographic symbols removed.
|
|
44
|
+
|
|
45
|
+
Tool footers are commonly prefixed with one (U+1F916 ROBOT FACE before
|
|
46
|
+
``Generated with ...``); the marker must match with or without it.
|
|
47
|
+
"""
|
|
48
|
+
text = normalize_text(line)
|
|
49
|
+
index = 0
|
|
50
|
+
while index < len(text) and (
|
|
51
|
+
text[index].isspace() or unicodedata.category(text[index]) in ("So", "Sk", "Sm", "Mn")
|
|
52
|
+
):
|
|
53
|
+
index += 1
|
|
54
|
+
return text[index:]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TrailerDetector(Detector):
|
|
58
|
+
name: ClassVar[str] = "trailer"
|
|
59
|
+
rules: ClassVar[frozenset[str]] = frozenset({RULE_AI_TRAILER, RULE_MALFORMED_TRAILER})
|
|
60
|
+
description: ClassVar[str] = (
|
|
61
|
+
"Configured AI attribution trailers/footers and malformed trailers."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __init__(self, rules: CompiledRules) -> None:
|
|
65
|
+
patterns = rules.rules.patterns
|
|
66
|
+
self._rules = rules
|
|
67
|
+
self._attribution: dict[str, TrailerRule] = {
|
|
68
|
+
key: rule for rule in patterns.attribution_trailers for key in rule.keys
|
|
69
|
+
}
|
|
70
|
+
self._malformed: dict[str, MalformedTrailerCheck] = {}
|
|
71
|
+
for check in patterns.malformed_trailer_checks:
|
|
72
|
+
for key in check.keys:
|
|
73
|
+
self._malformed.setdefault(key, check)
|
|
74
|
+
self._markers: dict[str, tuple[str, str, str]] = {
|
|
75
|
+
_marker_key(line): (marker.id, marker.agent, line)
|
|
76
|
+
for marker in patterns.message_markers
|
|
77
|
+
for line in marker.lines
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def detect(self, context: CommitContext) -> Sequence[Finding]:
|
|
81
|
+
commit = context.commit
|
|
82
|
+
require_complete_trailers(commit)
|
|
83
|
+
findings: list[Finding] = []
|
|
84
|
+
for trailer in commit.trailers:
|
|
85
|
+
key = trailer.normalized_key
|
|
86
|
+
if (rule := self._attribution.get(key)) is not None:
|
|
87
|
+
finding = self._attribution_finding(rule, trailer, commit.sha)
|
|
88
|
+
if finding is not None:
|
|
89
|
+
findings.append(finding)
|
|
90
|
+
if (check := self._malformed.get(key)) is not None:
|
|
91
|
+
finding = self._malformed_finding(check, trailer, commit.sha)
|
|
92
|
+
if finding is not None:
|
|
93
|
+
findings.append(finding)
|
|
94
|
+
findings.extend(self._marker_findings(commit.message, commit.sha))
|
|
95
|
+
return findings
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------ #
|
|
98
|
+
def _attribution_finding(
|
|
99
|
+
self, rule: TrailerRule, trailer: Trailer, sha: str | None
|
|
100
|
+
) -> Finding | None:
|
|
101
|
+
key_reason = MatchReason(
|
|
102
|
+
kind=MatchKind.TRAILER_KEY,
|
|
103
|
+
value=trailer.normalized_key,
|
|
104
|
+
rule=f"{PATTERNS_FILE}#{rule.id}",
|
|
105
|
+
)
|
|
106
|
+
reasons: tuple[MatchReason, ...]
|
|
107
|
+
if rule.match == "key_present":
|
|
108
|
+
if normalize_text(trailer.value) in rule.ignore_values:
|
|
109
|
+
return None
|
|
110
|
+
reasons, confidence, subject = (key_reason,), Confidence.HIGH, "AI assistance"
|
|
111
|
+
else:
|
|
112
|
+
match = self._rules.ai_matcher.match(trailer.name, trailer.email)
|
|
113
|
+
if match is None:
|
|
114
|
+
return None
|
|
115
|
+
reasons = (key_reason, *match.reasons)
|
|
116
|
+
confidence, subject = match.confidence, f"the AI agent {match.display_name}"
|
|
117
|
+
return Finding(
|
|
118
|
+
detector=self.name,
|
|
119
|
+
rule_id=RULE_AI_TRAILER,
|
|
120
|
+
severity=Severity.HIGH,
|
|
121
|
+
confidence=confidence,
|
|
122
|
+
title="AI attribution trailer detected",
|
|
123
|
+
message=f"The {trailer.key!r} trailer attributes the commit to {subject}.",
|
|
124
|
+
evidence=(
|
|
125
|
+
Evidence(
|
|
126
|
+
source=EvidenceSource.TRAILER,
|
|
127
|
+
value=trailer.raw,
|
|
128
|
+
line_number=trailer.line_number,
|
|
129
|
+
matched=reasons,
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
commit_sha=sha,
|
|
133
|
+
remediation="Remove the AI attribution trailer from the commit message before pushing.",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _malformed_finding(
|
|
137
|
+
self, check: MalformedTrailerCheck, trailer: Trailer, sha: str | None
|
|
138
|
+
) -> Finding | None:
|
|
139
|
+
# Characters before a key (a quoted "> Signed-off-by:" line) are recorded so that
|
|
140
|
+
# attribution cannot hide behind them, but are not by themselves malformed.
|
|
141
|
+
notes = [
|
|
142
|
+
f"trailer: {issue.value}"
|
|
143
|
+
for issue in trailer.issues
|
|
144
|
+
if issue is not TrailerIssue.LEADING_CHARACTERS
|
|
145
|
+
]
|
|
146
|
+
if check.require_identity:
|
|
147
|
+
notes.extend(f"identity: {issue.value}" for issue in trailer.identity.issues)
|
|
148
|
+
if not notes:
|
|
149
|
+
return None
|
|
150
|
+
return Finding(
|
|
151
|
+
detector=self.name,
|
|
152
|
+
rule_id=RULE_MALFORMED_TRAILER,
|
|
153
|
+
severity=Severity.LOW,
|
|
154
|
+
confidence=Confidence.HIGH,
|
|
155
|
+
title="Malformed trailer",
|
|
156
|
+
message=(
|
|
157
|
+
f"The {trailer.key!r} trailer is malformed. Tools disagree on how to read "
|
|
158
|
+
"malformed trailers, which can hide or misstate attribution."
|
|
159
|
+
),
|
|
160
|
+
evidence=(
|
|
161
|
+
Evidence(
|
|
162
|
+
source=EvidenceSource.TRAILER,
|
|
163
|
+
value=trailer.raw,
|
|
164
|
+
line_number=trailer.line_number,
|
|
165
|
+
notes=tuple(notes),
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
commit_sha=sha,
|
|
169
|
+
remediation="Rewrite the trailer as 'Key: Name <email>' or remove it.",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _marker_findings(self, message: str, sha: str | None) -> list[Finding]:
|
|
173
|
+
findings = []
|
|
174
|
+
for line_number, line in enumerate(message.splitlines(), start=1):
|
|
175
|
+
if len(line) > MAX_MARKER_LINE_CHARS:
|
|
176
|
+
continue
|
|
177
|
+
hit = self._markers.get(_marker_key(line))
|
|
178
|
+
if hit is None:
|
|
179
|
+
continue
|
|
180
|
+
marker_id, agent_id, marker_line = hit
|
|
181
|
+
agent = self._rules.agents[agent_id]
|
|
182
|
+
findings.append(
|
|
183
|
+
Finding(
|
|
184
|
+
detector=self.name,
|
|
185
|
+
rule_id=RULE_AI_TRAILER,
|
|
186
|
+
severity=Severity.HIGH,
|
|
187
|
+
confidence=Confidence.HIGH,
|
|
188
|
+
title="AI attribution footer detected",
|
|
189
|
+
message=(
|
|
190
|
+
f"The commit message contains an attribution line inserted by "
|
|
191
|
+
f"{agent.display_name}."
|
|
192
|
+
),
|
|
193
|
+
evidence=(
|
|
194
|
+
Evidence(
|
|
195
|
+
source=EvidenceSource.MESSAGE,
|
|
196
|
+
value=line.strip(),
|
|
197
|
+
line_number=line_number,
|
|
198
|
+
matched=(
|
|
199
|
+
MatchReason(
|
|
200
|
+
kind=MatchKind.MESSAGE_MARKER,
|
|
201
|
+
value=marker_line,
|
|
202
|
+
rule=f"{PATTERNS_FILE}#{marker_id}",
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
),
|
|
207
|
+
commit_sha=sha,
|
|
208
|
+
remediation="Remove the tool attribution line from the commit message.",
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return findings
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""CommitGuard exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Every error raised deliberately by CommitGuard derives from
|
|
4
|
+
:class:`CommitGuardError`, so callers (in particular the CLI and Git hooks) can
|
|
5
|
+
distinguish expected failures from programming errors and fail closed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from commitguard.exceptions.base import CommitGuardError, UnsafeInputError
|
|
9
|
+
from commitguard.exceptions.configuration import ConfigurationError, RulesError
|
|
10
|
+
from commitguard.exceptions.detection import DetectionError, DetectorRegistrationError
|
|
11
|
+
from commitguard.exceptions.git import (
|
|
12
|
+
GitCommandError,
|
|
13
|
+
GitError,
|
|
14
|
+
GitNotFoundError,
|
|
15
|
+
HookInstallError,
|
|
16
|
+
MalformedGitOutputError,
|
|
17
|
+
NotAGitRepositoryError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CommitGuardError",
|
|
22
|
+
"ConfigurationError",
|
|
23
|
+
"DetectionError",
|
|
24
|
+
"DetectorRegistrationError",
|
|
25
|
+
"GitCommandError",
|
|
26
|
+
"GitError",
|
|
27
|
+
"GitNotFoundError",
|
|
28
|
+
"HookInstallError",
|
|
29
|
+
"MalformedGitOutputError",
|
|
30
|
+
"NotAGitRepositoryError",
|
|
31
|
+
"RulesError",
|
|
32
|
+
"UnsafeInputError",
|
|
33
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Root exception types shared by every CommitGuard layer."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CommitGuardError(Exception):
|
|
5
|
+
"""Base class for all CommitGuard errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsafeInputError(CommitGuardError, ValueError):
|
|
9
|
+
"""Raised when untrusted input fails a security validation check."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Configuration errors.
|
|
2
|
+
|
|
3
|
+
Invalid configuration is always an error, never a warning: a typo in a policy
|
|
4
|
+
file must not silently weaken enforcement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(CommitGuardError):
|
|
13
|
+
"""Raised when a configuration file is missing, unreadable, or invalid."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, *, path: Path | None = None) -> None:
|
|
16
|
+
self.path = path
|
|
17
|
+
prefix = f"{path}: " if path is not None else ""
|
|
18
|
+
super().__init__(f"{prefix}{message}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RulesError(ConfigurationError):
|
|
22
|
+
"""Raised when detection rule files are missing or invalid."""
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Detection errors."""
|
|
2
|
+
|
|
3
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DetectionError(CommitGuardError):
|
|
7
|
+
"""Raised when a detector misbehaves (e.g. emits an undeclared rule)."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DetectorRegistrationError(DetectionError):
|
|
11
|
+
"""Raised when a detector cannot be registered (duplicate or invalid name)."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Git integration errors."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitError(CommitGuardError):
|
|
9
|
+
"""Base class for Git-related failures."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitNotFoundError(GitError):
|
|
13
|
+
"""Raised when the ``git`` executable cannot be located."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotAGitRepositoryError(GitError):
|
|
17
|
+
"""Raised when an operation requires a Git repository and none was found."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MalformedGitOutputError(GitError):
|
|
21
|
+
"""Raised when Git output does not match the expected, validated shape.
|
|
22
|
+
|
|
23
|
+
Commit metadata is untrusted; a crafted object can produce output that a
|
|
24
|
+
naive parser would mis-assign. We refuse to guess.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GitCommandError(GitError):
|
|
29
|
+
"""Raised when a Git command exits with a non-zero status."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, args: Sequence[str], returncode: int, stderr: str) -> None:
|
|
32
|
+
self.command = tuple(args)
|
|
33
|
+
self.returncode = returncode
|
|
34
|
+
self.stderr = stderr
|
|
35
|
+
# Only the git sub-command is echoed; arguments may contain user data.
|
|
36
|
+
subcommand = next((a for a in self.command[1:] if not a.startswith("-")), "?")
|
|
37
|
+
super().__init__(f"git {subcommand} failed with exit code {returncode}: {stderr}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HookInstallError(GitError):
|
|
41
|
+
"""Raised when hooks cannot be installed or removed safely."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Errors raised by long-running services (scan workers, integrations).
|
|
2
|
+
|
|
3
|
+
The split matters for enforcement: a *policy violation* is a successful scan
|
|
4
|
+
with a BLOCK result, while these errors mean the scan could not be completed.
|
|
5
|
+
Both fail the check, but they are recorded and reported differently.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
9
|
+
from commitguard.exceptions.configuration import ConfigurationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PolicyError(ConfigurationError):
|
|
13
|
+
"""A policy (e.g. a mandatory organisation policy) is invalid or cannot be applied."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ScanError(CommitGuardError):
|
|
17
|
+
"""A scan could not be completed (missing commits, too many commits, stale scan...)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StaleScanError(ScanError):
|
|
21
|
+
"""A newer scan owns the result; this scan must not publish anything."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InfrastructureError(CommitGuardError):
|
|
25
|
+
"""A dependency the service needs (Git, storage, network) failed."""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Git integration layer.
|
|
2
|
+
|
|
3
|
+
* :mod:`commitguard.git.commit` - the normalised :class:`Commit` data model
|
|
4
|
+
(pure data, no I/O; safe for detectors to import).
|
|
5
|
+
* :mod:`commitguard.git.commands` - the single choke point that executes Git.
|
|
6
|
+
* :mod:`commitguard.git.repository` - repository discovery and read-only queries.
|
|
7
|
+
* :mod:`commitguard.git.diff` - staged change inspection (planned).
|
|
8
|
+
* :mod:`commitguard.git.hooks` - hook installation and removal (planned).
|
|
9
|
+
|
|
10
|
+
The detection engine and detectors must never import the I/O modules; this is
|
|
11
|
+
enforced by ``tests/unit/test_architecture.py``.
|
|
12
|
+
"""
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The single choke point through which CommitGuard executes Git.
|
|
2
|
+
|
|
3
|
+
Hardening applied to every invocation:
|
|
4
|
+
|
|
5
|
+
* ``git`` is resolved once via ``PATH`` and invoked with an argument vector;
|
|
6
|
+
* ``--no-pager`` and ``GIT_TERMINAL_PROMPT=0``: never block on a pager/prompt;
|
|
7
|
+
* ``GIT_NO_REPLACE_OBJECTS=1``: ``git replace`` refs cannot substitute the
|
|
8
|
+
commit we inspect with a different, innocent-looking one;
|
|
9
|
+
* ``GIT_OPTIONAL_LOCKS=0``: read-only queries do not refresh the index;
|
|
10
|
+
* ``LC_ALL=C``: stable, parseable error messages.
|
|
11
|
+
|
|
12
|
+
Callers that pass revisions must place them after ``--end-of-options``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from collections.abc import Mapping, Sequence
|
|
19
|
+
from functools import cache
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from commitguard.exceptions.git import GitCommandError, GitError, GitNotFoundError
|
|
23
|
+
from commitguard.security.sanitization import sanitize_for_terminal
|
|
24
|
+
from commitguard.utils.subprocess import DEFAULT_TIMEOUT_SECONDS, CommandResult, run_command
|
|
25
|
+
|
|
26
|
+
# ``rev-parse --end-of-options`` and ``--path-format=absolute`` need Git 2.31.
|
|
27
|
+
MINIMUM_GIT_VERSION = (2, 31)
|
|
28
|
+
|
|
29
|
+
GIT_ENV_OVERRIDES: dict[str, str] = {
|
|
30
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
31
|
+
"GIT_NO_REPLACE_OBJECTS": "1",
|
|
32
|
+
"GIT_OPTIONAL_LOCKS": "0",
|
|
33
|
+
"LC_ALL": "C",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_VERSION_RE = re.compile(rb"git version (\d+)\.(\d+)(?:\.(\d+))?")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cache
|
|
40
|
+
def git_executable() -> str:
|
|
41
|
+
"""Return the absolute path of the ``git`` executable."""
|
|
42
|
+
path = shutil.which("git")
|
|
43
|
+
if path is None:
|
|
44
|
+
raise GitNotFoundError("git executable not found on PATH")
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_git(
|
|
49
|
+
args: Sequence[str],
|
|
50
|
+
*,
|
|
51
|
+
cwd: Path | None = None,
|
|
52
|
+
check: bool = True,
|
|
53
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
54
|
+
input_bytes: bytes | None = None,
|
|
55
|
+
extra_env: Mapping[str, str] | None = None,
|
|
56
|
+
) -> CommandResult:
|
|
57
|
+
"""Run ``git <args>`` with CommitGuard's hardening applied.
|
|
58
|
+
|
|
59
|
+
With ``check=True`` a non-zero exit raises :class:`GitCommandError` whose
|
|
60
|
+
message contains sanitised stderr only. ``extra_env`` may add variables but
|
|
61
|
+
never replace the hardening in :data:`GIT_ENV_OVERRIDES`.
|
|
62
|
+
"""
|
|
63
|
+
argv = [git_executable(), "--no-pager", *args]
|
|
64
|
+
env = dict(GIT_ENV_OVERRIDES)
|
|
65
|
+
if extra_env:
|
|
66
|
+
clash = sorted(set(extra_env) & set(GIT_ENV_OVERRIDES))
|
|
67
|
+
if clash:
|
|
68
|
+
raise ValueError(f"extra_env must not override git hardening: {', '.join(clash)}")
|
|
69
|
+
env.update(extra_env)
|
|
70
|
+
try:
|
|
71
|
+
result = run_command(
|
|
72
|
+
argv,
|
|
73
|
+
cwd=cwd,
|
|
74
|
+
env_overrides=env,
|
|
75
|
+
timeout=timeout,
|
|
76
|
+
input_bytes=input_bytes,
|
|
77
|
+
)
|
|
78
|
+
except FileNotFoundError as exc:
|
|
79
|
+
raise GitNotFoundError("git executable could not be started") from exc
|
|
80
|
+
except subprocess.TimeoutExpired as exc:
|
|
81
|
+
raise GitError(f"git command timed out after {timeout:g}s") from exc
|
|
82
|
+
|
|
83
|
+
if check and not result.ok:
|
|
84
|
+
stderr = sanitize_for_terminal(result.stderr.decode("utf-8", errors="replace").strip())
|
|
85
|
+
raise GitCommandError(argv, result.returncode, stderr)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def git_version() -> tuple[int, int, int]:
|
|
90
|
+
"""Return the installed Git version as ``(major, minor, patch)``."""
|
|
91
|
+
result = run_git(["--version"])
|
|
92
|
+
match = _VERSION_RE.search(result.stdout)
|
|
93
|
+
if match is None:
|
|
94
|
+
raise GitError("could not determine git version")
|
|
95
|
+
major, minor, patch = match.groups()
|
|
96
|
+
return int(major), int(minor), int(patch or 0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def git_version_supported() -> bool:
|
|
100
|
+
"""Return True if the installed Git meets :data:`MINIMUM_GIT_VERSION`."""
|
|
101
|
+
return git_version()[:2] >= MINIMUM_GIT_VERSION
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Normalised commit model.
|
|
2
|
+
|
|
3
|
+
This module is pure data: it has no Git or filesystem access, so detectors can
|
|
4
|
+
depend on it without depending on Git itself.
|
|
5
|
+
|
|
6
|
+
A :class:`Commit` may describe a commit that does not exist yet. During a
|
|
7
|
+
``commit-msg`` check there is a message and an identity but no object ID, so
|
|
8
|
+
``sha`` and ``parents`` are optional.
|
|
9
|
+
|
|
10
|
+
Trailers are always *derived* from ``message`` by
|
|
11
|
+
:func:`~commitguard.provenance.trailers.parse_trailers`; they cannot be passed
|
|
12
|
+
in separately, so they can never disagree with the message they came from.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
19
|
+
|
|
20
|
+
from commitguard.provenance.author import Identity
|
|
21
|
+
from commitguard.provenance.signatures import SignatureInfo
|
|
22
|
+
from commitguard.provenance.trailers import Trailer, parse_trailers
|
|
23
|
+
from commitguard.security.validation import validate_git_sha
|
|
24
|
+
|
|
25
|
+
SHORT_SHA_LENGTH = 7
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Commit(BaseModel):
|
|
29
|
+
"""A normalised, immutable view of a Git commit.
|
|
30
|
+
|
|
31
|
+
All string fields hold *untrusted* data exactly as recorded in the commit.
|
|
32
|
+
Sanitise before displaying (see :mod:`commitguard.security.sanitization`).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
36
|
+
|
|
37
|
+
sha: str | None = Field(default=None, description="Object ID; None for a pending commit")
|
|
38
|
+
parents: tuple[str, ...] = ()
|
|
39
|
+
author: Identity
|
|
40
|
+
committer: Identity
|
|
41
|
+
authored_at: datetime | None = None
|
|
42
|
+
committed_at: datetime | None = None
|
|
43
|
+
message: str
|
|
44
|
+
trailers: tuple[Trailer, ...] = Field(default=(), description="Derived from message")
|
|
45
|
+
trailers_truncated: bool = Field(
|
|
46
|
+
default=False, description="True if the message had more trailers than can be analysed"
|
|
47
|
+
)
|
|
48
|
+
signature: SignatureInfo | None = Field(
|
|
49
|
+
default=None,
|
|
50
|
+
description="Not collected yet (security intelligence phase); None means unknown",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@model_validator(mode="before")
|
|
54
|
+
@classmethod
|
|
55
|
+
def _derive_trailers(cls, data: Any) -> Any:
|
|
56
|
+
if isinstance(data, dict) and isinstance(data.get("message"), str):
|
|
57
|
+
parsed = parse_trailers(data["message"])
|
|
58
|
+
data = {**data, "trailers": parsed.trailers, "trailers_truncated": parsed.truncated}
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
@field_validator("sha")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _validate_sha(cls, value: str | None) -> str | None:
|
|
64
|
+
return None if value is None else validate_git_sha(value)
|
|
65
|
+
|
|
66
|
+
@field_validator("parents")
|
|
67
|
+
@classmethod
|
|
68
|
+
def _validate_parents(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
69
|
+
for parent in value:
|
|
70
|
+
validate_git_sha(parent)
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_pending(self) -> bool:
|
|
75
|
+
"""True if this commit has not been written to the object database."""
|
|
76
|
+
return self.sha is None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def is_merge(self) -> bool:
|
|
80
|
+
return len(self.parents) > 1
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def short_sha(self) -> str:
|
|
84
|
+
return self.sha[:SHORT_SHA_LENGTH] if self.sha else "pending"
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def timestamp(self) -> datetime | None:
|
|
88
|
+
"""Commit time (committer date), falling back to the author date."""
|
|
89
|
+
return self.committed_at or self.authored_at
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def subject(self) -> str:
|
|
93
|
+
"""First line of the message (untrusted; sanitise before display)."""
|
|
94
|
+
return self.message.split("\n", 1)[0]
|
|
95
|
+
|
|
96
|
+
def trailers_with_key(self, normalized_key: str) -> tuple[Trailer, ...]:
|
|
97
|
+
return tuple(t for t in self.trailers if t.normalized_key == normalized_key)
|
commitguard/git/diff.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Staged and committed change inspection.
|
|
2
|
+
|
|
3
|
+
TODO(phase-3): implement read-only diff inspection for the ``pre-commit`` and
|
|
4
|
+
``pre-push`` hooks, e.g. ``git diff --cached --name-status -z`` for staged
|
|
5
|
+
paths and ``git diff-tree`` for pushed commits. Requirements:
|
|
6
|
+
|
|
7
|
+
* NUL-delimited output only (paths may contain newlines or escape codes);
|
|
8
|
+
* binary-safe, size-bounded reads of blob content (for future secret detection);
|
|
9
|
+
* no content ever leaves the machine unless a policy explicitly opts in.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ChangeType(StrEnum):
|
|
18
|
+
"""Git name-status change codes."""
|
|
19
|
+
|
|
20
|
+
ADDED = "A"
|
|
21
|
+
COPIED = "C"
|
|
22
|
+
DELETED = "D"
|
|
23
|
+
MODIFIED = "M"
|
|
24
|
+
RENAMED = "R"
|
|
25
|
+
TYPE_CHANGED = "T"
|
|
26
|
+
UNMERGED = "U"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FileChange(BaseModel):
|
|
30
|
+
"""A single changed path. Not populated yet (Phase 3)."""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
33
|
+
|
|
34
|
+
path: str
|
|
35
|
+
change: ChangeType
|
|
36
|
+
old_path: str | None = None
|