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,6 @@
|
|
|
1
|
+
"""Core domain: scan context, findings, decisions and the detection engine.
|
|
2
|
+
|
|
3
|
+
The core knows nothing about the CLI, hooks, or how commits were obtained. It
|
|
4
|
+
receives a :class:`~commitguard.core.context.CommitContext`, runs registered
|
|
5
|
+
detectors, and returns a :class:`~commitguard.core.result.DetectionResult`.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Commit context: everything a detector is allowed to see.
|
|
2
|
+
|
|
3
|
+
Detectors receive a context rather than a repository handle. This keeps them
|
|
4
|
+
pure (no I/O, cannot modify the repository) and trivially unit-testable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from commitguard.git.commit import Commit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScanTrigger(StrEnum):
|
|
15
|
+
"""Why an analysis is running."""
|
|
16
|
+
|
|
17
|
+
MANUAL = "manual"
|
|
18
|
+
CHECK = "check"
|
|
19
|
+
PRE_COMMIT = "pre-commit"
|
|
20
|
+
COMMIT_MSG = "commit-msg"
|
|
21
|
+
PRE_PUSH = "pre-push"
|
|
22
|
+
CI = "ci"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CommitContext(BaseModel):
|
|
26
|
+
"""Immutable input to a single detection run over one commit."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
29
|
+
|
|
30
|
+
commit: Commit
|
|
31
|
+
trigger: ScanTrigger = ScanTrigger.MANUAL
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Security decisions.
|
|
2
|
+
|
|
3
|
+
A :class:`Decision` is the policy engine's verdict over a :class:`DetectionResult`,
|
|
4
|
+
with an explanation for every finding and failure that contributed to it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from commitguard.core.result import DetectorFailure, Finding
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Action(StrEnum):
|
|
15
|
+
"""What to do about a finding. Ordered from least to most restrictive."""
|
|
16
|
+
|
|
17
|
+
ALLOW = "allow"
|
|
18
|
+
WARN = "warn"
|
|
19
|
+
BLOCK = "block"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def rank(self) -> int:
|
|
23
|
+
return _ACTION_RANK[self]
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def most_restrictive(cls, actions: "list[Action]") -> "Action":
|
|
27
|
+
return max(actions, key=lambda action: action.rank, default=cls.ALLOW)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_ACTION_RANK = {action: index for index, action in enumerate(Action)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Explanation(BaseModel):
|
|
34
|
+
"""Why a particular action was taken for one finding or failure."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
37
|
+
|
|
38
|
+
action: Action
|
|
39
|
+
reason: str
|
|
40
|
+
policy_id: str | None = None
|
|
41
|
+
finding: Finding | None = None
|
|
42
|
+
failure: DetectorFailure | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Decision(BaseModel):
|
|
46
|
+
"""Final verdict for one scan, with full explanations."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
49
|
+
|
|
50
|
+
action: Action
|
|
51
|
+
explanations: tuple[Explanation, ...] = ()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def blocked(self) -> bool:
|
|
55
|
+
return self.action is Action.BLOCK
|
|
56
|
+
|
|
57
|
+
def explanations_for(self, action: Action) -> tuple[Explanation, ...]:
|
|
58
|
+
return tuple(e for e in self.explanations if e.action is action)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Detection engine: runs registered detectors over a commit context.
|
|
2
|
+
|
|
3
|
+
The engine is deliberately small and policy-agnostic:
|
|
4
|
+
|
|
5
|
+
* detectors run in a deterministic order (sorted by name);
|
|
6
|
+
* callers may pass the set of *enabled rule IDs*; a detector none of whose
|
|
7
|
+
rules are enabled is skipped (recorded in ``detectors_skipped``). The engine
|
|
8
|
+
never looks at actions - that remains the policy evaluator's job;
|
|
9
|
+
* a detector that raises is recorded as a :class:`DetectorFailure` rather than
|
|
10
|
+
crashing the scan - the policy evaluator then fails closed;
|
|
11
|
+
* output is validated: detectors must return :class:`Finding` objects for
|
|
12
|
+
rules they declared, attributed to themselves and to the scanned commit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Collection
|
|
16
|
+
|
|
17
|
+
from commitguard.core.context import CommitContext
|
|
18
|
+
from commitguard.core.result import DetectionResult, DetectorFailure, Finding
|
|
19
|
+
from commitguard.detectors.base import Detector
|
|
20
|
+
from commitguard.detectors.registry import DetectorRegistry
|
|
21
|
+
from commitguard.exceptions.detection import DetectionError
|
|
22
|
+
from commitguard.security.sanitization import sanitize_for_terminal
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DetectionEngine:
|
|
26
|
+
"""Execute detectors from a registry against a :class:`CommitContext`."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, registry: DetectorRegistry) -> None:
|
|
29
|
+
self._registry = registry
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def registry(self) -> DetectorRegistry:
|
|
33
|
+
return self._registry
|
|
34
|
+
|
|
35
|
+
def run(
|
|
36
|
+
self,
|
|
37
|
+
context: CommitContext,
|
|
38
|
+
*,
|
|
39
|
+
enabled_rules: Collection[str] | None = None,
|
|
40
|
+
) -> DetectionResult:
|
|
41
|
+
findings: list[Finding] = []
|
|
42
|
+
failures: list[DetectorFailure] = []
|
|
43
|
+
ran: list[str] = []
|
|
44
|
+
skipped: list[str] = []
|
|
45
|
+
|
|
46
|
+
for detector in self._registry.all():
|
|
47
|
+
if enabled_rules is not None and not detector.rules & set(enabled_rules):
|
|
48
|
+
skipped.append(detector.name)
|
|
49
|
+
continue
|
|
50
|
+
ran.append(detector.name)
|
|
51
|
+
try:
|
|
52
|
+
findings.extend(self._run_detector(detector, context))
|
|
53
|
+
except Exception as exc: # noqa: BLE001 - any detector failure must fail closed
|
|
54
|
+
failures.append(
|
|
55
|
+
DetectorFailure(
|
|
56
|
+
detector=detector.name,
|
|
57
|
+
error_type=type(exc).__name__,
|
|
58
|
+
message=sanitize_for_terminal(str(exc) or type(exc).__name__),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return DetectionResult(
|
|
63
|
+
commit_sha=context.commit.sha,
|
|
64
|
+
detectors_run=tuple(ran),
|
|
65
|
+
detectors_skipped=tuple(skipped),
|
|
66
|
+
findings=tuple(findings),
|
|
67
|
+
failures=tuple(failures),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _run_detector(detector: Detector, context: CommitContext) -> list[Finding]:
|
|
72
|
+
produced = list(detector.detect(context))
|
|
73
|
+
for finding in produced:
|
|
74
|
+
if not isinstance(finding, Finding):
|
|
75
|
+
raise DetectionError(f"detector returned {type(finding).__name__}, not Finding")
|
|
76
|
+
if finding.detector != detector.name:
|
|
77
|
+
raise DetectionError(f"finding attributed to {finding.detector!r}")
|
|
78
|
+
if finding.rule_id not in detector.rules:
|
|
79
|
+
raise DetectionError(f"finding uses undeclared rule {finding.rule_id!r}")
|
|
80
|
+
if finding.commit_sha != context.commit.sha:
|
|
81
|
+
raise DetectionError("finding refers to a different commit")
|
|
82
|
+
return produced
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Findings and detection results.
|
|
2
|
+
|
|
3
|
+
A :class:`Finding` is a detector's structured, explainable claim that commit
|
|
4
|
+
*metadata* matched a rule. It never contains a decision (that is the policy's
|
|
5
|
+
job) and never claims that code was written by an AI - only that the commit
|
|
6
|
+
contains an identity or attribution associated with one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
12
|
+
|
|
13
|
+
from commitguard.security.hashing import fingerprint
|
|
14
|
+
from commitguard.security.validation import validate_git_sha, validate_identifier
|
|
15
|
+
|
|
16
|
+
MAX_EVIDENCE_CHARS = 512
|
|
17
|
+
_TRUNCATION_MARKER = "...[truncated]"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Severity(StrEnum):
|
|
21
|
+
"""How serious a finding is, independent of the action a policy takes."""
|
|
22
|
+
|
|
23
|
+
INFO = "info"
|
|
24
|
+
LOW = "low"
|
|
25
|
+
MEDIUM = "medium"
|
|
26
|
+
HIGH = "high"
|
|
27
|
+
CRITICAL = "critical"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def rank(self) -> int:
|
|
31
|
+
return list(Severity).index(self)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Confidence(StrEnum):
|
|
35
|
+
"""How strong the evidence behind a finding is."""
|
|
36
|
+
|
|
37
|
+
LOW = "low"
|
|
38
|
+
MEDIUM = "medium"
|
|
39
|
+
HIGH = "high"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def rank(self) -> int:
|
|
43
|
+
return list(Confidence).index(self)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MatchKind(StrEnum):
|
|
47
|
+
"""Which piece of an identity matched a rule."""
|
|
48
|
+
|
|
49
|
+
NAME = "name" # full name equals a configured alias
|
|
50
|
+
NAME_PREFIX = "name_prefix" # name starts with a configured distinctive prefix
|
|
51
|
+
AMBIGUOUS_NAME = "ambiguous_name" # alias that is also a common human name
|
|
52
|
+
EMAIL = "email" # exact configured address
|
|
53
|
+
GITHUB_LOGIN = "github_login" # login from a GitHub noreply address or name
|
|
54
|
+
AUTOMATION_EMAIL = "automation_email" # automation local part at a vendor domain
|
|
55
|
+
VENDOR_DOMAIN = "vendor_domain" # vendor domain with a non-automation local part
|
|
56
|
+
TRAILER_KEY = "trailer_key" # trailer key configured as attribution
|
|
57
|
+
MESSAGE_MARKER = "message_marker" # exact attribution line inserted by a tool
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MatchReason(BaseModel):
|
|
61
|
+
"""One reason a rule matched, with a reference to the rule that matched."""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
64
|
+
|
|
65
|
+
kind: MatchKind
|
|
66
|
+
value: str = Field(description="The normalised value that matched")
|
|
67
|
+
rule: str = Field(description="Rule reference, e.g. 'ai-identities.yaml#claude'")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class EvidenceSource(StrEnum):
|
|
71
|
+
COAUTHOR_TRAILER = "coauthor_trailer"
|
|
72
|
+
TRAILER = "trailer"
|
|
73
|
+
AUTHOR = "author"
|
|
74
|
+
COMMITTER = "committer"
|
|
75
|
+
MESSAGE = "message"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def label(self) -> str:
|
|
79
|
+
return _SOURCE_LABELS[self]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_SOURCE_LABELS = {
|
|
83
|
+
EvidenceSource.COAUTHOR_TRAILER: "Co-authored-by trailer",
|
|
84
|
+
EvidenceSource.TRAILER: "commit trailer",
|
|
85
|
+
EvidenceSource.AUTHOR: "commit author",
|
|
86
|
+
EvidenceSource.COMMITTER: "commit committer",
|
|
87
|
+
EvidenceSource.MESSAGE: "commit message line",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Evidence(BaseModel):
|
|
92
|
+
"""The concise metadata that triggered a finding (never file contents).
|
|
93
|
+
|
|
94
|
+
``value`` is untrusted raw data, truncated to :data:`MAX_EVIDENCE_CHARS`;
|
|
95
|
+
it must be sanitised before display.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
99
|
+
|
|
100
|
+
source: EvidenceSource
|
|
101
|
+
value: str
|
|
102
|
+
line_number: int | None = Field(default=None, ge=1)
|
|
103
|
+
matched: tuple[MatchReason, ...] = ()
|
|
104
|
+
notes: tuple[str, ...] = Field(default=(), description="e.g. malformed-trailer issues")
|
|
105
|
+
|
|
106
|
+
@field_validator("value")
|
|
107
|
+
@classmethod
|
|
108
|
+
def _truncate(cls, value: str) -> str:
|
|
109
|
+
if len(value) > MAX_EVIDENCE_CHARS:
|
|
110
|
+
return value[: MAX_EVIDENCE_CHARS - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Finding(BaseModel):
|
|
115
|
+
"""A structured security finding produced by a detector."""
|
|
116
|
+
|
|
117
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
118
|
+
|
|
119
|
+
detector: str
|
|
120
|
+
rule_id: str
|
|
121
|
+
severity: Severity
|
|
122
|
+
confidence: Confidence
|
|
123
|
+
title: str = Field(min_length=1)
|
|
124
|
+
message: str = Field(min_length=1)
|
|
125
|
+
evidence: tuple[Evidence, ...] = Field(min_length=1)
|
|
126
|
+
commit_sha: str | None = None
|
|
127
|
+
remediation: str = Field(min_length=1)
|
|
128
|
+
|
|
129
|
+
@field_validator("detector")
|
|
130
|
+
@classmethod
|
|
131
|
+
def _validate_detector(cls, value: str) -> str:
|
|
132
|
+
return validate_identifier(value, kind="detector name")
|
|
133
|
+
|
|
134
|
+
@field_validator("rule_id")
|
|
135
|
+
@classmethod
|
|
136
|
+
def _validate_rule(cls, value: str) -> str:
|
|
137
|
+
return validate_identifier(value, kind="rule id")
|
|
138
|
+
|
|
139
|
+
@field_validator("commit_sha")
|
|
140
|
+
@classmethod
|
|
141
|
+
def _validate_commit_sha(cls, value: str | None) -> str | None:
|
|
142
|
+
return None if value is None else validate_git_sha(value)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def fingerprint(self) -> str:
|
|
146
|
+
"""Stable ID: same detector, rule, commit and evidence => same fingerprint."""
|
|
147
|
+
parts = [self.detector, self.rule_id, self.commit_sha or ""]
|
|
148
|
+
for item in self.evidence:
|
|
149
|
+
parts.extend([item.source.value, item.value, str(item.line_number or "")])
|
|
150
|
+
return fingerprint(parts)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class DetectorFailure(BaseModel):
|
|
154
|
+
"""A detector that raised or misbehaved. Evaluated fail-closed by policy."""
|
|
155
|
+
|
|
156
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
157
|
+
|
|
158
|
+
detector: str
|
|
159
|
+
error_type: str
|
|
160
|
+
message: str
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class DetectionResult(BaseModel):
|
|
164
|
+
"""Everything the engine produced for one commit."""
|
|
165
|
+
|
|
166
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
167
|
+
|
|
168
|
+
commit_sha: str | None
|
|
169
|
+
detectors_run: tuple[str, ...]
|
|
170
|
+
detectors_skipped: tuple[str, ...] = ()
|
|
171
|
+
findings: tuple[Finding, ...] = ()
|
|
172
|
+
failures: tuple[DetectorFailure, ...] = ()
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def complete(self) -> bool:
|
|
176
|
+
"""True if every detector that ran completed successfully."""
|
|
177
|
+
return not self.failures
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Detectors: independent, pure analyses that turn a commit into findings.
|
|
2
|
+
|
|
3
|
+
A detector answers "does this commit match rule X, and what is the evidence?".
|
|
4
|
+
It never decides whether that is acceptable (see :mod:`commitguard.policies`)
|
|
5
|
+
and never performs I/O or modifies the repository.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Detector interface.
|
|
2
|
+
|
|
3
|
+
Contract every detector must honour:
|
|
4
|
+
|
|
5
|
+
1. **Pure.** Output depends only on the :class:`CommitContext` and the rule
|
|
6
|
+
data given at construction. No network, subprocesses, files or Git.
|
|
7
|
+
2. **Deterministic.** The same context always yields the same findings in the
|
|
8
|
+
same order.
|
|
9
|
+
3. **Declared rules.** Every finding's ``rule_id`` is listed in :attr:`rules`;
|
|
10
|
+
the engine rejects anything else.
|
|
11
|
+
4. **Evidence.** Every finding carries the concise metadata that triggered it.
|
|
12
|
+
5. **No decisions.** Detectors report; policies decide allow/warn/block.
|
|
13
|
+
6. **Hostile input.** Commit data may be crafted to crash or evade a detector.
|
|
14
|
+
Raising is acceptable (the scan fails closed); silently skipping is not.
|
|
15
|
+
7. **Honest wording.** Findings describe attribution/identity *evidence*;
|
|
16
|
+
they never claim that code was written by an AI.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from typing import ClassVar
|
|
22
|
+
|
|
23
|
+
from commitguard.core.context import CommitContext
|
|
24
|
+
from commitguard.core.result import Finding
|
|
25
|
+
from commitguard.exceptions.detection import DetectionError
|
|
26
|
+
from commitguard.git.commit import Commit
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Detector(ABC):
|
|
30
|
+
"""Base class for all detectors."""
|
|
31
|
+
|
|
32
|
+
#: Unique, snake_case detector name (used in findings and output).
|
|
33
|
+
name: ClassVar[str]
|
|
34
|
+
#: Rule IDs this detector may emit. Each must have a policy.
|
|
35
|
+
rules: ClassVar[frozenset[str]]
|
|
36
|
+
#: One-line human description.
|
|
37
|
+
description: ClassVar[str]
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def detect(self, context: CommitContext) -> Sequence[Finding]:
|
|
41
|
+
"""Analyse ``context`` and return zero or more findings."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def require_complete_trailers(commit: Commit) -> None:
|
|
45
|
+
"""Fail closed if the commit has more trailers than can be analysed.
|
|
46
|
+
|
|
47
|
+
Otherwise an attacker could hide attribution behind a flood of trailers.
|
|
48
|
+
"""
|
|
49
|
+
if commit.trailers_truncated:
|
|
50
|
+
raise DetectionError("commit has too many trailers to analyse completely")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def group_by_rule[T](items: list[tuple[str, T]]) -> dict[str, list[T]]:
|
|
54
|
+
"""Group ``(rule_id, item)`` pairs preserving first-seen order (deterministic)."""
|
|
55
|
+
grouped: dict[str, list[T]] = {}
|
|
56
|
+
for key, item in items:
|
|
57
|
+
grouped.setdefault(key, []).append(item)
|
|
58
|
+
return grouped
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Bot detector: explicitly configured automation identities.
|
|
2
|
+
|
|
3
|
+
Bots are not AI agents. Dependabot or a release bot produce ``bot_identity``
|
|
4
|
+
findings - never ``ai_*`` findings - so a repository can allow bots while
|
|
5
|
+
blocking AI attribution. Only identities listed in ``rules/bot-identities.yaml``
|
|
6
|
+
match; there is no generic ``[bot]`` heuristic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import ClassVar
|
|
11
|
+
|
|
12
|
+
from commitguard.core.context import CommitContext
|
|
13
|
+
from commitguard.core.result import Evidence, EvidenceSource, Finding, Severity
|
|
14
|
+
from commitguard.detectors.base import Detector, group_by_rule, require_complete_trailers
|
|
15
|
+
from commitguard.rules.matcher import CompiledRules, IdentityMatch
|
|
16
|
+
|
|
17
|
+
RULE_BOT_IDENTITY = "bot_identity"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BotDetector(Detector):
|
|
21
|
+
name: ClassVar[str] = "bot"
|
|
22
|
+
rules: ClassVar[frozenset[str]] = frozenset({RULE_BOT_IDENTITY})
|
|
23
|
+
description: ClassVar[str] = (
|
|
24
|
+
"Configured automation/bot account as author, committer or co-author."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def __init__(self, rules: CompiledRules) -> None:
|
|
28
|
+
self._matcher = rules.bot_matcher
|
|
29
|
+
self._coauthor_keys = frozenset(rules.rules.patterns.coauthor_trailer_keys)
|
|
30
|
+
|
|
31
|
+
def detect(self, context: CommitContext) -> Sequence[Finding]:
|
|
32
|
+
commit = context.commit
|
|
33
|
+
require_complete_trailers(commit)
|
|
34
|
+
candidates: list[tuple[EvidenceSource, str, str | None, str | None, int | None]] = [
|
|
35
|
+
(
|
|
36
|
+
EvidenceSource.AUTHOR,
|
|
37
|
+
str(commit.author),
|
|
38
|
+
commit.author.name,
|
|
39
|
+
commit.author.email,
|
|
40
|
+
None,
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
EvidenceSource.COMMITTER,
|
|
44
|
+
str(commit.committer),
|
|
45
|
+
commit.committer.name,
|
|
46
|
+
commit.committer.email,
|
|
47
|
+
None,
|
|
48
|
+
),
|
|
49
|
+
]
|
|
50
|
+
candidates.extend(
|
|
51
|
+
(EvidenceSource.COAUTHOR_TRAILER, t.value, t.name, t.email, t.line_number)
|
|
52
|
+
for t in commit.trailers
|
|
53
|
+
if t.normalized_key in self._coauthor_keys
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
matches: list[tuple[str, tuple[Evidence, IdentityMatch]]] = []
|
|
57
|
+
for source, value, name, email, line_number in candidates:
|
|
58
|
+
match = self._matcher.match(name, email)
|
|
59
|
+
if match is not None:
|
|
60
|
+
evidence = Evidence(
|
|
61
|
+
source=source, value=value, line_number=line_number, matched=match.reasons
|
|
62
|
+
)
|
|
63
|
+
matches.append((match.rule_id, (evidence, match)))
|
|
64
|
+
|
|
65
|
+
findings = []
|
|
66
|
+
for items in group_by_rule(matches).values():
|
|
67
|
+
match = max((m for _, m in items), key=lambda m: m.confidence.rank)
|
|
68
|
+
findings.append(
|
|
69
|
+
Finding(
|
|
70
|
+
detector=self.name,
|
|
71
|
+
rule_id=RULE_BOT_IDENTITY,
|
|
72
|
+
severity=Severity.LOW,
|
|
73
|
+
confidence=match.confidence,
|
|
74
|
+
title="Bot identity detected",
|
|
75
|
+
message=(
|
|
76
|
+
f"The commit involves the configured automation identity "
|
|
77
|
+
f"{match.display_name}. This is a bot finding, not an AI attribution."
|
|
78
|
+
),
|
|
79
|
+
evidence=tuple(evidence for evidence, _ in items),
|
|
80
|
+
commit_sha=commit.sha,
|
|
81
|
+
remediation=(
|
|
82
|
+
"If this automation is expected, set `bot_identity` to `allow` in "
|
|
83
|
+
".commitguard.yaml; otherwise investigate where the commit came from."
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return findings
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Co-author detector: AI agents listed in ``Co-authored-by`` style trailers.
|
|
2
|
+
|
|
3
|
+
Example that produces an ``ai_coauthor`` finding::
|
|
4
|
+
|
|
5
|
+
feat: implement authentication
|
|
6
|
+
|
|
7
|
+
Co-authored-by: Claude <noreply@anthropic.com>
|
|
8
|
+
|
|
9
|
+
Every co-author trailer is matched *independently* against the AI identity
|
|
10
|
+
rules, so human co-authors in the same commit never produce findings.
|
|
11
|
+
Trailers are considered even when malformed or outside Git's trailer block,
|
|
12
|
+
because hiding attribution that way is still attribution.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from typing import ClassVar
|
|
17
|
+
|
|
18
|
+
from commitguard.core.context import CommitContext
|
|
19
|
+
from commitguard.core.result import Evidence, EvidenceSource, Finding, Severity
|
|
20
|
+
from commitguard.detectors.base import Detector, require_complete_trailers
|
|
21
|
+
from commitguard.provenance.normalization import uses_disguising_characters
|
|
22
|
+
from commitguard.provenance.trailers import Trailer
|
|
23
|
+
from commitguard.rules.matcher import CompiledRules
|
|
24
|
+
|
|
25
|
+
RULE_AI_COAUTHOR = "ai_coauthor"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CoauthorDetector(Detector):
|
|
29
|
+
name: ClassVar[str] = "coauthor"
|
|
30
|
+
rules: ClassVar[frozenset[str]] = frozenset({RULE_AI_COAUTHOR})
|
|
31
|
+
description: ClassVar[str] = "AI agent identity listed as a co-author in commit trailers."
|
|
32
|
+
|
|
33
|
+
def __init__(self, rules: CompiledRules) -> None:
|
|
34
|
+
self._matcher = rules.ai_matcher
|
|
35
|
+
self._keys = frozenset(rules.rules.patterns.coauthor_trailer_keys)
|
|
36
|
+
|
|
37
|
+
def detect(self, context: CommitContext) -> Sequence[Finding]:
|
|
38
|
+
commit = context.commit
|
|
39
|
+
require_complete_trailers(commit)
|
|
40
|
+
findings: list[Finding] = []
|
|
41
|
+
for trailer in commit.trailers:
|
|
42
|
+
if trailer.normalized_key not in self._keys:
|
|
43
|
+
continue
|
|
44
|
+
match = self._matcher.match(trailer.name, trailer.email)
|
|
45
|
+
if match is None:
|
|
46
|
+
continue
|
|
47
|
+
findings.append(
|
|
48
|
+
Finding(
|
|
49
|
+
detector=self.name,
|
|
50
|
+
rule_id=RULE_AI_COAUTHOR,
|
|
51
|
+
severity=Severity.HIGH,
|
|
52
|
+
confidence=match.confidence,
|
|
53
|
+
title="AI coauthor detected",
|
|
54
|
+
message=(
|
|
55
|
+
f"A co-author trailer names an identity associated with the AI agent "
|
|
56
|
+
f"{match.display_name}."
|
|
57
|
+
),
|
|
58
|
+
evidence=(
|
|
59
|
+
Evidence(
|
|
60
|
+
source=EvidenceSource.COAUTHOR_TRAILER,
|
|
61
|
+
value=trailer.value or trailer.raw,
|
|
62
|
+
line_number=trailer.line_number,
|
|
63
|
+
matched=match.reasons,
|
|
64
|
+
notes=_notes(trailer),
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
commit_sha=commit.sha,
|
|
68
|
+
remediation=(
|
|
69
|
+
"Remove the AI co-author attribution from the commit message before "
|
|
70
|
+
"pushing (for example with `git commit --amend`). CommitGuard never "
|
|
71
|
+
"rewrites commits itself."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return findings
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _notes(trailer: Trailer) -> tuple[str, ...]:
|
|
79
|
+
notes: list[str] = []
|
|
80
|
+
if uses_disguising_characters(trailer.raw):
|
|
81
|
+
notes.append("contains look-alike or invisible Unicode characters")
|
|
82
|
+
if not trailer.in_trailer_block:
|
|
83
|
+
notes.append("outside the commit's trailer block")
|
|
84
|
+
notes.extend(f"trailer: {issue.value}" for issue in trailer.issues)
|
|
85
|
+
notes.extend(f"identity: {issue.value}" for issue in trailer.identity.issues)
|
|
86
|
+
return tuple(notes)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Identity detector: AI agents recorded as the commit author or committer.
|
|
2
|
+
|
|
3
|
+
Some agents commit under their own identity instead of adding a trailer, e.g.
|
|
4
|
+
``Copilot <175728472+Copilot@users.noreply.github.com>`` as author.
|
|
5
|
+
|
|
6
|
+
Only identities configured as AI agents match. Automation accounts that are
|
|
7
|
+
not AI agents (Dependabot, CI) are the bot detector's concern.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import ClassVar
|
|
12
|
+
|
|
13
|
+
from commitguard.core.context import CommitContext
|
|
14
|
+
from commitguard.core.result import Evidence, EvidenceSource, Finding, Severity
|
|
15
|
+
from commitguard.detectors.base import Detector, group_by_rule
|
|
16
|
+
from commitguard.provenance.normalization import uses_disguising_characters
|
|
17
|
+
from commitguard.rules.matcher import CompiledRules, IdentityMatch
|
|
18
|
+
|
|
19
|
+
RULE_AI_IDENTITY = "ai_identity"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IdentityDetector(Detector):
|
|
23
|
+
name: ClassVar[str] = "identity"
|
|
24
|
+
rules: ClassVar[frozenset[str]] = frozenset({RULE_AI_IDENTITY})
|
|
25
|
+
description: ClassVar[str] = "AI agent identity recorded as the commit author or committer."
|
|
26
|
+
|
|
27
|
+
def __init__(self, rules: CompiledRules) -> None:
|
|
28
|
+
self._matcher = rules.ai_matcher
|
|
29
|
+
|
|
30
|
+
def detect(self, context: CommitContext) -> Sequence[Finding]:
|
|
31
|
+
commit = context.commit
|
|
32
|
+
matches: list[tuple[str, tuple[EvidenceSource, str, IdentityMatch]]] = []
|
|
33
|
+
for source, identity in (
|
|
34
|
+
(EvidenceSource.AUTHOR, commit.author),
|
|
35
|
+
(EvidenceSource.COMMITTER, commit.committer),
|
|
36
|
+
):
|
|
37
|
+
match = self._matcher.match(identity.name, identity.email)
|
|
38
|
+
if match is not None:
|
|
39
|
+
matches.append((match.rule_id, (source, str(identity), match)))
|
|
40
|
+
|
|
41
|
+
findings = []
|
|
42
|
+
for items in group_by_rule(matches).values():
|
|
43
|
+
match = max((m for _, _, m in items), key=lambda m: m.confidence.rank)
|
|
44
|
+
roles = " and ".join(source.label for source, _, _ in items)
|
|
45
|
+
findings.append(
|
|
46
|
+
Finding(
|
|
47
|
+
detector=self.name,
|
|
48
|
+
rule_id=RULE_AI_IDENTITY,
|
|
49
|
+
severity=Severity.HIGH,
|
|
50
|
+
confidence=match.confidence,
|
|
51
|
+
title="AI agent identity detected",
|
|
52
|
+
message=(
|
|
53
|
+
f"The {roles} identity is associated with the AI agent "
|
|
54
|
+
f"{match.display_name}."
|
|
55
|
+
),
|
|
56
|
+
evidence=tuple(
|
|
57
|
+
Evidence(
|
|
58
|
+
source=source,
|
|
59
|
+
value=value,
|
|
60
|
+
matched=m.reasons,
|
|
61
|
+
notes=(
|
|
62
|
+
("contains look-alike or invisible Unicode characters",)
|
|
63
|
+
if uses_disguising_characters(value)
|
|
64
|
+
else ()
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
for source, value, m in items
|
|
68
|
+
),
|
|
69
|
+
commit_sha=commit.sha,
|
|
70
|
+
remediation=(
|
|
71
|
+
"Recreate the commit under the responsible human contributor's identity "
|
|
72
|
+
"before pushing. CommitGuard never rewrites commits itself."
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return findings
|