commitguardian 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|