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,105 @@
|
|
|
1
|
+
"""Configuration schema.
|
|
2
|
+
|
|
3
|
+
Every model forbids extra keys and uses strict scalar types, so that e.g.
|
|
4
|
+
``enabled: "no"`` or a misspelled ``acton: allow`` is an error instead of a
|
|
5
|
+
silent change in security behaviour.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, field_validator
|
|
11
|
+
|
|
12
|
+
from commitguard.core.decision import Action
|
|
13
|
+
from commitguard.policies.defaults import KNOWN_POLICY_IDS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PolicyOverride(BaseModel):
|
|
17
|
+
"""Repository override for one built-in policy. Unset fields keep defaults."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
20
|
+
|
|
21
|
+
enabled: StrictBool | None = None
|
|
22
|
+
action: Action | None = None
|
|
23
|
+
|
|
24
|
+
@field_validator("enabled", "action", mode="before")
|
|
25
|
+
@classmethod
|
|
26
|
+
def _reject_explicit_null(cls, value: object) -> object:
|
|
27
|
+
# ``action:`` with no value in YAML is null; treat it as a mistake.
|
|
28
|
+
if value is None:
|
|
29
|
+
raise ValueError("must not be null; remove the key to use the default")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EnforcementOverride(BaseModel):
|
|
34
|
+
"""Which Git hooks enforce policy. Unset fields keep the value from lower layers.
|
|
35
|
+
|
|
36
|
+
Disabling a hook never changes *policies*; it only stops that hook from
|
|
37
|
+
running the analysis, and ``commitguard doctor`` reports enforcement as
|
|
38
|
+
incomplete.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
42
|
+
|
|
43
|
+
pre_commit: StrictBool | None = None
|
|
44
|
+
commit_msg: StrictBool | None = None
|
|
45
|
+
pre_push: StrictBool | None = None
|
|
46
|
+
max_push_commits: StrictInt | None = Field(default=None, ge=1, le=1_000_000)
|
|
47
|
+
|
|
48
|
+
@field_validator("pre_commit", "commit_msg", "pre_push", "max_push_commits", mode="before")
|
|
49
|
+
@classmethod
|
|
50
|
+
def _reject_explicit_null(cls, value: object) -> object:
|
|
51
|
+
if value is None:
|
|
52
|
+
raise ValueError("must not be null; remove the key to use the default")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RemediationOverride(BaseModel):
|
|
57
|
+
"""What CommitGuard may do about a violation, beyond reporting it.
|
|
58
|
+
|
|
59
|
+
``auto_remove`` lets the ``commit-msg`` hook delete the offending lines from
|
|
60
|
+
the pending message instead of refusing the commit. It only ever applies
|
|
61
|
+
when *every* blocking finding is a message line: attribution carried by the
|
|
62
|
+
author or committer identity cannot be fixed by editing text, and still
|
|
63
|
+
blocks. The stripped message is re-analysed, and the commit proceeds only if
|
|
64
|
+
it is then clean.
|
|
65
|
+
|
|
66
|
+
It is off by default, because it edits what the developer wrote.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
70
|
+
|
|
71
|
+
auto_remove: StrictBool | None = None
|
|
72
|
+
|
|
73
|
+
@field_validator("auto_remove", mode="before")
|
|
74
|
+
@classmethod
|
|
75
|
+
def _reject_explicit_null(cls, value: object) -> object:
|
|
76
|
+
if value is None:
|
|
77
|
+
raise ValueError("must not be null; remove the key to use the default")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CommitGuardConfig(BaseModel):
|
|
82
|
+
"""Top-level ``.commitguard.yaml`` document."""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
85
|
+
|
|
86
|
+
version: Literal[1]
|
|
87
|
+
policies: dict[str, PolicyOverride] = {}
|
|
88
|
+
enforcement: EnforcementOverride = EnforcementOverride()
|
|
89
|
+
remediation: RemediationOverride = RemediationOverride()
|
|
90
|
+
|
|
91
|
+
@field_validator("version", mode="before")
|
|
92
|
+
@classmethod
|
|
93
|
+
def _strict_version(cls, value: object) -> object:
|
|
94
|
+
if type(value) is not int:
|
|
95
|
+
raise ValueError("version must be the integer 1")
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
@field_validator("policies")
|
|
99
|
+
@classmethod
|
|
100
|
+
def _known_policies(cls, value: dict[str, PolicyOverride]) -> dict[str, PolicyOverride]:
|
|
101
|
+
unknown = sorted(set(value) - KNOWN_POLICY_IDS)
|
|
102
|
+
if unknown:
|
|
103
|
+
known = ", ".join(sorted(KNOWN_POLICY_IDS))
|
|
104
|
+
raise ValueError(f"unknown policy id(s): {', '.join(unknown)} (known: {known})")
|
|
105
|
+
return value
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Policy sources: where the policy that evaluates a change comes from.
|
|
2
|
+
|
|
3
|
+
The source of a policy is a security decision. A pull request that edits
|
|
4
|
+
``.commitguard.yaml`` to ``action: allow`` must not be evaluated with the policy
|
|
5
|
+
it introduces, otherwise it could approve itself.
|
|
6
|
+
|
|
7
|
+
================= ======================================= ======================
|
|
8
|
+
Kind Configuration read from Used by
|
|
9
|
+
================= ======================================= ======================
|
|
10
|
+
``working_tree`` global config + work tree + ``--config`` local scan/check/hooks
|
|
11
|
+
``revision`` the tree of a *trusted* commit (never CI (PR base, push
|
|
12
|
+
the work tree); no global config ``before``, default
|
|
13
|
+
branch)
|
|
14
|
+
``builtin`` built-in secure defaults only CI when no trusted
|
|
15
|
+
commit exists
|
|
16
|
+
================= ======================================= ======================
|
|
17
|
+
|
|
18
|
+
On top of any source, a central service can apply a :class:`MandatoryPolicy`
|
|
19
|
+
(e.g. the operator of the GitHub App, later an organisation). It is a floor,
|
|
20
|
+
not a layer: repository configuration cannot weaken it (see
|
|
21
|
+
:mod:`commitguard.policies.mandatory`). The intended future hierarchy is::
|
|
22
|
+
|
|
23
|
+
global mandatory policy -> organisation mandatory policy
|
|
24
|
+
-> trusted repository configuration -> (local developer configuration)
|
|
25
|
+
|
|
26
|
+
where every mandatory level can only tighten what the levels below produce.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from enum import StrEnum
|
|
30
|
+
from pathlib import Path, PurePosixPath
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
33
|
+
|
|
34
|
+
from commitguard.config.defaults import CONFIG_FILENAMES, MAX_CONFIG_BYTES
|
|
35
|
+
from commitguard.config.loader import (
|
|
36
|
+
ConfigLayer,
|
|
37
|
+
ConfigSource,
|
|
38
|
+
LoadedConfig,
|
|
39
|
+
load_config,
|
|
40
|
+
load_effective_config,
|
|
41
|
+
parse_config,
|
|
42
|
+
)
|
|
43
|
+
from commitguard.config.schema import CommitGuardConfig
|
|
44
|
+
from commitguard.exceptions.base import UnsafeInputError
|
|
45
|
+
from commitguard.exceptions.configuration import ConfigurationError
|
|
46
|
+
from commitguard.git.repository import Repository
|
|
47
|
+
from commitguard.policies.mandatory import validate_mandatory_config
|
|
48
|
+
from commitguard.security.hashing import sha256_hex
|
|
49
|
+
from commitguard.security.validation import validate_git_sha, validate_repository_path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PolicySourceKind(StrEnum):
|
|
53
|
+
WORKING_TREE = "working_tree"
|
|
54
|
+
REVISION = "revision"
|
|
55
|
+
BUILTIN = "builtin"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PolicySource(BaseModel):
|
|
59
|
+
"""A description of where policy is loaded from, shown in every report."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
62
|
+
|
|
63
|
+
kind: PolicySourceKind
|
|
64
|
+
revision: str | None = None
|
|
65
|
+
description: str
|
|
66
|
+
config_path: str | None = None # repository-relative; None = .commitguard.yaml/.yml
|
|
67
|
+
|
|
68
|
+
@field_validator("revision")
|
|
69
|
+
@classmethod
|
|
70
|
+
def _valid_revision(cls, value: str | None) -> str | None:
|
|
71
|
+
return None if value is None else validate_git_sha(value)
|
|
72
|
+
|
|
73
|
+
@field_validator("config_path")
|
|
74
|
+
@classmethod
|
|
75
|
+
def _valid_path(cls, value: str | None) -> str | None:
|
|
76
|
+
return None if value is None else validate_repository_path(value)
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str:
|
|
79
|
+
if self.kind is PolicySourceKind.REVISION and self.revision:
|
|
80
|
+
return f"{self.description} ({self.revision[:12]})"
|
|
81
|
+
return self.description
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MandatoryPolicy(BaseModel):
|
|
85
|
+
"""A policy floor applied after all configuration layers."""
|
|
86
|
+
|
|
87
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
88
|
+
|
|
89
|
+
config: CommitGuardConfig
|
|
90
|
+
description: str
|
|
91
|
+
fingerprint: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_mandatory_policy(path: Path, *, description: str | None = None) -> MandatoryPolicy:
|
|
95
|
+
"""Load and validate a mandatory policy file (same schema as ``.commitguard.yaml``)."""
|
|
96
|
+
config = load_config(path)
|
|
97
|
+
try:
|
|
98
|
+
validate_mandatory_config(config)
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
raise ConfigurationError(str(exc), path=path) from None
|
|
101
|
+
canonical = config.model_dump_json(exclude_unset=True)
|
|
102
|
+
return MandatoryPolicy(
|
|
103
|
+
config=config,
|
|
104
|
+
description=description or path.name,
|
|
105
|
+
fingerprint=sha256_hex(canonical.encode("utf-8")),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_config_at_revision(
|
|
110
|
+
repository: Repository, revision: str, *, config_path: str | None = None
|
|
111
|
+
) -> LoadedConfig:
|
|
112
|
+
"""Built-in defaults + the repository configuration stored in ``revision``.
|
|
113
|
+
|
|
114
|
+
Nothing is read from the work tree or the user's global configuration. With
|
|
115
|
+
``config_path`` the file must exist at that revision; otherwise the usual
|
|
116
|
+
names are looked up and a missing file means built-in defaults.
|
|
117
|
+
"""
|
|
118
|
+
layers: list[tuple[ConfigSource, CommitGuardConfig]] = [
|
|
119
|
+
(ConfigSource(layer=ConfigLayer.BUILTIN), CommitGuardConfig(version=1))
|
|
120
|
+
]
|
|
121
|
+
candidates = [config_path] if config_path else list(CONFIG_FILENAMES)
|
|
122
|
+
found: list[tuple[str, bytes]] = []
|
|
123
|
+
for name in candidates:
|
|
124
|
+
try:
|
|
125
|
+
data = repository.read_blob_at(revision, name, max_bytes=MAX_CONFIG_BYTES)
|
|
126
|
+
except UnsafeInputError as exc:
|
|
127
|
+
raise ConfigurationError(f"{exc} (trusted revision {revision[:12]})") from exc
|
|
128
|
+
if data is not None:
|
|
129
|
+
found.append((name, data))
|
|
130
|
+
if config_path and not found:
|
|
131
|
+
raise ConfigurationError(
|
|
132
|
+
f"configuration file {config_path} does not exist at trusted revision {revision[:12]}"
|
|
133
|
+
)
|
|
134
|
+
if len(found) > 1:
|
|
135
|
+
raise ConfigurationError(
|
|
136
|
+
f"multiple configuration files at {revision[:12]}: "
|
|
137
|
+
+ ", ".join(name for name, _ in found)
|
|
138
|
+
)
|
|
139
|
+
if found:
|
|
140
|
+
name, data = found[0]
|
|
141
|
+
display = Path(PurePosixPath(name))
|
|
142
|
+
try:
|
|
143
|
+
text = data.decode("utf-8")
|
|
144
|
+
except UnicodeDecodeError as exc:
|
|
145
|
+
raise ConfigurationError(
|
|
146
|
+
f"not valid UTF-8 text (trusted revision {revision[:12]})", path=display
|
|
147
|
+
) from exc
|
|
148
|
+
config = parse_config(text, path=display)
|
|
149
|
+
source = ConfigSource(layer=ConfigLayer.REPOSITORY, path=display, revision=revision)
|
|
150
|
+
layers.append((source, config))
|
|
151
|
+
return LoadedConfig(layers=tuple(layers))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_policy_source(repository: Repository, source: PolicySource) -> LoadedConfig:
|
|
155
|
+
if source.kind is PolicySourceKind.REVISION:
|
|
156
|
+
if source.revision is None:
|
|
157
|
+
raise ConfigurationError("revision policy source without a revision")
|
|
158
|
+
return load_config_at_revision(repository, source.revision, config_path=source.config_path)
|
|
159
|
+
if source.kind is PolicySourceKind.BUILTIN:
|
|
160
|
+
if source.config_path:
|
|
161
|
+
raise ConfigurationError(
|
|
162
|
+
f"configuration file {source.config_path} requested but no trusted revision exists"
|
|
163
|
+
)
|
|
164
|
+
return LoadedConfig(
|
|
165
|
+
layers=((ConfigSource(layer=ConfigLayer.BUILTIN), CommitGuardConfig(version=1)),)
|
|
166
|
+
)
|
|
167
|
+
explicit = repository.root / source.config_path if source.config_path else None
|
|
168
|
+
return load_effective_config(repository.root, explicit_path=explicit)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def config_differs(repository: Repository, trusted: str, head: str) -> list[str]:
|
|
172
|
+
"""Configuration file names whose content differs between two commits."""
|
|
173
|
+
changed = []
|
|
174
|
+
for name in CONFIG_FILENAMES:
|
|
175
|
+
try:
|
|
176
|
+
before = repository.read_blob_at(trusted, name, max_bytes=MAX_CONFIG_BYTES)
|
|
177
|
+
after = repository.read_blob_at(head, name, max_bytes=MAX_CONFIG_BYTES)
|
|
178
|
+
except UnsafeInputError:
|
|
179
|
+
changed.append(name) # e.g. replaced by a symlink or oversized: report it
|
|
180
|
+
continue
|
|
181
|
+
if before != after:
|
|
182
|
+
changed.append(name)
|
|
183
|
+
return changed
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The CommitGuard control plane: what the dashboard API exposes.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
CommitGuard core (detection, policy) -> ScanService -> ScanResult
|
|
6
|
+
| |
|
|
7
|
+
| ScanResultRecorder (findings, violation lifecycle)
|
|
8
|
+
v v
|
|
9
|
+
OrganizationPolicyService ---- mandatory floor ----> state database
|
|
10
|
+
^ ^
|
|
11
|
+
| |
|
|
12
|
+
dashboard API routes -> authentication -> authorization -> query/command services
|
|
13
|
+
|
|
14
|
+
Nothing here detects attribution or evaluates policy. Scans are produced by
|
|
15
|
+
the same :class:`~commitguard.services.scan.ScanService` the GitHub Action and
|
|
16
|
+
the GitHub App use; this package stores their results, tracks whether each
|
|
17
|
+
violation is still present, versions organisation policy, and answers
|
|
18
|
+
questions about that data for one authorised principal at a time.
|
|
19
|
+
|
|
20
|
+
Tenant isolation is structural: every read takes an
|
|
21
|
+
:class:`~commitguard.controlplane.access.AccessScope` built from the signed-in
|
|
22
|
+
session, and every query filters by the installations and repositories in that
|
|
23
|
+
scope.
|
|
24
|
+
"""
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Roles, permissions and the access scope of a signed-in user.
|
|
2
|
+
|
|
3
|
+
Tenant model
|
|
4
|
+
============
|
|
5
|
+
|
|
6
|
+
* A **tenant** (organisation) is a GitHub account - an organisation or a user -
|
|
7
|
+
identified by its immutable numeric account ID. It owns one GitHub App
|
|
8
|
+
installation (GitHub allows one per account), whose repositories, scans,
|
|
9
|
+
violations and audit events belong to it.
|
|
10
|
+
* A **member** is a GitHub user with a CommitGuard role in that tenant. Roles
|
|
11
|
+
are granted in CommitGuard (``commitguard dashboard members grant`` or by an
|
|
12
|
+
owner in the dashboard); GitHub does not grant them.
|
|
13
|
+
* Access needs both: a role in CommitGuard *and* access in GitHub. At sign-in
|
|
14
|
+
GitHub reports which installations and repositories the user can see
|
|
15
|
+
(``GET /user/installations``, ``GET /user/installations/{id}/repositories``);
|
|
16
|
+
CommitGuard stores that list with the session and never shows a repository
|
|
17
|
+
the user could not open on GitHub, whatever their CommitGuard role.
|
|
18
|
+
|
|
19
|
+
Roles
|
|
20
|
+
=====
|
|
21
|
+
|
|
22
|
+
================== =========================================================
|
|
23
|
+
Role Adds
|
|
24
|
+
================== =========================================================
|
|
25
|
+
``viewer`` read repositories, scans, violations, policies, rules,
|
|
26
|
+
exceptions, the organization and its security posture;
|
|
27
|
+
receive in-app notifications about them
|
|
28
|
+
``security_manager`` acknowledge violations, request re-scans, read the audit log,
|
|
29
|
+
request policy exceptions
|
|
30
|
+
``admin`` change, publish, approve and roll back policy, manage
|
|
31
|
+
repository groups, onboarding, rollouts, scan schedules,
|
|
32
|
+
organization rules and settings, approve and revoke
|
|
33
|
+
exceptions, stop/resume monitoring a repository, refresh
|
|
34
|
+
enforcement status, sync installation repositories, list
|
|
35
|
+
members, manage organisation notification settings and webhooks
|
|
36
|
+
``owner`` grant, change and remove member roles; emergency policy
|
|
37
|
+
publication (bypassing approval, always audited)
|
|
38
|
+
================== =========================================================
|
|
39
|
+
|
|
40
|
+
Each role includes every permission of the roles above it. The owner of a
|
|
41
|
+
personal (user-account) installation is always its ``owner``.
|
|
42
|
+
|
|
43
|
+
Authorization is by permission, never by role name: routes and services check
|
|
44
|
+
a :class:`Permission`, and the role table below is the only place that maps
|
|
45
|
+
roles to permissions. There is no separate ``member`` role: ``viewer`` is the
|
|
46
|
+
least-privileged member. Separation of duties (a policy change approved by
|
|
47
|
+
someone other than its author) is enforced by the policy workflow, not by roles.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
import json
|
|
51
|
+
from collections.abc import Iterable, Mapping
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
from datetime import datetime
|
|
54
|
+
from enum import StrEnum
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Permission(StrEnum):
|
|
58
|
+
REPOSITORIES_READ = "repositories:read"
|
|
59
|
+
REPOSITORIES_MANAGE = "repositories:manage"
|
|
60
|
+
SCANS_READ = "scans:read"
|
|
61
|
+
SCANS_TRIGGER = "scans:trigger"
|
|
62
|
+
VIOLATIONS_READ = "violations:read"
|
|
63
|
+
VIOLATIONS_MANAGE = "violations:manage"
|
|
64
|
+
POLICIES_READ = "policies:read"
|
|
65
|
+
POLICIES_WRITE = "policies:write"
|
|
66
|
+
POLICIES_ROLLBACK = "policies:rollback"
|
|
67
|
+
RULES_READ = "rules:read"
|
|
68
|
+
AUDIT_READ = "audit:read"
|
|
69
|
+
GITHUB_MANAGE = "github:manage"
|
|
70
|
+
MEMBERS_READ = "members:read"
|
|
71
|
+
MEMBERS_MANAGE = "members:manage"
|
|
72
|
+
NOTIFICATIONS_READ = "notifications:read"
|
|
73
|
+
NOTIFICATIONS_MANAGE = "notifications:manage"
|
|
74
|
+
# Phase 8: organization governance.
|
|
75
|
+
ORGANIZATION_READ = "organization:read"
|
|
76
|
+
ORGANIZATION_MANAGE = "organization:manage"
|
|
77
|
+
POLICIES_PUBLISH = "policies:publish"
|
|
78
|
+
POLICIES_APPROVE = "policies:approve"
|
|
79
|
+
POLICIES_EMERGENCY = "policies:emergency"
|
|
80
|
+
RULES_MANAGE = "rules:manage"
|
|
81
|
+
EXCEPTIONS_READ = "exceptions:read"
|
|
82
|
+
EXCEPTIONS_CREATE = "exceptions:create"
|
|
83
|
+
EXCEPTIONS_APPROVE = "exceptions:approve"
|
|
84
|
+
EXCEPTIONS_REVOKE = "exceptions:revoke"
|
|
85
|
+
SECURITY_READ = "security:read"
|
|
86
|
+
SECURITY_MANAGE = "security:manage"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Role(StrEnum):
|
|
90
|
+
VIEWER = "viewer"
|
|
91
|
+
SECURITY_MANAGER = "security_manager"
|
|
92
|
+
ADMIN = "admin"
|
|
93
|
+
OWNER = "owner"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def rank(self) -> int:
|
|
97
|
+
return list(Role).index(self)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def permissions(self) -> frozenset[Permission]:
|
|
101
|
+
return ROLE_PERMISSIONS[self]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_VIEWER = frozenset(
|
|
105
|
+
{
|
|
106
|
+
Permission.REPOSITORIES_READ,
|
|
107
|
+
Permission.SCANS_READ,
|
|
108
|
+
Permission.VIOLATIONS_READ,
|
|
109
|
+
Permission.POLICIES_READ,
|
|
110
|
+
Permission.RULES_READ,
|
|
111
|
+
Permission.NOTIFICATIONS_READ,
|
|
112
|
+
Permission.ORGANIZATION_READ,
|
|
113
|
+
Permission.EXCEPTIONS_READ,
|
|
114
|
+
Permission.SECURITY_READ,
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
_SECURITY_MANAGER = _VIEWER | {
|
|
118
|
+
Permission.VIOLATIONS_MANAGE,
|
|
119
|
+
Permission.SCANS_TRIGGER,
|
|
120
|
+
Permission.AUDIT_READ,
|
|
121
|
+
Permission.EXCEPTIONS_CREATE,
|
|
122
|
+
}
|
|
123
|
+
_ADMIN = _SECURITY_MANAGER | {
|
|
124
|
+
Permission.POLICIES_WRITE,
|
|
125
|
+
Permission.POLICIES_ROLLBACK,
|
|
126
|
+
Permission.POLICIES_PUBLISH,
|
|
127
|
+
Permission.POLICIES_APPROVE,
|
|
128
|
+
Permission.NOTIFICATIONS_MANAGE,
|
|
129
|
+
Permission.REPOSITORIES_MANAGE,
|
|
130
|
+
Permission.GITHUB_MANAGE,
|
|
131
|
+
Permission.MEMBERS_READ,
|
|
132
|
+
Permission.ORGANIZATION_MANAGE,
|
|
133
|
+
Permission.RULES_MANAGE,
|
|
134
|
+
Permission.EXCEPTIONS_APPROVE,
|
|
135
|
+
Permission.EXCEPTIONS_REVOKE,
|
|
136
|
+
Permission.SECURITY_MANAGE,
|
|
137
|
+
}
|
|
138
|
+
_OWNER = _ADMIN | {Permission.MEMBERS_MANAGE, Permission.POLICIES_EMERGENCY}
|
|
139
|
+
|
|
140
|
+
ROLE_PERMISSIONS: Mapping[Role, frozenset[Permission]] = {
|
|
141
|
+
Role.VIEWER: _VIEWER,
|
|
142
|
+
Role.SECURITY_MANAGER: frozenset(_SECURITY_MANAGER),
|
|
143
|
+
Role.ADMIN: frozenset(_ADMIN),
|
|
144
|
+
Role.OWNER: frozenset(_OWNER),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class AccessScope:
|
|
150
|
+
"""What one session may see for one permission.
|
|
151
|
+
|
|
152
|
+
``installation_ids`` are installations whose account grants the permission
|
|
153
|
+
to the user *and* that GitHub reported as accessible at sign-in.
|
|
154
|
+
``session_hash`` keys the per-session list of repositories GitHub reported;
|
|
155
|
+
repository-level rows are only visible when listed there.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
session_hash: str
|
|
159
|
+
installation_ids: tuple[int, ...]
|
|
160
|
+
account_ids: tuple[int, ...]
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def empty(self) -> bool:
|
|
164
|
+
return not self.installation_ids and not self.account_ids
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def installations_json(self) -> str:
|
|
168
|
+
"""The installation IDs as a JSON array, bound to ``json_each(?)`` in queries."""
|
|
169
|
+
return json.dumps(list(self.installation_ids))
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def accounts_json(self) -> str:
|
|
173
|
+
return json.dumps(list(self.account_ids))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, slots=True)
|
|
177
|
+
class Membership:
|
|
178
|
+
account_id: int
|
|
179
|
+
account_login: str
|
|
180
|
+
account_type: str
|
|
181
|
+
role: Role
|
|
182
|
+
implicit: bool = False # owner of a personal installation
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class Principal:
|
|
187
|
+
"""A signed-in user, as established by the session for one request."""
|
|
188
|
+
|
|
189
|
+
user_id: int
|
|
190
|
+
login: str
|
|
191
|
+
session_hash: str
|
|
192
|
+
session_public_id: str
|
|
193
|
+
authenticated_at: datetime
|
|
194
|
+
expires_at: datetime
|
|
195
|
+
memberships: Mapping[int, Membership] = field(default_factory=dict)
|
|
196
|
+
# installation_id -> account_id, for installations GitHub reported at sign-in
|
|
197
|
+
installations: Mapping[int, int] = field(default_factory=dict)
|
|
198
|
+
|
|
199
|
+
def role_in(self, account_id: int) -> Role | None:
|
|
200
|
+
membership = self.memberships.get(account_id)
|
|
201
|
+
return membership.role if membership else None
|
|
202
|
+
|
|
203
|
+
def can(self, permission: Permission, account_id: int) -> bool:
|
|
204
|
+
role = self.role_in(account_id)
|
|
205
|
+
return role is not None and permission in role.permissions
|
|
206
|
+
|
|
207
|
+
def accounts_with(self, permission: Permission) -> tuple[int, ...]:
|
|
208
|
+
return tuple(
|
|
209
|
+
sorted(a for a, m in self.memberships.items() if permission in m.role.permissions)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def scope(self, permission: Permission, *, account_id: int | None = None) -> AccessScope:
|
|
213
|
+
accounts = set(self.accounts_with(permission))
|
|
214
|
+
if account_id is not None:
|
|
215
|
+
accounts &= {account_id}
|
|
216
|
+
installations = tuple(
|
|
217
|
+
sorted(i for i, account in self.installations.items() if account in accounts)
|
|
218
|
+
)
|
|
219
|
+
return AccessScope(
|
|
220
|
+
session_hash=self.session_hash,
|
|
221
|
+
installation_ids=installations,
|
|
222
|
+
account_ids=tuple(sorted(accounts)),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def permissions_for(self, account_id: int) -> frozenset[Permission]:
|
|
226
|
+
role = self.role_in(account_id)
|
|
227
|
+
return role.permissions if role else frozenset()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def highest_role(roles: Iterable[Role]) -> Role | None:
|
|
231
|
+
return max(roles, key=lambda role: role.rank, default=None)
|