commitguardian 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,30 @@
1
+ """Deterministic hashing helpers.
2
+
3
+ Used to derive stable identifiers (e.g. finding fingerprints) so the same
4
+ violation on the same commit always produces the same ID for audit purposes.
5
+ """
6
+
7
+ import hashlib
8
+ from collections.abc import Iterable
9
+
10
+ _FIELD_SEPARATOR = b"\x1f" # ASCII unit separator: cannot be confused with text
11
+
12
+
13
+ def sha256_hex(data: bytes) -> str:
14
+ """Return the lowercase hex SHA-256 digest of ``data``."""
15
+ return hashlib.sha256(data).hexdigest()
16
+
17
+
18
+ def fingerprint(parts: Iterable[str]) -> str:
19
+ """Return a stable SHA-256 fingerprint over an ordered sequence of strings.
20
+
21
+ Each part is length-prefixed, so ``["ab", "c"]`` and ``["a", "bc"]`` never
22
+ collide even if a part contains the separator byte.
23
+ """
24
+ digest = hashlib.sha256()
25
+ for part in parts:
26
+ encoded = part.encode("utf-8", errors="surrogatepass")
27
+ digest.update(str(len(encoded)).encode("ascii"))
28
+ digest.update(_FIELD_SEPARATOR)
29
+ digest.update(encoded)
30
+ return digest.hexdigest()
@@ -0,0 +1,33 @@
1
+ """Fixed-window request rate limiting with bounded memory.
2
+
3
+ Used by the webhook endpoint (per client address) and the dashboard API (per
4
+ user or client address, per operation category). Limits are per process.
5
+ """
6
+
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable
10
+
11
+
12
+ class RequestRateLimiter:
13
+ """Fixed-window request counter per client address (bounded memory)."""
14
+
15
+ MAX_TRACKED = 10_000
16
+
17
+ def __init__(self, per_minute: int, clock: Callable[[], float] = time.monotonic) -> None:
18
+ self._limit = per_minute
19
+ self._clock = clock
20
+ self._lock = threading.Lock()
21
+ self._windows: dict[str, tuple[int, int]] = {}
22
+
23
+ def allow(self, key: str) -> bool:
24
+ window = int(self._clock() // 60)
25
+ with self._lock:
26
+ if len(self._windows) > self.MAX_TRACKED:
27
+ self._windows.clear()
28
+ start, count = self._windows.get(key, (window, 0))
29
+ if start != window:
30
+ start, count = window, 0
31
+ count += 1
32
+ self._windows[key] = (start, count)
33
+ return count <= self._limit
@@ -0,0 +1,69 @@
1
+ """Hardened YAML loading for configuration and rule files.
2
+
3
+ * ``yaml.SafeLoader`` only: no Python object construction;
4
+ * duplicate mapping keys are rejected (a later ``action: allow`` must not
5
+ silently override an earlier ``action: block``);
6
+ * anchors/aliases are rejected, removing "billion laughs" style expansion.
7
+ """
8
+
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+
14
+ class UnsafeYAMLError(yaml.YAMLError):
15
+ """Raised for YAML constructs CommitGuard refuses to load."""
16
+
17
+
18
+ class _StrictSafeLoader(yaml.SafeLoader):
19
+ def compose_node(self, parent: yaml.Node | None, index: int) -> yaml.Node | None:
20
+ if self.check_event(yaml.events.AliasEvent):
21
+ event = self.peek_event() # type: ignore[no-untyped-call]
22
+ raise yaml.composer.ComposerError(
23
+ None, None, "YAML aliases are not allowed", event.start_mark
24
+ )
25
+ return super().compose_node(parent, index)
26
+
27
+
28
+ def _construct_unique_mapping(
29
+ loader: _StrictSafeLoader, node: yaml.MappingNode, deep: bool = False
30
+ ) -> dict[Any, Any]:
31
+ seen: set[Any] = set()
32
+ for key_node, _ in node.value:
33
+ key = loader.construct_object(key_node, deep=deep)
34
+ try:
35
+ duplicate = key in seen
36
+ except TypeError as exc: # unhashable key such as a list
37
+ raise yaml.constructor.ConstructorError(
38
+ None, None, "mapping keys must be scalars", key_node.start_mark
39
+ ) from exc
40
+ if duplicate:
41
+ raise yaml.constructor.ConstructorError(
42
+ None, None, f"duplicate key {key!r}", key_node.start_mark
43
+ )
44
+ seen.add(key)
45
+ return loader.construct_mapping(node, deep=deep)
46
+
47
+
48
+ _StrictSafeLoader.add_constructor(
49
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping
50
+ )
51
+
52
+
53
+ def load_yaml(text: str) -> object:
54
+ """Parse one YAML document with the strict safe loader.
55
+
56
+ Raises :class:`yaml.YAMLError` (including for duplicate keys and aliases).
57
+ """
58
+ # SafeLoader subclass: no object construction (see tests for !!python tags).
59
+ return yaml.load(text, Loader=_StrictSafeLoader) # noqa: S506 # nosec B506
60
+
61
+
62
+ def load_yaml_for_inspection(text: str) -> object:
63
+ """Parse third-party YAML (e.g. GitHub workflow files) for read-only inspection.
64
+
65
+ Uses ``yaml.SafeLoader`` (no object construction) but, unlike
66
+ :func:`load_yaml`, accepts anchors and duplicate keys, which GitHub accepts
67
+ in workflows. Never use this for CommitGuard configuration or rules.
68
+ """
69
+ return yaml.safe_load(text)
@@ -0,0 +1,85 @@
1
+ """Sanitisation of untrusted text before it reaches a terminal or log.
2
+
3
+ Commit messages and trailers are attacker-controlled. Printing them verbatim
4
+ allows terminal escape injection, e.g. an ANSI "erase line" sequence that hides
5
+ ``Co-authored-by: <AI agent>`` from a reviewer while it still exists in the
6
+ commit. Sanitised output makes every such byte visible instead of executing it.
7
+ """
8
+
9
+ DEFAULT_MAX_LENGTH = 500
10
+ _TRUNCATION_MARKER = "...[truncated]"
11
+
12
+ # Bidirectional overrides can visually reorder text ("Trojan Source").
13
+ _BIDI_CONTROLS = frozenset(
14
+ chr(code)
15
+ for code in (
16
+ 0x200E,
17
+ 0x200F,
18
+ 0x202A,
19
+ 0x202B,
20
+ 0x202C,
21
+ 0x202D,
22
+ 0x202E,
23
+ 0x2066,
24
+ 0x2067,
25
+ 0x2068,
26
+ 0x2069,
27
+ )
28
+ )
29
+
30
+
31
+ def _escape_char(char: str) -> str:
32
+ code = ord(char)
33
+ if code <= 0xFF:
34
+ return f"\\x{code:02x}"
35
+ return f"\\u{code:04x}"
36
+
37
+
38
+ def sanitize_for_terminal(
39
+ text: str,
40
+ *,
41
+ max_length: int = DEFAULT_MAX_LENGTH,
42
+ keep_newlines: bool = False,
43
+ ) -> str:
44
+ """Return ``text`` made safe to print to a terminal or plain-text log.
45
+
46
+ * C0/C1 control characters (including ESC, so no ANSI sequence can take
47
+ effect) and Unicode bidi controls are rendered as visible escapes.
48
+ * Tabs become spaces; newlines are escaped unless ``keep_newlines`` is set.
49
+ * The result is truncated to at most ``max_length`` characters.
50
+ """
51
+ if max_length < len(_TRUNCATION_MARKER):
52
+ raise ValueError(f"max_length must be at least {len(_TRUNCATION_MARKER)}")
53
+
54
+ out: list[str] = []
55
+ for char in text:
56
+ code = ord(char)
57
+ if char == "\n" and keep_newlines:
58
+ out.append(char)
59
+ elif char == "\t":
60
+ out.append(" ")
61
+ elif code < 0x20 or 0x7F <= code <= 0x9F or char in _BIDI_CONTROLS:
62
+ out.append(_escape_char(char))
63
+ else:
64
+ out.append(char)
65
+ result = "".join(out)
66
+
67
+ if len(result) > max_length:
68
+ result = result[: max_length - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER
69
+ return result
70
+
71
+
72
+ def sanitize_block(text: str, *, max_length: int = DEFAULT_MAX_LENGTH, indent: str = " ") -> str:
73
+ """Sanitise multi-line text for a human-readable report block.
74
+
75
+ Newlines are kept - a validation error that lists several fields is
76
+ unreadable once they become ``\\x0a`` - but every line after the first is
77
+ indented, so text we did not write cannot produce a line that starts at
78
+ column 0 and impersonates one of ours (for example a forged ``Result:
79
+ PASS``). Everything :func:`sanitize_for_terminal` escapes is still escaped.
80
+ """
81
+ safe = sanitize_for_terminal(text, max_length=max_length, keep_newlines=True)
82
+ first, separator, rest = safe.partition("\n")
83
+ if not separator:
84
+ return first
85
+ return first + "\n" + "\n".join(indent + line for line in rest.split("\n"))
@@ -0,0 +1,169 @@
1
+ """Secret values and redaction.
2
+
3
+ Credentials (the GitHub App private key, the webhook secret, JWTs, installation
4
+ tokens) are wrapped in :class:`Secret` as soon as they are read, so an
5
+ accidental ``repr()``, f-string or log call prints ``**********`` instead of
6
+ the value. Only code that must send the value calls :meth:`Secret.reveal`.
7
+
8
+ :class:`SecretRedactor` is the second line of defence: every secret CommitGuard
9
+ mints or loads is registered with it, and structured logs, error messages and
10
+ HTTP responses are passed through :func:`redact`, which also removes
11
+ credential-shaped strings (GitHub tokens, JWTs, PEM keys, ``Authorization``
12
+ header values) that were never registered.
13
+ """
14
+
15
+ import hmac
16
+ import re
17
+ import threading
18
+ from typing import NoReturn
19
+
20
+ REDACTED = "[REDACTED]"
21
+ MIN_REGISTERED_SECRET_LENGTH = 8
22
+
23
+ _PATTERNS = (
24
+ # PEM blocks (private keys), even if truncated.
25
+ re.compile(
26
+ r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\Z)", re.S
27
+ ),
28
+ # GitHub token formats: ghs_ (installation), ghp_, gho_, ghu_, ghr_, github_pat_.
29
+ re.compile(r"\b(?:gh[posur]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})"),
30
+ # Authorization header values and bearer/basic credentials.
31
+ re.compile(r"(?i)(authorization\s*[:=]\s*)\S+(?:\s+\S+)?"),
32
+ re.compile(r"(?i)\b((?:bearer|basic)\s+)[A-Za-z0-9._~+/=-]{8,}"),
33
+ # Webhook signatures are not secrets, but they are credential-derived noise.
34
+ re.compile(r"sha256=[0-9a-fA-F]{64}"),
35
+ )
36
+
37
+ # JWTs (header.payload.signature, base64url, the header starting "eyJ") are found in two
38
+ # linear steps instead of one regular expression. The expression
39
+ # ``\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}`` backtracks quadratically
40
+ # on text such as "eyJ-eyJ-eyJ-..." (80 KB took 4.6 s; found by the ReDoS tests), and
41
+ # redaction runs on untrusted text. First, maximal dotted runs of base64url characters are
42
+ # matched (the look-behind means a run is only ever scanned from its first character);
43
+ # then each run is split on dots and checked without backtracking.
44
+ _DOTTED_RUN = re.compile(r"(?<![A-Za-z0-9_-])[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}")
45
+ _JWT_MIN_SEGMENT = 5
46
+
47
+
48
+ def _jwt_start(segment: str) -> int:
49
+ """Index of the first "eyJ" at a word boundary with a long enough header, else -1."""
50
+ index = segment.find("eyJ")
51
+ while index != -1:
52
+ if index == 0 or segment[index - 1] == "-":
53
+ return index if len(segment) - index - 3 >= _JWT_MIN_SEGMENT else -1
54
+ index = segment.find("eyJ", index + 1)
55
+ return -1
56
+
57
+
58
+ def _redact_dotted_run(match: re.Match[str]) -> str:
59
+ segments = match.group(0).split(".")
60
+ out: list[str] = []
61
+ i = 0
62
+ while i < len(segments):
63
+ start = -1
64
+ if (
65
+ i + 2 < len(segments)
66
+ and len(segments[i + 1]) >= _JWT_MIN_SEGMENT
67
+ and len(segments[i + 2]) >= _JWT_MIN_SEGMENT
68
+ ):
69
+ start = _jwt_start(segments[i])
70
+ if start < 0:
71
+ out.append(segments[i])
72
+ i += 1
73
+ else:
74
+ out.append(segments[i][:start] + REDACTED)
75
+ i += 3
76
+ return ".".join(out)
77
+
78
+
79
+ class Secret:
80
+ """A string that never shows its value unless explicitly revealed."""
81
+
82
+ __slots__ = ("_value",)
83
+
84
+ def __init__(self, value: str) -> None:
85
+ if not isinstance(value, str):
86
+ raise TypeError("Secret value must be a string")
87
+ self._value = value
88
+
89
+ def reveal(self) -> str:
90
+ return self._value
91
+
92
+ def __repr__(self) -> str:
93
+ return "Secret('**********')"
94
+
95
+ __str__ = __repr__
96
+
97
+ def __format__(self, format_spec: str) -> str:
98
+ return repr(self)
99
+
100
+ def __bool__(self) -> bool:
101
+ return bool(self._value)
102
+
103
+ def __len__(self) -> int:
104
+ return len(self._value)
105
+
106
+ def __eq__(self, other: object) -> bool:
107
+ if not isinstance(other, Secret):
108
+ return NotImplemented
109
+ return hmac.compare_digest(self._value.encode(), other._value.encode())
110
+
111
+ def __hash__(self) -> int:
112
+ return id(self)
113
+
114
+ def __reduce__(self) -> NoReturn:
115
+ raise TypeError("Secret values cannot be pickled")
116
+
117
+
118
+ class SecretRedactor:
119
+ """Thread-safe registry of secret strings that must never be emitted."""
120
+
121
+ def __init__(self) -> None:
122
+ self._lock = threading.Lock()
123
+ self._values: set[str] = set()
124
+
125
+ def register(self, value: "str | Secret") -> None:
126
+ raw = value.reveal() if isinstance(value, Secret) else value
127
+ if len(raw) < MIN_REGISTERED_SECRET_LENGTH:
128
+ return # too short to redact without mangling ordinary text
129
+ with self._lock:
130
+ self._values.add(raw)
131
+ # PEM keys may appear line by line or with escaped newlines.
132
+ for line in raw.splitlines():
133
+ if len(line) >= 16 and "-----" not in line:
134
+ self._values.add(line)
135
+
136
+ def forget(self, value: "str | Secret") -> None:
137
+ raw = value.reveal() if isinstance(value, Secret) else value
138
+ with self._lock:
139
+ self._values.discard(raw)
140
+
141
+ def redact(self, text: str) -> str:
142
+ with self._lock:
143
+ values = sorted(self._values, key=len, reverse=True)
144
+ for value in values:
145
+ if value in text:
146
+ text = text.replace(value, REDACTED)
147
+ text = _DOTTED_RUN.sub(_redact_dotted_run, text)
148
+ for pattern in _PATTERNS:
149
+ if pattern.groups:
150
+ text = pattern.sub(lambda m: f"{m.group(1)}{REDACTED}", text)
151
+ else:
152
+ text = pattern.sub(REDACTED, text)
153
+ return text
154
+
155
+
156
+ _default_redactor = SecretRedactor()
157
+
158
+
159
+ def default_redactor() -> SecretRedactor:
160
+ return _default_redactor
161
+
162
+
163
+ def register_secret(value: "str | Secret") -> None:
164
+ _default_redactor.register(value)
165
+
166
+
167
+ def redact(text: str) -> str:
168
+ """Remove registered secrets and credential-shaped strings from ``text``."""
169
+ return _default_redactor.redact(text)
@@ -0,0 +1,89 @@
1
+ """Validation of untrusted identifiers before they are used.
2
+
3
+ The most important rule here: a user- or metadata-supplied Git revision must
4
+ never be interpreted as a command-line option (``--output=/etc/passwd``).
5
+ """
6
+
7
+ import re
8
+
9
+ from commitguard.exceptions.base import UnsafeInputError
10
+
11
+ MAX_REVISION_LENGTH = 256
12
+
13
+ _SHA_RE = re.compile(r"\A(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
14
+ _IDENTIFIER_RE = re.compile(r"\A[a-z][a-z0-9_]{0,63}\Z")
15
+ # section[.subsection].key - subsections are restricted here to keep it simple.
16
+ _GIT_CONFIG_KEY_RE = re.compile(
17
+ r"\A[A-Za-z][A-Za-z0-9-]*(?:\.[A-Za-z0-9_./-]+)?\.[A-Za-z][A-Za-z0-9-]*\Z"
18
+ )
19
+
20
+
21
+ def validate_revision(revision: str) -> str:
22
+ """Validate a Git revision expression supplied by a user or hook.
23
+
24
+ Rejects empty values, option-like values, control characters and overly
25
+ long input. This is defence in depth: the Git wrapper additionally passes
26
+ ``--end-of-options`` before revisions.
27
+ """
28
+ if not revision:
29
+ raise UnsafeInputError("revision must not be empty")
30
+ if len(revision) > MAX_REVISION_LENGTH:
31
+ raise UnsafeInputError("revision is too long")
32
+ if revision.startswith("-"):
33
+ raise UnsafeInputError("revision must not start with '-'")
34
+ if any(ord(c) < 0x20 or ord(c) == 0x7F for c in revision):
35
+ raise UnsafeInputError("revision must not contain control characters")
36
+ return revision
37
+
38
+
39
+ def is_git_sha(value: str) -> bool:
40
+ """Return True if ``value`` is a full lowercase SHA-1 or SHA-256 object ID."""
41
+ return bool(_SHA_RE.match(value))
42
+
43
+
44
+ def validate_git_sha(value: str) -> str:
45
+ """Validate a full Git object ID (SHA-1 or SHA-256)."""
46
+ if not is_git_sha(value):
47
+ raise UnsafeInputError("value is not a full Git object id")
48
+ return value
49
+
50
+
51
+ def validate_identifier(value: str, *, kind: str = "identifier") -> str:
52
+ """Validate an internal identifier (detector name, rule ID, policy ID).
53
+
54
+ Identifiers are lowercase snake_case, start with a letter, and are at most
55
+ 64 characters, so they are safe to use as YAML keys, log fields and IDs.
56
+ """
57
+ if not _IDENTIFIER_RE.match(value):
58
+ raise UnsafeInputError(
59
+ f"invalid {kind} {value!r}: expected lowercase snake_case (max 64 chars)"
60
+ )
61
+ return value
62
+
63
+
64
+ def validate_git_config_key(key: str) -> str:
65
+ """Validate a Git configuration key such as ``core.hooksPath``."""
66
+ if len(key) > MAX_REVISION_LENGTH or not _GIT_CONFIG_KEY_RE.match(key):
67
+ raise UnsafeInputError(f"invalid git config key {key!r}")
68
+ return key
69
+
70
+
71
+ MAX_REPOSITORY_PATH_LENGTH = 1024
72
+
73
+
74
+ def validate_repository_path(path: str) -> str:
75
+ """Validate a repository-relative POSIX path such as ``.commitguard.yaml``.
76
+
77
+ Rejects absolute paths, ``..`` or empty components, backslashes, leading
78
+ ``-`` and control characters, so the path can only name a file inside the
79
+ repository tree.
80
+ """
81
+ if not path or len(path) > MAX_REPOSITORY_PATH_LENGTH:
82
+ raise UnsafeInputError("repository path must be non-empty and reasonably short")
83
+ if path.startswith(("/", "-")) or "\\" in path or ":" in path.split("/", 1)[0]:
84
+ raise UnsafeInputError(f"invalid repository path {path!r}")
85
+ if any(ord(c) < 0x20 or ord(c) == 0x7F for c in path):
86
+ raise UnsafeInputError("repository path must not contain control characters")
87
+ if any(part in ("", ".", "..") for part in path.split("/")):
88
+ raise UnsafeInputError(f"invalid repository path {path!r}")
89
+ return path
@@ -0,0 +1,15 @@
1
+ """Application services shared by every entry point.
2
+
3
+ * ``analysis`` - the Analyzer pipeline used by scan, check and hooks;
4
+ * ``ci`` - commit range and trusted policy planning for server-side checks;
5
+ * ``scan`` - ScanService: ScanRequest -> ScanResult (Action and GitHub App);
6
+ * ``enforcement`` - maps results to exit codes and check conclusions;
7
+ * ``audit`` - creates audit events with correlation IDs.
8
+
9
+ ``commitguard scan``, ``commitguard check``, the Git hooks, ``ci github`` and the GitHub App all
10
+ run the same pipeline through this package, so no entry point re-implements
11
+ detection or policy logic::
12
+
13
+ Repository --> Commit --> Analyzer(DetectionEngine + PolicyEvaluator)
14
+ --> CommitReport --> ScanReport (text / JSON / future audit log)
15
+ """
@@ -0,0 +1,119 @@
1
+ """The analysis pipeline shared by ``scan``, ``check`` and future hooks/CI."""
2
+
3
+ from collections.abc import Sequence
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+
7
+ from commitguard import __version__
8
+ from commitguard.config.loader import LoadedConfig, load_effective_config
9
+ from commitguard.core.context import CommitContext, ScanTrigger
10
+ from commitguard.core.decision import Action
11
+ from commitguard.core.engine import DetectionEngine
12
+ from commitguard.detectors.registry import builtin_registry
13
+ from commitguard.git.commit import Commit
14
+ from commitguard.git.repository import Repository
15
+ from commitguard.policies.evaluator import PolicyEvaluator
16
+ from commitguard.policies.loader import build_policy_set
17
+ from commitguard.policies.model import PolicySet
18
+ from commitguard.provenance.author import Identity
19
+ from commitguard.rules.loader import load_builtin_rules
20
+ from commitguard.rules.matcher import CompiledRules
21
+ from commitguard.services.reports import CIReport, CommitReport, ScanReport
22
+ from commitguard.utils.filesystem import read_bytes_limited
23
+
24
+ DEFAULT_MAX_COMMITS = 1000
25
+ MAX_MESSAGE_FILE_BYTES = 1024 * 1024
26
+
27
+
28
+ class Analyzer:
29
+ """Detection engine + policy evaluator for a fixed rule set and policy set."""
30
+
31
+ def __init__(self, engine: DetectionEngine, policies: PolicySet) -> None:
32
+ self._engine = engine
33
+ self._policies = policies
34
+ self._evaluator = PolicyEvaluator(policies)
35
+ self._enabled_rules = frozenset(p.id for p in policies.values() if p.enabled)
36
+
37
+ @classmethod
38
+ def create(cls, policies: PolicySet, rules: CompiledRules | None = None) -> "Analyzer":
39
+ rules = rules if rules is not None else load_builtin_rules()
40
+ return cls(DetectionEngine(builtin_registry(rules)), policies)
41
+
42
+ @property
43
+ def policies(self) -> PolicySet:
44
+ return self._policies
45
+
46
+ def analyze(self, commit: Commit, trigger: ScanTrigger = ScanTrigger.MANUAL) -> CommitReport:
47
+ context = CommitContext(commit=commit, trigger=trigger)
48
+ detection = self._engine.run(context, enabled_rules=self._enabled_rules)
49
+ decision = self._evaluator.evaluate(detection)
50
+ return CommitReport.build(commit.short_sha, detection, decision, subject=commit.subject)
51
+
52
+
53
+ def load_analyzer(
54
+ repository: Repository | None, *, config_path: Path | None = None
55
+ ) -> tuple[Analyzer, LoadedConfig]:
56
+ """Load configuration layers and rules, and build an :class:`Analyzer`."""
57
+ config = load_effective_config(
58
+ repository.root if repository else None, explicit_path=config_path
59
+ )
60
+ return Analyzer.create(build_policy_set(*config.configs)), config
61
+
62
+
63
+ def build_report(
64
+ reports: Sequence[CommitReport],
65
+ *,
66
+ repository: Repository | None,
67
+ target: str,
68
+ trigger: ScanTrigger,
69
+ config: LoadedConfig,
70
+ ci: CIReport | None = None,
71
+ extra_config_sources: Sequence[str] = (),
72
+ ) -> ScanReport:
73
+ return ScanReport(
74
+ tool_version=__version__,
75
+ generated_at=datetime.now(UTC),
76
+ repository=str(repository.root) if repository else None,
77
+ target=target,
78
+ trigger=trigger.value,
79
+ config_sources=(*(str(source) for source in config.sources), *extra_config_sources),
80
+ action=Action.most_restrictive([report.action for report in reports]),
81
+ commits=tuple(reports),
82
+ ci=ci,
83
+ )
84
+
85
+
86
+ def analyze_revisions(
87
+ repository: Repository,
88
+ analyzer: Analyzer,
89
+ revision_range: str,
90
+ *,
91
+ max_commits: int = DEFAULT_MAX_COMMITS,
92
+ trigger: ScanTrigger = ScanTrigger.MANUAL,
93
+ ) -> list[CommitReport]:
94
+ """Analyse every commit selected by ``revision_range``."""
95
+ shas = repository.list_commits(revision_range, max_count=max_commits)
96
+ return [analyzer.analyze(commit, trigger) for commit in repository.read_commits(shas)]
97
+
98
+
99
+ def analyze_message_file(
100
+ repository: Repository,
101
+ analyzer: Analyzer,
102
+ message_file: Path,
103
+ *,
104
+ trigger: ScanTrigger = ScanTrigger.CHECK,
105
+ ) -> CommitReport:
106
+ """Analyse a commit that does not exist yet (message file + configured identity).
107
+
108
+ TODO(phase-3): apply Git's message cleanup (comment stripping) the way
109
+ ``git commit`` does before the commit-msg hook result is used.
110
+ """
111
+ data = read_bytes_limited(message_file, max_bytes=MAX_MESSAGE_FILE_BYTES)
112
+ author, committer = repository.pending_identities()
113
+ return analyzer.analyze(
114
+ pending_commit(data.decode("utf-8", "replace"), author, committer), trigger
115
+ )
116
+
117
+
118
+ def pending_commit(message: str, author: Identity, committer: Identity) -> Commit:
119
+ return Commit(author=author, committer=committer, message=message)