gitpro 0.3.1__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.
- gitpro/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
gitpro/policy.py
ADDED
|
@@ -0,0 +1,1808 @@
|
|
|
1
|
+
"""Team policy (spec 14): an org's policy TOML (allowed email domains per remote owner, signing, trailer
|
|
2
|
+
rules), checked on this machine (`gitpro check --policy`, `gitpro team`), in the guard's hooks
|
|
3
|
+
(`hook_commit`, `hook_push`, `hook_message`, named by guard.HOOKS) and in CI (`gitpro check --policy FILE
|
|
4
|
+
--range A..B`, wrapped by the composite action.yml at the repo root).
|
|
5
|
+
|
|
6
|
+
Hot path: the guard's hooks import this module, so it imports only the standard library, gitpro.config,
|
|
7
|
+
gitpro.trailers and gitpro.resolve (loaded already by the guard; needed for resolve.EXTRA_CHECKS) at
|
|
8
|
+
module level. audit, guard, health, forge and agents are imported inside functions.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import posixpath
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass, replace
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
from . import config, resolve, trailers
|
|
24
|
+
from .config import ConfigError
|
|
25
|
+
|
|
26
|
+
PROG = "gitpro"
|
|
27
|
+
BLOCK = 3 # guard.BLOCK
|
|
28
|
+
GIT_TIMEOUT = 5 # guard.GIT_TIMEOUT: every git call inside a hook
|
|
29
|
+
RANGE_TIMEOUT = 120 # --range: one git log over a PR's commits (signature checks included)
|
|
30
|
+
REPO_POLICY = ".gitpro-policy.toml"
|
|
31
|
+
FORMATS = ("ssh", "gpg", "x509")
|
|
32
|
+
TOP_KEYS = ("policy", "rule")
|
|
33
|
+
POLICY_KEYS = ("name", "version", "contact", "allowed_signers")
|
|
34
|
+
RULE_KEYS = ("host", "owners", "allowed_email_domains", "allowed_emails", "require_signing", "signing_formats",
|
|
35
|
+
"co_authored_by", "forbid_emails", "require_verified_email")
|
|
36
|
+
VERSION = 1 # the newest policy format this gitpro understands
|
|
37
|
+
DOMAIN_RE = re.compile(r"^[A-Za-z0-9.-]+$")
|
|
38
|
+
REL_PATH_RE = re.compile(r"^[A-Za-z0-9._@+-][A-Za-z0-9._/@+-]*$")
|
|
39
|
+
SIGNED = ("G", "U", "X", "Y", "E", "R")
|
|
40
|
+
WARNINGS = {"X": "expired", "Y": "expired key", "E": "unverifiable here"}
|
|
41
|
+
GOOD = {"G": "good signature", "U": "good signature, unknown validity"}
|
|
42
|
+
_RANK = {"allow": 0, "require": 1, "forbid": 2} # rules disagree: forbid wins over require over allow
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------- data
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Rule:
|
|
49
|
+
host: str
|
|
50
|
+
owners: Tuple[str, ...] = ()
|
|
51
|
+
allowed_email_domains: Tuple[str, ...] = ()
|
|
52
|
+
allowed_emails: Tuple[str, ...] = ()
|
|
53
|
+
require_signing: bool = False
|
|
54
|
+
signing_formats: Tuple[str, ...] = ()
|
|
55
|
+
co_authored_by: str = "allow"
|
|
56
|
+
forbid_emails: Tuple[str, ...] = ()
|
|
57
|
+
require_verified_email: bool = False
|
|
58
|
+
index: int = 1 # position in the file, from 1
|
|
59
|
+
|
|
60
|
+
def to_json(self) -> dict:
|
|
61
|
+
return {"index": self.index, "host": self.host, "owners": list(self.owners),
|
|
62
|
+
"allowed_email_domains": list(self.allowed_email_domains),
|
|
63
|
+
"allowed_emails": list(self.allowed_emails), "require_signing": self.require_signing,
|
|
64
|
+
"signing_formats": list(self.signing_formats), "co_authored_by": self.co_authored_by,
|
|
65
|
+
"forbid_emails": list(self.forbid_emails), "require_verified_email": self.require_verified_email}
|
|
66
|
+
|
|
67
|
+
def emails(self) -> bool:
|
|
68
|
+
return bool(self.allowed_email_domains or self.allowed_emails)
|
|
69
|
+
|
|
70
|
+
def signing(self) -> bool:
|
|
71
|
+
return self.require_signing or bool(self.signing_formats)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Policy:
|
|
76
|
+
name: str
|
|
77
|
+
path: str # the file, ~ expanded
|
|
78
|
+
rules: Tuple[Rule, ...]
|
|
79
|
+
version: int = 1
|
|
80
|
+
contact: Optional[str] = None
|
|
81
|
+
allowed_signers: Optional[str] = None # absolute path of the ssh allowed_signers file it ships
|
|
82
|
+
|
|
83
|
+
def cause(self) -> str:
|
|
84
|
+
return f"policy: {self.name}" + (f" ({self.contact})" if self.contact else "")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Found = List[Tuple[Policy, List[Rule]]] # policies that apply, each with the rules that match
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- loading and validation
|
|
91
|
+
|
|
92
|
+
_CACHE: Dict[str, Tuple[Tuple[int, int], Policy]] = {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _tilde(path: object) -> str:
|
|
96
|
+
return resolve._tilde(str(path))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _strings(v: object, where: str, key: str, errs: List[str], pattern: Optional["re.Pattern[str]"] = None,
|
|
100
|
+
allowed: Sequence[str] = ()) -> Tuple[str, ...]:
|
|
101
|
+
if v is None:
|
|
102
|
+
return ()
|
|
103
|
+
if not isinstance(v, list) or not all(isinstance(s, str) and s for s in v):
|
|
104
|
+
errs.append(f"{where}: {key} must be a list of strings")
|
|
105
|
+
return ()
|
|
106
|
+
for s in v:
|
|
107
|
+
if config.CONTROL_RE.search(s):
|
|
108
|
+
errs.append(f"{where}: {key} contains a control character")
|
|
109
|
+
elif pattern is not None and not pattern.match(s):
|
|
110
|
+
errs.append(f"{where}: {key} has an invalid entry {s!r}")
|
|
111
|
+
elif allowed and s not in allowed:
|
|
112
|
+
errs.append(f"{where}: {key} has {s!r} (allowed: {', '.join(allowed)})")
|
|
113
|
+
return tuple(v)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _bool(v: object, where: str, key: str, errs: List[str]) -> bool:
|
|
117
|
+
if v is None:
|
|
118
|
+
return False
|
|
119
|
+
if not isinstance(v, bool):
|
|
120
|
+
errs.append(f"{where}: {key} must be true or false")
|
|
121
|
+
return False
|
|
122
|
+
return v
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _text(v: object, where: str, key: str, errs: List[str]) -> Optional[str]:
|
|
126
|
+
if v is None:
|
|
127
|
+
return None
|
|
128
|
+
if not isinstance(v, str) or not v.strip() or config.CONTROL_RE.search(v):
|
|
129
|
+
errs.append(f"{where}: {key} must be a non-empty string without control characters")
|
|
130
|
+
return None
|
|
131
|
+
return v
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _unknown(table: dict, allowed: Sequence[str], where: str, errs: List[str]) -> None:
|
|
135
|
+
extra = sorted(set(table) - set(allowed))
|
|
136
|
+
if extra:
|
|
137
|
+
errs.append(f"{where}: unknown key(s) {', '.join(extra)} (allowed: {', '.join(allowed)})")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _within(path: object, root: object) -> bool:
|
|
141
|
+
"""Does path, symlinks resolved, stay inside root (symlinks resolved)?"""
|
|
142
|
+
real, top = os.path.realpath(str(path)), os.path.realpath(str(root))
|
|
143
|
+
return real == top or real.startswith(top.rstrip(os.sep) + os.sep)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _signers(v: object, path: Path, errs: List[str], *, base: Optional[Path] = None,
|
|
147
|
+
inside: Optional[Path] = None) -> Optional[str]:
|
|
148
|
+
"""The allowed_signers file: relative to base (default: the policy's directory), or ~/... or /...
|
|
149
|
+
With inside (a policy that comes from a repo), it must be relative and stay inside that directory."""
|
|
150
|
+
if v is None:
|
|
151
|
+
return None
|
|
152
|
+
ok = isinstance(v, str) and (config.PATH_RE.match(v) or REL_PATH_RE.match(v)) and ".." not in v.split("/")
|
|
153
|
+
if not ok:
|
|
154
|
+
errs.append(f"{path}: [policy]: allowed_signers must be a path relative to the policy file, or start "
|
|
155
|
+
f"with ~/ or /")
|
|
156
|
+
return None
|
|
157
|
+
assert isinstance(v, str)
|
|
158
|
+
if inside is not None and (v.startswith("~/") or v.startswith("/")):
|
|
159
|
+
errs.append(f"{path}: [policy]: allowed_signers must be a path inside the repo, relative to the policy file "
|
|
160
|
+
f"(got {v})")
|
|
161
|
+
return None
|
|
162
|
+
p = Path(os.path.expanduser(v)) if v.startswith("~/") else (
|
|
163
|
+
Path(v) if v.startswith("/") else (base or path.parent) / v)
|
|
164
|
+
if inside is not None and not _within(p, inside):
|
|
165
|
+
errs.append(f"{path}: [policy]: allowed_signers {v} leaves the repo (a symlink?); refused")
|
|
166
|
+
return None
|
|
167
|
+
if not p.is_file():
|
|
168
|
+
errs.append(f"{path}: allowed_signers {p} not found")
|
|
169
|
+
return str(p)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _rule(r: object, n: int, path: Path, errs: List[str]) -> Optional[Rule]:
|
|
173
|
+
where = f"{path}: [[rule]] {n}"
|
|
174
|
+
if not isinstance(r, dict):
|
|
175
|
+
errs.append(f"{where}: must be a table")
|
|
176
|
+
return None
|
|
177
|
+
_unknown(r, RULE_KEYS, where, errs)
|
|
178
|
+
host = r.get("host")
|
|
179
|
+
if not isinstance(host, str) or not config.HOST_RE.match(host):
|
|
180
|
+
errs.append(f"{where}: host must be a host name like github.com" if host is not None
|
|
181
|
+
else f"{where}: host is required")
|
|
182
|
+
host = ""
|
|
183
|
+
co = r.get("co_authored_by", "allow")
|
|
184
|
+
if co not in trailers.VALUES:
|
|
185
|
+
errs.append(f"{where}: co_authored_by must be one of {', '.join(trailers.VALUES)} (got {co!r})")
|
|
186
|
+
co = "allow"
|
|
187
|
+
return Rule(host=host,
|
|
188
|
+
owners=_strings(r.get("owners"), where, "owners", errs, config.OWNER_RE),
|
|
189
|
+
allowed_email_domains=_strings(r.get("allowed_email_domains"), where, "allowed_email_domains", errs,
|
|
190
|
+
DOMAIN_RE),
|
|
191
|
+
allowed_emails=_strings(r.get("allowed_emails"), where, "allowed_emails", errs),
|
|
192
|
+
require_signing=_bool(r.get("require_signing"), where, "require_signing", errs),
|
|
193
|
+
signing_formats=_strings(r.get("signing_formats"), where, "signing_formats", errs, allowed=FORMATS),
|
|
194
|
+
co_authored_by=co,
|
|
195
|
+
forbid_emails=_strings(r.get("forbid_emails"), where, "forbid_emails", errs),
|
|
196
|
+
require_verified_email=_bool(r.get("require_verified_email"), where, "require_verified_email", errs),
|
|
197
|
+
index=n)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse(data: dict, path: Path, *, signers_base: Optional[Path] = None,
|
|
201
|
+
inside: Optional[Path] = None) -> Tuple[Optional[Policy], List[str]]:
|
|
202
|
+
"""(Policy, []) for a valid policy table, else (None, every error as "<path>: <where>: <problem>").
|
|
203
|
+
signers_base: where a relative allowed_signers is looked up (default: path's directory); inside: the
|
|
204
|
+
repo a repo policy comes from (its allowed_signers must stay inside)."""
|
|
205
|
+
errs: List[str] = []
|
|
206
|
+
_unknown(data, TOP_KEYS, str(path), errs)
|
|
207
|
+
head = data.get("policy", {})
|
|
208
|
+
if not isinstance(head, dict):
|
|
209
|
+
errs.append(f"{path}: [policy]: must be a table")
|
|
210
|
+
head = {}
|
|
211
|
+
_unknown(head, POLICY_KEYS, f"{path}: [policy]", errs)
|
|
212
|
+
name = _text(head.get("name"), f"{path}: [policy]", "name", errs) or path.name
|
|
213
|
+
contact = _text(head.get("contact"), f"{path}: [policy]", "contact", errs)
|
|
214
|
+
version = head.get("version", VERSION)
|
|
215
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
|
216
|
+
errs.append(f"{path}: [policy]: version must be a whole number (1)")
|
|
217
|
+
version = VERSION
|
|
218
|
+
elif version > VERSION:
|
|
219
|
+
errs.append(f"{path}: [policy]: version {version} is newer than this gitpro understands ({VERSION}); "
|
|
220
|
+
f"upgrade gitpro")
|
|
221
|
+
signers = _signers(head.get("allowed_signers"), path, errs, base=signers_base, inside=inside)
|
|
222
|
+
raw = data.get("rule")
|
|
223
|
+
rules: List[Rule] = []
|
|
224
|
+
if raw is None:
|
|
225
|
+
errs.append(f"{path}: [[rule]]: at least one [[rule]] is required")
|
|
226
|
+
elif not isinstance(raw, list):
|
|
227
|
+
errs.append(f"{path}: rule: write rules as [[rule]]")
|
|
228
|
+
else:
|
|
229
|
+
for n, r in enumerate(raw, 1):
|
|
230
|
+
rule = _rule(r, n, path, errs)
|
|
231
|
+
if rule is not None:
|
|
232
|
+
rules.append(rule)
|
|
233
|
+
if errs:
|
|
234
|
+
return None, errs
|
|
235
|
+
return Policy(name, str(path), tuple(rules), version, contact, signers), []
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _toml(text: str, path: Path) -> dict:
|
|
239
|
+
"""config._load_toml, with the parser's own failures on hostile input (a TOML nested 100 000 deep
|
|
240
|
+
exhausts the recursion limit) as ConfigError too, so they exit 2 like any invalid policy."""
|
|
241
|
+
try:
|
|
242
|
+
return config._load_toml(text, path)
|
|
243
|
+
except (RecursionError, MemoryError):
|
|
244
|
+
raise ConfigError(f"{path}: invalid TOML: nested too deeply") from None
|
|
245
|
+
except (ValueError, TypeError) as e:
|
|
246
|
+
raise ConfigError(f"{path}: invalid TOML: {e}") from None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _read(path: Path, *, check_perms: bool, who: str) -> Tuple[os.stat_result, Path]:
|
|
250
|
+
try:
|
|
251
|
+
st = path.stat()
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
raise ConfigError(f"policy {_tilde(path)}: not found" + (f" ({who})" if who else "")) from None
|
|
254
|
+
except OSError as e:
|
|
255
|
+
raise ConfigError(f"policy {_tilde(path)}: {e.strerror or e}") from None
|
|
256
|
+
if check_perms:
|
|
257
|
+
config.check(st.st_uid == os.getuid() and not st.st_mode & 0o022,
|
|
258
|
+
f"{path}: must be owned by you and not group/world-writable (chmod 600 {path})")
|
|
259
|
+
return st, path
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def load(path: object, *, check_perms: bool = True, who: str = "", inside: Optional[Path] = None) -> Policy:
|
|
263
|
+
"""The validated policy in path; ConfigError when it is missing, invalid or (check_perms, local use)
|
|
264
|
+
group/world-writable or not the user's. Cached by (path, mtime_ns, size). who names the referrer
|
|
265
|
+
("identity work-github") in the not-found message. inside: a repo's own policy, which (and whose
|
|
266
|
+
allowed_signers) must not leave that directory through a symlink."""
|
|
267
|
+
p = Path(os.path.expanduser(str(path)))
|
|
268
|
+
if inside is not None and p.exists() and not _within(p, inside):
|
|
269
|
+
raise ConfigError(f"{p} leaves the repo (a symlink?); refused")
|
|
270
|
+
st, _ = _read(p, check_perms=check_perms, who=who)
|
|
271
|
+
key = (st.st_mtime_ns, st.st_size)
|
|
272
|
+
ckey = str(p) if inside is None else f"{p}\0{inside}"
|
|
273
|
+
hit = _CACHE.get(ckey)
|
|
274
|
+
if hit is not None and hit[0] == key:
|
|
275
|
+
return hit[1]
|
|
276
|
+
try:
|
|
277
|
+
text = p.read_text(encoding="utf-8")
|
|
278
|
+
except UnicodeError:
|
|
279
|
+
raise ConfigError(f"{p}: not UTF-8 text") from None
|
|
280
|
+
except OSError as e:
|
|
281
|
+
raise ConfigError(f"policy {_tilde(p)}: {e.strerror or e}") from None
|
|
282
|
+
data = _toml(text, p)
|
|
283
|
+
pol, errs = parse(data, p, inside=inside)
|
|
284
|
+
if pol is None:
|
|
285
|
+
raise ConfigError(errs[0])
|
|
286
|
+
_CACHE[ckey] = (key, pol)
|
|
287
|
+
return pol
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def lint(path: object) -> Tuple[Optional[Policy], List[str]]:
|
|
291
|
+
"""(policy, every error) for `policy lint`; no permission check, no cache."""
|
|
292
|
+
p = Path(os.path.expanduser(str(path)))
|
|
293
|
+
try:
|
|
294
|
+
text = p.read_text(encoding="utf-8")
|
|
295
|
+
except FileNotFoundError:
|
|
296
|
+
return None, [f"policy {_tilde(p)}: not found"]
|
|
297
|
+
except (OSError, UnicodeError) as e:
|
|
298
|
+
return None, [f"{p}: {e}"]
|
|
299
|
+
try:
|
|
300
|
+
data = _toml(text, p)
|
|
301
|
+
except ConfigError as e:
|
|
302
|
+
return None, [str(e)]
|
|
303
|
+
return parse(data, p)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------- which rules apply
|
|
307
|
+
|
|
308
|
+
def _glob(pattern: str, text: str) -> bool:
|
|
309
|
+
"""An owner glob, case-insensitive, with gitpro's wildmatch rules: "*", "?" and [...] stay inside one
|
|
310
|
+
path segment; "**" as a whole segment crosses "/"."""
|
|
311
|
+
return resolve.wildmatch(pattern.casefold(), text.casefold())
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _owner_ok(globs: Sequence[str], owner: Optional[str]) -> bool:
|
|
315
|
+
"""owner (maybe nested: group/sub) matches one of the globs, itself or through a parent group (a rule
|
|
316
|
+
for a GitLab group covers its subgroups). "*" never matches "/"."""
|
|
317
|
+
if not globs:
|
|
318
|
+
return True
|
|
319
|
+
if not owner:
|
|
320
|
+
return False
|
|
321
|
+
parts = owner.split("/")
|
|
322
|
+
prefixes = ["/".join(parts[:i]) for i in range(1, len(parts) + 1)]
|
|
323
|
+
return any(_glob(g, pre) for g in globs for pre in prefixes)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _where(url: str, cfg: object = None) -> Tuple[Optional[str], Optional[str]]:
|
|
327
|
+
"""(host, owner) of a remote url; an identity's ssh_alias host counts as the identity's real host."""
|
|
328
|
+
host, owner, _ = resolve.parse_url(url)
|
|
329
|
+
if host and cfg is not None:
|
|
330
|
+
for i in getattr(cfg, "identities", ()):
|
|
331
|
+
if i.ssh_alias and i.ssh_alias.casefold() == host.casefold():
|
|
332
|
+
host = i.host
|
|
333
|
+
return host, owner
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def rule_matches(rule: Rule, url: str, cfg: object = None) -> bool:
|
|
337
|
+
host, owner = _where(url, cfg)
|
|
338
|
+
return bool(host) and rule.host.casefold() == host.casefold() and _owner_ok(rule.owners, owner)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _overlaps(rule: Rule, ident: object) -> bool:
|
|
342
|
+
"""Could rule apply to a repo of this identity (same host, owners overlapping)?"""
|
|
343
|
+
if rule.host.casefold() != str(getattr(ident, "host", "")).casefold():
|
|
344
|
+
return False
|
|
345
|
+
owners = tuple(getattr(ident, "owners", ()) or ())
|
|
346
|
+
if not rule.owners or not owners:
|
|
347
|
+
return True
|
|
348
|
+
return any(_owner_ok(rule.owners, o) or any(_glob(o, g) for g in rule.owners) for o in owners)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def match(policies: Sequence[Policy], urls: Sequence[str], cfg: object = None) -> Found:
|
|
352
|
+
out: Found = []
|
|
353
|
+
for pol in policies:
|
|
354
|
+
rules = [r for r in pol.rules if any(rule_matches(r, u, cfg) for u in urls)]
|
|
355
|
+
if rules:
|
|
356
|
+
out.append((pol, rules))
|
|
357
|
+
return out
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def sources(cfg: object, identity: object = None, extra: Sequence[str] = ()) -> List[Tuple[str, str]]:
|
|
361
|
+
"""(path, who) of every policy file that may apply: extra (--policy), [policy].files, identity.policy."""
|
|
362
|
+
out = [(str(p), "--policy") for p in extra]
|
|
363
|
+
out += [(f, "[policy] files") for f in getattr(getattr(cfg, "policy", None), "files", ()) or ()]
|
|
364
|
+
if identity is not None and getattr(identity, "policy", None):
|
|
365
|
+
out.append((identity.policy, f"identity {identity.id}"))
|
|
366
|
+
seen, uniq = set(), []
|
|
367
|
+
for path, who in out:
|
|
368
|
+
key = os.path.realpath(os.path.expanduser(path))
|
|
369
|
+
if key not in seen:
|
|
370
|
+
seen.add(key)
|
|
371
|
+
uniq.append((path, who))
|
|
372
|
+
return uniq
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _load_all(srcs: Sequence[Tuple[str, str]], check_perms: bool) -> List[Policy]:
|
|
376
|
+
return [load(p, check_perms=check_perms, who="" if who == "--policy" else who) for p, who in srcs]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def any_policy(cfg: object) -> bool:
|
|
380
|
+
"""Does the config name a policy at all (no file is read)?"""
|
|
381
|
+
return bool(getattr(getattr(cfg, "policy", None), "files", ())) or \
|
|
382
|
+
any(getattr(i, "policy", None) for i in getattr(cfg, "identities", ()))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _repo_urls(remotes: Dict[str, str], matched: Optional[str] = None) -> List[str]:
|
|
386
|
+
"""The url the rules judge: the one that matched the identity, else origin, else every remote's."""
|
|
387
|
+
if matched:
|
|
388
|
+
return [matched]
|
|
389
|
+
if "origin" in remotes:
|
|
390
|
+
return [remotes["origin"]]
|
|
391
|
+
return list(remotes.values())
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _looks_like_url(s: str) -> bool:
|
|
395
|
+
return not os.path.isdir(s) and ("://" in s or bool(re.match(r"^[^/:]+:", s)))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def applicable(repo_or_url: object, cfg: object, *, extra: Sequence[str] = (), check_perms: bool = True) -> Found:
|
|
399
|
+
"""The policies (with their matching rules) for a repo directory or a remote url: [policy].files plus
|
|
400
|
+
the matched identity's policy (plus extra paths, `--policy`). Rules match on the remote's host and
|
|
401
|
+
owner; an empty owners list matches every owner on the host."""
|
|
402
|
+
s = str(repo_or_url)
|
|
403
|
+
if _looks_like_url(s):
|
|
404
|
+
ident = resolve.match_url(s, cfg)[0] if cfg is not None else None
|
|
405
|
+
urls = [s]
|
|
406
|
+
else:
|
|
407
|
+
repo = Path(s)
|
|
408
|
+
ident, matched = None, None
|
|
409
|
+
if cfg is not None:
|
|
410
|
+
exp = resolve.expected(repo, cfg)
|
|
411
|
+
ident, matched = exp.match.identity, exp.match.url
|
|
412
|
+
urls = _repo_urls(resolve.remotes(repo), matched)
|
|
413
|
+
if not urls:
|
|
414
|
+
return []
|
|
415
|
+
return match(_load_all(sources(cfg, ident, extra), check_perms), urls, cfg)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------- the rules themselves
|
|
419
|
+
|
|
420
|
+
def email_problem(rules: Sequence[Rule], email: Optional[str]) -> Optional[Tuple[str, str]]:
|
|
421
|
+
"""(rule key, message) for the first rule this author/committer email breaks, else None."""
|
|
422
|
+
if not email:
|
|
423
|
+
return None
|
|
424
|
+
for r in rules:
|
|
425
|
+
hit = next((g for g in r.forbid_emails if trailers.glob_match(g, email)), None)
|
|
426
|
+
if hit is not None:
|
|
427
|
+
return "forbid_emails", f"email {email} is forbidden (forbid_emails has \"{hit}\")"
|
|
428
|
+
domain = email.rpartition("@")[2].casefold()
|
|
429
|
+
for r in rules:
|
|
430
|
+
if not r.emails():
|
|
431
|
+
continue
|
|
432
|
+
if domain in {d.casefold() for d in r.allowed_email_domains}:
|
|
433
|
+
continue
|
|
434
|
+
if any(trailers.glob_match(g, email) for g in r.allowed_emails):
|
|
435
|
+
continue
|
|
436
|
+
shown = ", ".join(r.allowed_email_domains) or "only the addresses in allowed_emails"
|
|
437
|
+
return "allowed_email_domains", f"email domain {domain} not allowed ({shown})"
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _domains(rules: Sequence[Rule]) -> List[str]:
|
|
442
|
+
return list(dict.fromkeys(d for r in rules for d in r.allowed_email_domains))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def trailer_policy(pol: Policy, rules: Sequence[Rule]) -> trailers.Policy:
|
|
446
|
+
"""The trailer rules of these policy rules as a trailers.Policy (forbid > require > allow)."""
|
|
447
|
+
co = max((r.co_authored_by for r in rules), key=lambda v: _RANK[v], default="allow")
|
|
448
|
+
emails = tuple(dict.fromkeys(g for r in rules for g in r.forbid_emails))
|
|
449
|
+
return trailers.Policy(co, emails, f"policy {pol.name}", "[[rule]]", "[[rule]]")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def trailer_problems(pol: Policy, rules: Sequence[Rule], found: Sequence[trailers.Trailer],
|
|
453
|
+
merge: bool = False) -> List[Tuple[str, str]]:
|
|
454
|
+
"""(rule key, message) per trailer violation; a merge needs no Co-Authored-By."""
|
|
455
|
+
tp = trailer_policy(pol, rules)
|
|
456
|
+
co = "allow" if merge and tp.co_authored_by == "require" else tp.co_authored_by
|
|
457
|
+
out = [("co_authored_by", m) for m in trailers.check(found, replace(tp, co_authored_by=co, forbid_emails=()))]
|
|
458
|
+
if co != "forbid" and tp.forbid_emails:
|
|
459
|
+
out += [("forbid_emails", m) for m in trailers.check(found, replace(tp, co_authored_by="allow"))]
|
|
460
|
+
return out
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _sig_format(body: bytes) -> Optional[str]:
|
|
464
|
+
"""ssh | gpg | x509 | unknown from a raw commit's gpgsig header, None when it carries no signature."""
|
|
465
|
+
head = body.split(b"\n\n", 1)[0]
|
|
466
|
+
for line in head.split(b"\n"):
|
|
467
|
+
if line.startswith(b"gpgsig ") or line.startswith(b"gpgsig-sha256 "):
|
|
468
|
+
first = line.split(b" ", 1)[1]
|
|
469
|
+
if b"SSH SIGNATURE" in first:
|
|
470
|
+
return "ssh"
|
|
471
|
+
if b"PGP SIGNATURE" in first:
|
|
472
|
+
return "gpg"
|
|
473
|
+
if b"SIGNED MESSAGE" in first:
|
|
474
|
+
return "x509"
|
|
475
|
+
return "unknown"
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def classify(git_status: str, fmt: Optional[str], signer: str) -> str:
|
|
480
|
+
"""The %G? status we judge: git says N for a signed commit when no allowed signers are configured and
|
|
481
|
+
U with no signer for an ssh key that is not in them; both are E (cannot check) here."""
|
|
482
|
+
if fmt is None:
|
|
483
|
+
return "N"
|
|
484
|
+
if git_status == "N":
|
|
485
|
+
return "E"
|
|
486
|
+
if git_status == "U" and fmt == "ssh" and not signer:
|
|
487
|
+
return "E"
|
|
488
|
+
return git_status or "E"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
SSH_KEY_TYPES = ("ssh-ed25519", "ssh-rsa", "ssh-dss", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384",
|
|
492
|
+
"ecdsa-sha2-nistp521", "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _ssh_fields(line: str) -> List[str]:
|
|
496
|
+
"""Whitespace-separated fields of an allowed_signers line; double quotes group (and are removed)."""
|
|
497
|
+
out: List[str] = []
|
|
498
|
+
cur: List[str] = []
|
|
499
|
+
quoted = started = False
|
|
500
|
+
for ch in line:
|
|
501
|
+
if ch == '"':
|
|
502
|
+
quoted, started = not quoted, True
|
|
503
|
+
elif ch.isspace() and not quoted:
|
|
504
|
+
if started:
|
|
505
|
+
out.append("".join(cur))
|
|
506
|
+
cur, started = [], False
|
|
507
|
+
else:
|
|
508
|
+
cur.append(ch)
|
|
509
|
+
started = True
|
|
510
|
+
if started:
|
|
511
|
+
out.append("".join(cur))
|
|
512
|
+
return out
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _ssh_options(text: str) -> Dict[str, str]:
|
|
516
|
+
"""name (lowercased) -> value of an options field: comma-separated, quotes may hold commas."""
|
|
517
|
+
out: Dict[str, str] = {}
|
|
518
|
+
cur: List[str] = []
|
|
519
|
+
quoted = False
|
|
520
|
+
for ch in text + ",":
|
|
521
|
+
if ch == '"':
|
|
522
|
+
quoted = not quoted
|
|
523
|
+
elif ch == "," and not quoted:
|
|
524
|
+
item = "".join(cur)
|
|
525
|
+
if item:
|
|
526
|
+
k, _, v = item.partition("=")
|
|
527
|
+
out[k.strip().lower()] = v
|
|
528
|
+
cur = []
|
|
529
|
+
else:
|
|
530
|
+
cur.append(ch)
|
|
531
|
+
return out
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def ssh_fingerprint(blob_b64: str) -> Optional[str]:
|
|
535
|
+
"""OpenSSH's SHA256 fingerprint of a base64 public key blob ("SHA256:" + unpadded base64)."""
|
|
536
|
+
import base64
|
|
537
|
+
import binascii
|
|
538
|
+
import hashlib
|
|
539
|
+
try:
|
|
540
|
+
blob = base64.b64decode(blob_b64.encode("ascii"), validate=True)
|
|
541
|
+
except (binascii.Error, ValueError, UnicodeError):
|
|
542
|
+
return None
|
|
543
|
+
return "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode("ascii").rstrip("=")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def signer_principals(files: Sequence[str]) -> Dict[str, List[str]]:
|
|
547
|
+
"""key fingerprint -> the principal patterns of every allowed_signers line for that key (lines whose
|
|
548
|
+
namespaces option excludes "git" and cert-authority lines are skipped). git records only the first
|
|
549
|
+
principal that verifies as %GS, so the committer is checked against this union instead."""
|
|
550
|
+
out: Dict[str, List[str]] = {}
|
|
551
|
+
for name in dict.fromkeys(files):
|
|
552
|
+
try:
|
|
553
|
+
text = Path(name).read_text(encoding="utf-8", errors="replace")
|
|
554
|
+
except OSError:
|
|
555
|
+
continue
|
|
556
|
+
for line in text.splitlines():
|
|
557
|
+
line = line.strip()
|
|
558
|
+
if not line or line.startswith("#"):
|
|
559
|
+
continue
|
|
560
|
+
f = _ssh_fields(line)
|
|
561
|
+
if len(f) < 3:
|
|
562
|
+
continue
|
|
563
|
+
if f[1] in SSH_KEY_TYPES:
|
|
564
|
+
opts, blob = {}, f[2]
|
|
565
|
+
elif len(f) >= 4 and f[2] in SSH_KEY_TYPES:
|
|
566
|
+
opts, blob = _ssh_options(f[1]), f[3]
|
|
567
|
+
else:
|
|
568
|
+
continue
|
|
569
|
+
if "cert-authority" in opts:
|
|
570
|
+
continue
|
|
571
|
+
ns = opts.get("namespaces")
|
|
572
|
+
if ns is not None and not _pattern_list_ok(ns.split(","), "git", fold=False):
|
|
573
|
+
continue
|
|
574
|
+
fp = ssh_fingerprint(blob)
|
|
575
|
+
if fp:
|
|
576
|
+
out.setdefault(fp, []).extend(p.strip() for p in f[0].split(",") if p.strip())
|
|
577
|
+
return out
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _pattern_list_ok(pats: Sequence[str], text: str, *, fold: bool = True) -> bool:
|
|
581
|
+
"""OpenSSH pattern-list rules: a matching "!pattern" denies, else any matching pattern allows."""
|
|
582
|
+
match = trailers.glob_match if fold else (lambda g, t: resolve.wildmatch(g, t))
|
|
583
|
+
pats = [p.strip() for p in pats if p.strip()]
|
|
584
|
+
if any(p.startswith("!") and match(p[1:], text) for p in pats):
|
|
585
|
+
return False
|
|
586
|
+
return any(not p.startswith("!") and match(p, text) for p in pats)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def principal_ok(signer: object, email: Optional[str]) -> bool:
|
|
590
|
+
"""Does the committer email match the signer's principal patterns (a list, or one comma-separated
|
|
591
|
+
string; ssh pattern-list rules, "!" negates; case-insensitive)? git itself does not compare them: a key
|
|
592
|
+
listed for alice@ verifies a commit whose committer claims to be bob@ as G."""
|
|
593
|
+
raw = signer.split(",") if isinstance(signer, str) else list(signer or ())
|
|
594
|
+
pats = [p.strip() for p in raw if p and p.strip()]
|
|
595
|
+
if not email or not pats:
|
|
596
|
+
return False
|
|
597
|
+
if any(p.startswith("!") and trailers.glob_match(p[1:], email) for p in pats):
|
|
598
|
+
return False
|
|
599
|
+
return any(not p.startswith("!") and trailers.glob_match(p, email) for p in pats)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def signing_problems(rules: Sequence[Rule], status: str, fmt: Optional[str], ships_signers: bool,
|
|
603
|
+
merge: bool = False, *, signer: str = "", committer: str = "",
|
|
604
|
+
principals: Optional[Sequence[str]] = None) -> List[Tuple[str, str]]:
|
|
605
|
+
"""(rule key, message) per signing violation of one commit. With the policy's allowed_signers, an ssh
|
|
606
|
+
signature must also come from a principal matching the committer email (rule allowed_signers):
|
|
607
|
+
principals are the patterns of the key's allowed_signers lines (signer_principals), else %GS alone."""
|
|
608
|
+
out: List[Tuple[str, str]] = []
|
|
609
|
+
required = any(r.require_signing for r in rules)
|
|
610
|
+
if required:
|
|
611
|
+
if status == "N":
|
|
612
|
+
out.append(("require_signing", "not signed (require_signing)"))
|
|
613
|
+
elif status == "B":
|
|
614
|
+
out.append(("require_signing", "bad signature (require_signing)"))
|
|
615
|
+
elif status == "R":
|
|
616
|
+
out.append(("require_signing", "signed with a revoked key"))
|
|
617
|
+
elif status == "E" and ships_signers:
|
|
618
|
+
if fmt == "ssh":
|
|
619
|
+
out.append(("require_signing", "signed by a key that is not in the policy's allowed_signers"))
|
|
620
|
+
else:
|
|
621
|
+
out.append(("require_signing", f"{fmt or 'unknown'} signature cannot be checked: the policy's "
|
|
622
|
+
"allowed_signers lists ssh keys only"))
|
|
623
|
+
pats = list(principals) if principals else ([signer] if signer else [])
|
|
624
|
+
if ships_signers and fmt == "ssh" and status in ("G", "U") and pats and not principal_ok(pats, committer):
|
|
625
|
+
out.append(("allowed_signers", f"signed by {','.join(pats)} (allowed_signers), but committed as {committer}"))
|
|
626
|
+
if fmt is not None:
|
|
627
|
+
for r in rules:
|
|
628
|
+
if r.signing_formats and fmt not in r.signing_formats:
|
|
629
|
+
out.append(("signing_formats", f"format {fmt} not allowed ({', '.join(r.signing_formats)})"))
|
|
630
|
+
break
|
|
631
|
+
return out
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---------------------------------------------------------------- git
|
|
635
|
+
|
|
636
|
+
def _git(repo: Path, *argv: str, pre: Sequence[str] = (), stdin: Optional[bytes] = None,
|
|
637
|
+
timeout: int = GIT_TIMEOUT, what: str = "") -> bytes:
|
|
638
|
+
try:
|
|
639
|
+
r = subprocess.run(["git", *pre, "-C", str(repo), *argv], input=stdin, capture_output=True,
|
|
640
|
+
env=resolve._child_env(), timeout=timeout,
|
|
641
|
+
**({} if stdin is not None else {"stdin": subprocess.DEVNULL}))
|
|
642
|
+
except FileNotFoundError:
|
|
643
|
+
raise ConfigError("git not found") from None
|
|
644
|
+
except subprocess.TimeoutExpired:
|
|
645
|
+
raise ConfigError(f"{what or 'git ' + argv[0]} timed out after {timeout}s") from None
|
|
646
|
+
if r.returncode:
|
|
647
|
+
err = r.stderr.decode("utf-8", "replace").strip().splitlines()
|
|
648
|
+
raise ConfigError(f"{what or 'git ' + argv[0]} failed: {err[-1] if err else r.returncode}")
|
|
649
|
+
return r.stdout
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _formats(repo: Path, shas: Sequence[str], timeout: int) -> Dict[str, Optional[str]]:
|
|
653
|
+
"""sha -> signature format from the raw commit headers (one `git cat-file --batch`)."""
|
|
654
|
+
if not shas:
|
|
655
|
+
return {}
|
|
656
|
+
out = _git(repo, "cat-file", "--batch", stdin="".join(s + "\n" for s in shas).encode(), timeout=timeout)
|
|
657
|
+
res: Dict[str, Optional[str]] = {}
|
|
658
|
+
pos = 0
|
|
659
|
+
while pos < len(out):
|
|
660
|
+
nl = out.find(b"\n", pos)
|
|
661
|
+
if nl < 0:
|
|
662
|
+
break
|
|
663
|
+
head = out[pos:nl].split()
|
|
664
|
+
pos = nl + 1
|
|
665
|
+
if len(head) < 3:
|
|
666
|
+
continue
|
|
667
|
+
size = int(head[2])
|
|
668
|
+
res[head[0].decode()] = _sig_format(out[pos:pos + size])
|
|
669
|
+
pos += size + 1
|
|
670
|
+
return res
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
@dataclass
|
|
674
|
+
class _Commit:
|
|
675
|
+
sha: str
|
|
676
|
+
status: str
|
|
677
|
+
key: str
|
|
678
|
+
signer: str
|
|
679
|
+
author_email: str
|
|
680
|
+
committer_email: str
|
|
681
|
+
date: str
|
|
682
|
+
parents: Tuple[str, ...]
|
|
683
|
+
trailers: List[trailers.Trailer]
|
|
684
|
+
subject: str = ""
|
|
685
|
+
fmt: Optional[str] = None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _signers_args(found: Found, tmp: List[str]) -> Tuple[List[str], bool]:
|
|
689
|
+
"""-c gpg.ssh.allowedSignersFile=... for the allowed_signers the policies ship (several: one temp file)."""
|
|
690
|
+
files = list(dict.fromkeys(p.allowed_signers for p, _ in found if p.allowed_signers))
|
|
691
|
+
if not files:
|
|
692
|
+
return [], False
|
|
693
|
+
path = files[0]
|
|
694
|
+
if len(files) > 1:
|
|
695
|
+
fd, path = tempfile.mkstemp(prefix="gitpro-signers-")
|
|
696
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
697
|
+
for name in files:
|
|
698
|
+
f.write(Path(name).read_text(encoding="utf-8", errors="replace").rstrip("\n") + "\n")
|
|
699
|
+
tmp.append(path)
|
|
700
|
+
return ["-c", f"gpg.ssh.allowedSignersFile={path}"], True
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
_FIELDS = ("%H", "%G?", "%GK", "%GS", "%ae", "%ce", "%cs", "%P", "%s", "%(trailers:only,unfold)")
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _log(repo: Path, revs: Sequence[str], *, signing: bool, pre: Sequence[str], timeout: int) -> List[_Commit]:
|
|
707
|
+
# Count and identify records independently of untrusted commit text.
|
|
708
|
+
shas = _git(repo, "rev-list", *revs, "--", timeout=timeout).decode("ascii").splitlines()
|
|
709
|
+
if not shas:
|
|
710
|
+
return []
|
|
711
|
+
fields = [f if signing or f not in ("%G?", "%GK", "%GS") else "-" for f in _FIELDS]
|
|
712
|
+
fmt = "%x00".join(fields)
|
|
713
|
+
raw = _git(repo, "log", "-z", "--no-color", "--no-show-signature", f"--format={fmt}",
|
|
714
|
+
"--no-walk=unsorted", "--stdin", pre=pre, stdin="".join(s + "\n" for s in shas).encode(),
|
|
715
|
+
timeout=timeout, what=f"git log {' '.join(revs)}")
|
|
716
|
+
parts = raw.split(b"\0")
|
|
717
|
+
if parts[-1] != b"" or len(parts) != len(shas) * len(fields) + 1:
|
|
718
|
+
raise ConfigError("cannot check policy: malformed commit records from git log")
|
|
719
|
+
out: List[_Commit] = []
|
|
720
|
+
for n, sha in enumerate(shas):
|
|
721
|
+
f = [p.decode("utf-8", "replace") for p in parts[n * len(fields):(n + 1) * len(fields)]]
|
|
722
|
+
if f[0] != sha:
|
|
723
|
+
raise ConfigError(f"cannot check policy: git log did not return expected commit {sha}")
|
|
724
|
+
out.append(_Commit(f[0], f[1] if signing else "", f[2] if signing else "", f[3] if signing else "",
|
|
725
|
+
f[4], f[5], f[6], tuple(f[7].split()), trailers.split_lines(f[9]), f[8]))
|
|
726
|
+
if signing:
|
|
727
|
+
fmts = _formats(repo, [c.sha for c in out], timeout)
|
|
728
|
+
for c in out:
|
|
729
|
+
c.fmt = fmts.get(c.sha)
|
|
730
|
+
c.status = classify(c.status, c.fmt, c.signer)
|
|
731
|
+
return out
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _judge(c: _Commit, found: Found, signing: bool, ships: bool,
|
|
735
|
+
by_key: Optional[Dict[str, List[str]]] = None) -> List[dict]:
|
|
736
|
+
"""Every violation of one commit: {"policy", "rule", "which", "message"}. by_key: signer_principals."""
|
|
737
|
+
out: List[dict] = []
|
|
738
|
+
merge = len(c.parents) > 1
|
|
739
|
+
for pol, rules in found:
|
|
740
|
+
a = email_problem(rules, c.author_email)
|
|
741
|
+
k = email_problem(rules, c.committer_email)
|
|
742
|
+
if a and k and a == k:
|
|
743
|
+
out.append({"policy": pol.name, "rule": a[0], "which": "both", "message": a[1]})
|
|
744
|
+
else:
|
|
745
|
+
if a:
|
|
746
|
+
out.append({"policy": pol.name, "rule": a[0], "which": "author", "message": a[1]})
|
|
747
|
+
if k:
|
|
748
|
+
out.append({"policy": pol.name, "rule": k[0], "which": "committer", "message": "committer " + k[1]})
|
|
749
|
+
if signing:
|
|
750
|
+
for key, msg in signing_problems(rules, c.status, c.fmt, ships, merge, signer=c.signer,
|
|
751
|
+
committer=c.committer_email,
|
|
752
|
+
principals=(by_key or {}).get(c.key)):
|
|
753
|
+
out.append({"policy": pol.name, "rule": key, "which": "signature", "message": msg})
|
|
754
|
+
for key, msg in trailer_problems(pol, rules, c.trailers, merge):
|
|
755
|
+
out.append({"policy": pol.name, "rule": key, "which": "trailer", "message": msg})
|
|
756
|
+
return out
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# ---------------------------------------------------------------- CI mode: --range
|
|
760
|
+
|
|
761
|
+
def _empty_range(text: str) -> bool:
|
|
762
|
+
return text.strip() in ("", "..", "...")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def check_range(repo: Path, rng: str, found: Found, *, timeout: int = RANGE_TIMEOUT) -> dict:
|
|
766
|
+
"""The commits of rng in repo judged against found: {"commits", "violations", "signing", "rows"}."""
|
|
767
|
+
from . import audit
|
|
768
|
+
if _empty_range(rng):
|
|
769
|
+
raise ConfigError(f"commit range is empty ({rng!r}); pass --range BASE..HEAD")
|
|
770
|
+
revs = audit.range_tokens(rng)
|
|
771
|
+
signing = any(r.signing() for _, rules in found for r in rules)
|
|
772
|
+
tmp: List[str] = []
|
|
773
|
+
try:
|
|
774
|
+
pre, ships = _signers_args(found, tmp) if signing else ([], False)
|
|
775
|
+
commits = _log(repo, revs, signing=signing, pre=pre, timeout=timeout)
|
|
776
|
+
finally:
|
|
777
|
+
for t in tmp:
|
|
778
|
+
try:
|
|
779
|
+
os.unlink(t)
|
|
780
|
+
except OSError:
|
|
781
|
+
pass
|
|
782
|
+
by_key = signer_principals([p.allowed_signers for p, _ in found if p.allowed_signers]) if ships else {}
|
|
783
|
+
violations, sig, rows = [], [], []
|
|
784
|
+
for c in commits:
|
|
785
|
+
found_v = _judge(c, found, signing, ships, by_key)
|
|
786
|
+
for v in found_v:
|
|
787
|
+
violations.append({"sha": c.sha, "author_email": c.author_email, "committer_email": c.committer_email,
|
|
788
|
+
**v})
|
|
789
|
+
if signing:
|
|
790
|
+
sig.append({"sha": c.sha, "status": c.status, "format": c.fmt, "signed": c.status in SIGNED,
|
|
791
|
+
"warning": WARNINGS.get(c.status)})
|
|
792
|
+
rows.append((c, found_v))
|
|
793
|
+
unverifiable = sum(1 for c in commits if signing and c.status == "E")
|
|
794
|
+
return {"commits": len(commits), "violations": violations, "signing": sig, "rows": rows,
|
|
795
|
+
"signing_checked": signing, "unverifiable": unverifiable}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _ok_text(c: _Commit, signing: bool) -> str:
|
|
799
|
+
if not signing or c.status not in SIGNED:
|
|
800
|
+
return "✓"
|
|
801
|
+
what = c.fmt or "unknown format"
|
|
802
|
+
if c.status in GOOD:
|
|
803
|
+
return f"✓ signed ({what}, {GOOD[c.status]})"
|
|
804
|
+
return f"✓ signed ({what}) ⚠ {WARNINGS.get(c.status, c.status)}"
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _render_range(data: dict, names: str, where: str, rng: str, contact: str) -> List[str]:
|
|
808
|
+
n = data["commits"]
|
|
809
|
+
lines = [f"policy {names} for {where}, {n} commit{'s' if n != 1 else ''} in {rng}"]
|
|
810
|
+
pad = " " * (2 + 7 + 2 + 10 + 2 + 22)
|
|
811
|
+
bad = 0
|
|
812
|
+
for c, found_v in data["rows"]:
|
|
813
|
+
lead = f" {c.sha[:7]} {c.date:<10} {c.author_email:<21} "
|
|
814
|
+
if not found_v:
|
|
815
|
+
lines.append(lead + _ok_text(c, data["signing_checked"]))
|
|
816
|
+
continue
|
|
817
|
+
bad += 1
|
|
818
|
+
lines.append(lead + f"✗ {found_v[0]['message']}")
|
|
819
|
+
lines += [pad + f"✗ {v['message']}" for v in found_v[1:]]
|
|
820
|
+
tail = f"; contact {contact}" if contact else ""
|
|
821
|
+
if bad:
|
|
822
|
+
lines.append(f"{bad} of {n} commit{'s' if n != 1 else ''} violate{'s' if n == 1 else ''} the policy{tail}")
|
|
823
|
+
else:
|
|
824
|
+
lines.append(f"✓ {n} commit{'s' if n != 1 else ''} meet{'s' if n == 1 else ''} the policy")
|
|
825
|
+
k = data.get("unverifiable", 0)
|
|
826
|
+
if k:
|
|
827
|
+
lines.append(f"⚠ {k} signature{'s' if k != 1 else ''} could not be verified in this environment (key not "
|
|
828
|
+
f"known here; gpg/x509 use this machine's keyring, ssh the policy's allowed_signers)")
|
|
829
|
+
return lines
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _config_or_none(args, warnings: Optional[List[str]] = None) -> Optional[object]:
|
|
833
|
+
"""The user's config, None when there is none. A broken one raises, or with warnings (range mode)
|
|
834
|
+
is noted there and skipped."""
|
|
835
|
+
try:
|
|
836
|
+
path, how = config.find(getattr(args, "config", None), Path(getattr(args, "home", None) or Path.home()))
|
|
837
|
+
except ConfigError:
|
|
838
|
+
return None
|
|
839
|
+
if not path.is_file() and how != "--config":
|
|
840
|
+
return None
|
|
841
|
+
try:
|
|
842
|
+
return config.load_all(path, how)
|
|
843
|
+
except ConfigError as e:
|
|
844
|
+
if warnings is None:
|
|
845
|
+
raise
|
|
846
|
+
warnings.append(f"config not used: {e}")
|
|
847
|
+
return None
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _need_repo(path: object) -> Path:
|
|
851
|
+
top = resolve.is_repo(Path(path))
|
|
852
|
+
if top is None:
|
|
853
|
+
raise ConfigError(f"{path}: not a git repository")
|
|
854
|
+
return top
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _show_where(url: Optional[str], repo: Path) -> str:
|
|
858
|
+
if not url:
|
|
859
|
+
return _tilde(repo)
|
|
860
|
+
host, owner, name = resolve.parse_url(url)
|
|
861
|
+
return "/".join(p for p in (host, owner, name) if p) or (resolve.redact_url(url) or "")
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _range_base(repo: Path, revs: Sequence[str]) -> Optional[str]:
|
|
865
|
+
"""The one commit the range excludes (A of A..B, the merge base of A...B), else None."""
|
|
866
|
+
out = _git(repo, "rev-parse", "--revs-only", *revs, what="git rev-parse").decode("utf-8", "replace").split()
|
|
867
|
+
neg = list(dict.fromkeys(x[1:] for x in out if x.startswith("^")))
|
|
868
|
+
return neg[0] if len(neg) == 1 else None
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _base_blob(repo: Path, base: str, rel: str) -> Optional[bytes]:
|
|
872
|
+
"""The file rel as committed in base; None when base has no such path. A symlink or a directory there
|
|
873
|
+
is refused (a repo policy must not point outside the repo)."""
|
|
874
|
+
out = _git(repo, "ls-tree", "-z", base, "--", rel).decode("utf-8", "replace")
|
|
875
|
+
entries = [e for e in out.split("\0") if e]
|
|
876
|
+
entry = next((e for e in entries if e.split("\t", 1)[-1] == rel), None)
|
|
877
|
+
if entry is None:
|
|
878
|
+
return None
|
|
879
|
+
mode = entry.split(None, 1)[0]
|
|
880
|
+
if mode == "120000":
|
|
881
|
+
raise ConfigError(f"{rel} is a symlink in {base[:7]}; a repo policy file must be a regular file inside the repo")
|
|
882
|
+
if not mode.startswith("100"):
|
|
883
|
+
raise ConfigError(f"{rel} in {base[:7]} is not a file")
|
|
884
|
+
return _git(repo, "cat-file", "blob", f"{base}:{rel}")
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
class _RangePolicies:
|
|
888
|
+
"""Loads the policies of a --range run. A policy file inside the repo (and the allowed_signers it
|
|
889
|
+
names) is read from the range's base commit, never from the checkout the range produced: a pull
|
|
890
|
+
request cannot widen the policy that judges it."""
|
|
891
|
+
|
|
892
|
+
def __init__(self, repo: Path, revs: Sequence[str], tmp: str, warnings: List[str]):
|
|
893
|
+
self.repo, self.revs, self.tmp, self.warnings = repo, list(revs), Path(tmp), warnings
|
|
894
|
+
self._base: Optional[str] = None
|
|
895
|
+
self.added = False
|
|
896
|
+
|
|
897
|
+
def base(self, rel: str) -> str:
|
|
898
|
+
if self._base is None:
|
|
899
|
+
self._base = _range_base(self.repo, self.revs)
|
|
900
|
+
if self._base is None:
|
|
901
|
+
raise ConfigError(f"the range {' '.join(self.revs)} has no single base commit to read {rel} from; use "
|
|
902
|
+
f"BASE..HEAD, or pass --policy FILE from outside the repo")
|
|
903
|
+
return self._base
|
|
904
|
+
|
|
905
|
+
def rel(self, path: object) -> Optional[str]:
|
|
906
|
+
"""Resolve external aliases only until entering the repo; checkout symlinks cannot rename a
|
|
907
|
+
policy's protected-base entry."""
|
|
908
|
+
p = os.path.abspath(os.path.expanduser(str(path)))
|
|
909
|
+
tops = tuple(dict.fromkeys((os.path.abspath(self.repo), os.path.realpath(self.repo))))
|
|
910
|
+
for _ in range(40):
|
|
911
|
+
for top in tops:
|
|
912
|
+
if p.startswith(top.rstrip(os.sep) + os.sep):
|
|
913
|
+
return Path(os.path.relpath(p, top)).as_posix()
|
|
914
|
+
current = Path(p)
|
|
915
|
+
for ancestor in (*reversed(current.parents), current):
|
|
916
|
+
if ancestor.is_symlink():
|
|
917
|
+
try:
|
|
918
|
+
target = Path(os.readlink(ancestor))
|
|
919
|
+
except OSError as e:
|
|
920
|
+
raise ConfigError(f"cannot resolve policy path {path}: {e}") from None
|
|
921
|
+
if not target.is_absolute():
|
|
922
|
+
target = ancestor.parent / target
|
|
923
|
+
p = os.path.abspath(target / current.relative_to(ancestor))
|
|
924
|
+
break
|
|
925
|
+
else:
|
|
926
|
+
return None
|
|
927
|
+
raise ConfigError(f"cannot resolve policy path {path}: too many symlinks")
|
|
928
|
+
|
|
929
|
+
def changed(self, rels: Sequence[str], base: str) -> List[str]:
|
|
930
|
+
"""Which of rels the range touches (a commit in it, merges included against their first parent, or
|
|
931
|
+
the checkout differs from base)."""
|
|
932
|
+
out = _git(self.repo, "log", "--format=", "--name-only", "--diff-merges=first-parent", *self.revs, "--",
|
|
933
|
+
*rels, timeout=RANGE_TIMEOUT)
|
|
934
|
+
hit = {x.strip() for x in out.decode("utf-8", "replace").splitlines() if x.strip()}
|
|
935
|
+
for rel in rels:
|
|
936
|
+
if rel in hit:
|
|
937
|
+
continue
|
|
938
|
+
wt = self.repo / rel
|
|
939
|
+
try:
|
|
940
|
+
now = wt.read_bytes() if wt.is_file() and not wt.is_symlink() else None
|
|
941
|
+
except OSError:
|
|
942
|
+
now = None
|
|
943
|
+
if now is not None and now != _base_blob(self.repo, base, rel):
|
|
944
|
+
hit.add(rel)
|
|
945
|
+
return [r for r in rels if r in hit]
|
|
946
|
+
|
|
947
|
+
def load(self, path: object, who: str = "", *, optional: bool = False) -> Optional[Policy]:
|
|
948
|
+
rel = self.rel(path)
|
|
949
|
+
if rel is None:
|
|
950
|
+
return load(path, check_perms=False, who=who)
|
|
951
|
+
try:
|
|
952
|
+
base = self.base(rel)
|
|
953
|
+
except ConfigError:
|
|
954
|
+
if optional and not (self.repo / rel).exists():
|
|
955
|
+
return None
|
|
956
|
+
raise
|
|
957
|
+
shown = self.repo / rel
|
|
958
|
+
blob = _base_blob(self.repo, base, rel)
|
|
959
|
+
if blob is None:
|
|
960
|
+
if (self.repo / rel).exists() or self.changed([rel], base):
|
|
961
|
+
self.warnings.append(f"{rel}: policy added in this range; not enforced until merged")
|
|
962
|
+
self.added = True
|
|
963
|
+
return None
|
|
964
|
+
if optional:
|
|
965
|
+
return None
|
|
966
|
+
raise ConfigError(f"policy {rel}: not found in the range base {base[:7]}" + (f" ({who})" if who else ""))
|
|
967
|
+
try:
|
|
968
|
+
text = blob.decode("utf-8")
|
|
969
|
+
except UnicodeError:
|
|
970
|
+
raise ConfigError(f"{shown}: not UTF-8 text (at {base[:7]})") from None
|
|
971
|
+
data = _toml(text, shown)
|
|
972
|
+
root = self.tmp / f"base{len(list(self.tmp.iterdir()))}"
|
|
973
|
+
here = posixpath.dirname(rel)
|
|
974
|
+
head = data.get("policy")
|
|
975
|
+
v = head.get("allowed_signers") if isinstance(head, dict) else None
|
|
976
|
+
srel = None
|
|
977
|
+
if isinstance(v, str) and REL_PATH_RE.match(v) and ".." not in v.split("/"):
|
|
978
|
+
srel = posixpath.normpath(posixpath.join(here, v))
|
|
979
|
+
sblob = _base_blob(self.repo, base, srel)
|
|
980
|
+
if sblob is None:
|
|
981
|
+
raise ConfigError(f"{shown}: allowed_signers {self.repo / srel} not found in the range base {base[:7]}")
|
|
982
|
+
dst = root / srel
|
|
983
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
984
|
+
dst.write_bytes(sblob)
|
|
985
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
986
|
+
pol, errs = parse(data, shown, signers_base=root / here if here else root, inside=root)
|
|
987
|
+
if pol is None:
|
|
988
|
+
raise ConfigError(errs[0])
|
|
989
|
+
for f in self.changed([rel] + ([srel] if srel else []), base):
|
|
990
|
+
self.warnings.append(f"this range changes {f}; it is judged by the base version ({base[:7]})")
|
|
991
|
+
return pol
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _cmd_range(args) -> int:
|
|
995
|
+
from . import audit, jsonout
|
|
996
|
+
if _empty_range(args.range):
|
|
997
|
+
raise ConfigError(f"commit range is empty ({args.range!r}); pass --range BASE..HEAD")
|
|
998
|
+
revs = audit.range_tokens(args.range)
|
|
999
|
+
repo = _need_repo(args.repo or os.getcwd())
|
|
1000
|
+
rems = resolve.remotes(repo)
|
|
1001
|
+
urls = _repo_urls(rems)
|
|
1002
|
+
url = urls[0] if urls else None
|
|
1003
|
+
shown = resolve.redact_url(url) if url else _tilde(repo)
|
|
1004
|
+
hint = "; pass --policy FILE or add .gitpro-policy.toml"
|
|
1005
|
+
warnings: List[str] = []
|
|
1006
|
+
with tempfile.TemporaryDirectory(prefix="gitpro-policy-") as tmp:
|
|
1007
|
+
src = _RangePolicies(repo, revs, tmp, warnings)
|
|
1008
|
+
if args.policy_files:
|
|
1009
|
+
pols = [p for p in (src.load(f) for f in args.policy_files) if p is not None]
|
|
1010
|
+
found = match(pols, urls)
|
|
1011
|
+
if pols and not found:
|
|
1012
|
+
raise ConfigError(f"no policy applies to {shown} (no rule of {', '.join(args.policy_files)} matches)")
|
|
1013
|
+
else:
|
|
1014
|
+
found = []
|
|
1015
|
+
cfg = _config_or_none(args, warnings)
|
|
1016
|
+
if cfg is not None and urls:
|
|
1017
|
+
ident = resolve.match_url(url, cfg)[0]
|
|
1018
|
+
pols = [p for p in (src.load(f, who) for f, who in sources(cfg, ident)) if p is not None]
|
|
1019
|
+
found = match(pols, urls, cfg)
|
|
1020
|
+
if not found:
|
|
1021
|
+
pol = src.load(repo / REPO_POLICY, optional=True)
|
|
1022
|
+
if pol is not None:
|
|
1023
|
+
found = match([pol], urls)
|
|
1024
|
+
if not found and not src.added:
|
|
1025
|
+
raise ConfigError(f"no policy applies to {shown}{hint}")
|
|
1026
|
+
for w in warnings:
|
|
1027
|
+
print(f"⚠ {w}", file=sys.stderr)
|
|
1028
|
+
data = check_range(repo, args.range, found)
|
|
1029
|
+
contact = ", ".join(dict.fromkeys(p.contact for p, _ in found if p.contact))
|
|
1030
|
+
if getattr(args, "json", False):
|
|
1031
|
+
jsonout.emit("check", {
|
|
1032
|
+
"ok": not data["violations"], "mode": "policy", "repo": str(repo), "remote": shown,
|
|
1033
|
+
"policies": [{"name": p.name, "file": p.path, "rules": len(rules)} for p, rules in found],
|
|
1034
|
+
"range": args.range, "commits": data["commits"], "violations": data["violations"],
|
|
1035
|
+
"signing": data["signing"], "unverifiable": data["unverifiable"], "contact": contact or None,
|
|
1036
|
+
"warnings": warnings})
|
|
1037
|
+
elif not data["commits"]:
|
|
1038
|
+
print(f"✓ no commits in {args.range}")
|
|
1039
|
+
elif not found:
|
|
1040
|
+
print(f"no policy enforced for {data['commits']} commit{'s' if data['commits'] != 1 else ''} in {args.range}")
|
|
1041
|
+
else:
|
|
1042
|
+
names = ", ".join(p.name for p, _ in found)
|
|
1043
|
+
print("\n".join(_render_range(data, names, _show_where(url, repo), args.range, contact)))
|
|
1044
|
+
return 1 if data["violations"] else 0
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
# ---------------------------------------------------------------- machine mode
|
|
1048
|
+
|
|
1049
|
+
@dataclass(frozen=True)
|
|
1050
|
+
class _Person:
|
|
1051
|
+
id: str
|
|
1052
|
+
label: str # "identity work-github" | "[default]"
|
|
1053
|
+
email: Optional[str]
|
|
1054
|
+
sign: bool
|
|
1055
|
+
ssh_key: Optional[str]
|
|
1056
|
+
trailers: trailers.Policy
|
|
1057
|
+
table: str # where a trailers fix goes
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _person(cfg, ident) -> _Person:
|
|
1061
|
+
d = cfg.default
|
|
1062
|
+
tp = trailers.policy_for(ident, d)
|
|
1063
|
+
if ident is None:
|
|
1064
|
+
return _Person("(default)", "[default]", d.email, bool(d.sign), d.ssh_key, tp, "[default.trailers]")
|
|
1065
|
+
return _Person(ident.id, f"identity {ident.id}", ident.email, config.signs(ident, d), ident.ssh_key or d.ssh_key,
|
|
1066
|
+
tp, "[identity.trailers]")
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _check(name: str, label: str, level: str, message: str, fix: str = "") -> dict:
|
|
1070
|
+
return {"name": name, "label": label, "ok": level != "error", "level": level, "message": message, "fix": fix}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
_VERB = {"allow": "allows", "forbid": "forbids", "require": "requires"}
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def identity_checks(cfg, person: _Person, pol: Policy, rules: Sequence[Rule], keys: Optional[dict] = None,
|
|
1077
|
+
online: bool = False, verified: Optional[Dict[str, Optional[bool]]] = None,
|
|
1078
|
+
verified_why: str = "") -> List[dict]:
|
|
1079
|
+
"""The rows of `check --policy` for one identity and policy. keys: pub path -> registered (True/False/None)
|
|
1080
|
+
from `team --online`; verified: email -> verified (True/False/None, missing = unknown); verified_why: why
|
|
1081
|
+
the forge could not be asked."""
|
|
1082
|
+
out: List[dict] = []
|
|
1083
|
+
if any(r.emails() or r.forbid_emails for r in rules):
|
|
1084
|
+
email = person.email or "(no email)"
|
|
1085
|
+
prob = email_problem(rules, person.email) if person.email else ("allowed_email_domains", "no email set")
|
|
1086
|
+
if prob:
|
|
1087
|
+
doms = _domains(rules)
|
|
1088
|
+
fix = (f"use an address in {', '.join(doms)}" if doms else "use an allowed address") + \
|
|
1089
|
+
(f", or ask {pol.contact} for an exception" if pol.contact else "")
|
|
1090
|
+
out.append(_check("email domain", email, "error", prob[1], fix))
|
|
1091
|
+
else:
|
|
1092
|
+
dom = (person.email or "").rpartition("@")[2]
|
|
1093
|
+
hit = next((d for d in _domains(rules) if d.casefold() == dom.casefold()), None)
|
|
1094
|
+
out.append(_check("email domain", email, "ok", hit or "allowed_emails" if any(r.emails() for r in rules)
|
|
1095
|
+
else "not forbidden"))
|
|
1096
|
+
if any(r.signing() for r in rules):
|
|
1097
|
+
required = any(r.require_signing for r in rules)
|
|
1098
|
+
fmts = list(dict.fromkeys(f for r in rules for f in r.signing_formats))
|
|
1099
|
+
label = "required" if required else "formats " + ", ".join(fmts)
|
|
1100
|
+
if required and not person.sign:
|
|
1101
|
+
out.append(_check("signing", label, "error", f"{person.label} has sign = false",
|
|
1102
|
+
f"set sign = true, run {PROG} apply"))
|
|
1103
|
+
elif person.sign and fmts and "ssh" not in fmts:
|
|
1104
|
+
out.append(_check("signing", label, "error", f"{PROG} signs with ssh but the policy allows {', '.join(fmts)}",
|
|
1105
|
+
"sign with an allowed format outside gitpro"))
|
|
1106
|
+
else:
|
|
1107
|
+
out.append(_check("signing", label, "ok", "sign = true" if person.sign else "not required"))
|
|
1108
|
+
if required:
|
|
1109
|
+
if not person.ssh_key:
|
|
1110
|
+
out.append(_check("signing key", "(none)", "error", f"{person.label} has no ssh_key to sign with",
|
|
1111
|
+
"set ssh_key in the identity"))
|
|
1112
|
+
else:
|
|
1113
|
+
pub = os.path.expanduser(person.ssh_key) + ".pub"
|
|
1114
|
+
shown = _tilde(pub)
|
|
1115
|
+
reg = (keys or {}).get(pub) if online else None
|
|
1116
|
+
if not os.path.isfile(pub):
|
|
1117
|
+
out.append(_check("signing key", shown, "error", f"{shown} not found", f"{PROG} keys --generate"))
|
|
1118
|
+
elif reg is True:
|
|
1119
|
+
out.append(_check("signing key", shown, "ok", "registered as a signing key"))
|
|
1120
|
+
elif reg is False:
|
|
1121
|
+
out.append(_check("signing key", shown, "error", "not registered as a signing key",
|
|
1122
|
+
f"{PROG} keys --upload"))
|
|
1123
|
+
elif online:
|
|
1124
|
+
out.append(_check("signing key", shown, "warning", "could not check that it is registered"))
|
|
1125
|
+
else:
|
|
1126
|
+
out.append(_check("signing key", shown, "warning",
|
|
1127
|
+
"not checked (run gitpro team --online to verify it is registered)"))
|
|
1128
|
+
tp = trailer_policy(pol, rules)
|
|
1129
|
+
if tp.co_authored_by != "allow":
|
|
1130
|
+
mine = person.trailers
|
|
1131
|
+
if mine.co_authored_by == tp.co_authored_by:
|
|
1132
|
+
out.append(_check("trailers", tp.co_authored_by, "ok",
|
|
1133
|
+
f'{mine.table} co_authored_by = "{mine.co_authored_by}"'))
|
|
1134
|
+
else:
|
|
1135
|
+
out.append(_check("trailers", tp.co_authored_by, "warning",
|
|
1136
|
+
f"policy {_VERB[tp.co_authored_by]} Co-Authored-By but the "
|
|
1137
|
+
f"{'identity' if person.id != '(default)' else '[default]'} "
|
|
1138
|
+
f"{_VERB[mine.co_authored_by]} it",
|
|
1139
|
+
f'set {person.table} co_authored_by = "{tp.co_authored_by}"'))
|
|
1140
|
+
if any(r.require_verified_email for r in rules):
|
|
1141
|
+
state = (verified or {}).get((person.email or "").casefold()) if online else None
|
|
1142
|
+
if state is True:
|
|
1143
|
+
out.append(_check("verified email", person.email or "", "ok", "verified on the forge"))
|
|
1144
|
+
elif state is False:
|
|
1145
|
+
out.append(_check("verified email", person.email or "", "error", "not verified on the forge",
|
|
1146
|
+
"verify the address in the forge's email settings"))
|
|
1147
|
+
else:
|
|
1148
|
+
out.append(_check("verified email", person.email or "", "warning",
|
|
1149
|
+
"not checked (needs --online)" if not online else
|
|
1150
|
+
"could not check" + (f" ({verified_why})" if verified_why else "")))
|
|
1151
|
+
return out
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def machine_data(cfg, repo: Optional[Path] = None, extra: Sequence[str] = (), *, check_perms: bool = True,
|
|
1155
|
+
keys: Optional[dict] = None, online: bool = False,
|
|
1156
|
+
verified: Optional[Dict[str, Optional[bool]]] = None) -> List[dict]:
|
|
1157
|
+
"""One entry per (identity, policy) that applies: {"id", "policy", "file", "contact", "checks"}.
|
|
1158
|
+
With repo: the identity git uses there and the rules matching its remote. Without: every identity with
|
|
1159
|
+
a policy (its own, [policy].files, extra), with the rules for its host and owners."""
|
|
1160
|
+
out: List[dict] = []
|
|
1161
|
+
|
|
1162
|
+
def add(ident, found: Found) -> None:
|
|
1163
|
+
person = _person(cfg, ident)
|
|
1164
|
+
for pol, rules in found:
|
|
1165
|
+
out.append({"id": person.id, "policy": pol.name, "file": pol.path, "contact": pol.contact,
|
|
1166
|
+
"checks": identity_checks(cfg, person, pol, rules, keys, online, verified)})
|
|
1167
|
+
|
|
1168
|
+
if repo is not None:
|
|
1169
|
+
exp = resolve.expected(repo, cfg)
|
|
1170
|
+
ident = exp.match.identity
|
|
1171
|
+
urls = _repo_urls(resolve.remotes(repo), exp.match.url)
|
|
1172
|
+
if urls:
|
|
1173
|
+
add(ident, match(_load_all(sources(cfg, ident, extra), check_perms), urls, cfg))
|
|
1174
|
+
return out
|
|
1175
|
+
for ident in cfg.identities:
|
|
1176
|
+
pols = _load_all(sources(cfg, ident, extra), check_perms)
|
|
1177
|
+
found = [(p, [r for r in p.rules if _overlaps(r, ident)]) for p in pols]
|
|
1178
|
+
add(ident, [(p, rules) for p, rules in found if rules])
|
|
1179
|
+
return out
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def check_machine(cfg, repo: Optional[Path] = None, extra: Sequence[str] = ()) -> List[resolve.Problem]:
|
|
1183
|
+
"""What this machine violates, as resolve.Problem (code "policy"); a policy that cannot be loaded is
|
|
1184
|
+
one problem too. Never raises ConfigError (doctor's policy row)."""
|
|
1185
|
+
try:
|
|
1186
|
+
entries = machine_data(cfg, repo, extra)
|
|
1187
|
+
except ConfigError as e:
|
|
1188
|
+
return [resolve.Problem("policy", str(e), None)]
|
|
1189
|
+
return [resolve.Problem("policy", f"policy {e['policy']}: {c['message']}", c["fix"] or None)
|
|
1190
|
+
for e in entries for c in e["checks"] if c["level"] == "error"]
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def check_repo(repo: Path, cfg, extra: Sequence[str] = ()) -> List[dict]:
|
|
1194
|
+
"""machine_data for one repo (`check --policy --repo DIR`)."""
|
|
1195
|
+
return machine_data(cfg, _need_repo(repo), extra)
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
_MARK = {"ok": "✓", "warning": "⚠", "error": "✗"}
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _render_machine(entries: List[dict]) -> List[str]:
|
|
1202
|
+
lines: List[str] = []
|
|
1203
|
+
for e in entries:
|
|
1204
|
+
lines.append(f"policy {e['policy']} ({_tilde(e['file'])}) for {e['id']}")
|
|
1205
|
+
for c in e["checks"]:
|
|
1206
|
+
text = f"{_MARK[c['level']]} {c['message']}" + (f" → {c['fix']}" if c["fix"] and c["level"] != "ok" else "")
|
|
1207
|
+
lines.append(f" {c['name']:<14} {c['label']:<18} {text}")
|
|
1208
|
+
return lines
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _cmd_machine(args) -> int:
|
|
1212
|
+
from . import jsonout
|
|
1213
|
+
path, how = config.find(args.config, Path(args.home))
|
|
1214
|
+
cfg = config.load_all(path, how)
|
|
1215
|
+
repo = _need_repo(args.repo) if args.repo else resolve.is_repo(Path(os.getcwd()))
|
|
1216
|
+
entries = machine_data(cfg, repo, args.policy_files or ())
|
|
1217
|
+
problems = sum(1 for e in entries for c in e["checks"] if c["level"] == "error")
|
|
1218
|
+
if getattr(args, "json", False):
|
|
1219
|
+
jsonout.emit("check", {"ok": problems == 0, "mode": "machine", "repo": str(repo) if repo else None,
|
|
1220
|
+
"identities": [{**e, "checks": [{k: c[k] for k in ("name", "ok", "level", "message",
|
|
1221
|
+
"fix")} for c in e["checks"]]}
|
|
1222
|
+
for e in entries], "problems": problems})
|
|
1223
|
+
return 1 if problems else 0
|
|
1224
|
+
if not entries:
|
|
1225
|
+
where = f"to {_tilde(repo)}" if repo else "to any identity (set policy = \"~/…\" in an identity, or pass " \
|
|
1226
|
+
"--policy FILE)"
|
|
1227
|
+
print(f"no policy applies {where}")
|
|
1228
|
+
return 0
|
|
1229
|
+
lines = _render_machine(entries)
|
|
1230
|
+
lines.append(f"{problems} problem{'s' if problems != 1 else ''}" if problems else "✓ this machine meets the policy")
|
|
1231
|
+
print("\n".join(lines))
|
|
1232
|
+
return 1 if problems else 0
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _cmd_check(args) -> int:
|
|
1236
|
+
resolve.require_git()
|
|
1237
|
+
if args.range is not None:
|
|
1238
|
+
return _cmd_range(args)
|
|
1239
|
+
return _cmd_machine(args)
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
# ---------------------------------------------------------------- resolve.EXTRA_CHECKS
|
|
1243
|
+
|
|
1244
|
+
def check_report(report) -> List[resolve.Problem]:
|
|
1245
|
+
"""The identity's own policy against what resolve expects (email, signing); code "policy". Only the
|
|
1246
|
+
identity's `policy` is visible here (the callback gets the Report, not the config)."""
|
|
1247
|
+
ident = report.expected.match.identity
|
|
1248
|
+
if ident is None or not getattr(ident, "policy", None):
|
|
1249
|
+
return []
|
|
1250
|
+
try:
|
|
1251
|
+
pol = load(ident.policy, who=f"identity {ident.id}")
|
|
1252
|
+
except ConfigError as e:
|
|
1253
|
+
return [resolve.Problem("policy", str(e), None)]
|
|
1254
|
+
urls = _repo_urls(report.effective.remotes, report.expected.match.url)
|
|
1255
|
+
rules = [r for r in pol.rules if any(rule_matches(r, u) for u in urls)]
|
|
1256
|
+
if not rules:
|
|
1257
|
+
return []
|
|
1258
|
+
out: List[resolve.Problem] = []
|
|
1259
|
+
prob = email_problem(rules, report.expected.email)
|
|
1260
|
+
if prob:
|
|
1261
|
+
doms = _domains(rules)
|
|
1262
|
+
out.append(resolve.Problem("policy", f"policy {pol.name}: identity {ident.id} email {report.expected.email}: "
|
|
1263
|
+
f"{prob[1]}",
|
|
1264
|
+
f"use an address in {', '.join(doms)}" if doms else None))
|
|
1265
|
+
if any(r.require_signing for r in rules) and not report.expected.sign:
|
|
1266
|
+
out.append(resolve.Problem("policy", f"policy {pol.name} requires signed commits but identity {ident.id} has "
|
|
1267
|
+
f"sign = false", f"set sign = true, run {PROG} apply"))
|
|
1268
|
+
return out
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
resolve.EXTRA_CHECKS[:] = [f for f in resolve.EXTRA_CHECKS
|
|
1272
|
+
if not (getattr(f, "__module__", None) == __name__
|
|
1273
|
+
and getattr(f, "__qualname__", None) == "check_report")]
|
|
1274
|
+
resolve.EXTRA_CHECKS.append(check_report)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
# ---------------------------------------------------------------- guard entries (01 §5.6)
|
|
1278
|
+
|
|
1279
|
+
def _hook_found(ctx, url: Optional[str] = None) -> Found:
|
|
1280
|
+
"""The policies for the hook's repo; [] at once (no git, no file read) when the config names none."""
|
|
1281
|
+
cfg = ctx.cfg
|
|
1282
|
+
if not any_policy(cfg):
|
|
1283
|
+
return []
|
|
1284
|
+
rep = getattr(ctx, "report", None)
|
|
1285
|
+
if rep is not None:
|
|
1286
|
+
ident = rep.expected.match.identity
|
|
1287
|
+
urls = [url] if url else _repo_urls(rep.effective.remotes, rep.expected.match.url)
|
|
1288
|
+
else:
|
|
1289
|
+
exp = resolve.expected(Path(ctx.repo), cfg)
|
|
1290
|
+
ident = exp.match.identity
|
|
1291
|
+
urls = [url] if url else _repo_urls(resolve.remotes(Path(ctx.repo)), exp.match.url)
|
|
1292
|
+
if not urls:
|
|
1293
|
+
return []
|
|
1294
|
+
srcs = sources(cfg, ident)
|
|
1295
|
+
if not srcs:
|
|
1296
|
+
return []
|
|
1297
|
+
return match(_load_all(srcs, check_perms=True), urls, cfg)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _block(ctx, pol: Policy, head: str, lines: Sequence[str], fix: str) -> int:
|
|
1301
|
+
warn = getattr(getattr(ctx.cfg, "guard", None), "on_mismatch", "block") != "block"
|
|
1302
|
+
ctx.say(("⚠" if warn else "✗") + f" gitpro: {head}")
|
|
1303
|
+
for line in lines:
|
|
1304
|
+
ctx.say(" " + line)
|
|
1305
|
+
ctx.say(f" cause: {pol.cause()}")
|
|
1306
|
+
if fix:
|
|
1307
|
+
ctx.say(f" fix: {fix}")
|
|
1308
|
+
if warn:
|
|
1309
|
+
ctx.say(' (not blocking: [guard] on_mismatch = "warn")')
|
|
1310
|
+
return BLOCK
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _safe(ctx, fn) -> int:
|
|
1314
|
+
try:
|
|
1315
|
+
return fn(ctx)
|
|
1316
|
+
except KeyboardInterrupt:
|
|
1317
|
+
raise
|
|
1318
|
+
except BaseException as e: # noqa: BLE001 -- a broken policy must never block a commit
|
|
1319
|
+
msg = str(e) if isinstance(e, ConfigError) else (f"{type(e).__name__}: {e}" if str(e) else type(e).__name__)
|
|
1320
|
+
try:
|
|
1321
|
+
ctx.say(f"⚠ gitpro policy: {msg}; not blocking")
|
|
1322
|
+
except Exception: # noqa: BLE001
|
|
1323
|
+
pass
|
|
1324
|
+
return 0
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _email_fix(rules: Sequence[Rule], key: str) -> str:
|
|
1328
|
+
doms = _domains(rules)
|
|
1329
|
+
if key == "allowed_email_domains" and doms:
|
|
1330
|
+
return f"commit as an address in {', '.join(doms)} (git config user.email …)"
|
|
1331
|
+
return "commit as another address (git config user.email …)"
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _hook_commit(ctx) -> int:
|
|
1335
|
+
found = _hook_found(ctx)
|
|
1336
|
+
for pol, rules in found:
|
|
1337
|
+
for which, email in (("committed", ctx.committer_email), ("authored", ctx.author_email)):
|
|
1338
|
+
prob = email_problem(rules, email)
|
|
1339
|
+
if prob:
|
|
1340
|
+
return _block(ctx, pol, f"this commit would be {which} as {email}: {prob[1]}", (),
|
|
1341
|
+
_email_fix(rules, prob[0]))
|
|
1342
|
+
return 0
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def hook_commit(ctx) -> int:
|
|
1346
|
+
"""pre-commit: the committer and author emails against the repo's policies. 3 to refuse, else 0."""
|
|
1347
|
+
return _safe(ctx, _hook_commit)
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _outgoing(ctx, signing: bool, pre: Sequence[str]) -> List[_Commit]:
|
|
1351
|
+
"""The commits this push sends that no remote-tracking ref has (the listing guard uses)."""
|
|
1352
|
+
seen, out = set(), []
|
|
1353
|
+
for line in (ctx.stdin or "").splitlines():
|
|
1354
|
+
parts = line.split()
|
|
1355
|
+
if len(parts) != 4 or set(parts[1]) == {"0"}:
|
|
1356
|
+
continue
|
|
1357
|
+
local, remote = parts[1], parts[3]
|
|
1358
|
+
revs = [local]
|
|
1359
|
+
if set(remote) != {"0"}:
|
|
1360
|
+
try:
|
|
1361
|
+
_git(ctx.repo, "cat-file", "-e", f"{remote}^{{commit}}")
|
|
1362
|
+
revs.append(f"^{remote}")
|
|
1363
|
+
except ConfigError:
|
|
1364
|
+
pass
|
|
1365
|
+
for c in _log(ctx.repo, [*revs, "--not", "--remotes"], signing=signing, pre=pre, timeout=GIT_TIMEOUT):
|
|
1366
|
+
if c.sha not in seen:
|
|
1367
|
+
seen.add(c.sha)
|
|
1368
|
+
out.append(c)
|
|
1369
|
+
return out
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _hook_push(ctx) -> int:
|
|
1373
|
+
url = ctx.args[1] if len(ctx.args) > 1 else None
|
|
1374
|
+
found = _hook_found(ctx, url)
|
|
1375
|
+
if not found:
|
|
1376
|
+
return 0
|
|
1377
|
+
signing = any(r.signing() for _, rules in found for r in rules)
|
|
1378
|
+
tmp: List[str] = []
|
|
1379
|
+
try:
|
|
1380
|
+
pre, ships = _signers_args(found, tmp) if signing else ([], False)
|
|
1381
|
+
commits = _outgoing(ctx, signing, pre)
|
|
1382
|
+
finally:
|
|
1383
|
+
for t in tmp:
|
|
1384
|
+
try:
|
|
1385
|
+
os.unlink(t)
|
|
1386
|
+
except OSError:
|
|
1387
|
+
pass
|
|
1388
|
+
for pol, rules in found:
|
|
1389
|
+
bad = []
|
|
1390
|
+
for c in commits:
|
|
1391
|
+
for v in _judge(c, [(pol, rules)], signing, ships):
|
|
1392
|
+
bad.append(f"{c.sha[:7]} {c.author_email} {v['message']}")
|
|
1393
|
+
if bad:
|
|
1394
|
+
n = len(bad)
|
|
1395
|
+
shown = bad[:5] + ([f"… and {n - 5} more"] if n > 5 else [])
|
|
1396
|
+
return _block(ctx, pol, f"this push breaks policy {pol.name} ({n} problem{'s' if n != 1 else ''})", shown,
|
|
1397
|
+
"rewrite the listed commits (git rebase -i; sign with git commit --amend -S), then push")
|
|
1398
|
+
return 0
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def hook_push(ctx) -> int:
|
|
1402
|
+
"""pre-push: every outgoing commit against the remote's policies (emails, trailers, and %G? when a rule
|
|
1403
|
+
requires signing). 3 to refuse, else 0."""
|
|
1404
|
+
return _safe(ctx, _hook_push)
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def _in_merge() -> bool:
|
|
1408
|
+
fn = getattr(trailers, "_in_merge", None)
|
|
1409
|
+
return bool(fn()) if fn else False
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _hook_message(ctx) -> int:
|
|
1413
|
+
if not any_policy(ctx.cfg):
|
|
1414
|
+
return 0
|
|
1415
|
+
if not ctx.args:
|
|
1416
|
+
raise ConfigError("commit-msg called without a message file")
|
|
1417
|
+
found = _hook_found(ctx)
|
|
1418
|
+
if not found:
|
|
1419
|
+
return 0
|
|
1420
|
+
msg = Path(ctx.args[0])
|
|
1421
|
+
if not msg.is_absolute() and not msg.exists():
|
|
1422
|
+
msg = Path(ctx.repo) / msg
|
|
1423
|
+
parsed = trailers.parse(msg)
|
|
1424
|
+
merge = _in_merge()
|
|
1425
|
+
for pol, rules in found:
|
|
1426
|
+
probs = trailer_problems(pol, rules, parsed, merge)
|
|
1427
|
+
if probs:
|
|
1428
|
+
head, rest = probs[0][1], [f"also: {m}" for _, m in probs[1:]]
|
|
1429
|
+
fix = "remove the trailer from the message" if not any(
|
|
1430
|
+
"requires" in m for _, m in probs) else "end the message with a trailer line: Co-Authored-By: Name <email>"
|
|
1431
|
+
return _block(ctx, pol, head, rest, fix)
|
|
1432
|
+
return 0
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def hook_message(ctx) -> int:
|
|
1436
|
+
"""commit-msg: the message's trailers against the repo's policies. 3 to refuse, else 0."""
|
|
1437
|
+
return _safe(ctx, _hook_message)
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
# ---------------------------------------------------------------- team
|
|
1441
|
+
|
|
1442
|
+
def _optional(name: str):
|
|
1443
|
+
import importlib
|
|
1444
|
+
try:
|
|
1445
|
+
return importlib.import_module(name)
|
|
1446
|
+
except ImportError:
|
|
1447
|
+
return None
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _registered(cfg, home: Path) -> Dict[str, Optional[bool]]:
|
|
1451
|
+
"""pub path -> registered as a signing key (09 health.check_keys --online); {} without health. The
|
|
1452
|
+
caller has announced already (the forge session's gate), so check_keys prints nothing."""
|
|
1453
|
+
health = _optional("gitpro.health")
|
|
1454
|
+
if health is None or not hasattr(health, "check_keys"):
|
|
1455
|
+
return {}
|
|
1456
|
+
out: Dict[str, Optional[bool]] = {}
|
|
1457
|
+
data = health.check_keys(cfg, online=True, announce=False, home=home)
|
|
1458
|
+
for k in data.get("keys", []):
|
|
1459
|
+
reg = k.get("registered")
|
|
1460
|
+
pub = k.get("pub") or (str(k.get("path")) + ".pub")
|
|
1461
|
+
out[os.path.expanduser(str(pub))] = None if not isinstance(reg, dict) or reg.get("signing") is None \
|
|
1462
|
+
else bool(reg.get("signing"))
|
|
1463
|
+
return out
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
TEAM_WHAT = "which SSH keys are registered on your accounts"
|
|
1467
|
+
TEAM_WHAT_EMAILS = TEAM_WHAT + " and which of your emails they have verified"
|
|
1468
|
+
TEAM_SENDS = "only the list requests; keys and emails are compared locally"
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _session_ids(cfg) -> List[str]:
|
|
1472
|
+
"""The forge targets of `team --online`: [default] when it has an account (health.check_keys asks it
|
|
1473
|
+
too), then every identity."""
|
|
1474
|
+
d = getattr(cfg, "default", None)
|
|
1475
|
+
return (["default"] if getattr(d, "gh_account", None) or getattr(d, "glab_account", None) else []) + \
|
|
1476
|
+
[i.id for i in cfg.identities]
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _mentions(text: str, word: str) -> bool:
|
|
1480
|
+
return bool(word) and re.search(rf"(?<![\w.-]){re.escape(word)}(?![\w-])", text) is not None
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _verified(cfg, home: Path, as_json: bool, session: object = None, why: Optional[List[str]] = None,
|
|
1484
|
+
reasons: Optional[Dict[str, str]] = None) -> Dict[str, Optional[bool]]:
|
|
1485
|
+
"""casefolded email -> verified on the forge: True / False / None (unknown; an email missing from the
|
|
1486
|
+
map is unknown too). session: the gated forge.Session of the run, else one is made and gated here.
|
|
1487
|
+
A failure is kept as unknown, its reason appended to why (and warned through the session). reasons
|
|
1488
|
+
gets, per casefolded email and per identity id left unknown, the forge warnings that name its account
|
|
1489
|
+
or identity."""
|
|
1490
|
+
from . import forge
|
|
1491
|
+
make, emails = getattr(forge, "Session", None), getattr(forge, "emails", None)
|
|
1492
|
+
if make is None or emails is None:
|
|
1493
|
+
if why is not None:
|
|
1494
|
+
why.append("this gitpro has no forge support")
|
|
1495
|
+
return {}
|
|
1496
|
+
s = session
|
|
1497
|
+
try:
|
|
1498
|
+
if s is None:
|
|
1499
|
+
s = make(cfg, home, _session_ids(cfg), as_json=as_json)
|
|
1500
|
+
if hasattr(s, "gate"):
|
|
1501
|
+
rc = s.gate("team", TEAM_WHAT_EMAILS, TEAM_SENDS, explicit=True,
|
|
1502
|
+
args=argparse.Namespace(json=as_json, command="team"))
|
|
1503
|
+
if rc is not None:
|
|
1504
|
+
raise ConfigError("the forge could not be asked")
|
|
1505
|
+
seen = len(getattr(s, "warnings", None) or [])
|
|
1506
|
+
got = emails(s) or ()
|
|
1507
|
+
new = list(getattr(s, "warnings", None) or [])[seen:]
|
|
1508
|
+
except (ConfigError, OSError, subprocess.SubprocessError, UnicodeError, ValueError) as e:
|
|
1509
|
+
msg = f"verified email: could not ask the forge: {e}"
|
|
1510
|
+
if why is not None:
|
|
1511
|
+
why.append(str(e))
|
|
1512
|
+
if hasattr(s, "warn"):
|
|
1513
|
+
s.warn(msg)
|
|
1514
|
+
else:
|
|
1515
|
+
print(f"⚠ {msg}", file=sys.stderr)
|
|
1516
|
+
return {}
|
|
1517
|
+
out: Dict[str, Optional[bool]] = {}
|
|
1518
|
+
for e in got:
|
|
1519
|
+
if isinstance(e, dict) and e.get("email"):
|
|
1520
|
+
v = e.get("verified")
|
|
1521
|
+
key = str(e["email"]).casefold()
|
|
1522
|
+
if v is True or v is False:
|
|
1523
|
+
out[key] = False if out.get(key) is False else v # two accounts disagree: not verified
|
|
1524
|
+
else:
|
|
1525
|
+
out.setdefault(key, None)
|
|
1526
|
+
said = [w for w in new if _mentions(w, str(e.get("account") or "")) or _mentions(w, str(e.get("id") or ""))]
|
|
1527
|
+
if said and reasons is not None:
|
|
1528
|
+
reasons.setdefault(key, "; ".join(said))
|
|
1529
|
+
if reasons is not None:
|
|
1530
|
+
for i in getattr(cfg, "identities", ()) or ():
|
|
1531
|
+
said = [w for w in new if _mentions(w, i.id)]
|
|
1532
|
+
if said and (i.email or "").casefold() not in out:
|
|
1533
|
+
reasons.setdefault(i.id, "; ".join(said))
|
|
1534
|
+
return out
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def _worst(checks: Sequence[dict], *names: str) -> Optional[str]:
|
|
1538
|
+
levels = [c["level"] for c in checks if c["name"] in names]
|
|
1539
|
+
for lv in ("error", "warning", "ok"):
|
|
1540
|
+
if lv in levels:
|
|
1541
|
+
return lv
|
|
1542
|
+
return None
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def _cell(level: Optional[str], online: bool = True) -> str:
|
|
1546
|
+
if level is None:
|
|
1547
|
+
return "·"
|
|
1548
|
+
if level == "warning" and not online:
|
|
1549
|
+
return "⚠ (--online)"
|
|
1550
|
+
return _MARK[level]
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _needs_verified(cfg) -> bool:
|
|
1554
|
+
"""Does any rule that applies to an identity require a verified email? (Unreadable policies count as no:
|
|
1555
|
+
the rows report them.)"""
|
|
1556
|
+
for ident in cfg.identities:
|
|
1557
|
+
for path, who in sources(cfg, ident):
|
|
1558
|
+
try:
|
|
1559
|
+
pol = load(path, who=who)
|
|
1560
|
+
except ConfigError:
|
|
1561
|
+
continue
|
|
1562
|
+
if any(r.require_verified_email and _overlaps(r, ident) for r in pol.rules):
|
|
1563
|
+
return True
|
|
1564
|
+
return False
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def team_data(cfg, home: Path, *, online: bool = False, as_json: bool = False,
|
|
1568
|
+
explicit_config: Optional[Path] = None, session: object = None,
|
|
1569
|
+
needs_verified: Optional[bool] = None) -> dict:
|
|
1570
|
+
"""The rows of `team`. online: session is the gated forge.Session (cmd_team opens it)."""
|
|
1571
|
+
keys = _registered(cfg, home) if online else None
|
|
1572
|
+
if needs_verified is None:
|
|
1573
|
+
needs_verified = online and _needs_verified(cfg)
|
|
1574
|
+
why: List[str] = []
|
|
1575
|
+
reasons: Dict[str, str] = {}
|
|
1576
|
+
verified = _verified(cfg, home, as_json, session, why, reasons) if online and needs_verified else None
|
|
1577
|
+
ids = []
|
|
1578
|
+
for ident in cfg.identities:
|
|
1579
|
+
pols = _load_all(sources(cfg, ident), check_perms=True)
|
|
1580
|
+
found = [(p, [r for r in p.rules if _overlaps(r, ident)]) for p in pols]
|
|
1581
|
+
found = [(p, rules) for p, rules in found if rules]
|
|
1582
|
+
person = _person(cfg, ident)
|
|
1583
|
+
checks = [c for p, rules in found
|
|
1584
|
+
for c in identity_checks(cfg, person, p, rules, keys, online, verified,
|
|
1585
|
+
reasons.get((person.email or "").casefold()) or reasons.get(ident.id)
|
|
1586
|
+
or "; ".join(why))]
|
|
1587
|
+
ids.append({"id": ident.id, "policy": ", ".join(p.name for p, _ in found) or None,
|
|
1588
|
+
"policies": [{"name": p.name, "file": p.path, "contact": p.contact} for p, _ in found],
|
|
1589
|
+
"ok": not any(c["level"] == "error" for c in checks),
|
|
1590
|
+
"checks": [{k: c[k] for k in ("name", "ok", "level", "message", "fix")} for c in checks]})
|
|
1591
|
+
guard = _optional("gitpro.guard")
|
|
1592
|
+
agents = _optional("gitpro.agents")
|
|
1593
|
+
g = a = None
|
|
1594
|
+
if guard is not None and hasattr(guard, "status"):
|
|
1595
|
+
try:
|
|
1596
|
+
g = guard.status(home)
|
|
1597
|
+
except Exception as e: # noqa: BLE001
|
|
1598
|
+
g = {"installed": "none", "error": str(e)}
|
|
1599
|
+
if agents is not None and hasattr(agents, "status_data"):
|
|
1600
|
+
try:
|
|
1601
|
+
a = agents.status_data(home, explicit_config)
|
|
1602
|
+
except Exception as e: # noqa: BLE001
|
|
1603
|
+
a = {"error": str(e)}
|
|
1604
|
+
return {"ok": all(i["ok"] for i in ids), "identities": ids, "guard": g, "agents": a,
|
|
1605
|
+
"warnings": list(getattr(session, "warnings", None) or [])}
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _shell_path(path: str) -> str:
|
|
1609
|
+
"""path for a command line the user can paste: ~/rest when under home, quoted where needed."""
|
|
1610
|
+
import shlex
|
|
1611
|
+
shown = _tilde(path)
|
|
1612
|
+
return "~/" + shlex.quote(shown[2:]) if shown.startswith("~/") else shlex.quote(shown)
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def check_hint(ids: Sequence[dict]) -> str:
|
|
1616
|
+
"""The command that shows the details of failing identities: `gitpro check --policy FILE` per policy
|
|
1617
|
+
file they name (machine mode, every identity with a policy)."""
|
|
1618
|
+
files = list(dict.fromkeys(p["file"] for i in ids if not i["ok"] for p in i.get("policies") or () if p.get("file")))
|
|
1619
|
+
return " ".join([PROG, "check"] + [f"--policy {_shell_path(f)}" for f in files])
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _render_team(data: dict, online: bool) -> List[str]:
|
|
1623
|
+
ids = data["identities"]
|
|
1624
|
+
w = max([len("identity")] + [len(i["id"]) for i in ids]) + 2
|
|
1625
|
+
lines = ["team status (this machine)",
|
|
1626
|
+
f" {'identity':<{w}}{'policy':<29}{'email':<7}{'signing':<9}{'key registered':<16}"
|
|
1627
|
+
f"{'verified':<14}trailers"]
|
|
1628
|
+
for i in ids:
|
|
1629
|
+
if not i["policy"]:
|
|
1630
|
+
lines.append(f" {i['id']:<{w}}{'(none)':<29}{'·':<7}{'·':<9}{'·':<16}{'·':<14}·")
|
|
1631
|
+
continue
|
|
1632
|
+
name = i["policy"] if len(i["policy"]) <= 27 else i["policy"][:26] + "…"
|
|
1633
|
+
c = i["checks"]
|
|
1634
|
+
lines.append(f" {i['id']:<{w}}{name:<29}{_cell(_worst(c, 'email domain')):<7}"
|
|
1635
|
+
f"{_cell(_worst(c, 'signing')):<9}{_cell(_worst(c, 'signing key'), online):<16}"
|
|
1636
|
+
f"{_cell(_worst(c, 'verified email'), online):<14}{_cell(_worst(c, 'trailers'))}")
|
|
1637
|
+
g = data["guard"]
|
|
1638
|
+
if g is None:
|
|
1639
|
+
guard = "guard: · not installed"
|
|
1640
|
+
elif g.get("installed", "none") != "none":
|
|
1641
|
+
guard = f"guard: installed ({g.get('on_mismatch', 'block')})"
|
|
1642
|
+
else:
|
|
1643
|
+
guard = "guard: not installed"
|
|
1644
|
+
a = data["agents"]
|
|
1645
|
+
if a is None:
|
|
1646
|
+
agent = "agent hook: · not installed"
|
|
1647
|
+
else:
|
|
1648
|
+
marks = []
|
|
1649
|
+
for name, v in (a.get("agents") or {}).items():
|
|
1650
|
+
on = isinstance(v, dict) and any(v.get(k) for k in ("user", "project", "plugin"))
|
|
1651
|
+
marks.append(f"{name} {'✓' if on else '·'}")
|
|
1652
|
+
agent = "agent hook: " + (" ".join(marks) or "·")
|
|
1653
|
+
lines.append(f"{guard:<29}{agent}")
|
|
1654
|
+
bad = sum(1 for i in ids if not i["ok"])
|
|
1655
|
+
if bad:
|
|
1656
|
+
lines.append(f"{bad} identit{'y violates its' if bad == 1 else 'ies violate their'} policy → "
|
|
1657
|
+
f"{check_hint(ids)}")
|
|
1658
|
+
elif any(i["policy"] for i in ids):
|
|
1659
|
+
lines.append("✓ every identity meets its policy")
|
|
1660
|
+
else:
|
|
1661
|
+
lines.append("no identity has a policy (set policy = \"~/…\" in an identity)")
|
|
1662
|
+
return lines
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def cmd_team(args) -> int:
|
|
1666
|
+
from . import jsonout
|
|
1667
|
+
resolve.require_git()
|
|
1668
|
+
home = Path(args.home)
|
|
1669
|
+
path, how = config.find(args.config, home)
|
|
1670
|
+
cfg = config.load_all(path, how)
|
|
1671
|
+
as_json = getattr(args, "json", False)
|
|
1672
|
+
session = None
|
|
1673
|
+
needs = False
|
|
1674
|
+
if args.online:
|
|
1675
|
+
from . import forge
|
|
1676
|
+
if not (forge.available("gh") or forge.available("glab")):
|
|
1677
|
+
raise ConfigError("team --online needs gh or glab on PATH (signed in) to ask the forge which keys and "
|
|
1678
|
+
"emails are registered")
|
|
1679
|
+
# one announce line for every forge call of this run (key lists, verified emails): first on stdout,
|
|
1680
|
+
# on stderr under --json, through the session's gate
|
|
1681
|
+
needs = _needs_verified(cfg)
|
|
1682
|
+
session = forge.Session(cfg, home, _session_ids(cfg), as_json=as_json)
|
|
1683
|
+
rc = session.gate("team", TEAM_WHAT_EMAILS if needs else TEAM_WHAT, TEAM_SENDS, explicit=True, args=args)
|
|
1684
|
+
if rc is not None:
|
|
1685
|
+
return rc
|
|
1686
|
+
data = team_data(cfg, home, online=args.online, as_json=as_json, explicit_config=args.config,
|
|
1687
|
+
session=session, needs_verified=needs)
|
|
1688
|
+
if getattr(args, "json", False):
|
|
1689
|
+
jsonout.emit("team", data)
|
|
1690
|
+
else:
|
|
1691
|
+
print("\n".join(_render_team(data, args.online)))
|
|
1692
|
+
return 0 if data["ok"] else 1
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
# ---------------------------------------------------------------- policy show / lint
|
|
1696
|
+
|
|
1697
|
+
def _show_lines(found: Found) -> List[str]:
|
|
1698
|
+
lines: List[str] = []
|
|
1699
|
+
for pol, rules in found:
|
|
1700
|
+
lines.append(f"policy {pol.name} ({_tilde(pol.path)})" + (f", contact {pol.contact}" if pol.contact else ""))
|
|
1701
|
+
for r in rules:
|
|
1702
|
+
lines.append(f" rule {r.index}: {r.host}/{', '.join(r.owners) if r.owners else '*'}")
|
|
1703
|
+
if r.emails():
|
|
1704
|
+
lines.append(f" email domains {', '.join(r.allowed_email_domains) or '(none)'}"
|
|
1705
|
+
+ (f"; also {', '.join(r.allowed_emails)}" if r.allowed_emails else ""))
|
|
1706
|
+
if r.signing():
|
|
1707
|
+
lines.append(f" signing {'required' if r.require_signing else 'optional'}"
|
|
1708
|
+
+ (f" ({', '.join(r.signing_formats)})" if r.signing_formats else ""))
|
|
1709
|
+
lines.append(f" co_authored_by {r.co_authored_by}")
|
|
1710
|
+
if r.forbid_emails:
|
|
1711
|
+
lines.append(f" forbid_emails {', '.join(r.forbid_emails)}")
|
|
1712
|
+
if r.require_verified_email:
|
|
1713
|
+
lines.append(" verified email required (checked by gitpro team --online)")
|
|
1714
|
+
return lines
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def cmd_show(args) -> int:
|
|
1718
|
+
from . import jsonout
|
|
1719
|
+
resolve.require_git()
|
|
1720
|
+
repo = _need_repo(args.dir)
|
|
1721
|
+
cfg = _config_or_none(args)
|
|
1722
|
+
found: Found = applicable(repo, cfg) if cfg is not None else []
|
|
1723
|
+
if not found and (repo / REPO_POLICY).is_file():
|
|
1724
|
+
found = match([load(repo / REPO_POLICY, check_perms=False, inside=repo)], _repo_urls(resolve.remotes(repo)),
|
|
1725
|
+
cfg)
|
|
1726
|
+
if getattr(args, "json", False):
|
|
1727
|
+
jsonout.emit("policy", {"repo": str(repo), "policies": [
|
|
1728
|
+
{"name": p.name, "file": p.path, "version": p.version, "contact": p.contact,
|
|
1729
|
+
"allowed_signers": p.allowed_signers, "rules": [r.to_json() for r in rules]} for p, rules in found]})
|
|
1730
|
+
return 0
|
|
1731
|
+
if not found:
|
|
1732
|
+
print(f"no policy applies to {_tilde(repo)}")
|
|
1733
|
+
return 0
|
|
1734
|
+
print("\n".join(_show_lines(found)))
|
|
1735
|
+
return 0
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def cmd_lint(args) -> int:
|
|
1739
|
+
from . import jsonout
|
|
1740
|
+
pol, errs = lint(args.file)
|
|
1741
|
+
if getattr(args, "json", False):
|
|
1742
|
+
jsonout.emit("policy", {"ok": not errs, "file": str(args.file), "errors": errs,
|
|
1743
|
+
"name": pol.name if pol else None, "rules": len(pol.rules) if pol else None})
|
|
1744
|
+
return 2 if errs else 0
|
|
1745
|
+
if errs:
|
|
1746
|
+
for e in errs:
|
|
1747
|
+
print(f"✗ {e}", file=sys.stderr)
|
|
1748
|
+
return 2
|
|
1749
|
+
assert pol is not None
|
|
1750
|
+
print(f"✓ {_tilde(args.file)}: policy {pol.name}, {len(pol.rules)} rule{'s' if len(pol.rules) != 1 else ''}")
|
|
1751
|
+
return 0
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
# ---------------------------------------------------------------- doctor
|
|
1755
|
+
|
|
1756
|
+
def doctor_section(ctx) -> dict:
|
|
1757
|
+
"""16's policy row: does this machine meet the policies its config names?"""
|
|
1758
|
+
cfg = ctx.need_cfg()
|
|
1759
|
+
if not any_policy(cfg):
|
|
1760
|
+
return {"status": "ok", "summary": "no policy configured", "problems": [], "details": []}
|
|
1761
|
+
probs = check_machine(cfg)
|
|
1762
|
+
return {"status": "error" if probs else "ok",
|
|
1763
|
+
"summary": probs[0].message if probs else "machine meets the team policy",
|
|
1764
|
+
"problems": [{"message": p.message, "fix": p.fix or "", "level": "error"} for p in probs],
|
|
1765
|
+
"details": []}
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
# ---------------------------------------------------------------- CLI
|
|
1769
|
+
|
|
1770
|
+
def add_check_flags(p: argparse.ArgumentParser) -> None:
|
|
1771
|
+
p.add_argument("--policy", dest="policy_files", action="append", metavar="FILE",
|
|
1772
|
+
help="check against a team policy file (repeatable; default: the policies the config names)")
|
|
1773
|
+
p.add_argument("--range", metavar="A..B", help="CI mode: check the commits of this range against the policy")
|
|
1774
|
+
p.add_argument("--repo", metavar="DIR", help="the repo to check (default: the current directory)")
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
def check_handler(args) -> Optional[int]:
|
|
1778
|
+
if not (getattr(args, "policy_files", None) or getattr(args, "range", None) is not None
|
|
1779
|
+
or getattr(args, "repo", None)):
|
|
1780
|
+
return None
|
|
1781
|
+
return _cmd_check(args)
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def register(sub, common, confirm) -> None:
|
|
1785
|
+
from .args import capability
|
|
1786
|
+
sp = sub.add_parser("team", parents=[common], help="does this machine meet each identity's team policy?")
|
|
1787
|
+
sp.add_argument("--online", action="store_true", help="also ask gh/glab which keys and emails are registered")
|
|
1788
|
+
sp.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1789
|
+
sp.set_defaults(func=cmd_team)
|
|
1790
|
+
pp = sub.add_parser("policy", help="show or lint team policy files")
|
|
1791
|
+
ps = pp.add_subparsers(dest="policy_command", metavar="ACTION")
|
|
1792
|
+
ps.required = True
|
|
1793
|
+
s = ps.add_parser("show", parents=[common], help="the policy rules that apply to a repo")
|
|
1794
|
+
s.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
|
|
1795
|
+
s.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1796
|
+
s.set_defaults(func=cmd_show)
|
|
1797
|
+
s = ps.add_parser("lint", help="validate a policy file (every error, no permission check)")
|
|
1798
|
+
s.add_argument("file", type=Path, help="the policy TOML")
|
|
1799
|
+
s.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1800
|
+
s.set_defaults(func=cmd_lint)
|
|
1801
|
+
capability("policy", "team")
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def extend(subs) -> None:
|
|
1805
|
+
from .args import capability
|
|
1806
|
+
from .cli import extend_command
|
|
1807
|
+
extend_command(subs, "check", add_check_flags, check_handler)
|
|
1808
|
+
capability("check.policy")
|