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,48 @@
|
|
|
1
|
+
"""Built-in policy defaults.
|
|
2
|
+
|
|
3
|
+
These are the secure defaults applied when a repository has no configuration,
|
|
4
|
+
and the base that repository configuration overrides. Every rule a built-in
|
|
5
|
+
detector can emit must appear here (enforced by tests); configuration may only
|
|
6
|
+
reference IDs listed here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
|
|
11
|
+
from commitguard.core.decision import Action
|
|
12
|
+
from commitguard.policies.model import Policy, PolicySet
|
|
13
|
+
|
|
14
|
+
_DEFAULTS: tuple[Policy, ...] = (
|
|
15
|
+
Policy(
|
|
16
|
+
id="ai_coauthor",
|
|
17
|
+
action=Action.BLOCK,
|
|
18
|
+
description="AI agent listed as a co-author (e.g. Co-authored-by trailer).",
|
|
19
|
+
),
|
|
20
|
+
Policy(
|
|
21
|
+
id="ai_identity",
|
|
22
|
+
action=Action.BLOCK,
|
|
23
|
+
description="AI agent recorded as the commit author or committer.",
|
|
24
|
+
),
|
|
25
|
+
Policy(
|
|
26
|
+
id="ai_trailer",
|
|
27
|
+
action=Action.BLOCK,
|
|
28
|
+
description="AI attribution in other trailers (e.g. Generated-by, Assisted-by).",
|
|
29
|
+
),
|
|
30
|
+
Policy(
|
|
31
|
+
id="malformed_trailer",
|
|
32
|
+
action=Action.WARN,
|
|
33
|
+
description="Trailer-like line that does not parse (possible evasion attempt).",
|
|
34
|
+
),
|
|
35
|
+
Policy(
|
|
36
|
+
id="bot_identity",
|
|
37
|
+
action=Action.WARN,
|
|
38
|
+
description="Automation or bot account as author, committer or co-author.",
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
DEFAULT_POLICIES = MappingProxyType({policy.id: policy for policy in _DEFAULTS})
|
|
43
|
+
KNOWN_POLICY_IDS = frozenset(DEFAULT_POLICIES)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_policy_set() -> PolicySet:
|
|
47
|
+
"""Return the built-in policy set."""
|
|
48
|
+
return PolicySet(DEFAULT_POLICIES)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Policy evaluation: :class:`DetectionResult` + :class:`PolicySet` -> :class:`Decision`.
|
|
2
|
+
|
|
3
|
+
Evaluation rules (all fail closed):
|
|
4
|
+
|
|
5
|
+
* finding for a rule with an enabled policy -> that policy's action;
|
|
6
|
+
* finding for a rule whose policy is disabled -> ALLOW (recorded, not hidden);
|
|
7
|
+
* finding for a rule with **no** policy -> BLOCK;
|
|
8
|
+
* detector failure -> BLOCK (an incomplete scan
|
|
9
|
+
cannot prove the commit is clean);
|
|
10
|
+
* the overall decision is the most restrictive individual action.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from commitguard.core.decision import Action, Decision, Explanation
|
|
14
|
+
from commitguard.core.result import DetectionResult
|
|
15
|
+
from commitguard.policies.model import PolicySet
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PolicyEvaluator:
|
|
19
|
+
"""Apply a :class:`PolicySet` to scan results."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, policies: PolicySet) -> None:
|
|
22
|
+
self._policies = policies
|
|
23
|
+
|
|
24
|
+
def evaluate(self, result: DetectionResult) -> Decision:
|
|
25
|
+
explanations: list[Explanation] = []
|
|
26
|
+
|
|
27
|
+
for finding in result.findings:
|
|
28
|
+
policy = self._policies.get(finding.rule_id)
|
|
29
|
+
if policy is None:
|
|
30
|
+
explanations.append(
|
|
31
|
+
Explanation(
|
|
32
|
+
action=Action.BLOCK,
|
|
33
|
+
reason=f"no policy configured for rule {finding.rule_id!r}; failing closed",
|
|
34
|
+
finding=finding,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
elif not policy.enabled:
|
|
38
|
+
explanations.append(
|
|
39
|
+
Explanation(
|
|
40
|
+
action=Action.ALLOW,
|
|
41
|
+
reason=f"policy {policy.id!r} is disabled",
|
|
42
|
+
policy_id=policy.id,
|
|
43
|
+
finding=finding,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
explanations.append(
|
|
48
|
+
Explanation(
|
|
49
|
+
action=policy.action,
|
|
50
|
+
reason=f"policy {policy.id!r} action is {policy.action.value}",
|
|
51
|
+
policy_id=policy.id,
|
|
52
|
+
finding=finding,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
for failure in result.failures:
|
|
57
|
+
explanations.append(
|
|
58
|
+
Explanation(
|
|
59
|
+
action=Action.BLOCK,
|
|
60
|
+
reason=f"detector {failure.detector!r} did not complete; failing closed",
|
|
61
|
+
failure=failure,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
action = Action.most_restrictive([e.action for e in explanations])
|
|
66
|
+
return Decision(action=action, explanations=tuple(explanations))
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Governed policy resolution: which policy applies to a repository, and why.
|
|
2
|
+
|
|
3
|
+
The organization layer decides *what configuration applies*; the policy engine
|
|
4
|
+
(:mod:`commitguard.policies.evaluator`) still decides *what a finding means*.
|
|
5
|
+
This module is the pure part of that split: given the governance inputs for
|
|
6
|
+
one repository (policy layers, approved exceptions, onboarding mode) and,
|
|
7
|
+
at scan time, the repository's own ``.commitguard.yaml``, it builds the
|
|
8
|
+
effective :class:`~commitguard.policies.model.PolicySet` together with the
|
|
9
|
+
provenance of every rule. It performs no I/O and evaluates no findings.
|
|
10
|
+
|
|
11
|
+
Precedence, per rule (later steps win, subject to their own limits)::
|
|
12
|
+
|
|
13
|
+
1. built-in default rules/policies/defaults.py
|
|
14
|
+
2. organization policy - default replaced by a narrower default below
|
|
15
|
+
3. repository group - default several groups: the most restrictive
|
|
16
|
+
4. repository policy - default (set in the dashboard for one repository)
|
|
17
|
+
5. repository configuration .commitguard.yaml at the trusted revision
|
|
18
|
+
6. mandatory requirements (floor) the most restrictive mandatory entry of the
|
|
19
|
+
service, organization, group and repository
|
|
20
|
+
policies; nothing above can weaken it
|
|
21
|
+
7. approved exception the only way below a floor; scoped, expiring,
|
|
22
|
+
the most specific scope applies
|
|
23
|
+
8. monitor mode block is reported as warn (report only)
|
|
24
|
+
|
|
25
|
+
Two strengths exist. A **mandatory** entry (``warn`` or ``block``) is a floor:
|
|
26
|
+
lower layers may make the rule stricter but never weaker, and a lower layer
|
|
27
|
+
that asks for less is recorded as a :class:`PolicyConflict` - never silently
|
|
28
|
+
dropped. A **default** entry is a baseline that narrower layers and the
|
|
29
|
+
repository configuration may replace in either direction. A rule no layer
|
|
30
|
+
mentions is decided by the repository configuration (or the built-in default).
|
|
31
|
+
|
|
32
|
+
Exceptions never add or remove detection: a finding under an exception is still
|
|
33
|
+
recorded, with the lowered action and the exception that lowered it.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from enum import StrEnum
|
|
40
|
+
|
|
41
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
42
|
+
|
|
43
|
+
from commitguard.config.schema import CommitGuardConfig, PolicyOverride
|
|
44
|
+
from commitguard.core.decision import Action
|
|
45
|
+
from commitguard.policies.defaults import DEFAULT_POLICIES
|
|
46
|
+
from commitguard.policies.loader import build_policy_set
|
|
47
|
+
from commitguard.policies.mandatory import apply_mandatory_policies
|
|
48
|
+
from commitguard.policies.model import Policy, PolicySet
|
|
49
|
+
from commitguard.security.hashing import fingerprint, sha256_hex
|
|
50
|
+
|
|
51
|
+
_STRICT = ConfigDict(frozen=True, extra="forbid")
|
|
52
|
+
|
|
53
|
+
#: Limits on governance inputs (resource exhaustion protection).
|
|
54
|
+
MAX_LAYERS = 64
|
|
55
|
+
MAX_EXCEPTIONS = 256
|
|
56
|
+
MAX_LABEL_CHARS = 200
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Enforcement(StrEnum):
|
|
60
|
+
"""How strongly a policy layer requires an action."""
|
|
61
|
+
|
|
62
|
+
MANDATORY = "mandatory" # a floor: nothing below may weaken it
|
|
63
|
+
DEFAULT = "default" # a baseline: narrower layers may replace it
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PolicyLevel(StrEnum):
|
|
67
|
+
BUILT_IN = "built_in"
|
|
68
|
+
SERVICE = "service"
|
|
69
|
+
ORGANIZATION = "organization"
|
|
70
|
+
GROUP = "group"
|
|
71
|
+
REPOSITORY_POLICY = "repository_policy"
|
|
72
|
+
REPOSITORY_CONFIGURATION = "repository_configuration"
|
|
73
|
+
EXCEPTION = "exception"
|
|
74
|
+
MONITOR_MODE = "monitor_mode"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: Order used to break ties between floors of equal strength (broadest first).
|
|
78
|
+
_LEVEL_ORDER = {
|
|
79
|
+
PolicyLevel.SERVICE: 0,
|
|
80
|
+
PolicyLevel.ORGANIZATION: 1,
|
|
81
|
+
PolicyLevel.GROUP: 2,
|
|
82
|
+
PolicyLevel.REPOSITORY_POLICY: 3,
|
|
83
|
+
}
|
|
84
|
+
LAYER_LEVELS = frozenset(_LEVEL_ORDER)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RepositoryMode(StrEnum):
|
|
88
|
+
ENFORCE = "enforce"
|
|
89
|
+
MONITOR = "monitor"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ExceptionScope(StrEnum):
|
|
93
|
+
ORGANIZATION = "organization"
|
|
94
|
+
GROUP = "group"
|
|
95
|
+
REPOSITORY = "repository"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_SCOPE_SPECIFICITY = {
|
|
99
|
+
ExceptionScope.REPOSITORY: 2,
|
|
100
|
+
ExceptionScope.GROUP: 1,
|
|
101
|
+
ExceptionScope.ORGANIZATION: 0,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RuleRequirement(BaseModel):
|
|
106
|
+
"""One layer's requirement for one rule."""
|
|
107
|
+
|
|
108
|
+
model_config = _STRICT
|
|
109
|
+
|
|
110
|
+
action: Action
|
|
111
|
+
enforcement: Enforcement = Enforcement.MANDATORY
|
|
112
|
+
|
|
113
|
+
@model_validator(mode="after")
|
|
114
|
+
def _floor_actions(self) -> "RuleRequirement":
|
|
115
|
+
if self.enforcement is Enforcement.MANDATORY and self.action is Action.ALLOW:
|
|
116
|
+
raise ValueError("a mandatory requirement must be warn or block")
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_rule_ids(rules: Iterable[str]) -> None:
|
|
121
|
+
unknown = sorted(set(rules) - set(DEFAULT_POLICIES))
|
|
122
|
+
if unknown:
|
|
123
|
+
raise ValueError(f"unknown policy id(s): {', '.join(unknown)}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PolicyLayer(BaseModel):
|
|
127
|
+
"""A published policy version that applies to the repository."""
|
|
128
|
+
|
|
129
|
+
model_config = _STRICT
|
|
130
|
+
|
|
131
|
+
level: PolicyLevel
|
|
132
|
+
source_id: str = Field(default="", max_length=64) # group ID or repository ID
|
|
133
|
+
label: str = Field(min_length=1, max_length=MAX_LABEL_CHARS)
|
|
134
|
+
version: int | None = Field(default=None, ge=1)
|
|
135
|
+
rules: dict[str, RuleRequirement] = {}
|
|
136
|
+
|
|
137
|
+
@field_validator("level")
|
|
138
|
+
@classmethod
|
|
139
|
+
def _layer_level(cls, value: PolicyLevel) -> PolicyLevel:
|
|
140
|
+
if value not in LAYER_LEVELS:
|
|
141
|
+
raise ValueError(f"{value.value} is not a policy layer")
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
@field_validator("rules")
|
|
145
|
+
@classmethod
|
|
146
|
+
def _known(cls, value: dict[str, RuleRequirement]) -> dict[str, RuleRequirement]:
|
|
147
|
+
validate_rule_ids(value)
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ExceptionGrant(BaseModel):
|
|
152
|
+
"""An approved, active exception as seen by the resolver."""
|
|
153
|
+
|
|
154
|
+
model_config = _STRICT
|
|
155
|
+
|
|
156
|
+
exception_id: str = Field(min_length=1, max_length=64)
|
|
157
|
+
rule_id: str
|
|
158
|
+
action: Action
|
|
159
|
+
scope: ExceptionScope
|
|
160
|
+
scope_label: str = Field(min_length=1, max_length=MAX_LABEL_CHARS)
|
|
161
|
+
expires_at: datetime | None = None # None only for a documented permanent exception
|
|
162
|
+
|
|
163
|
+
@field_validator("rule_id")
|
|
164
|
+
@classmethod
|
|
165
|
+
def _known(cls, value: str) -> str:
|
|
166
|
+
validate_rule_ids([value])
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
@field_validator("action")
|
|
170
|
+
@classmethod
|
|
171
|
+
def _lowering(cls, value: Action) -> Action:
|
|
172
|
+
if value is Action.BLOCK:
|
|
173
|
+
raise ValueError("an exception lowers enforcement to warn or allow")
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class GovernanceInputs(BaseModel):
|
|
178
|
+
"""Everything the organization layer decides for one repository."""
|
|
179
|
+
|
|
180
|
+
model_config = _STRICT
|
|
181
|
+
|
|
182
|
+
layers: tuple[PolicyLayer, ...] = ()
|
|
183
|
+
exceptions: tuple[ExceptionGrant, ...] = ()
|
|
184
|
+
mode: RepositoryMode = RepositoryMode.ENFORCE
|
|
185
|
+
|
|
186
|
+
@field_validator("layers")
|
|
187
|
+
@classmethod
|
|
188
|
+
def _bounded_layers(cls, value: tuple[PolicyLayer, ...]) -> tuple[PolicyLayer, ...]:
|
|
189
|
+
if len(value) > MAX_LAYERS:
|
|
190
|
+
raise ValueError(f"more than {MAX_LAYERS} policy layers")
|
|
191
|
+
return value
|
|
192
|
+
|
|
193
|
+
@field_validator("exceptions")
|
|
194
|
+
@classmethod
|
|
195
|
+
def _bounded_exceptions(cls, value: tuple[ExceptionGrant, ...]) -> tuple[ExceptionGrant, ...]:
|
|
196
|
+
if len(value) > MAX_EXCEPTIONS:
|
|
197
|
+
raise ValueError(f"more than {MAX_EXCEPTIONS} exceptions")
|
|
198
|
+
return value
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def empty(self) -> bool:
|
|
202
|
+
return not self.layers and not self.exceptions and self.mode is RepositoryMode.ENFORCE
|
|
203
|
+
|
|
204
|
+
def canonical(self) -> str:
|
|
205
|
+
"""Deterministic JSON of the inputs that affect enforcement (labels excluded)."""
|
|
206
|
+
ordered = sorted(self.layers, key=lambda item: (_LEVEL_ORDER[item.level], item.source_id))
|
|
207
|
+
document = {
|
|
208
|
+
"layers": [
|
|
209
|
+
{
|
|
210
|
+
"level": layer.level.value,
|
|
211
|
+
"source": layer.source_id,
|
|
212
|
+
"version": layer.version,
|
|
213
|
+
"rules": {
|
|
214
|
+
rule: [req.action.value, req.enforcement.value]
|
|
215
|
+
for rule, req in sorted(layer.rules.items())
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
for layer in ordered
|
|
219
|
+
],
|
|
220
|
+
"exceptions": sorted(
|
|
221
|
+
[g.exception_id, g.rule_id, g.action.value, g.scope.value] for g in self.exceptions
|
|
222
|
+
),
|
|
223
|
+
"mode": self.mode.value,
|
|
224
|
+
}
|
|
225
|
+
return json.dumps(document, separators=(",", ":"), sort_keys=True)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def fingerprint(self) -> str:
|
|
229
|
+
return sha256_hex(self.canonical().encode("utf-8"))
|
|
230
|
+
|
|
231
|
+
def describe(self) -> str:
|
|
232
|
+
parts = [layer.label for layer in self.ordered_layers()]
|
|
233
|
+
if self.exceptions:
|
|
234
|
+
parts.append(f"{len(self.exceptions)} exception(s)")
|
|
235
|
+
if self.mode is RepositoryMode.MONITOR:
|
|
236
|
+
parts.append("monitor mode")
|
|
237
|
+
return " + ".join(parts) if parts else "no organization governance"
|
|
238
|
+
|
|
239
|
+
def ordered_layers(self) -> list[PolicyLayer]:
|
|
240
|
+
return sorted(self.layers, key=lambda layer: (_LEVEL_ORDER[layer.level], layer.label))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class PolicyConflict(BaseModel):
|
|
244
|
+
"""A lower layer asked for less than a mandatory requirement allows."""
|
|
245
|
+
|
|
246
|
+
model_config = _STRICT
|
|
247
|
+
|
|
248
|
+
policy_id: str
|
|
249
|
+
requested_action: Action
|
|
250
|
+
requested_enabled: bool
|
|
251
|
+
requested_by: PolicyLevel
|
|
252
|
+
requested_label: str
|
|
253
|
+
required_action: Action
|
|
254
|
+
required_by: PolicyLevel
|
|
255
|
+
required_label: str
|
|
256
|
+
effective_action: Action
|
|
257
|
+
reason: str
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class RuleProvenance(BaseModel):
|
|
261
|
+
"""The effective policy for one rule and where every part of it came from."""
|
|
262
|
+
|
|
263
|
+
model_config = _STRICT
|
|
264
|
+
|
|
265
|
+
policy_id: str
|
|
266
|
+
enabled: bool
|
|
267
|
+
action: Action
|
|
268
|
+
source: PolicyLevel
|
|
269
|
+
source_label: str
|
|
270
|
+
enforcement: Enforcement | None = None
|
|
271
|
+
required_action: Action | None = None # the floor, when a mandatory entry exists
|
|
272
|
+
required_by: PolicyLevel | None = None
|
|
273
|
+
required_label: str | None = None
|
|
274
|
+
conflict: PolicyConflict | None = None
|
|
275
|
+
exception_id: str | None = None
|
|
276
|
+
exception_expires_at: datetime | None = None
|
|
277
|
+
action_before_exception: Action | None = None
|
|
278
|
+
monitor_mode: bool = False # block reported as warn
|
|
279
|
+
repository_configuration_known: bool = True
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class EffectivePolicy(BaseModel):
|
|
283
|
+
"""The resolved policy set for one repository plus its provenance."""
|
|
284
|
+
|
|
285
|
+
model_config = _STRICT
|
|
286
|
+
|
|
287
|
+
policies: tuple[Policy, ...]
|
|
288
|
+
rules: tuple[RuleProvenance, ...]
|
|
289
|
+
inputs_fingerprint: str
|
|
290
|
+
description: str
|
|
291
|
+
mode: RepositoryMode
|
|
292
|
+
|
|
293
|
+
def policy_set(self) -> PolicySet:
|
|
294
|
+
return PolicySet({p.id: p for p in self.policies})
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def conflicts(self) -> tuple[PolicyConflict, ...]:
|
|
298
|
+
return tuple(r.conflict for r in self.rules if r.conflict is not None)
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def fingerprint(self) -> str:
|
|
302
|
+
"""Fingerprint of the evaluated policies (same scheme as scan results)."""
|
|
303
|
+
return policy_set_fingerprint(self.policy_set())
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def policy_set_fingerprint(policies: Mapping[str, Policy]) -> str:
|
|
307
|
+
parts: list[str] = []
|
|
308
|
+
for policy in policies.values():
|
|
309
|
+
parts += [policy.id, str(policy.enabled), policy.action.value]
|
|
310
|
+
return fingerprint(parts)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _level_label(level: PolicyLevel) -> str:
|
|
314
|
+
return {
|
|
315
|
+
PolicyLevel.BUILT_IN: "Built-in default",
|
|
316
|
+
PolicyLevel.REPOSITORY_CONFIGURATION: "Repository configuration (.commitguard.yaml)",
|
|
317
|
+
}.get(level, level.value.replace("_", " ").capitalize())
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _touched(configs: Sequence[CommitGuardConfig], policy_id: str) -> bool:
|
|
321
|
+
return any(
|
|
322
|
+
policy_id in config.policies and config.policies[policy_id].model_fields_set
|
|
323
|
+
for config in configs
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _effective_rank(policy: Policy) -> int:
|
|
328
|
+
return policy.action.rank if policy.enabled else Action.ALLOW.rank
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def resolve_policy(
|
|
332
|
+
inputs: GovernanceInputs,
|
|
333
|
+
repository_configs: Sequence[CommitGuardConfig] | None = None,
|
|
334
|
+
) -> EffectivePolicy:
|
|
335
|
+
"""Resolve the effective policy for one repository.
|
|
336
|
+
|
|
337
|
+
``repository_configs`` are the repository's configuration layers as loaded
|
|
338
|
+
from the trusted revision (the built-in empty layer may be included). Pass
|
|
339
|
+
``None`` when they are not known (dashboard views between scans): the result
|
|
340
|
+
then shows what governance guarantees, and marks the repository layer unknown.
|
|
341
|
+
"""
|
|
342
|
+
layers = inputs.ordered_layers()
|
|
343
|
+
known = repository_configs is not None
|
|
344
|
+
configs = list(repository_configs or ())
|
|
345
|
+
|
|
346
|
+
# Steps 2-4: defaults, narrower levels replacing broader ones.
|
|
347
|
+
defaults: dict[str, tuple[Action, PolicyLevel, str]] = {}
|
|
348
|
+
for level in (PolicyLevel.ORGANIZATION, PolicyLevel.GROUP, PolicyLevel.REPOSITORY_POLICY):
|
|
349
|
+
level_entries: dict[str, list[tuple[Action, str]]] = {}
|
|
350
|
+
for layer in (candidate for candidate in layers if candidate.level is level):
|
|
351
|
+
for rule, requirement in layer.rules.items():
|
|
352
|
+
if requirement.enforcement is Enforcement.DEFAULT:
|
|
353
|
+
level_entries.setdefault(rule, []).append((requirement.action, layer.label))
|
|
354
|
+
for rule, entries in level_entries.items():
|
|
355
|
+
# Several groups: the most restrictive default wins (ties: first label).
|
|
356
|
+
action, label = max(entries, key=lambda entry: entry[0].rank)
|
|
357
|
+
defaults[rule] = (action, level, label)
|
|
358
|
+
defaults_config = CommitGuardConfig(
|
|
359
|
+
version=1,
|
|
360
|
+
policies={rule: PolicyOverride(action=entry[0]) for rule, entry in defaults.items()},
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Step 5: the repository configuration overrides defaults (policy engine layering).
|
|
364
|
+
requested = build_policy_set(defaults_config, *configs)
|
|
365
|
+
|
|
366
|
+
# Step 6: mandatory floors.
|
|
367
|
+
floors: dict[str, tuple[Action, PolicyLevel, str]] = {}
|
|
368
|
+
for layer in layers:
|
|
369
|
+
for rule, requirement in layer.rules.items():
|
|
370
|
+
if requirement.enforcement is not Enforcement.MANDATORY:
|
|
371
|
+
continue
|
|
372
|
+
current = floors.get(rule)
|
|
373
|
+
if current is None or requirement.action.rank > current[0].rank:
|
|
374
|
+
floors[rule] = (requirement.action, layer.level, layer.label)
|
|
375
|
+
floor_config = CommitGuardConfig(
|
|
376
|
+
version=1,
|
|
377
|
+
policies={rule: PolicyOverride(action=entry[0]) for rule, entry in floors.items()},
|
|
378
|
+
)
|
|
379
|
+
floored = apply_mandatory_policies(requested, floor_config) if floors else requested
|
|
380
|
+
|
|
381
|
+
grants: dict[str, list[ExceptionGrant]] = {}
|
|
382
|
+
for grant in inputs.exceptions:
|
|
383
|
+
grants.setdefault(grant.rule_id, []).append(grant)
|
|
384
|
+
|
|
385
|
+
policies: list[Policy] = []
|
|
386
|
+
provenance: list[RuleProvenance] = []
|
|
387
|
+
for policy_id in sorted(DEFAULT_POLICIES):
|
|
388
|
+
before_floor = requested[policy_id]
|
|
389
|
+
if _touched(configs, policy_id):
|
|
390
|
+
source, label, enforcement = (
|
|
391
|
+
PolicyLevel.REPOSITORY_CONFIGURATION,
|
|
392
|
+
_level_label(PolicyLevel.REPOSITORY_CONFIGURATION),
|
|
393
|
+
None,
|
|
394
|
+
)
|
|
395
|
+
elif policy_id in defaults:
|
|
396
|
+
_, source, label = defaults[policy_id]
|
|
397
|
+
enforcement = Enforcement.DEFAULT
|
|
398
|
+
else:
|
|
399
|
+
source, label, enforcement = (
|
|
400
|
+
PolicyLevel.BUILT_IN,
|
|
401
|
+
_level_label(PolicyLevel.BUILT_IN),
|
|
402
|
+
None,
|
|
403
|
+
)
|
|
404
|
+
policy = floored[policy_id]
|
|
405
|
+
conflict = None
|
|
406
|
+
floor = floors.get(policy_id)
|
|
407
|
+
if floor is not None:
|
|
408
|
+
floor_action, floor_level, floor_label = floor
|
|
409
|
+
if source is PolicyLevel.BUILT_IN:
|
|
410
|
+
# Nobody asked for less: the floor simply decides the rule.
|
|
411
|
+
if policy.action.rank == floor_action.rank:
|
|
412
|
+
source, label, enforcement = floor_level, floor_label, Enforcement.MANDATORY
|
|
413
|
+
elif _effective_rank(before_floor) < floor_action.rank:
|
|
414
|
+
asked = before_floor.action if before_floor.enabled else Action.ALLOW
|
|
415
|
+
conflict = PolicyConflict(
|
|
416
|
+
policy_id=policy_id,
|
|
417
|
+
requested_action=asked,
|
|
418
|
+
requested_enabled=before_floor.enabled,
|
|
419
|
+
requested_by=source,
|
|
420
|
+
requested_label=label,
|
|
421
|
+
required_action=floor_action,
|
|
422
|
+
required_by=floor_level,
|
|
423
|
+
required_label=floor_label,
|
|
424
|
+
effective_action=policy.action,
|
|
425
|
+
reason=(
|
|
426
|
+
f"{floor_label} requires at least {floor_action.value} and is mandatory: "
|
|
427
|
+
f"{label} cannot weaken it."
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
source, label, enforcement = floor_level, floor_label, Enforcement.MANDATORY
|
|
431
|
+
|
|
432
|
+
exception_id = None
|
|
433
|
+
exception_expires = None
|
|
434
|
+
before_exception = None
|
|
435
|
+
applicable = grants.get(policy_id, [])
|
|
436
|
+
if applicable:
|
|
437
|
+
specificity = max(_SCOPE_SPECIFICITY[g.scope] for g in applicable)
|
|
438
|
+
narrowest = [g for g in applicable if _SCOPE_SPECIFICITY[g.scope] == specificity]
|
|
439
|
+
# The most restrictive of the most specific exceptions (safer on overlap).
|
|
440
|
+
grant = max(narrowest, key=lambda g: (g.action.rank, g.exception_id))
|
|
441
|
+
if _effective_rank(policy) > grant.action.rank:
|
|
442
|
+
before_exception = policy.action
|
|
443
|
+
policy = policy.model_copy(update={"enabled": True, "action": grant.action})
|
|
444
|
+
exception_id, exception_expires = grant.exception_id, grant.expires_at
|
|
445
|
+
source, label = PolicyLevel.EXCEPTION, f"Exception ({grant.scope_label})"
|
|
446
|
+
|
|
447
|
+
monitor = False
|
|
448
|
+
if (
|
|
449
|
+
inputs.mode is RepositoryMode.MONITOR
|
|
450
|
+
and policy.enabled
|
|
451
|
+
and policy.action is Action.BLOCK
|
|
452
|
+
):
|
|
453
|
+
policy = policy.model_copy(update={"action": Action.WARN})
|
|
454
|
+
monitor = True
|
|
455
|
+
|
|
456
|
+
policies.append(policy)
|
|
457
|
+
provenance.append(
|
|
458
|
+
RuleProvenance(
|
|
459
|
+
policy_id=policy_id,
|
|
460
|
+
enabled=policy.enabled,
|
|
461
|
+
action=policy.action,
|
|
462
|
+
source=source,
|
|
463
|
+
source_label=label,
|
|
464
|
+
enforcement=enforcement,
|
|
465
|
+
required_action=floor[0] if floor else None,
|
|
466
|
+
required_by=floor[1] if floor else None,
|
|
467
|
+
required_label=floor[2] if floor else None,
|
|
468
|
+
conflict=conflict,
|
|
469
|
+
exception_id=exception_id,
|
|
470
|
+
exception_expires_at=exception_expires,
|
|
471
|
+
action_before_exception=before_exception,
|
|
472
|
+
monitor_mode=monitor,
|
|
473
|
+
repository_configuration_known=known,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
return EffectivePolicy(
|
|
477
|
+
policies=tuple(policies),
|
|
478
|
+
rules=tuple(provenance),
|
|
479
|
+
inputs_fingerprint=inputs.fingerprint,
|
|
480
|
+
description=inputs.describe(),
|
|
481
|
+
mode=inputs.mode,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def floor_config(inputs: GovernanceInputs) -> CommitGuardConfig | None:
|
|
486
|
+
"""The combined mandatory floor of ``inputs`` (for display and compatibility)."""
|
|
487
|
+
floors: dict[str, Action] = {}
|
|
488
|
+
for layer in inputs.layers:
|
|
489
|
+
for rule, requirement in layer.rules.items():
|
|
490
|
+
if requirement.enforcement is Enforcement.MANDATORY:
|
|
491
|
+
floors[rule] = Action.most_restrictive(
|
|
492
|
+
[requirement.action, floors.get(rule, Action.WARN)]
|
|
493
|
+
)
|
|
494
|
+
if not floors:
|
|
495
|
+
return None
|
|
496
|
+
return CommitGuardConfig(
|
|
497
|
+
version=1, policies={rule: PolicyOverride(action=action) for rule, action in floors.items()}
|
|
498
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Build the effective :class:`PolicySet` from validated configuration layers.
|
|
2
|
+
|
|
3
|
+
Merge semantics: start from :data:`DEFAULT_POLICIES`; each configuration layer
|
|
4
|
+
(in precedence order, lowest first) overrides only the policy fields it sets.
|
|
5
|
+
A policy that is *omitted* keeps its value from the layer below and,
|
|
6
|
+
ultimately, its secure default - weakening enforcement always requires an
|
|
7
|
+
explicit entry.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from commitguard.config.schema import CommitGuardConfig
|
|
11
|
+
from commitguard.policies.defaults import DEFAULT_POLICIES
|
|
12
|
+
from commitguard.policies.model import Policy, PolicySet
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_policy_set(*configs: CommitGuardConfig) -> PolicySet:
|
|
16
|
+
"""Return the effective policies for configuration layers ``configs``."""
|
|
17
|
+
effective: dict[str, Policy] = dict(DEFAULT_POLICIES)
|
|
18
|
+
for config in configs:
|
|
19
|
+
for policy_id, override in config.policies.items():
|
|
20
|
+
# Schema validation guarantees policy_id is a known ID.
|
|
21
|
+
updates = override.model_dump(exclude_unset=True)
|
|
22
|
+
effective[policy_id] = effective[policy_id].model_copy(update=updates)
|
|
23
|
+
return PolicySet(effective)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Mandatory policies: a floor that repository configuration cannot weaken.
|
|
2
|
+
|
|
3
|
+
Ordinary configuration layers *override* each other (a repository may change
|
|
4
|
+
``bot_identity`` from warn to allow). A mandatory policy - set by the operator
|
|
5
|
+
of a central service such as the GitHub App, and in future by an organisation -
|
|
6
|
+
is applied *after* those layers and can only make enforcement stricter:
|
|
7
|
+
|
|
8
|
+
* a policy it names is always enabled;
|
|
9
|
+
* its action is the more restrictive of the mandatory action and the action
|
|
10
|
+
the lower layers produced.
|
|
11
|
+
|
|
12
|
+
So with a mandatory ``ai_coauthor: block``, a repository ``ai_coauthor: allow``
|
|
13
|
+
(or ``enabled: false``) still results in BLOCK. A mandatory policy cannot
|
|
14
|
+
disable or relax anything; ``enabled: false``, local hook enforcement and
|
|
15
|
+
``remediation`` (which can turn a block into a commit) are rejected as
|
|
16
|
+
configuration errors rather than silently ignored.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from commitguard.config.schema import CommitGuardConfig
|
|
20
|
+
from commitguard.core.decision import Action
|
|
21
|
+
from commitguard.policies.defaults import DEFAULT_POLICIES
|
|
22
|
+
from commitguard.policies.model import PolicySet
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_mandatory_config(config: CommitGuardConfig) -> None:
|
|
26
|
+
"""Raise ``ValueError`` if ``config`` tries to weaken rather than enforce."""
|
|
27
|
+
disabled = sorted(pid for pid, o in config.policies.items() if o.enabled is False)
|
|
28
|
+
if disabled:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"a mandatory policy can only enforce policies; enabled: false is not allowed for "
|
|
31
|
+
+ ", ".join(disabled)
|
|
32
|
+
)
|
|
33
|
+
if config.enforcement.model_fields_set:
|
|
34
|
+
raise ValueError("a mandatory policy cannot configure local hook enforcement")
|
|
35
|
+
if config.remediation.model_fields_set:
|
|
36
|
+
# auto_remove turns a block into a commit. A floor may only make
|
|
37
|
+
# enforcement stricter, so it must never be able to switch that on.
|
|
38
|
+
raise ValueError("a mandatory policy cannot configure remediation")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def apply_mandatory_policies(policies: PolicySet, mandatory: CommitGuardConfig) -> PolicySet:
|
|
42
|
+
"""Return ``policies`` with the mandatory floor applied (never weaker)."""
|
|
43
|
+
validate_mandatory_config(mandatory)
|
|
44
|
+
effective = dict(policies)
|
|
45
|
+
for policy_id, override in mandatory.policies.items():
|
|
46
|
+
current = effective[policy_id]
|
|
47
|
+
floor = override.action or DEFAULT_POLICIES[policy_id].action
|
|
48
|
+
action = current.action if current.enabled else Action.ALLOW
|
|
49
|
+
effective[policy_id] = current.model_copy(
|
|
50
|
+
update={"enabled": True, "action": Action.most_restrictive([action, floor])}
|
|
51
|
+
)
|
|
52
|
+
return PolicySet(effective)
|