leftblock 0.3.2__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.
- leftblock/__init__.py +1 -0
- leftblock/cli.py +444 -0
- leftblock/core.py +162 -0
- leftblock/data/gitleaks.toml +245 -0
- leftblock/hooks.py +298 -0
- leftblock/merge.py +172 -0
- leftblock-0.3.2.dist-info/METADATA +390 -0
- leftblock-0.3.2.dist-info/RECORD +12 -0
- leftblock-0.3.2.dist-info/WHEEL +5 -0
- leftblock-0.3.2.dist-info/entry_points.txt +2 -0
- leftblock-0.3.2.dist-info/licenses/LICENSE +21 -0
- leftblock-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# LeftBlock shipped gitleaks ruleset.
|
|
2
|
+
#
|
|
3
|
+
# Extends gitleaks' default secret rules (AWS, Google API keys, private
|
|
4
|
+
# keys, generic API keys, ...) with personal-data and wallet rules so a
|
|
5
|
+
# freshly installed LeftBlock finds sensitive information with zero custom
|
|
6
|
+
# configuration:
|
|
7
|
+
#
|
|
8
|
+
# - personal email addresses (infrastructure identities allowlisted)
|
|
9
|
+
# - home-folder paths: /Users/<name>/, /home/<name>/, C:\Users\<name>
|
|
10
|
+
# - session UUIDs (bare UUIDs outside structural id fields)
|
|
11
|
+
# - actor/user IDs
|
|
12
|
+
# - OpenAI (sk-, sk-proj-, sk-svcacct-) and Anthropic (sk-ant-) keys
|
|
13
|
+
# (Gemini/Google AI keys AIza... are covered by gitleaks' default
|
|
14
|
+
# gcp-api-key rule)
|
|
15
|
+
# - crypto wallet material: 0x-prefixed 64-hex private keys, keyword-
|
|
16
|
+
# context bare 64-hex keys (generic-api-key covers labeled ones),
|
|
17
|
+
# WIF-format Bitcoin keys, BIP-39 mnemonics
|
|
18
|
+
#
|
|
19
|
+
# Public wallet addresses and hash-like hex (SHA-256, git SHAs) are NOT
|
|
20
|
+
# flagged by themselves: bare 64-hex without 0x or a key-like keyword is
|
|
21
|
+
# indistinguishable from SHA-256 and is intentionally left to the
|
|
22
|
+
# generic-api-key keyword context (see docs).
|
|
23
|
+
#
|
|
24
|
+
# Allowlists are scoped to exact structural values and field names
|
|
25
|
+
# (placeholder usernames/actor ids, Kanbus structural id fields, the
|
|
26
|
+
# 00000000-0000-4000-8000-... example UUID shape) — never whole paths.
|
|
27
|
+
|
|
28
|
+
title = "LeftBlock sensitive-data rules"
|
|
29
|
+
|
|
30
|
+
[extend]
|
|
31
|
+
useDefault = true
|
|
32
|
+
|
|
33
|
+
# Line-scoped global allowance: quoted, all-lowercase scanner keyword
|
|
34
|
+
# literals (fixture-corpus generators list trigger keywords like
|
|
35
|
+
# "-----begin rsa private key-----" as data). Real PEM material is
|
|
36
|
+
# mixed-case with base64 payload and is never shaped like this.
|
|
37
|
+
[allowlist]
|
|
38
|
+
description = "lowercase keyword-list literals in fixture generators"
|
|
39
|
+
regexTarget = "line"
|
|
40
|
+
regexes = [
|
|
41
|
+
'''"[a-z -]*private key-----"''',
|
|
42
|
+
]
|
|
43
|
+
# Compiled bytecode duplicates string constants from the sources (which
|
|
44
|
+
# are themselves scanned) and produces garbage matches from folded
|
|
45
|
+
# literals; Python projects regenerate these caches constantly.
|
|
46
|
+
paths = [
|
|
47
|
+
'''(^|/)__pycache__/''',
|
|
48
|
+
'''\.pyc$''',
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# --------------------------------------------------------------------
|
|
52
|
+
# Personal data
|
|
53
|
+
# --------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
[[rules]]
|
|
56
|
+
id = "leftblock-personal-email"
|
|
57
|
+
description = "Personal email address"
|
|
58
|
+
regex = '''[a-zA-Z0-9._%+\-']+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'''
|
|
59
|
+
keywords = ["@"]
|
|
60
|
+
|
|
61
|
+
[rules.allowlist]
|
|
62
|
+
regexes = [
|
|
63
|
+
# Infrastructure and placeholder identities, not personal ones.
|
|
64
|
+
'''@users\.noreply\.github\.com''',
|
|
65
|
+
'''git@github\.com''',
|
|
66
|
+
'''@(example|github)\.(com|org|net|io|ai)''',
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[[rules]]
|
|
70
|
+
id = "leftblock-home-path-posix"
|
|
71
|
+
description = "Home-folder path (/Users/<name> or /home/<name>)"
|
|
72
|
+
# The path segment after the prefix is the secret. Requiring a
|
|
73
|
+
# non-path character before /Users or /home keeps mid-path segments
|
|
74
|
+
# like /tmp/home/... out (test fixtures use those legitimately).
|
|
75
|
+
regex = '''(?:^|[^a-zA-Z0-9._\-/])(?:/Users|/home)(/[a-zA-Z0-9._\-]+)'''
|
|
76
|
+
keywords = ["/Users/", "/home/"]
|
|
77
|
+
|
|
78
|
+
[rules.allowlist]
|
|
79
|
+
regexes = [
|
|
80
|
+
# Anonymized placeholder usernames, not real ones.
|
|
81
|
+
'''^/example(-[a-z0-9]+)?$''',
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[[rules]]
|
|
85
|
+
id = "leftblock-home-path-windows"
|
|
86
|
+
description = "Home-folder path (C:\\Users\\<name> or \\Users\\<name>)"
|
|
87
|
+
regex = '''\\+Users\\+([a-zA-Z0-9._\-]+)'''
|
|
88
|
+
keywords = ["Users"]
|
|
89
|
+
|
|
90
|
+
[rules.allowlist]
|
|
91
|
+
regexes = [
|
|
92
|
+
'''^\\?example(-[a-z0-9]+)?$''',
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
[[rules]]
|
|
96
|
+
id = "leftblock-session-uuid"
|
|
97
|
+
description = "Bare UUID (potential session/agent identifier)"
|
|
98
|
+
regex = '''\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b'''
|
|
99
|
+
keywords = ["-"]
|
|
100
|
+
|
|
101
|
+
[[rules.allowlists]]
|
|
102
|
+
regexTarget = "match"
|
|
103
|
+
regexes = [
|
|
104
|
+
# Match only the example UUID itself, never another value on its line.
|
|
105
|
+
'''^00000000-0000-4000-8000-[0-9a-f]{12}$''',
|
|
106
|
+
'''^11111111-1111-4111-8111-1111111111[0-9a-f]{2}$''',
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
# Kanbus serializes board records as indented JSON. Accept a complete
|
|
110
|
+
# structural field on its own line in board issue/event files. Prefixes
|
|
111
|
+
# alone, prose, array elements and a second value on that line do not
|
|
112
|
+
# qualify. This is a syntax-based exception, not JSON schema validation.
|
|
113
|
+
# Gitleaks includes the preceding newline in its line context; permit that
|
|
114
|
+
# single boundary byte, plus CRLF endings, without spanning properties.
|
|
115
|
+
[[rules.allowlists]]
|
|
116
|
+
description = "Kanbus board structural identifier fields"
|
|
117
|
+
condition = "AND"
|
|
118
|
+
paths = [
|
|
119
|
+
'''(^|/)project/(issues|events)/[^/]+\.json$''',
|
|
120
|
+
]
|
|
121
|
+
regexTarget = "line"
|
|
122
|
+
regexes = [
|
|
123
|
+
'''^\n?[ \t]*"(id|comment_id|event_id|issue_id|parent_id|parent|target|target_id|from|to)"[ \t]*:[ \t]*"(?:[A-Za-z][A-Za-z0-9_]*-)*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",?[ \t]*\r?$''',
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# Event actor attribution is structural too. Keep this separate from
|
|
127
|
+
# session_id/provider_session_id/resumed_session, which remain detected.
|
|
128
|
+
[[rules.allowlists]]
|
|
129
|
+
description = "Kanbus event UUID actor attribution"
|
|
130
|
+
condition = "AND"
|
|
131
|
+
paths = [
|
|
132
|
+
'''(^|/)project/events/[^/]+\.json$''',
|
|
133
|
+
]
|
|
134
|
+
regexTarget = "line"
|
|
135
|
+
regexes = [
|
|
136
|
+
'''^\n? "actor_id"[ \t]*:[ \t]*"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",?[ \t]*\r?$''',
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
# Coordination events refer to a generated claim by its prefixed key.
|
|
140
|
+
# Require the canonical payload-property depth; bare session values,
|
|
141
|
+
# root/deeper properties, prose and neighboring values are not exempt.
|
|
142
|
+
[[rules.allowlists]]
|
|
143
|
+
description = "Kanbus coordination payload claim reference"
|
|
144
|
+
condition = "AND"
|
|
145
|
+
paths = [
|
|
146
|
+
'''(^|/)project/events/[^/]+\.json$''',
|
|
147
|
+
]
|
|
148
|
+
regexTarget = "line"
|
|
149
|
+
regexes = [
|
|
150
|
+
'''^\n? "claim_id"[ \t]*:[ \t]*"(?:[A-Za-z][A-Za-z0-9_]*-)+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",?[ \t]*\r?$''',
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
# The legacy Kanbus cache duplicates issue identifiers and stores numeric
|
|
154
|
+
# mtimes keyed by issue filename. Do not exempt the cache file: it also
|
|
155
|
+
# contains full descriptions and comments that still require scanning.
|
|
156
|
+
[[rules.allowlists]]
|
|
157
|
+
description = "Kanbus cache issue identifiers and numeric filename mtimes"
|
|
158
|
+
condition = "AND"
|
|
159
|
+
paths = [
|
|
160
|
+
'''(^|/)project/\.cache/index\.json$''',
|
|
161
|
+
]
|
|
162
|
+
regexTarget = "line"
|
|
163
|
+
regexes = [
|
|
164
|
+
'''^\n? "(id|parent)"[ \t]*:[ \t]*"(?:[A-Za-z][A-Za-z0-9_]*-)+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",?[ \t]*\r?$''',
|
|
165
|
+
'''^\n? "(?:[A-Za-z][A-Za-z0-9_]*-)+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json"[ \t]*:[ \t]*-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?,?[ \t]*\r?$''',
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
[[rules]]
|
|
169
|
+
id = "leftblock-actor-id"
|
|
170
|
+
description = "actor/user/author ID value"
|
|
171
|
+
regex = '''"(?:actor_id|user_id|author_id)"\s*:\s*"([^"]{4,})"'''
|
|
172
|
+
keywords = ["actor_id", "user_id", "author_id"]
|
|
173
|
+
|
|
174
|
+
[[rules.allowlists]]
|
|
175
|
+
regexes = [
|
|
176
|
+
# Anonymized placeholder ids, not real ones.
|
|
177
|
+
'''^example[-_a-z0-9]*$''',
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
# Kanbus event files record the local actor that made each board change
|
|
181
|
+
# ("actor_id"). That is the board's attribution design, not a leaked user
|
|
182
|
+
# id; scoped to project/events and a two-space root property line. Nested
|
|
183
|
+
# payload actor values and attribution outside event files remain detected.
|
|
184
|
+
[[rules.allowlists]]
|
|
185
|
+
description = "Kanbus board event actor attribution"
|
|
186
|
+
condition = "AND"
|
|
187
|
+
paths = [
|
|
188
|
+
'''(^|/)project/events/[^/]+\.json$''',
|
|
189
|
+
]
|
|
190
|
+
regexTarget = "line"
|
|
191
|
+
regexes = [
|
|
192
|
+
'''^\n? "actor_id"[ \t]*:[ \t]*"[^"]+",?[ \t]*\r?$''',
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
# --------------------------------------------------------------------
|
|
196
|
+
# LLM provider API keys not covered by gitleaks defaults
|
|
197
|
+
# (OpenAI sk- / sk-proj- / sk-svcacct-, Anthropic sk-ant-. Google
|
|
198
|
+
# AI/Gemini keys AIza... are covered by the default gcp-api-key rule.)
|
|
199
|
+
# --------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
[[rules]]
|
|
202
|
+
id = "leftblock-openai-key"
|
|
203
|
+
description = "OpenAI API key (sk-, sk-proj-, sk-svcacct-)"
|
|
204
|
+
regex = '''\bsk-(?:proj|svcacct)-[A-Za-z0-9_\-]{40,}|\bsk-[A-Za-z0-9]{20,}'''
|
|
205
|
+
keywords = ["sk-"]
|
|
206
|
+
|
|
207
|
+
[[rules]]
|
|
208
|
+
id = "leftblock-anthropic-key"
|
|
209
|
+
description = "Anthropic API key (sk-ant-)"
|
|
210
|
+
regex = '''\bsk-ant-[A-Za-z0-9_\-]{20,}'''
|
|
211
|
+
keywords = ["sk-ant-"]
|
|
212
|
+
|
|
213
|
+
# --------------------------------------------------------------------
|
|
214
|
+
# Crypto wallet material
|
|
215
|
+
# --------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
[[rules]]
|
|
218
|
+
id = "leftblock-eth-private-key"
|
|
219
|
+
description = "Ethereum-style private key (0x-prefixed 64 hex chars)"
|
|
220
|
+
# Only the 0x-prefixed form is reported context-free: bare 64-hex
|
|
221
|
+
# strings are indistinguishable from SHA-256 digests and are covered
|
|
222
|
+
# by generic-api-key's keyword context instead.
|
|
223
|
+
regex = '''\b0x[0-9a-fA-F]{64}\b'''
|
|
224
|
+
keywords = ["0x"]
|
|
225
|
+
|
|
226
|
+
[[rules]]
|
|
227
|
+
id = "leftblock-wif-key"
|
|
228
|
+
description = "Bitcoin WIF private key (base58check)"
|
|
229
|
+
regex = '''\b[KLM][1-9A-HJ-NP-Za-km-z]{51}\b|\b5[1-9A-HJ-NP-Za-km-z]{50}\b'''
|
|
230
|
+
keywords = ["K", "L", "5"]
|
|
231
|
+
|
|
232
|
+
[[rules]]
|
|
233
|
+
id = "leftblock-bip39-mnemonic"
|
|
234
|
+
description = "BIP-39 seed phrase (12-24 lowercase words, seed/mnemonic context)"
|
|
235
|
+
# Separation from prose and from SHA-256-style hex noise: the phrase
|
|
236
|
+
# must follow seed/mnemonic/recovery-style context (assignment or
|
|
237
|
+
# label, optionally quoted), and BIP-39 wordlist words are 3-8 letters.
|
|
238
|
+
# Bare, unlabeled 12+ word lowercase sequences are NOT flagged — English
|
|
239
|
+
# prose is shape-identical and a bare phrase cannot be told apart from
|
|
240
|
+
# unpunctuated prose by regex alone (documented residual gap: prose that
|
|
241
|
+
# itself contains "seed"/"mnemonic" within the context window plus a
|
|
242
|
+
# long unpunctuated lowercase run; BIP-39 checksum validation belongs in
|
|
243
|
+
# a future validator pass).
|
|
244
|
+
regex = '''(?:seed[_-]?phrase|recovery[_-]?phrase|mnemonic|vault[_-]?seed|seed|secret[_-]?words)[^=\n"']{0,24}[=:\s][\s"']{0,3}(?:[a-z]{3,8} ){11,23}[a-z]{3,8}\b"?'''
|
|
245
|
+
keywords = ["seed", "mnemonic", "recovery"]
|
leftblock/hooks.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Install only LeftBlock-owned hooks without replacing a project's hook setup."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
STATE_NAME = ".leftblock-commit-msg.json"
|
|
17
|
+
HOOK_MARKER = "# LeftBlock managed commit-msg hook v1\n"
|
|
18
|
+
MANUAL_INTEGRATION = (
|
|
19
|
+
'Keep the existing hook setup and compose `leftblock check` into its pre-commit '
|
|
20
|
+
'hook, or `leftblock hook "$1"` into its commit-msg hook, propagating failures.'
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _git(repo_root, *args):
|
|
25
|
+
return subprocess.run(
|
|
26
|
+
["git", *args], cwd=repo_root, text=True, capture_output=True, check=False
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _regular_file(path):
|
|
31
|
+
"""Reject symlinks, directories, devices, and other non-regular targets."""
|
|
32
|
+
if os.path.lexists(path) and not stat.S_ISREG(path.lstat().st_mode):
|
|
33
|
+
raise ValueError("Hook or ownership state is not a regular file. " + MANUAL_INTEGRATION)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _destination(repo_root, hook_name="commit-msg", state_name=STATE_NAME):
|
|
37
|
+
configured = _git(repo_root, "config", "--get-all", "core.hooksPath")
|
|
38
|
+
if configured.returncode == 0:
|
|
39
|
+
raise ValueError("core.hooksPath is configured; no hooks changed. " + MANUAL_INTEGRATION)
|
|
40
|
+
if configured.returncode != 1:
|
|
41
|
+
raise ValueError("Could not inspect Git hook configuration; no hooks changed.")
|
|
42
|
+
common = _git(repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
43
|
+
if common.returncode or not common.stdout.strip():
|
|
44
|
+
raise ValueError("Could not locate the repository's common Git directory.")
|
|
45
|
+
hook_dir = Path(common.stdout.strip()) / "hooks"
|
|
46
|
+
if os.path.lexists(hook_dir) and (
|
|
47
|
+
hook_dir.is_symlink() or not hook_dir.is_dir()
|
|
48
|
+
):
|
|
49
|
+
raise ValueError("The hooks directory is not a regular directory. " + MANUAL_INTEGRATION)
|
|
50
|
+
return hook_dir / hook_name, hook_dir / state_name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_ownership(hook, state):
|
|
54
|
+
_regular_file(hook)
|
|
55
|
+
_regular_file(state)
|
|
56
|
+
if not hook.exists() and not state.exists():
|
|
57
|
+
return None
|
|
58
|
+
if not hook.exists() or not state.exists():
|
|
59
|
+
raise ValueError("An existing hook or ownership state is unmanaged. " + MANUAL_INTEGRATION)
|
|
60
|
+
try:
|
|
61
|
+
metadata = json.loads(state.read_text())
|
|
62
|
+
digest = metadata["sha256"]
|
|
63
|
+
valid_state = (
|
|
64
|
+
metadata["version"] == 1
|
|
65
|
+
and metadata["hook"] == "commit-msg"
|
|
66
|
+
and isinstance(digest, str)
|
|
67
|
+
and re.fullmatch(r"[0-9a-f]{64}", digest) is not None
|
|
68
|
+
)
|
|
69
|
+
except (ValueError, KeyError, TypeError):
|
|
70
|
+
valid_state = False
|
|
71
|
+
data = hook.read_bytes()
|
|
72
|
+
if not valid_state or (
|
|
73
|
+
HOOK_MARKER.encode() not in data.splitlines(keepends=True)[:3]
|
|
74
|
+
or hashlib.sha256(data).hexdigest() != digest
|
|
75
|
+
):
|
|
76
|
+
raise ValueError("The existing hook was changed or is not managed by LeftBlock. " + MANUAL_INTEGRATION)
|
|
77
|
+
if not hook.stat().st_mode & stat.S_IXUSR:
|
|
78
|
+
raise ValueError("The existing LeftBlock hook was disabled; no hooks changed. " + MANUAL_INTEGRATION)
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_hook_install(repo_root):
|
|
83
|
+
"""Read-only preflight; raise instead of touching another hook setup."""
|
|
84
|
+
hook, state = _destination(repo_root)
|
|
85
|
+
_check_ownership(hook, state)
|
|
86
|
+
return hook
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hook_script():
|
|
90
|
+
# Pin the installer environment so noninteractive Git does not need an
|
|
91
|
+
# activated virtualenv. Captured PATH keeps the configured scanner visible.
|
|
92
|
+
return (
|
|
93
|
+
"#!/bin/sh\n"
|
|
94
|
+
+ HOOK_MARKER
|
|
95
|
+
+ "# PATH captured at installation time for configured command checkers.\n"
|
|
96
|
+
+ "export PATH=" + shlex.quote(os.environ.get("PATH", "")) + ":${PATH:-}\n"
|
|
97
|
+
+ "exec " + shlex.quote(sys.executable) + ' -m leftblock.cli hook "$1"\n'
|
|
98
|
+
).encode()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _replace_file(path, data, mode):
|
|
102
|
+
fd, temporary = tempfile.mkstemp(prefix=".leftblock-", dir=str(path.parent))
|
|
103
|
+
try:
|
|
104
|
+
with os.fdopen(fd, "wb") as output:
|
|
105
|
+
output.write(data)
|
|
106
|
+
output.flush()
|
|
107
|
+
os.fchmod(output.fileno(), mode)
|
|
108
|
+
os.replace(temporary, path)
|
|
109
|
+
finally:
|
|
110
|
+
if os.path.exists(temporary):
|
|
111
|
+
os.unlink(temporary)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def install_hook(repo_root):
|
|
115
|
+
"""Install or refresh an unchanged managed hook; never prompt to overwrite.
|
|
116
|
+
|
|
117
|
+
Hook managers, configured hooks paths, legacy hooks, and owner edits require
|
|
118
|
+
explicit composition by the project. Ownership state is local Git metadata,
|
|
119
|
+
not a security boundary against a user who can change the hook itself.
|
|
120
|
+
"""
|
|
121
|
+
hook, state = _destination(repo_root)
|
|
122
|
+
previous = _check_ownership(hook, state)
|
|
123
|
+
runtime = subprocess.run(
|
|
124
|
+
[sys.executable, "-B", "-m", "leftblock.cli", "hook", "--help"],
|
|
125
|
+
cwd=repo_root, text=True, capture_output=True, check=False,
|
|
126
|
+
)
|
|
127
|
+
if runtime.returncode:
|
|
128
|
+
raise ValueError("Install LeftBlock in this Python environment before installing its hook.")
|
|
129
|
+
data = _hook_script()
|
|
130
|
+
if previous == data:
|
|
131
|
+
return True
|
|
132
|
+
hook.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
# Recheck immediately before writing to catch changes during installation.
|
|
134
|
+
if _check_ownership(hook, state) != previous:
|
|
135
|
+
raise ValueError("Hook changed during installation; no hooks changed.")
|
|
136
|
+
if previous is None:
|
|
137
|
+
# Exclusive creation also preserves a hook created after preflight.
|
|
138
|
+
fd = os.open(str(hook), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o755)
|
|
139
|
+
with os.fdopen(fd, "wb") as output:
|
|
140
|
+
output.write(data)
|
|
141
|
+
os.fchmod(output.fileno(), 0o755)
|
|
142
|
+
else:
|
|
143
|
+
_replace_file(hook, data, 0o755)
|
|
144
|
+
metadata = {
|
|
145
|
+
"version": 1,
|
|
146
|
+
"hook": "commit-msg",
|
|
147
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
148
|
+
}
|
|
149
|
+
_replace_file(state, (json.dumps(metadata, sort_keys=True) + "\n").encode(), 0o600)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
LOCAL_STATE_NAME = ".leftblock-pre-commit.json"
|
|
154
|
+
LOCAL_HOOK_MARKER = "# LeftBlock managed local pre-commit hook v1\n"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _local_paths(repo_root):
|
|
158
|
+
hook, state = _destination(repo_root, "pre-commit", LOCAL_STATE_NAME)
|
|
159
|
+
policy_dir = hook.parent.parent / "leftblock"
|
|
160
|
+
if os.path.lexists(policy_dir) and (
|
|
161
|
+
policy_dir.is_symlink() or not policy_dir.is_dir()
|
|
162
|
+
):
|
|
163
|
+
raise ValueError("The local policy directory is not a regular directory.")
|
|
164
|
+
return {
|
|
165
|
+
"hook": hook,
|
|
166
|
+
"state": state,
|
|
167
|
+
"policy": policy_dir / "policy.json",
|
|
168
|
+
"rules": policy_dir / "gitleaks.toml",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _local_owned_contents(paths):
|
|
173
|
+
for path in paths.values():
|
|
174
|
+
_regular_file(path)
|
|
175
|
+
if not any(path.exists() for path in paths.values()):
|
|
176
|
+
return {name: None for name in paths}
|
|
177
|
+
if not all(path.exists() for path in paths.values()):
|
|
178
|
+
raise ValueError("Existing local pre-commit hook or policy is unmanaged; no files changed.")
|
|
179
|
+
contents = {name: path.read_bytes() for name, path in paths.items()}
|
|
180
|
+
try:
|
|
181
|
+
state = json.loads(contents["state"])
|
|
182
|
+
valid = state["version"] == 1 and state["hook"] == "pre-commit"
|
|
183
|
+
for name, key in (("hook", "sha256"), ("policy", "policy_sha256"), ("rules", "rules_sha256")):
|
|
184
|
+
valid = valid and state[key] == hashlib.sha256(contents[name]).hexdigest()
|
|
185
|
+
except (ValueError, TypeError, KeyError):
|
|
186
|
+
valid = False
|
|
187
|
+
if (
|
|
188
|
+
not valid
|
|
189
|
+
or LOCAL_HOOK_MARKER.encode() not in contents["hook"].splitlines(keepends=True)[:3]
|
|
190
|
+
or not paths["hook"].stat().st_mode & stat.S_IXUSR
|
|
191
|
+
):
|
|
192
|
+
raise ValueError("Existing local hook or policy was edited or disabled; no files changed.")
|
|
193
|
+
return contents
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def validate_local_check_install(repo_root):
|
|
197
|
+
"""Read-only preflight for the opt-in, secret-free local pre-commit gate."""
|
|
198
|
+
paths = _local_paths(repo_root)
|
|
199
|
+
_local_owned_contents(paths)
|
|
200
|
+
return paths
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _local_bundle(paths, gitleaks=None):
|
|
204
|
+
binary = shutil.which(gitleaks or "gitleaks")
|
|
205
|
+
if not binary or not os.path.isfile(binary) or not os.access(binary, os.X_OK):
|
|
206
|
+
raise ValueError("An existing executable Gitleaks installation is required.")
|
|
207
|
+
runtime = subprocess.run(
|
|
208
|
+
[sys.executable, "-I", "-B", "-m", "leftblock.cli", "check", "--help"],
|
|
209
|
+
text=True, capture_output=True, check=False,
|
|
210
|
+
)
|
|
211
|
+
if runtime.returncode or "--config" not in runtime.stdout:
|
|
212
|
+
raise ValueError("Install LeftBlock with local policy support in this Python environment first.")
|
|
213
|
+
binary = os.path.abspath(binary)
|
|
214
|
+
rules = (Path(__file__).resolve().parent / "data" / "gitleaks.toml").read_bytes()
|
|
215
|
+
command = [
|
|
216
|
+
binary, "protect", "--staged", "--config", str(paths["rules"]),
|
|
217
|
+
"--redact", "--no-banner", "--report-format", "json",
|
|
218
|
+
"--report-path", "{report_file}",
|
|
219
|
+
]
|
|
220
|
+
policy = {
|
|
221
|
+
"version": 1,
|
|
222
|
+
"checkers": [{
|
|
223
|
+
"name": "gitleaks", "type": "command",
|
|
224
|
+
"command": " ".join(shlex.quote(arg) for arg in command),
|
|
225
|
+
"success_codes": [0], "finding_codes": [1],
|
|
226
|
+
}],
|
|
227
|
+
}
|
|
228
|
+
# -I ignores PYTHONPATH and the repository's current directory. The
|
|
229
|
+
# installing Python must have LeftBlock installed in its own environment.
|
|
230
|
+
hook = (
|
|
231
|
+
"#!/bin/sh\n" + LOCAL_HOOK_MARKER
|
|
232
|
+
+ "export PATH=" + shlex.quote(os.environ.get("PATH", "")) + ":${PATH:-}\n"
|
|
233
|
+
+ "exec " + shlex.quote(sys.executable)
|
|
234
|
+
+ " -I -B -m leftblock.cli check --config " + shlex.quote(str(paths["policy"])) + "\n"
|
|
235
|
+
).encode()
|
|
236
|
+
bundle = {
|
|
237
|
+
"hook": hook,
|
|
238
|
+
"policy": (json.dumps(policy, indent=2) + "\n").encode(),
|
|
239
|
+
"rules": rules,
|
|
240
|
+
}
|
|
241
|
+
state = {
|
|
242
|
+
"version": 1, "hook": "pre-commit",
|
|
243
|
+
"sha256": hashlib.sha256(hook).hexdigest(),
|
|
244
|
+
"policy_sha256": hashlib.sha256(bundle["policy"]).hexdigest(),
|
|
245
|
+
"rules_sha256": hashlib.sha256(rules).hexdigest(),
|
|
246
|
+
}
|
|
247
|
+
bundle["state"] = (json.dumps(state, sort_keys=True) + "\n").encode()
|
|
248
|
+
return bundle
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def install_local_check_hook(repo_root, gitleaks=None):
|
|
252
|
+
"""Install a fixed Gitleaks baseline without tracked policy or credentials.
|
|
253
|
+
|
|
254
|
+
The pre-commit slot is independent of existing commit-msg receipts. An
|
|
255
|
+
existing root policy is not executed or changed by this opt-in local mode.
|
|
256
|
+
"""
|
|
257
|
+
paths = validate_local_check_install(repo_root)
|
|
258
|
+
previous = _local_owned_contents(paths)
|
|
259
|
+
desired = _local_bundle(paths, gitleaks)
|
|
260
|
+
if previous == desired:
|
|
261
|
+
return True
|
|
262
|
+
created_dirs = []
|
|
263
|
+
written = []
|
|
264
|
+
try:
|
|
265
|
+
for directory in (paths["hook"].parent, paths["policy"].parent):
|
|
266
|
+
if not directory.exists():
|
|
267
|
+
directory.mkdir()
|
|
268
|
+
created_dirs.append(directory)
|
|
269
|
+
# Activate the hook last. Rollback only our own unchanged writes if any
|
|
270
|
+
# step fails; never remove an edit made concurrently by another process.
|
|
271
|
+
for name in ("rules", "policy", "state", "hook"):
|
|
272
|
+
path = paths[name]
|
|
273
|
+
_regular_file(path)
|
|
274
|
+
current = path.read_bytes() if path.exists() else None
|
|
275
|
+
if current != previous[name]:
|
|
276
|
+
raise ValueError("Local hook or policy changed during installation.")
|
|
277
|
+
mode = 0o755 if name == "hook" else 0o600
|
|
278
|
+
if previous[name] is None:
|
|
279
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
280
|
+
with os.fdopen(fd, "wb") as output:
|
|
281
|
+
output.write(desired[name])
|
|
282
|
+
os.fchmod(output.fileno(), mode)
|
|
283
|
+
else:
|
|
284
|
+
_replace_file(path, desired[name], mode)
|
|
285
|
+
written.append(name)
|
|
286
|
+
return True
|
|
287
|
+
except BaseException:
|
|
288
|
+
for name in reversed(written):
|
|
289
|
+
path = paths[name]
|
|
290
|
+
if path.is_file() and not path.is_symlink() and path.read_bytes() == desired[name]:
|
|
291
|
+
if previous[name] is None:
|
|
292
|
+
path.unlink()
|
|
293
|
+
else:
|
|
294
|
+
_replace_file(path, previous[name], 0o755 if name == "hook" else 0o600)
|
|
295
|
+
for directory in reversed(created_dirs):
|
|
296
|
+
if directory.is_dir() and not any(directory.iterdir()):
|
|
297
|
+
directory.rmdir()
|
|
298
|
+
raise
|