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,95 @@
|
|
|
1
|
+
"""AuditService: create audit events with correlation IDs and record them."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
from commitguard.audit.logger import AuditLogger
|
|
7
|
+
from commitguard.audit.models import SYSTEM_ACTOR, Actor, AuditEvent, AuditEventType, AuditValue
|
|
8
|
+
from commitguard.audit.storage import AuditStorage
|
|
9
|
+
from commitguard.core.decision import Action
|
|
10
|
+
from commitguard.observability.logging import current_correlation
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuditService:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
storages: Sequence[AuditStorage] = (),
|
|
17
|
+
*,
|
|
18
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
19
|
+
) -> None:
|
|
20
|
+
self._logger = AuditLogger(storages)
|
|
21
|
+
self._now = now
|
|
22
|
+
|
|
23
|
+
def record(
|
|
24
|
+
self,
|
|
25
|
+
event_type: AuditEventType,
|
|
26
|
+
*,
|
|
27
|
+
actor: Actor = SYSTEM_ACTOR,
|
|
28
|
+
account_id: int | None = None,
|
|
29
|
+
installation_id: int | None = None,
|
|
30
|
+
repository_id: int | None = None,
|
|
31
|
+
repository: str | None = None,
|
|
32
|
+
head_sha: str | None = None,
|
|
33
|
+
action: Action | None = None,
|
|
34
|
+
extra: Mapping[str, AuditValue] | None = None,
|
|
35
|
+
**data: AuditValue,
|
|
36
|
+
) -> AuditEvent:
|
|
37
|
+
"""Record an event. ``extra`` carries data built elsewhere (merged with ``data``)."""
|
|
38
|
+
event = self.build(
|
|
39
|
+
event_type,
|
|
40
|
+
actor=actor,
|
|
41
|
+
account_id=account_id,
|
|
42
|
+
installation_id=installation_id,
|
|
43
|
+
repository_id=repository_id,
|
|
44
|
+
repository=repository,
|
|
45
|
+
head_sha=head_sha,
|
|
46
|
+
action=action,
|
|
47
|
+
extra={**(extra or {}), **data},
|
|
48
|
+
)
|
|
49
|
+
self._logger.record(event)
|
|
50
|
+
return event
|
|
51
|
+
|
|
52
|
+
def build(
|
|
53
|
+
self,
|
|
54
|
+
event_type: AuditEventType,
|
|
55
|
+
*,
|
|
56
|
+
actor: Actor = SYSTEM_ACTOR,
|
|
57
|
+
account_id: int | None = None,
|
|
58
|
+
installation_id: int | None = None,
|
|
59
|
+
repository_id: int | None = None,
|
|
60
|
+
repository: str | None = None,
|
|
61
|
+
head_sha: str | None = None,
|
|
62
|
+
action: Action | None = None,
|
|
63
|
+
extra: Mapping[str, AuditValue] | None = None,
|
|
64
|
+
**data: AuditValue,
|
|
65
|
+
) -> AuditEvent:
|
|
66
|
+
"""Create an event without storing it (for writes inside a larger transaction)."""
|
|
67
|
+
data = {**(extra or {}), **data}
|
|
68
|
+
correlation = current_correlation()
|
|
69
|
+
|
|
70
|
+
def _str(key: str) -> str | None:
|
|
71
|
+
value = correlation.get(key)
|
|
72
|
+
return str(value) if value is not None else None
|
|
73
|
+
|
|
74
|
+
return AuditEvent(
|
|
75
|
+
type=event_type,
|
|
76
|
+
occurred_at=self._now(),
|
|
77
|
+
delivery_id=_str("delivery_id"),
|
|
78
|
+
request_id=_str("request_id"),
|
|
79
|
+
job_id=_str("job_id"),
|
|
80
|
+
scan_id=_str("scan_id"),
|
|
81
|
+
actor_type=actor.type,
|
|
82
|
+
actor_id=actor.id,
|
|
83
|
+
actor_login=actor.login,
|
|
84
|
+
account_id=account_id,
|
|
85
|
+
installation_id=installation_id,
|
|
86
|
+
repository_id=repository_id,
|
|
87
|
+
repository=repository,
|
|
88
|
+
head_sha=head_sha,
|
|
89
|
+
action=action,
|
|
90
|
+
data=data,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def log_stored(self, event: AuditEvent) -> None:
|
|
94
|
+
"""Emit the log line for an event another component stored transactionally."""
|
|
95
|
+
self._logger.log(event)
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""CI enforcement service: the server-side counterpart of the pre-push hook.
|
|
2
|
+
|
|
3
|
+
Given a provider-neutral :class:`~commitguard.ci.context.CIContext` it decides
|
|
4
|
+
|
|
5
|
+
1. **which commits** the event introduces (a :class:`CommitRange`), and
|
|
6
|
+
2. **which policy** evaluates them (a trusted :class:`PolicySource`),
|
|
7
|
+
|
|
8
|
+
then runs the same :class:`~commitguard.services.analysis.Analyzer` as
|
|
9
|
+
``commitguard scan`` and the Git hooks. There is no CI-specific detection.
|
|
10
|
+
|
|
11
|
+
Trust model (see docs/github-enforcement.md):
|
|
12
|
+
|
|
13
|
+
============================== ========================== =========================
|
|
14
|
+
Event Commits analysed Policy read from
|
|
15
|
+
============================== ========================== =========================
|
|
16
|
+
pull request / merge group ``head ^base`` base commit's tree
|
|
17
|
+
push, ``before`` known ``after ^before`` ``before`` commit's tree
|
|
18
|
+
push, new ref (not default) ``after ^<default branch>`` default branch tip's tree
|
|
19
|
+
push, new default branch / no ``after`` (bounded) built-in defaults
|
|
20
|
+
trusted commit available
|
|
21
|
+
push, ref deleted nothing -
|
|
22
|
+
============================== ========================== =========================
|
|
23
|
+
|
|
24
|
+
The evaluated commits' own ``.commitguard.yaml`` is never applied; a change to
|
|
25
|
+
it is reported as a notice and takes effect once it is on the trusted branch.
|
|
26
|
+
A change that would *weaken* a policy (disable it or lower its action) is
|
|
27
|
+
reported as a security policy modification. Detection rules always come from
|
|
28
|
+
the installed CommitGuard package. A central service may add a mandatory
|
|
29
|
+
policy (:mod:`commitguard.policies.mandatory`) that no repository can weaken,
|
|
30
|
+
or - for the GitHub App - the complete organization governance inputs
|
|
31
|
+
(:mod:`commitguard.policies.governance`): policy layers, approved exceptions and
|
|
32
|
+
monitor mode, resolved together with the trusted repository configuration.
|
|
33
|
+
|
|
34
|
+
Planning (:func:`plan_ci`) and execution (:func:`execute_ci_plan`) are separate
|
|
35
|
+
so a service can report the commit count before analysis starts.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from collections.abc import Sequence
|
|
39
|
+
|
|
40
|
+
from pydantic import BaseModel, ConfigDict
|
|
41
|
+
|
|
42
|
+
from commitguard.ci.context import CIContext, CIEventKind
|
|
43
|
+
from commitguard.config.schema import CommitGuardConfig
|
|
44
|
+
from commitguard.config.sources import (
|
|
45
|
+
MandatoryPolicy,
|
|
46
|
+
PolicySource,
|
|
47
|
+
PolicySourceKind,
|
|
48
|
+
config_differs,
|
|
49
|
+
load_config_at_revision,
|
|
50
|
+
load_policy_source,
|
|
51
|
+
)
|
|
52
|
+
from commitguard.core.context import ScanTrigger
|
|
53
|
+
from commitguard.exceptions.configuration import ConfigurationError
|
|
54
|
+
from commitguard.git.ranges import CommitRange, require_commit, resolve_commit_range
|
|
55
|
+
from commitguard.git.repository import Repository
|
|
56
|
+
from commitguard.policies.governance import EffectivePolicy, GovernanceInputs, resolve_policy
|
|
57
|
+
from commitguard.policies.loader import build_policy_set
|
|
58
|
+
from commitguard.policies.mandatory import apply_mandatory_policies
|
|
59
|
+
from commitguard.policies.model import Policy, PolicySet
|
|
60
|
+
from commitguard.rules.matcher import CompiledRules
|
|
61
|
+
from commitguard.security.hashing import fingerprint
|
|
62
|
+
from commitguard.services.analysis import Analyzer, build_report
|
|
63
|
+
from commitguard.services.reports import CIReport, ScanReport
|
|
64
|
+
|
|
65
|
+
DEFAULT_CI_MAX_COMMITS = 10_000
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CIPlan(BaseModel):
|
|
69
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
70
|
+
|
|
71
|
+
range: CommitRange
|
|
72
|
+
policy_source: PolicySource
|
|
73
|
+
config_changes: tuple[str, ...] = ()
|
|
74
|
+
policy_weakenings: tuple[str, ...] = ()
|
|
75
|
+
notices: tuple[str, ...] = ()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CIRun(BaseModel):
|
|
79
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
80
|
+
|
|
81
|
+
context: CIContext
|
|
82
|
+
plan: CIPlan
|
|
83
|
+
report: ScanReport
|
|
84
|
+
policy_fingerprint: str # effective policies (trusted config + mandatory floor)
|
|
85
|
+
policies: tuple[Policy, ...] = () # the effective policies that evaluated the commits
|
|
86
|
+
#: The repository configuration's own overrides (rule -> fields it set), kept so a
|
|
87
|
+
#: policy simulation can re-resolve historical scans under a draft policy.
|
|
88
|
+
repository_overrides: dict[str, dict[str, str | bool]] = {}
|
|
89
|
+
effective: EffectivePolicy | None = None # provenance, when governance applied
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def repository_overrides(
|
|
93
|
+
configs: Sequence[CommitGuardConfig],
|
|
94
|
+
) -> dict[str, dict[str, str | bool]]:
|
|
95
|
+
"""The merged per-rule fields the configuration layers set (later layers win)."""
|
|
96
|
+
merged: dict[str, dict[str, str | bool]] = {}
|
|
97
|
+
for config in configs:
|
|
98
|
+
for rule, override in config.policies.items():
|
|
99
|
+
for key, value in override.model_dump(mode="json", exclude_unset=True).items():
|
|
100
|
+
merged.setdefault(rule, {})[key] = value
|
|
101
|
+
return dict(sorted(merged.items()))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def policy_set_fingerprint(policies: PolicySet) -> str:
|
|
105
|
+
parts: list[str] = []
|
|
106
|
+
for policy in policies.values():
|
|
107
|
+
parts += [policy.id, str(policy.enabled), policy.action.value]
|
|
108
|
+
return fingerprint(parts)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def policy_weakenings(
|
|
112
|
+
repository: Repository, trusted: str, head: str, *, config_path: str | None = None
|
|
113
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
114
|
+
"""Policies the evaluated commits' configuration would weaken, plus notices.
|
|
115
|
+
|
|
116
|
+
Only used for reporting: the evaluated commits' configuration is never applied.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
before = build_policy_set(
|
|
120
|
+
*load_config_at_revision(repository, trusted, config_path=config_path).configs
|
|
121
|
+
)
|
|
122
|
+
except ConfigurationError:
|
|
123
|
+
return (), () # the trusted configuration itself is invalid: the scan fails later
|
|
124
|
+
try:
|
|
125
|
+
after = build_policy_set(
|
|
126
|
+
*load_config_at_revision(repository, head, config_path=config_path).configs
|
|
127
|
+
)
|
|
128
|
+
except ConfigurationError:
|
|
129
|
+
return (), (
|
|
130
|
+
"the evaluated commits contain an invalid or missing CommitGuard configuration; "
|
|
131
|
+
"it was not applied",
|
|
132
|
+
)
|
|
133
|
+
weakened = []
|
|
134
|
+
for policy_id, old in before.items():
|
|
135
|
+
new = after[policy_id]
|
|
136
|
+
if old.enabled and not new.enabled:
|
|
137
|
+
weakened.append(f"{policy_id}: {old.action.value} -> disabled")
|
|
138
|
+
elif old.enabled and new.action.rank < old.action.rank:
|
|
139
|
+
weakened.append(f"{policy_id}: {old.action.value} -> {new.action.value}")
|
|
140
|
+
return tuple(weakened), ()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _config_notices(
|
|
144
|
+
repository: Repository,
|
|
145
|
+
trusted: str,
|
|
146
|
+
head: str,
|
|
147
|
+
changes: tuple[str, ...],
|
|
148
|
+
label: str,
|
|
149
|
+
config_path: str | None,
|
|
150
|
+
) -> tuple[tuple[str, ...], list[str]]:
|
|
151
|
+
if not changes:
|
|
152
|
+
return (), []
|
|
153
|
+
notices = [
|
|
154
|
+
f"{', '.join(changes)} is changed by the evaluated commits; the policy from the "
|
|
155
|
+
f"{label} was used. Policy changes take effect after they reach the trusted branch."
|
|
156
|
+
]
|
|
157
|
+
weakened, extra = policy_weakenings(repository, trusted, head, config_path=config_path)
|
|
158
|
+
notices.extend(extra)
|
|
159
|
+
if weakened:
|
|
160
|
+
notices.append(
|
|
161
|
+
"Security policy modification detected: the evaluated commits attempt to weaken "
|
|
162
|
+
f"an existing CommitGuard policy ({'; '.join(weakened)}). The trusted policy was "
|
|
163
|
+
"used; additional authorization may be required."
|
|
164
|
+
)
|
|
165
|
+
return weakened, notices
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _default_branch_tip(repository: Repository, context: CIContext) -> str | None:
|
|
169
|
+
if not context.default_branch:
|
|
170
|
+
return None
|
|
171
|
+
for ref in (
|
|
172
|
+
f"refs/remotes/origin/{context.default_branch}",
|
|
173
|
+
f"refs/heads/{context.default_branch}",
|
|
174
|
+
):
|
|
175
|
+
tip = repository.ref_commit(ref)
|
|
176
|
+
if tip is not None:
|
|
177
|
+
return tip
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def plan_ci(
|
|
182
|
+
repository: Repository,
|
|
183
|
+
context: CIContext,
|
|
184
|
+
*,
|
|
185
|
+
config_path: str | None = None,
|
|
186
|
+
max_commits: int = DEFAULT_CI_MAX_COMMITS,
|
|
187
|
+
) -> CIPlan:
|
|
188
|
+
notices: list[str] = []
|
|
189
|
+
|
|
190
|
+
if context.event in (CIEventKind.PULL_REQUEST, CIEventKind.MERGE_GROUP):
|
|
191
|
+
if context.base_sha is None or context.head_sha is None: # guaranteed by CIContext
|
|
192
|
+
raise ValueError("pull request context without base and head commits")
|
|
193
|
+
base = require_commit(repository, context.base_sha, role="base")
|
|
194
|
+
head = require_commit(repository, context.head_sha, role="head")
|
|
195
|
+
commit_range = resolve_commit_range(
|
|
196
|
+
repository, head, [base], base=base, max_count=max_commits
|
|
197
|
+
)
|
|
198
|
+
label = (
|
|
199
|
+
"pull request base" if context.event is CIEventKind.PULL_REQUEST else "merge queue base"
|
|
200
|
+
)
|
|
201
|
+
source = PolicySource(
|
|
202
|
+
kind=PolicySourceKind.REVISION,
|
|
203
|
+
revision=base,
|
|
204
|
+
description=label,
|
|
205
|
+
config_path=config_path,
|
|
206
|
+
)
|
|
207
|
+
changes = tuple(config_differs(repository, base, head))
|
|
208
|
+
weakened, config_notices = _config_notices(
|
|
209
|
+
repository, base, head, changes, label, config_path
|
|
210
|
+
)
|
|
211
|
+
notices.extend(config_notices)
|
|
212
|
+
return CIPlan(
|
|
213
|
+
range=commit_range,
|
|
214
|
+
policy_source=source,
|
|
215
|
+
config_changes=changes,
|
|
216
|
+
policy_weakenings=weakened,
|
|
217
|
+
notices=tuple(notices),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# push
|
|
221
|
+
if context.ref_deleted or context.after_sha is None:
|
|
222
|
+
return CIPlan(
|
|
223
|
+
range=CommitRange(),
|
|
224
|
+
policy_source=PolicySource(kind=PolicySourceKind.BUILTIN, description="not needed"),
|
|
225
|
+
notices=("ref deleted: no commits to analyse",),
|
|
226
|
+
)
|
|
227
|
+
if not repository.object_exists(context.after_sha):
|
|
228
|
+
require_commit(repository, context.after_sha, role="pushed") # raises a clear error
|
|
229
|
+
pushed = repository.peel_to_commit(context.after_sha)
|
|
230
|
+
if pushed is None:
|
|
231
|
+
return CIPlan(
|
|
232
|
+
range=CommitRange(),
|
|
233
|
+
policy_source=PolicySource(kind=PolicySourceKind.BUILTIN, description="not needed"),
|
|
234
|
+
notices=("pushed object is not a commit (e.g. a tag of a tree): nothing to analyse",),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
before = repository.peel_to_commit(context.before_sha) if context.before_sha else None
|
|
238
|
+
if context.before_sha and before is None:
|
|
239
|
+
notices.append(
|
|
240
|
+
f"previous commit {context.before_sha[:12]} is not in this clone (force push?); "
|
|
241
|
+
"falling back to the default branch"
|
|
242
|
+
)
|
|
243
|
+
if before is not None:
|
|
244
|
+
exclude = [before]
|
|
245
|
+
source = PolicySource(
|
|
246
|
+
kind=PolicySourceKind.REVISION,
|
|
247
|
+
revision=before,
|
|
248
|
+
description="commit before the push",
|
|
249
|
+
config_path=config_path,
|
|
250
|
+
)
|
|
251
|
+
changes = tuple(config_differs(repository, before, pushed))
|
|
252
|
+
else:
|
|
253
|
+
is_default = bool(context.default_branch) and context.ref == (
|
|
254
|
+
f"refs/heads/{context.default_branch}"
|
|
255
|
+
)
|
|
256
|
+
tip = None if is_default else _default_branch_tip(repository, context)
|
|
257
|
+
if tip is not None:
|
|
258
|
+
exclude = [tip]
|
|
259
|
+
source = PolicySource(
|
|
260
|
+
kind=PolicySourceKind.REVISION,
|
|
261
|
+
revision=tip,
|
|
262
|
+
description="default branch",
|
|
263
|
+
config_path=config_path,
|
|
264
|
+
)
|
|
265
|
+
changes = tuple(config_differs(repository, tip, pushed))
|
|
266
|
+
else:
|
|
267
|
+
exclude = []
|
|
268
|
+
source = PolicySource(
|
|
269
|
+
kind=PolicySourceKind.BUILTIN,
|
|
270
|
+
description="built-in defaults (no trusted commit available)",
|
|
271
|
+
config_path=config_path,
|
|
272
|
+
)
|
|
273
|
+
changes = ()
|
|
274
|
+
notices.append(
|
|
275
|
+
"no trusted commit to read policy from or to limit the range; analysing all "
|
|
276
|
+
"commits reachable from the pushed commit with built-in default policies"
|
|
277
|
+
)
|
|
278
|
+
push_weakened: tuple[str, ...] = ()
|
|
279
|
+
if changes and source.revision is not None:
|
|
280
|
+
push_weakened, push_notices = _config_notices(
|
|
281
|
+
repository, source.revision, pushed, changes, source.description, config_path
|
|
282
|
+
)
|
|
283
|
+
notices.extend(push_notices)
|
|
284
|
+
commit_range = resolve_commit_range(
|
|
285
|
+
repository, pushed, exclude, base=exclude[0] if exclude else None, max_count=max_commits
|
|
286
|
+
)
|
|
287
|
+
return CIPlan(
|
|
288
|
+
range=commit_range,
|
|
289
|
+
policy_source=source,
|
|
290
|
+
config_changes=changes,
|
|
291
|
+
policy_weakenings=push_weakened,
|
|
292
|
+
notices=tuple(notices),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def execute_ci_plan(
|
|
297
|
+
repository: Repository,
|
|
298
|
+
context: CIContext,
|
|
299
|
+
plan: CIPlan,
|
|
300
|
+
*,
|
|
301
|
+
rules: CompiledRules | None = None,
|
|
302
|
+
mandatory: MandatoryPolicy | None = None,
|
|
303
|
+
governance: GovernanceInputs | None = None,
|
|
304
|
+
) -> CIRun:
|
|
305
|
+
"""Analyse the planned commits with the planned (trusted) policy.
|
|
306
|
+
|
|
307
|
+
``governance`` replaces ``mandatory`` when given (its service layer carries
|
|
308
|
+
the same floor): the effective policy is resolved from the governance inputs
|
|
309
|
+
and the trusted repository configuration.
|
|
310
|
+
"""
|
|
311
|
+
loaded = load_policy_source(repository, plan.policy_source)
|
|
312
|
+
repository_policies = build_policy_set(*loaded.configs)
|
|
313
|
+
policies = repository_policies
|
|
314
|
+
extra_sources: tuple[str, ...] = ()
|
|
315
|
+
effective: EffectivePolicy | None = None
|
|
316
|
+
if governance is not None:
|
|
317
|
+
effective = resolve_policy(governance, loaded.configs)
|
|
318
|
+
policies = effective.policy_set()
|
|
319
|
+
extra_sources = (f"organization governance: {governance.describe()}",)
|
|
320
|
+
elif mandatory is not None:
|
|
321
|
+
policies = apply_mandatory_policies(policies, mandatory.config)
|
|
322
|
+
extra_sources = (f"mandatory: {mandatory.description}",)
|
|
323
|
+
analyzer = Analyzer.create(policies, rules)
|
|
324
|
+
# Commits are read and analysed in batches; only the compact reports are kept.
|
|
325
|
+
reports = [
|
|
326
|
+
analyzer.analyze(commit, ScanTrigger.CI)
|
|
327
|
+
for commit in repository.iter_commits(plan.range.commits)
|
|
328
|
+
]
|
|
329
|
+
policy_source = str(plan.policy_source)
|
|
330
|
+
if governance is not None and not governance.empty:
|
|
331
|
+
policy_source += f" + {governance.describe()}"
|
|
332
|
+
elif mandatory is not None and governance is None:
|
|
333
|
+
policy_source += f" + mandatory policy ({mandatory.description})"
|
|
334
|
+
ci_report = CIReport(
|
|
335
|
+
provider=context.provider.value,
|
|
336
|
+
event=context.event_name,
|
|
337
|
+
repository=context.repository,
|
|
338
|
+
ref=context.ref,
|
|
339
|
+
pull_request_number=context.pull_request_number,
|
|
340
|
+
from_fork=context.from_fork,
|
|
341
|
+
base_sha=plan.range.base,
|
|
342
|
+
head_sha=plan.range.head,
|
|
343
|
+
policy_source=policy_source,
|
|
344
|
+
config_changes=plan.config_changes,
|
|
345
|
+
policy_weakenings=plan.policy_weakenings,
|
|
346
|
+
notices=plan.notices,
|
|
347
|
+
)
|
|
348
|
+
target = (
|
|
349
|
+
f"{plan.range.base[:12]}..{plan.range.head[:12]}"
|
|
350
|
+
if plan.range.base and plan.range.head
|
|
351
|
+
else (plan.range.head[:12] if plan.range.head else "nothing")
|
|
352
|
+
)
|
|
353
|
+
report = build_report(
|
|
354
|
+
reports,
|
|
355
|
+
repository=None,
|
|
356
|
+
target=target,
|
|
357
|
+
trigger=ScanTrigger.CI,
|
|
358
|
+
config=loaded,
|
|
359
|
+
ci=ci_report,
|
|
360
|
+
extra_config_sources=extra_sources,
|
|
361
|
+
)
|
|
362
|
+
return CIRun(
|
|
363
|
+
context=context,
|
|
364
|
+
plan=plan,
|
|
365
|
+
report=report,
|
|
366
|
+
policy_fingerprint=policy_set_fingerprint(policies),
|
|
367
|
+
policies=tuple(policies.values()),
|
|
368
|
+
repository_overrides=repository_overrides(loaded.configs),
|
|
369
|
+
effective=effective,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def run_ci(
|
|
374
|
+
repository: Repository,
|
|
375
|
+
context: CIContext,
|
|
376
|
+
*,
|
|
377
|
+
config_path: str | None = None,
|
|
378
|
+
max_commits: int = DEFAULT_CI_MAX_COMMITS,
|
|
379
|
+
rules: CompiledRules | None = None,
|
|
380
|
+
mandatory: MandatoryPolicy | None = None,
|
|
381
|
+
) -> CIRun:
|
|
382
|
+
plan = plan_ci(repository, context, config_path=config_path, max_commits=max_commits)
|
|
383
|
+
return execute_ci_plan(repository, context, plan, rules=rules, mandatory=mandatory)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Enforcement: translate a policy result into what an entry point does.
|
|
2
|
+
|
|
3
|
+
The policy engine decides ALLOW / WARN / BLOCK. This service turns that
|
|
4
|
+
decision - or the failure to reach one - into an :class:`EnforcementDecision`.
|
|
5
|
+
``commitguard ci github`` and the GitHub App use it directly; the Git hooks
|
|
6
|
+
(:mod:`commitguard.services.hooks`) follow the same exit-code contract:
|
|
7
|
+
|
|
8
|
+
===================== ============== ================== ================
|
|
9
|
+
State CLI / Action Git hook GitHub Check
|
|
10
|
+
===================== ============== ================== ================
|
|
11
|
+
passed exit 0 commit/push allowed ``success``
|
|
12
|
+
passed_with_warnings exit 0 allowed ``success`` (+ warnings)
|
|
13
|
+
blocked exit 1 rejected ``failure``
|
|
14
|
+
error exit 2 rejected ``failure`` / ``timed_out``
|
|
15
|
+
===================== ============== ================== ================
|
|
16
|
+
|
|
17
|
+
``blocked`` (the scan worked and found a violation) and ``error`` (the scan
|
|
18
|
+
could not be completed) are kept apart for audit and reporting even though
|
|
19
|
+
both fail closed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict
|
|
25
|
+
|
|
26
|
+
from commitguard.core.decision import Action
|
|
27
|
+
from commitguard.services.reports import ScanReport
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EnforcementState(StrEnum):
|
|
31
|
+
PASSED = "passed"
|
|
32
|
+
PASSED_WITH_WARNINGS = "passed_with_warnings"
|
|
33
|
+
BLOCKED = "blocked"
|
|
34
|
+
ERROR = "error"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FailureKind(StrEnum):
|
|
38
|
+
CONFIGURATION = "configuration" # invalid trusted/mandatory policy or rules
|
|
39
|
+
AUTHORIZATION = "authorization" # the integration may not access the repository
|
|
40
|
+
INFRASTRUCTURE = "infrastructure" # Git, storage, GitHub API unavailable
|
|
41
|
+
TIMEOUT = "timeout"
|
|
42
|
+
INTERNAL = "internal" # unexpected error
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EnforcementDecision(BaseModel):
|
|
46
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
47
|
+
|
|
48
|
+
state: EnforcementState
|
|
49
|
+
action: Action | None = None
|
|
50
|
+
failure: FailureKind | None = None
|
|
51
|
+
reason: str
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def allowed(self) -> bool:
|
|
55
|
+
return self.state in (EnforcementState.PASSED, EnforcementState.PASSED_WITH_WARNINGS)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def exit_code(self) -> int:
|
|
59
|
+
"""0 allowed, 1 blocked by policy, 2 could not be verified (stable CLI contract)."""
|
|
60
|
+
return {
|
|
61
|
+
EnforcementState.PASSED: 0,
|
|
62
|
+
EnforcementState.PASSED_WITH_WARNINGS: 0,
|
|
63
|
+
EnforcementState.BLOCKED: 1,
|
|
64
|
+
EnforcementState.ERROR: 2,
|
|
65
|
+
}[self.state]
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def check_conclusion(self) -> str:
|
|
69
|
+
if self.allowed:
|
|
70
|
+
return "success"
|
|
71
|
+
if self.failure is FailureKind.TIMEOUT:
|
|
72
|
+
return "timed_out"
|
|
73
|
+
return "failure"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EnforcementService:
|
|
77
|
+
def __init__(self, fail_on: Action = Action.BLOCK) -> None:
|
|
78
|
+
if fail_on is Action.ALLOW:
|
|
79
|
+
raise ValueError("fail_on must be block or warn")
|
|
80
|
+
self.fail_on = fail_on
|
|
81
|
+
|
|
82
|
+
def decide(self, report: ScanReport) -> EnforcementDecision:
|
|
83
|
+
action = report.action
|
|
84
|
+
if action.rank >= self.fail_on.rank:
|
|
85
|
+
return EnforcementDecision(
|
|
86
|
+
state=EnforcementState.BLOCKED,
|
|
87
|
+
action=action,
|
|
88
|
+
reason=f"policy result {action.value} is at or above fail-on {self.fail_on.value}",
|
|
89
|
+
)
|
|
90
|
+
if action is Action.WARN:
|
|
91
|
+
return EnforcementDecision(
|
|
92
|
+
state=EnforcementState.PASSED_WITH_WARNINGS,
|
|
93
|
+
action=action,
|
|
94
|
+
reason="warnings only",
|
|
95
|
+
)
|
|
96
|
+
return EnforcementDecision(
|
|
97
|
+
state=EnforcementState.PASSED, action=action, reason="all policies passed"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def failed(kind: FailureKind, reason: str) -> EnforcementDecision:
|
|
102
|
+
return EnforcementDecision(state=EnforcementState.ERROR, failure=kind, reason=reason)
|