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
leftblock/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LeftBlock: local inspection gates for Git commits."""
|
leftblock/cli.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
import getpass
|
|
7
|
+
import yaml
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from leftblock.merge import (
|
|
10
|
+
MergeScopeError, build_scoped_index, checker_environment, index_entries, merge_state,
|
|
11
|
+
)
|
|
12
|
+
from leftblock.core import (
|
|
13
|
+
get_secret, compute_hmac, get_tree_hash, get_commit_tree_hash,
|
|
14
|
+
load_config, validate_checkers, execute_checkers, get_trailer, run_cmd, SECRET_FILE_DEFAULT,
|
|
15
|
+
CheckerResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def print_info(msg):
|
|
19
|
+
print(f"\033[1;34m[leftblock]\033[0m {msg}", file=sys.stderr)
|
|
20
|
+
|
|
21
|
+
def print_warn(msg):
|
|
22
|
+
print(f"\033[1;33m[leftblock]\033[0m {msg}", file=sys.stderr)
|
|
23
|
+
|
|
24
|
+
def print_err(msg):
|
|
25
|
+
print(f"\033[1;31m[leftblock ERROR]\033[0m {msg}", file=sys.stderr)
|
|
26
|
+
|
|
27
|
+
DEFAULT_LEFTBLOCK_YML = """\
|
|
28
|
+
version: 1
|
|
29
|
+
checkers:
|
|
30
|
+
- name: gitleaks
|
|
31
|
+
type: command
|
|
32
|
+
command: gitleaks protect --staged --config .leftblock/gitleaks.toml --redact --no-banner --report-format json --report-path {report_file}
|
|
33
|
+
success_codes: [0]
|
|
34
|
+
finding_codes: [1]
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def write_default_configs(repo_root):
|
|
38
|
+
"""Install the zero-config defaults into a repo.
|
|
39
|
+
|
|
40
|
+
Writes a default .leftblock.yml (pointing at the shipped gitleaks
|
|
41
|
+
ruleset) and copies the packaged gitleaks.toml into
|
|
42
|
+
<repo>/.leftblock/. Existing files are never overwritten.
|
|
43
|
+
"""
|
|
44
|
+
config_dir = os.path.join(repo_root, ".leftblock")
|
|
45
|
+
leftblock_yml = os.path.join(repo_root, ".leftblock.yml")
|
|
46
|
+
ruleset_target = os.path.join(config_dir, "gitleaks.toml")
|
|
47
|
+
|
|
48
|
+
if not os.path.exists(leftblock_yml):
|
|
49
|
+
with open(leftblock_yml, "w") as f:
|
|
50
|
+
f.write(DEFAULT_LEFTBLOCK_YML)
|
|
51
|
+
print_info(f"Wrote default checker config at {leftblock_yml}")
|
|
52
|
+
else:
|
|
53
|
+
print_info(f"Existing {leftblock_yml} left untouched.")
|
|
54
|
+
|
|
55
|
+
packaged_ruleset = os.path.join(
|
|
56
|
+
os.path.dirname(os.path.abspath(__file__)), "data", "gitleaks.toml"
|
|
57
|
+
)
|
|
58
|
+
if os.path.exists(packaged_ruleset):
|
|
59
|
+
os.makedirs(config_dir, exist_ok=True)
|
|
60
|
+
if not os.path.exists(ruleset_target):
|
|
61
|
+
import shutil
|
|
62
|
+
shutil.copyfile(packaged_ruleset, ruleset_target)
|
|
63
|
+
print_info(f"Installed shipped gitleaks ruleset at {ruleset_target}")
|
|
64
|
+
else:
|
|
65
|
+
print_info(f"Existing {ruleset_target} left untouched.")
|
|
66
|
+
else:
|
|
67
|
+
print_warn(f"Packaged ruleset missing at {packaged_ruleset}; default gitleaks rules only.")
|
|
68
|
+
|
|
69
|
+
def cmd_install(args):
|
|
70
|
+
"""Setup the git hook and secret."""
|
|
71
|
+
repo_root = run_cmd(
|
|
72
|
+
["git", "rev-parse", "--show-toplevel"], check=False
|
|
73
|
+
).stdout.strip()
|
|
74
|
+
if not repo_root:
|
|
75
|
+
print_err("Run installation inside a Git repository.")
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
if getattr(args, "local", False) or getattr(args, "check_only", False):
|
|
78
|
+
if not (args.local and args.check_only):
|
|
79
|
+
print_err("Use --local and --check-only together for a local pre-commit gate.")
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
from leftblock.hooks import install_local_check_hook
|
|
82
|
+
try:
|
|
83
|
+
install_local_check_hook(repo_root, getattr(args, "gitleaks", None))
|
|
84
|
+
except (ValueError, OSError) as exc:
|
|
85
|
+
print_err(str(exc))
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
print_info("Installed local LeftBlock pre-commit gate; no signing secret is needed.")
|
|
88
|
+
return
|
|
89
|
+
if getattr(args, "gitleaks", None):
|
|
90
|
+
print_err("--gitleaks is supported with --local --check-only.")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
from leftblock.hooks import install_hook
|
|
93
|
+
try:
|
|
94
|
+
install_hook(repo_root)
|
|
95
|
+
except (ValueError, OSError) as exc:
|
|
96
|
+
print_err(str(exc))
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
write_default_configs(repo_root)
|
|
99
|
+
print_info("Installed LeftBlock commit-msg hook.")
|
|
100
|
+
|
|
101
|
+
# Generate secret if needed
|
|
102
|
+
secret_dir = os.path.dirname(SECRET_FILE_DEFAULT)
|
|
103
|
+
if not os.path.exists(SECRET_FILE_DEFAULT):
|
|
104
|
+
os.makedirs(secret_dir, exist_ok=True)
|
|
105
|
+
secret = os.urandom(16).hex()
|
|
106
|
+
with open(SECRET_FILE_DEFAULT, 'w') as f:
|
|
107
|
+
f.write(secret)
|
|
108
|
+
os.chmod(SECRET_FILE_DEFAULT, 0o600)
|
|
109
|
+
print_info(f"Generated new shared secret at {SECRET_FILE_DEFAULT}")
|
|
110
|
+
else:
|
|
111
|
+
print_info(f"Shared secret already exists at {SECRET_FILE_DEFAULT}")
|
|
112
|
+
|
|
113
|
+
def configured_checks(repo_root, config_path=None):
|
|
114
|
+
try:
|
|
115
|
+
config = load_config(repo_root, config_path)
|
|
116
|
+
checkers = validate_checkers(config)
|
|
117
|
+
except (OSError, ValueError, yaml.YAMLError):
|
|
118
|
+
print_err("Missing or invalid checker configuration. Inspection blocked; review the selected policy.")
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
# Inherited whole files at the same path can be omitted during a merge.
|
|
121
|
+
# Changed/resolved files and renamed paths keep their full staged diff.
|
|
122
|
+
# Any failure to scope falls back to the full staged scan (stricter).
|
|
123
|
+
scoped_index = None
|
|
124
|
+
try:
|
|
125
|
+
base_env = checker_environment()
|
|
126
|
+
except (MergeScopeError, ValueError):
|
|
127
|
+
return config, [CheckerResult(
|
|
128
|
+
"git-environment", False, "Invalid Git checker environment.", error=True,
|
|
129
|
+
)]
|
|
130
|
+
try:
|
|
131
|
+
state = merge_state(repo_root)
|
|
132
|
+
if state:
|
|
133
|
+
scoped_index = build_scoped_index(repo_root, state[1], head=state[0])
|
|
134
|
+
entries = index_entries(repo_root, scoped_index)
|
|
135
|
+
env = checker_environment(scoped_index)
|
|
136
|
+
except (MergeScopeError, OSError, ValueError):
|
|
137
|
+
if scoped_index and os.path.exists(scoped_index):
|
|
138
|
+
os.remove(scoped_index)
|
|
139
|
+
scoped_index = None
|
|
140
|
+
try:
|
|
141
|
+
results = execute_checkers(checkers, cwd=repo_root, env=env if scoped_index else base_env)
|
|
142
|
+
if scoped_index:
|
|
143
|
+
try:
|
|
144
|
+
stable = merge_state(repo_root) == state and index_entries(repo_root, scoped_index) == entries
|
|
145
|
+
except (MergeScopeError, OSError, ValueError):
|
|
146
|
+
stable = False
|
|
147
|
+
if not stable:
|
|
148
|
+
results.append(CheckerResult(
|
|
149
|
+
"merge-scope", False,
|
|
150
|
+
"Merge state or inspected index changed during inspection. Retry the check.",
|
|
151
|
+
error=True,
|
|
152
|
+
))
|
|
153
|
+
return config, results
|
|
154
|
+
finally:
|
|
155
|
+
if scoped_index and os.path.exists(scoped_index):
|
|
156
|
+
os.remove(scoped_index)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def print_failures(failures):
|
|
160
|
+
for result in failures:
|
|
161
|
+
print_warn(f"Checker {json.dumps(str(result.name))} did not pass.")
|
|
162
|
+
if isinstance(result.findings, list):
|
|
163
|
+
for finding in result.findings:
|
|
164
|
+
print(" " + json.dumps(finding, ensure_ascii=True), file=sys.stderr)
|
|
165
|
+
else:
|
|
166
|
+
print_warn(str(result.findings))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_check(args):
|
|
170
|
+
"""Noninteractive staged inspection for existing pre-commit hook managers."""
|
|
171
|
+
repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
|
|
172
|
+
tree_hash = get_tree_hash()
|
|
173
|
+
_, results = configured_checks(repo_root, getattr(args, "config", None))
|
|
174
|
+
if get_tree_hash() != tree_hash:
|
|
175
|
+
print_err("Staged tree changed during inspection. Check blocked; run it again.")
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
failures = [result for result in results if not result.clean]
|
|
178
|
+
if failures:
|
|
179
|
+
print_failures(failures)
|
|
180
|
+
print_err("Inspection blocked. No approval override is available for 'check'.")
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
print_info("All configured checks passed for staged changes.")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_hook(args):
|
|
186
|
+
"""Run checkers on staged files and sign the commit."""
|
|
187
|
+
repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
|
|
188
|
+
try:
|
|
189
|
+
secret = get_secret()
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
print_err("Shared secret not found. Run 'leftblock install' first.")
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
|
|
194
|
+
tree_hash = get_tree_hash()
|
|
195
|
+
print_info(f"Scanning tree {tree_hash}...")
|
|
196
|
+
|
|
197
|
+
config, results = configured_checks(repo_root)
|
|
198
|
+
if get_tree_hash() != tree_hash:
|
|
199
|
+
print_err("Staged tree changed during inspection. Commit blocked; run the checks again.")
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
|
|
202
|
+
all_clean = True
|
|
203
|
+
failed_checkers = []
|
|
204
|
+
for res in results:
|
|
205
|
+
if not res.clean:
|
|
206
|
+
all_clean = False
|
|
207
|
+
failed_checkers.append(res)
|
|
208
|
+
|
|
209
|
+
validator_str = f"leftblock-v{config.get('version', '1')}"
|
|
210
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
211
|
+
|
|
212
|
+
if all_clean:
|
|
213
|
+
result_str = "clean"
|
|
214
|
+
print_info("✓ All checks passed.")
|
|
215
|
+
else:
|
|
216
|
+
print_warn(f"✗ Found issues in {len(failed_checkers)} checker(s):")
|
|
217
|
+
print_failures(failed_checkers)
|
|
218
|
+
if any(result.error for result in failed_checkers):
|
|
219
|
+
print_err("A checker could not complete. Commit blocked; fix the checker before retrying.")
|
|
220
|
+
sys.exit(1)
|
|
221
|
+
|
|
222
|
+
if not sys.stdin.isatty():
|
|
223
|
+
print_err("Findings require human approval but no interactive terminal is available. Commit blocked.")
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
|
|
226
|
+
print_warn("Commit will be blocked unless you approve these findings.")
|
|
227
|
+
password = getpass.getpass(f"\033[1m[leftblock]\033[0m Enter approval passphrase to override: ").strip()
|
|
228
|
+
|
|
229
|
+
if password != secret:
|
|
230
|
+
print_err("Incorrect passphrase. Commit blocked.")
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
|
|
233
|
+
result_str = f"override:{len(failed_checkers)}-checkers"
|
|
234
|
+
print_info("✓ Override approved.")
|
|
235
|
+
|
|
236
|
+
if get_tree_hash() != tree_hash:
|
|
237
|
+
print_err("Staged tree changed before signing. Commit blocked; run the checks again.")
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
hmac_sig = compute_hmac(tree_hash, validator_str, result_str, timestamp, secret)
|
|
240
|
+
|
|
241
|
+
# Append trailers
|
|
242
|
+
msg_file = args.commit_msg_file
|
|
243
|
+
with open(msg_file, 'r') as f:
|
|
244
|
+
msg_content = f.read()
|
|
245
|
+
|
|
246
|
+
proc = subprocess.run(
|
|
247
|
+
["git", "interpret-trailers",
|
|
248
|
+
f"--trailer=Inspected-by: {validator_str}",
|
|
249
|
+
f"--trailer=Inspection-tree: {tree_hash}",
|
|
250
|
+
f"--trailer=Inspection-result: {result_str}",
|
|
251
|
+
f"--trailer=Inspection-at: {timestamp}",
|
|
252
|
+
f"--trailer=Inspection-sig: hmac-sha256:{hmac_sig}"],
|
|
253
|
+
input=msg_content, text=True, capture_output=True, check=True
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
with open(msg_file, 'w') as f:
|
|
257
|
+
f.write(proc.stdout)
|
|
258
|
+
|
|
259
|
+
print_info(f"✓ Commit signed: hmac-sha256:{hmac_sig[:16]}...")
|
|
260
|
+
|
|
261
|
+
def cmd_approve(args):
|
|
262
|
+
"""Retroactively approve commits by generating a paperwork commit."""
|
|
263
|
+
repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
secret = get_secret()
|
|
267
|
+
except FileNotFoundError:
|
|
268
|
+
print_err("Shared secret not found. Run 'leftblock install'.")
|
|
269
|
+
sys.exit(1)
|
|
270
|
+
|
|
271
|
+
range_str = args.range
|
|
272
|
+
if ".." in range_str:
|
|
273
|
+
commits = run_cmd(["git", "rev-list", "--reverse", range_str]).stdout.splitlines()
|
|
274
|
+
else:
|
|
275
|
+
commits = [run_cmd(["git", "rev-parse", range_str]).stdout.strip()]
|
|
276
|
+
|
|
277
|
+
if not commits:
|
|
278
|
+
print_info("No commits found in range.")
|
|
279
|
+
sys.exit(0)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
config = load_config(repo_root)
|
|
283
|
+
checkers = validate_checkers(config)
|
|
284
|
+
except (OSError, ValueError, yaml.YAMLError):
|
|
285
|
+
print_err("Missing or invalid checker configuration. Approval blocked.")
|
|
286
|
+
sys.exit(1)
|
|
287
|
+
|
|
288
|
+
validator_str = f"leftblock-v{config.get('version', '1')}"
|
|
289
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
290
|
+
|
|
291
|
+
trailers = []
|
|
292
|
+
|
|
293
|
+
for sha in commits:
|
|
294
|
+
short = sha[:7]
|
|
295
|
+
print_info(f"Checking commit {short}...")
|
|
296
|
+
actual_tree = get_commit_tree_hash(sha)
|
|
297
|
+
|
|
298
|
+
# Set up a worktree for the commit
|
|
299
|
+
import tempfile
|
|
300
|
+
wt_dir = tempfile.mkdtemp()
|
|
301
|
+
run_cmd(["git", "worktree", "add", "--detach", wt_dir, sha])
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# We want to scan the diff introduced by this commit, so we soft reset to its parent.
|
|
305
|
+
parent = run_cmd(["git", "rev-parse", f"{sha}~1"], check=False).stdout.strip()
|
|
306
|
+
if not parent:
|
|
307
|
+
# Root commit
|
|
308
|
+
empty_tree = run_cmd(["git", "hash-object", "-t", "tree", "/dev/null"]).stdout.strip()
|
|
309
|
+
run_cmd(["git", "reset", "--soft", empty_tree], cwd=wt_dir)
|
|
310
|
+
else:
|
|
311
|
+
run_cmd(["git", "reset", "--soft", parent], cwd=wt_dir)
|
|
312
|
+
|
|
313
|
+
results = execute_checkers(checkers, cwd=wt_dir)
|
|
314
|
+
finally:
|
|
315
|
+
run_cmd(["git", "worktree", "remove", "-f", wt_dir])
|
|
316
|
+
|
|
317
|
+
all_clean = True
|
|
318
|
+
failed_checkers = []
|
|
319
|
+
for res in results:
|
|
320
|
+
if not res.clean:
|
|
321
|
+
all_clean = False
|
|
322
|
+
failed_checkers.append(res)
|
|
323
|
+
|
|
324
|
+
if all_clean:
|
|
325
|
+
result_str = "clean"
|
|
326
|
+
else:
|
|
327
|
+
print_warn(f"✗ Found issues in {short} for {len(failed_checkers)} checker(s).")
|
|
328
|
+
if any(result.error for result in failed_checkers):
|
|
329
|
+
print_err("A checker could not complete. Approval blocked.")
|
|
330
|
+
sys.exit(1)
|
|
331
|
+
if not sys.stdin.isatty():
|
|
332
|
+
print_err("Findings require human approval but no interactive terminal is available. Approval blocked.")
|
|
333
|
+
sys.exit(1)
|
|
334
|
+
|
|
335
|
+
password = getpass.getpass(f"\033[1m[leftblock]\033[0m Enter approval passphrase to override for {short}: ").strip()
|
|
336
|
+
if password != secret:
|
|
337
|
+
print_err("Incorrect passphrase. Approval blocked.")
|
|
338
|
+
sys.exit(1)
|
|
339
|
+
result_str = f"override:{len(failed_checkers)}-checkers"
|
|
340
|
+
|
|
341
|
+
hmac_sig = compute_hmac(actual_tree, validator_str, result_str, timestamp, secret)
|
|
342
|
+
|
|
343
|
+
trailers.append(f"--trailer=Inspected-by: {validator_str}")
|
|
344
|
+
trailers.append(f"--trailer=Inspection-tree: {actual_tree}")
|
|
345
|
+
trailers.append(f"--trailer=Inspection-result: {result_str}")
|
|
346
|
+
trailers.append(f"--trailer=Inspection-at: {timestamp}")
|
|
347
|
+
trailers.append(f"--trailer=Inspection-sig: hmac-sha256:{hmac_sig}")
|
|
348
|
+
|
|
349
|
+
# Create the approval commit
|
|
350
|
+
commit_msg = "chore: retroactive leftblock approval\n"
|
|
351
|
+
cmd = ["git", "commit", "--allow-empty", "-F", "-"] + trailers
|
|
352
|
+
proc = subprocess.run(cmd, input=commit_msg, text=True, capture_output=True, check=True)
|
|
353
|
+
print_info(f"Created approval commit:\n{proc.stdout.strip()}")
|
|
354
|
+
|
|
355
|
+
def cmd_verify(args):
|
|
356
|
+
"""Verify signatures for a commit range."""
|
|
357
|
+
try:
|
|
358
|
+
secret = get_secret()
|
|
359
|
+
except FileNotFoundError:
|
|
360
|
+
print_err("Shared secret not found. Run 'leftblock install'.")
|
|
361
|
+
sys.exit(1)
|
|
362
|
+
|
|
363
|
+
range_str = args.range
|
|
364
|
+
if ".." in range_str:
|
|
365
|
+
commits = run_cmd(["git", "rev-list", range_str]).stdout.splitlines()
|
|
366
|
+
else:
|
|
367
|
+
commits = [run_cmd(["git", "rev-parse", range_str]).stdout.strip()]
|
|
368
|
+
|
|
369
|
+
# Pool all valid signatures found in the range
|
|
370
|
+
valid_trees = set()
|
|
371
|
+
|
|
372
|
+
for sha in commits:
|
|
373
|
+
# Get all trailers from the commit message
|
|
374
|
+
msg = run_cmd(["git", "log", "-1", sha, "--format=%B"]).stdout
|
|
375
|
+
# Basic parsing of trailers. git log format for multiple identical keys is tricky,
|
|
376
|
+
# so we parse the raw message.
|
|
377
|
+
import re
|
|
378
|
+
trees = re.findall(r"^Inspection-tree:\s*(.+)$", msg, re.MULTILINE)
|
|
379
|
+
sigs = re.findall(r"^Inspection-sig:\s*hmac-sha256:(.+)$", msg, re.MULTILINE)
|
|
380
|
+
validators = re.findall(r"^Inspected-by:\s*(.+)$", msg, re.MULTILINE)
|
|
381
|
+
results = re.findall(r"^Inspection-result:\s*(.+)$", msg, re.MULTILINE)
|
|
382
|
+
timestamps = re.findall(r"^Inspection-at:\s*(.+)$", msg, re.MULTILINE)
|
|
383
|
+
|
|
384
|
+
# Zip them up (assuming they appear in blocks)
|
|
385
|
+
for t, s, v, r, ts in zip(trees, sigs, validators, results, timestamps):
|
|
386
|
+
expected = compute_hmac(t, v, r, ts, secret)
|
|
387
|
+
if s == expected:
|
|
388
|
+
valid_trees.add(t)
|
|
389
|
+
|
|
390
|
+
failures = 0
|
|
391
|
+
for sha in commits:
|
|
392
|
+
short = sha[:7]
|
|
393
|
+
actual_tree = get_commit_tree_hash(sha)
|
|
394
|
+
|
|
395
|
+
if actual_tree not in valid_trees:
|
|
396
|
+
print_err(f"{short}: No valid signature found for tree {actual_tree[:12]}.")
|
|
397
|
+
failures += 1
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
subject = run_cmd(["git", "log", "-1", sha, "--format=%s"]).stdout.strip()[:50]
|
|
401
|
+
print(f"\033[1;32mPASS:\033[0m {short}: {subject} [verified]")
|
|
402
|
+
|
|
403
|
+
if failures > 0:
|
|
404
|
+
print_err(f"Verification failed: {failures}/{len(commits)} commit(s) invalid.")
|
|
405
|
+
sys.exit(1)
|
|
406
|
+
else:
|
|
407
|
+
print(f"\033[1;32mAll {len(commits)} commit(s) verified.\033[0m")
|
|
408
|
+
|
|
409
|
+
def main():
|
|
410
|
+
parser = argparse.ArgumentParser(description="LeftBlock - A pluggable inspection gate for Git commits")
|
|
411
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
412
|
+
|
|
413
|
+
install_p = subparsers.add_parser("install", help="Install the commit-msg hook and setup secrets")
|
|
414
|
+
install_p.add_argument("--local", action="store_true", help="Keep policy in the common Git directory (with --check-only)")
|
|
415
|
+
install_p.add_argument("--check-only", action="store_true", help="Install a pre-commit check without signing (with --local)")
|
|
416
|
+
install_p.add_argument("--gitleaks", help="Existing Gitleaks executable for a local check-only installation")
|
|
417
|
+
|
|
418
|
+
check_p = subparsers.add_parser("check", help="Inspect staged changes without signing or allowing overrides")
|
|
419
|
+
check_p.add_argument("--config", help="Explicit local checker policy; defaults to .leftblock.yml")
|
|
420
|
+
|
|
421
|
+
hook_p = subparsers.add_parser("hook", help="Run checkers (used internally by git hook)")
|
|
422
|
+
hook_p.add_argument("commit_msg_file", help="Path to the commit message file")
|
|
423
|
+
|
|
424
|
+
verify_p = subparsers.add_parser("verify", help="Verify commits")
|
|
425
|
+
verify_p.add_argument("range", nargs="?", default="HEAD", help="Commit range (e.g. HEAD~3..HEAD)")
|
|
426
|
+
|
|
427
|
+
approve_p = subparsers.add_parser("approve", help="Retroactively approve commits")
|
|
428
|
+
approve_p.add_argument("range", help="Commit range to approve")
|
|
429
|
+
|
|
430
|
+
args = parser.parse_args()
|
|
431
|
+
|
|
432
|
+
if args.command == "install":
|
|
433
|
+
cmd_install(args)
|
|
434
|
+
elif args.command == "check":
|
|
435
|
+
cmd_check(args)
|
|
436
|
+
elif args.command == "hook":
|
|
437
|
+
cmd_hook(args)
|
|
438
|
+
elif args.command == "verify":
|
|
439
|
+
cmd_verify(args)
|
|
440
|
+
elif args.command == "approve":
|
|
441
|
+
cmd_approve(args)
|
|
442
|
+
|
|
443
|
+
if __name__ == "__main__":
|
|
444
|
+
main()
|
leftblock/core.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import subprocess
|
|
4
|
+
import hmac
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import shlex
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import List, Dict, Any, Tuple
|
|
11
|
+
import tempfile
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
SECRET_FILE_DEFAULT = os.environ.get("LEFTBLOCK_SECRET_PATH", os.path.expanduser("~/.config/leftblock/secret"))
|
|
15
|
+
|
|
16
|
+
def get_secret(secret_path: str = SECRET_FILE_DEFAULT) -> str:
|
|
17
|
+
if not os.path.exists(secret_path):
|
|
18
|
+
raise FileNotFoundError(f"Secret file not found at {secret_path}")
|
|
19
|
+
with open(secret_path, 'r') as f:
|
|
20
|
+
return f.read().strip()
|
|
21
|
+
|
|
22
|
+
def compute_hmac(tree_hash: str, validator: str, result: str, timestamp: str, secret: str) -> str:
|
|
23
|
+
msg = f"{tree_hash}|{validator}|{result}|{timestamp}".encode('utf-8')
|
|
24
|
+
return hmac.new(secret.encode('utf-8'), msg, hashlib.sha256).hexdigest()
|
|
25
|
+
|
|
26
|
+
def run_cmd(cmd: List[str], check: bool = True, capture_output: bool = True, **kwargs) -> subprocess.CompletedProcess:
|
|
27
|
+
return subprocess.run(cmd, check=check, capture_output=capture_output, text=True, **kwargs)
|
|
28
|
+
|
|
29
|
+
def get_tree_hash() -> str:
|
|
30
|
+
return run_cmd(["git", "write-tree"]).stdout.strip()
|
|
31
|
+
|
|
32
|
+
def get_commit_tree_hash(commit_sha: str) -> str:
|
|
33
|
+
output = run_cmd(["git", "cat-file", "-p", commit_sha]).stdout
|
|
34
|
+
for line in output.splitlines():
|
|
35
|
+
if line.startswith("tree "):
|
|
36
|
+
return line.split(" ")[1].strip()
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
def load_config(repo_root: str, config_path: str = None) -> Dict[str, Any]:
|
|
40
|
+
if config_path is None:
|
|
41
|
+
config_path = os.path.join(repo_root, ".leftblock.yml")
|
|
42
|
+
if not os.path.exists(config_path):
|
|
43
|
+
return {}
|
|
44
|
+
with open(config_path, 'r') as f:
|
|
45
|
+
return yaml.safe_load(f) or {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_checkers(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
49
|
+
"""Reject missing or unusable policy instead of signing an uninspected tree."""
|
|
50
|
+
if not isinstance(config, dict):
|
|
51
|
+
raise ValueError("Invalid checker configuration")
|
|
52
|
+
checkers = config.get("checkers")
|
|
53
|
+
if not isinstance(checkers, list) or not checkers:
|
|
54
|
+
raise ValueError("No checkers configured")
|
|
55
|
+
for checker in checkers:
|
|
56
|
+
if not isinstance(checker, dict):
|
|
57
|
+
raise ValueError("Invalid checker configuration")
|
|
58
|
+
ctype = checker.get("type", "command")
|
|
59
|
+
key = "command" if ctype == "command" else "procedure"
|
|
60
|
+
if ctype not in ("command", "tactus") or not isinstance(checker.get(key), str) or not checker[key].strip():
|
|
61
|
+
raise ValueError("Invalid checker configuration")
|
|
62
|
+
if ctype == "command":
|
|
63
|
+
success = checker.get("success_codes", [0])
|
|
64
|
+
findings = checker.get("finding_codes", [1])
|
|
65
|
+
for codes in (success, findings):
|
|
66
|
+
if not isinstance(codes, list) or not codes or any(type(c) is not int for c in codes):
|
|
67
|
+
raise ValueError("Invalid checker exit-code policy")
|
|
68
|
+
if set(success) & set(findings):
|
|
69
|
+
raise ValueError("Checker success and finding codes must not overlap")
|
|
70
|
+
return checkers
|
|
71
|
+
|
|
72
|
+
class CheckerResult:
|
|
73
|
+
def __init__(self, name: str, clean: bool, findings: Any = None, error: bool = False):
|
|
74
|
+
self.name = name
|
|
75
|
+
self.clean = clean
|
|
76
|
+
self.findings = findings
|
|
77
|
+
self.error = error
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def index_environment(env=None):
|
|
81
|
+
"""Resolve a caller-relative alternate index before changing checker cwd."""
|
|
82
|
+
source = os.environ if env is None else env
|
|
83
|
+
index = source.get("GIT_INDEX_FILE")
|
|
84
|
+
if index and not os.path.isabs(index):
|
|
85
|
+
return dict(source, GIT_INDEX_FILE=os.path.abspath(index))
|
|
86
|
+
return env
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def run_command_checker(checker: Dict[str, Any], cwd: str = None, env: Dict[str, str] = None) -> CheckerResult:
|
|
90
|
+
name = checker.get('name', 'unknown')
|
|
91
|
+
cmd = checker.get('command', '')
|
|
92
|
+
success_codes = checker.get('success_codes', [0])
|
|
93
|
+
finding_codes = checker.get('finding_codes', [1])
|
|
94
|
+
report_file = None
|
|
95
|
+
try:
|
|
96
|
+
# Split before substitution so a TMPDIR with spaces stays one argument.
|
|
97
|
+
argv = shlex.split(cmd)
|
|
98
|
+
if "{report_file}" in cmd:
|
|
99
|
+
fd, report_file = tempfile.mkstemp(suffix=".json")
|
|
100
|
+
os.close(fd)
|
|
101
|
+
argv = [arg.replace("{report_file}", report_file) for arg in argv]
|
|
102
|
+
proc = run_cmd(argv, check=False, capture_output=True, cwd=cwd, env=index_environment(env))
|
|
103
|
+
if proc.returncode in success_codes:
|
|
104
|
+
return CheckerResult(name, True)
|
|
105
|
+
if proc.returncode not in finding_codes:
|
|
106
|
+
return CheckerResult(name, False, "Checker failed; output suppressed.", error=True)
|
|
107
|
+
findings = "Findings detected; checker output suppressed."
|
|
108
|
+
if report_file:
|
|
109
|
+
try:
|
|
110
|
+
with open(report_file, 'r') as f:
|
|
111
|
+
report = json.load(f)
|
|
112
|
+
if not isinstance(report, list) or not report or any(not isinstance(item, dict) for item in report):
|
|
113
|
+
raise ValueError("Invalid report")
|
|
114
|
+
# Never carry matched values, source lines, descriptions or raw logs
|
|
115
|
+
# into the UI. Files/rule identifiers are the diagnostic boundary.
|
|
116
|
+
findings = [
|
|
117
|
+
{key: item[key] for key in ("File", "RuleID") if isinstance(item.get(key), str)}
|
|
118
|
+
for item in report
|
|
119
|
+
]
|
|
120
|
+
except (OSError, ValueError, TypeError):
|
|
121
|
+
return CheckerResult(name, False, "Checker report unavailable or invalid; output suppressed.", error=True)
|
|
122
|
+
return CheckerResult(name, False, findings)
|
|
123
|
+
except (OSError, ValueError):
|
|
124
|
+
return CheckerResult(name, False, "Checker could not run; check its installation and configuration.", error=True)
|
|
125
|
+
finally:
|
|
126
|
+
if report_file and os.path.exists(report_file):
|
|
127
|
+
os.remove(report_file)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_tactus_checker(checker: Dict[str, Any], cwd: str = None) -> CheckerResult:
|
|
131
|
+
name = checker.get('name', 'tactus-scan')
|
|
132
|
+
procedure = checker.get('procedure')
|
|
133
|
+
if not procedure:
|
|
134
|
+
return CheckerResult(name, False, "No procedure specified for tactus checker", error=True)
|
|
135
|
+
try:
|
|
136
|
+
proc = run_cmd(
|
|
137
|
+
["tactus", "run", procedure], check=False, capture_output=True,
|
|
138
|
+
cwd=cwd, env=index_environment(),
|
|
139
|
+
)
|
|
140
|
+
except OSError:
|
|
141
|
+
return CheckerResult(name, False, "Tactus could not run; check its installation.", error=True)
|
|
142
|
+
is_clean = proc.returncode == 0
|
|
143
|
+
return CheckerResult(
|
|
144
|
+
name, is_clean,
|
|
145
|
+
"Tactus reported a failure; output suppressed." if not is_clean else None,
|
|
146
|
+
error=proc.returncode not in (0, 1),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def execute_checkers(checkers: List[Dict[str, Any]], cwd: str = None, env: Dict[str, str] = None) -> List[CheckerResult]:
|
|
150
|
+
results = []
|
|
151
|
+
for checker in checkers:
|
|
152
|
+
ctype = checker.get('type', 'command')
|
|
153
|
+
if ctype == 'command':
|
|
154
|
+
results.append(run_command_checker(checker, cwd=cwd, env=env))
|
|
155
|
+
elif ctype == 'tactus':
|
|
156
|
+
results.append(run_tactus_checker(checker, cwd=cwd))
|
|
157
|
+
else:
|
|
158
|
+
results.append(CheckerResult(checker.get('name', 'unknown'), False, f"Unknown checker type: {ctype}"))
|
|
159
|
+
return results
|
|
160
|
+
|
|
161
|
+
def get_trailer(commit_sha: str, key: str) -> str:
|
|
162
|
+
return run_cmd(["git", "log", "-1", commit_sha, f"--format=%(trailers:key={key},valueonly)"]).stdout.strip()
|