leftblock 0.3.5__tar.gz → 0.4.0__tar.gz
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-0.3.5 → leftblock-0.4.0}/PKG-INFO +119 -8
- {leftblock-0.3.5 → leftblock-0.4.0}/README.md +118 -7
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock/allow.py +13 -5
- leftblock-0.4.0/leftblock/bypass.py +119 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock/cli.py +256 -36
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock/core.py +7 -4
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock/hooks.py +102 -4
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock.egg-info/PKG-INFO +119 -8
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock.egg-info/SOURCES.txt +3 -0
- leftblock-0.4.0/pyproject.toml +47 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/tests/test_branding.py +7 -2
- leftblock-0.4.0/tests/test_bypasses.py +183 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/tests/test_commit_gate.py +20 -6
- {leftblock-0.3.5 → leftblock-0.4.0}/tests/test_false_positives.py +1 -1
- leftblock-0.4.0/tests/test_kanbus_advice.py +96 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/tests/test_local_install.py +87 -1
- {leftblock-0.3.5 → leftblock-0.4.0}/tests/test_recorded_overrides.py +131 -2
- leftblock-0.3.5/pyproject.toml +0 -24
- {leftblock-0.3.5 → leftblock-0.4.0}/LICENSE +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock/__init__.py +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock/data/gitleaks.toml +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock/merge.py +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock.egg-info/dependency_links.txt +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock.egg-info/entry_points.txt +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock.egg-info/requires.txt +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/leftblock.egg-info/top_level.txt +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/setup.cfg +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/tests/test_default_config.py +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/tests/test_install.py +0 -0
- {leftblock-0.3.5 → leftblock-0.4.0}/tests/test_sensitive_rules.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: leftblock
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: LeftBlock: a pluggable inspection gate for Git commits
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -124,7 +124,7 @@ configures the integration; it does not install scanner binaries.
|
|
|
124
124
|
In the intended Python environment:
|
|
125
125
|
|
|
126
126
|
```bash
|
|
127
|
-
python -m pip install leftblock==0.
|
|
127
|
+
python -m pip install leftblock==0.4.0
|
|
128
128
|
python -m leftblock.cli --help
|
|
129
129
|
```
|
|
130
130
|
|
|
@@ -154,6 +154,73 @@ with the existing hook manager instead of replacing it. An earlier hook
|
|
|
154
154
|
without the current ownership record requires a reviewed migration even
|
|
155
155
|
if it is still functional.
|
|
156
156
|
|
|
157
|
+
#### Upgrades and the stable runtime link
|
|
158
|
+
|
|
159
|
+
When `install --local --check-only` runs from a virtualenv, it also keeps two
|
|
160
|
+
symlinks pointing at that environment:
|
|
161
|
+
|
|
162
|
+
- `$LEFTBLOCK_HOME/current` (default `~/.local/share/leftblock/current`) points
|
|
163
|
+
at the virtualenv, and every managed hook runs `current/bin/python` instead
|
|
164
|
+
of a versioned interpreter path.
|
|
165
|
+
- `~/.local/bin/leftblock` points at `current/bin/leftblock`. It is replaced
|
|
166
|
+
only if it is absent or already a symlink; a regular file is left alone.
|
|
167
|
+
|
|
168
|
+
To upgrade, install the new version into a new virtualenv and run
|
|
169
|
+
`leftblock install --local --check-only` once, in any repository. That
|
|
170
|
+
repoints `current`, so every repository's hook uses the new runtime. The
|
|
171
|
+
per-repository rules copy (in `.git/leftblock/`) is refreshed only when
|
|
172
|
+
you re-run the install in that repository.
|
|
173
|
+
|
|
174
|
+
If the runtime link is broken, the hook fails closed: it prints
|
|
175
|
+
`LeftBlock is not installed at <path>` with the reinstall steps and exits 1.
|
|
176
|
+
Outside a virtualenv, the hook keeps using the installing interpreter and no
|
|
177
|
+
links are made.
|
|
178
|
+
|
|
179
|
+
`leftblock --version` prints the installed version. A blocked check starts
|
|
180
|
+
with `leftblock <version>, ruleset <id>`, where the ruleset id is the first 12
|
|
181
|
+
hex characters of the SHA-256 of the gitleaks config file the checker names
|
|
182
|
+
with `--config` (`unknown` when there is none), so a report can say exactly
|
|
183
|
+
which rules ran.
|
|
184
|
+
|
|
185
|
+
#### Dry run
|
|
186
|
+
|
|
187
|
+
`leftblock check` is a dry run of the next commit: it inspects the staged
|
|
188
|
+
changes with the same policy the hook uses and changes nothing. Without
|
|
189
|
+
`--config` it uses the local install policy when one exists, otherwise
|
|
190
|
+
`.leftblock.yml`. A blocked run ends by printing `Re-run this check:
|
|
191
|
+
leftblock check` and the final summary line.
|
|
192
|
+
|
|
193
|
+
#### Exit codes and JSON output
|
|
194
|
+
|
|
195
|
+
`leftblock check` exits with:
|
|
196
|
+
|
|
197
|
+
| Code | Meaning |
|
|
198
|
+
|---|---|
|
|
199
|
+
| 0 | Passed (overrides may have been applied) |
|
|
200
|
+
| 1 | Blocked by findings |
|
|
201
|
+
| 2 | A checker or the configuration could not complete, including an invalid `.leftblock-allow.yml`; this wins over 1 when both occur |
|
|
202
|
+
|
|
203
|
+
`leftblock check --format json` also prints one JSON object on stdout and
|
|
204
|
+
leaves the human-readable stderr output unchanged:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"version": "0.3.5",
|
|
209
|
+
"result": "blocked",
|
|
210
|
+
"findings": [{"file": "page.tsx", "line": 2, "rule": "leftblock-personal-email",
|
|
211
|
+
"id": "3f2a9c1e7b4d5a60", "description": "Personal email address"}],
|
|
212
|
+
"overrides_applied": [{"file": "a.md", "rule": "leftblock-home-path-posix",
|
|
213
|
+
"id": "0123456789abcdef", "reason": "Documented example path"}],
|
|
214
|
+
"errors": [],
|
|
215
|
+
"override_command": "leftblock allow 3f2a9c1e7b4d5a60 --reason \"<why this is not sensitive>\""
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`result` is `passed`, `blocked` or `error`; `errors` is `[{checker, message}]`;
|
|
220
|
+
`override_command` is `null` when nothing can be overridden. Like the text
|
|
221
|
+
output, it never contains matched values or source lines. The git hooks only
|
|
222
|
+
need a nonzero status, so they are unaffected.
|
|
223
|
+
|
|
157
224
|
To run a noninteractive staged check with an explicit policy:
|
|
158
225
|
|
|
159
226
|
```bash
|
|
@@ -172,8 +239,9 @@ metadata or another private location.
|
|
|
172
239
|
|
|
173
240
|
A block stays the default. If a finding is a false alarm (for example a
|
|
174
241
|
public role address in a privacy notice), record a reviewed override. The
|
|
175
|
-
blocked check prints
|
|
176
|
-
the
|
|
242
|
+
blocked check prints each finding as `file:LINE: rule [id …] (description)`,
|
|
243
|
+
where the description is the rule's own text from the ruleset, never the
|
|
244
|
+
matched value, source line or secret, and then the exact command to run:
|
|
177
245
|
|
|
178
246
|
```bash
|
|
179
247
|
leftblock allow ID [ID ...] --reason "Why this is not sensitive"
|
|
@@ -190,10 +258,14 @@ allow:
|
|
|
190
258
|
- id: "3f2a9c1e7b4d5a60"
|
|
191
259
|
rule: "leftblock-personal-email"
|
|
192
260
|
file: "apps/web/privacy/page.tsx"
|
|
261
|
+
kind: "Personal email address"
|
|
193
262
|
reason: "Public role address required in the privacy notice."
|
|
194
263
|
added: 2026-10-20
|
|
195
264
|
```
|
|
196
265
|
|
|
266
|
+
- **Category for reviewers.** `kind` is the rule's description, written so a
|
|
267
|
+
reviewer of the pull request sees what kind of finding was approved. It is
|
|
268
|
+
informational: it is not part of matching and may be omitted.
|
|
197
269
|
- **Scoped to line content.** The id hashes the rule, the file, and the exact
|
|
198
270
|
staged content of the flagged lines. Editing that line, or adding another
|
|
199
271
|
finding anywhere, blocks again.
|
|
@@ -210,10 +282,34 @@ allow:
|
|
|
210
282
|
policy when present, otherwise `.leftblock.yml`; pass the same `--config`
|
|
211
283
|
you use for `check` if it differs.
|
|
212
284
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
Gitleaks
|
|
216
|
-
|
|
285
|
+
#### Gitleaks' own suppressions
|
|
286
|
+
|
|
287
|
+
Gitleaks skips a line carrying its inline allow comment and any entry in a
|
|
288
|
+
`.gitleaksignore` file, with no recorded reason. LeftBlock closes the silent
|
|
289
|
+
paths it can see: it reports, as ordinary findings that carry an id and can be
|
|
290
|
+
overridden only through a recorded reason as above,
|
|
291
|
+
|
|
292
|
+
- `leftblock-gitleaks-allow-marker`: any staged added line containing the
|
|
293
|
+
Gitleaks allow comment (the word `gitleaks`, a colon, then `allow`); and
|
|
294
|
+
- `leftblock-gitleaksignore-change`: any staged addition or edit of a
|
|
295
|
+
`.gitleaksignore` file at any path (removing one is not a finding).
|
|
296
|
+
|
|
297
|
+
Both are read from the staged diff (the merge-scoped index during a merge).
|
|
298
|
+
Removing a suppression is the fix, not a finding.
|
|
299
|
+
|
|
300
|
+
**Residual risk.** Tested with Gitleaks 8.30: a `.gitleaksignore` in the
|
|
301
|
+
repository root that already exists, committed or only in the working tree, is
|
|
302
|
+
still honoured by `protect --staged`, and no option turns that off. Passing
|
|
303
|
+
`--gitleaks-ignore-path` with an empty file or directory adds to the sources it
|
|
304
|
+
reads; it does not replace the root file. Ignore files in subdirectories and
|
|
305
|
+
ones that exist only in the index are not read. LeftBlock therefore cannot
|
|
306
|
+
stop an ignore file that was already there before the check ran, but every
|
|
307
|
+
`leftblock check` warns when a root `.gitleaksignore` exists, so its skips are
|
|
308
|
+
never silent. Review it (`git ls-files '*.gitleaksignore'`), and treat a file
|
|
309
|
+
that is untracked or edited outside the index as outside this check. The
|
|
310
|
+
installed policy passes `--ignore-gitleaks-allow`, so Gitleaks still scans
|
|
311
|
+
lines that carry the inline comment: a real finding on such a line is reported
|
|
312
|
+
alongside the marker finding.
|
|
217
313
|
|
|
218
314
|
During an in-progress merge, command checkers skip only regular files whose
|
|
219
315
|
complete blob and mode already exist at the same path in a parent. Changed
|
|
@@ -257,6 +353,12 @@ Python environment or Gitleaks executable. For manual composition, run
|
|
|
257
353
|
`leftblock hook "$1"` in the `commit-msg` flow when receipts are needed.
|
|
258
354
|
Preserve existing behavior and propagate any failure's exit status.
|
|
259
355
|
|
|
356
|
+
A receipt records `Inspection-result: clean` when nothing was found, and
|
|
357
|
+
`Inspection-result: allowed:N` when every finding was covered by `N` staged,
|
|
358
|
+
reasoned overrides (see [Overriding a false alarm](#overriding-a-false-alarm)).
|
|
359
|
+
Neither needs a passphrase. An interactive passphrase override of unrecorded
|
|
360
|
+
findings is still recorded as `override:N-checkers`.
|
|
361
|
+
|
|
260
362
|
Receipts use HMAC trailers, not Git GPG/SSH signatures; they do not appear
|
|
261
363
|
in `git log --show-signature`. Every machine running `leftblock verify`
|
|
262
364
|
needs the same secret, stored by default at `~/.config/leftblock/secret`
|
|
@@ -306,6 +408,15 @@ PEM private keys, and other standard secret formats.
|
|
|
306
408
|
| `leftblock-wif-key` | Bitcoin WIF private keys (compressed `K/L/M…`, uncompressed `5…`) |
|
|
307
409
|
| `leftblock-bip39-mnemonic` | BIP-39 seed phrases (12–24 words) in seed/mnemonic/recovery context |
|
|
308
410
|
|
|
411
|
+
A blocked check prints a one-line hint after each rule's findings (once per
|
|
412
|
+
rule): use a role or placeholder address for personal emails; a relative path,
|
|
413
|
+
`$HOME` or `~` for home paths; drop or describe session and actor identifiers;
|
|
414
|
+
and for API, private and wallet keys, remove the value **and rotate the key**,
|
|
415
|
+
since it may already be exposed. Findings in Kanbus board data
|
|
416
|
+
(`project/issues/*.json`, `project/events/*.json`) also say that Kanbus writes
|
|
417
|
+
those files and that the content should be fixed through `kbs` (for example
|
|
418
|
+
`kbs update` or `kbs comment`) rather than by hand.
|
|
419
|
+
|
|
309
420
|
Kanbus exceptions apply to complete identifier property lines in
|
|
310
421
|
`project/issues/*.json` and `project/events/*.json`. Event actor attribution
|
|
311
422
|
must occupy a root property line in the canonical two-space format.
|
|
@@ -91,7 +91,7 @@ configures the integration; it does not install scanner binaries.
|
|
|
91
91
|
In the intended Python environment:
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
|
-
python -m pip install leftblock==0.
|
|
94
|
+
python -m pip install leftblock==0.4.0
|
|
95
95
|
python -m leftblock.cli --help
|
|
96
96
|
```
|
|
97
97
|
|
|
@@ -121,6 +121,73 @@ with the existing hook manager instead of replacing it. An earlier hook
|
|
|
121
121
|
without the current ownership record requires a reviewed migration even
|
|
122
122
|
if it is still functional.
|
|
123
123
|
|
|
124
|
+
#### Upgrades and the stable runtime link
|
|
125
|
+
|
|
126
|
+
When `install --local --check-only` runs from a virtualenv, it also keeps two
|
|
127
|
+
symlinks pointing at that environment:
|
|
128
|
+
|
|
129
|
+
- `$LEFTBLOCK_HOME/current` (default `~/.local/share/leftblock/current`) points
|
|
130
|
+
at the virtualenv, and every managed hook runs `current/bin/python` instead
|
|
131
|
+
of a versioned interpreter path.
|
|
132
|
+
- `~/.local/bin/leftblock` points at `current/bin/leftblock`. It is replaced
|
|
133
|
+
only if it is absent or already a symlink; a regular file is left alone.
|
|
134
|
+
|
|
135
|
+
To upgrade, install the new version into a new virtualenv and run
|
|
136
|
+
`leftblock install --local --check-only` once, in any repository. That
|
|
137
|
+
repoints `current`, so every repository's hook uses the new runtime. The
|
|
138
|
+
per-repository rules copy (in `.git/leftblock/`) is refreshed only when
|
|
139
|
+
you re-run the install in that repository.
|
|
140
|
+
|
|
141
|
+
If the runtime link is broken, the hook fails closed: it prints
|
|
142
|
+
`LeftBlock is not installed at <path>` with the reinstall steps and exits 1.
|
|
143
|
+
Outside a virtualenv, the hook keeps using the installing interpreter and no
|
|
144
|
+
links are made.
|
|
145
|
+
|
|
146
|
+
`leftblock --version` prints the installed version. A blocked check starts
|
|
147
|
+
with `leftblock <version>, ruleset <id>`, where the ruleset id is the first 12
|
|
148
|
+
hex characters of the SHA-256 of the gitleaks config file the checker names
|
|
149
|
+
with `--config` (`unknown` when there is none), so a report can say exactly
|
|
150
|
+
which rules ran.
|
|
151
|
+
|
|
152
|
+
#### Dry run
|
|
153
|
+
|
|
154
|
+
`leftblock check` is a dry run of the next commit: it inspects the staged
|
|
155
|
+
changes with the same policy the hook uses and changes nothing. Without
|
|
156
|
+
`--config` it uses the local install policy when one exists, otherwise
|
|
157
|
+
`.leftblock.yml`. A blocked run ends by printing `Re-run this check:
|
|
158
|
+
leftblock check` and the final summary line.
|
|
159
|
+
|
|
160
|
+
#### Exit codes and JSON output
|
|
161
|
+
|
|
162
|
+
`leftblock check` exits with:
|
|
163
|
+
|
|
164
|
+
| Code | Meaning |
|
|
165
|
+
|---|---|
|
|
166
|
+
| 0 | Passed (overrides may have been applied) |
|
|
167
|
+
| 1 | Blocked by findings |
|
|
168
|
+
| 2 | A checker or the configuration could not complete, including an invalid `.leftblock-allow.yml`; this wins over 1 when both occur |
|
|
169
|
+
|
|
170
|
+
`leftblock check --format json` also prints one JSON object on stdout and
|
|
171
|
+
leaves the human-readable stderr output unchanged:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"version": "0.3.5",
|
|
176
|
+
"result": "blocked",
|
|
177
|
+
"findings": [{"file": "page.tsx", "line": 2, "rule": "leftblock-personal-email",
|
|
178
|
+
"id": "3f2a9c1e7b4d5a60", "description": "Personal email address"}],
|
|
179
|
+
"overrides_applied": [{"file": "a.md", "rule": "leftblock-home-path-posix",
|
|
180
|
+
"id": "0123456789abcdef", "reason": "Documented example path"}],
|
|
181
|
+
"errors": [],
|
|
182
|
+
"override_command": "leftblock allow 3f2a9c1e7b4d5a60 --reason \"<why this is not sensitive>\""
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`result` is `passed`, `blocked` or `error`; `errors` is `[{checker, message}]`;
|
|
187
|
+
`override_command` is `null` when nothing can be overridden. Like the text
|
|
188
|
+
output, it never contains matched values or source lines. The git hooks only
|
|
189
|
+
need a nonzero status, so they are unaffected.
|
|
190
|
+
|
|
124
191
|
To run a noninteractive staged check with an explicit policy:
|
|
125
192
|
|
|
126
193
|
```bash
|
|
@@ -139,8 +206,9 @@ metadata or another private location.
|
|
|
139
206
|
|
|
140
207
|
A block stays the default. If a finding is a false alarm (for example a
|
|
141
208
|
public role address in a privacy notice), record a reviewed override. The
|
|
142
|
-
blocked check prints
|
|
143
|
-
the
|
|
209
|
+
blocked check prints each finding as `file:LINE: rule [id …] (description)`,
|
|
210
|
+
where the description is the rule's own text from the ruleset, never the
|
|
211
|
+
matched value, source line or secret, and then the exact command to run:
|
|
144
212
|
|
|
145
213
|
```bash
|
|
146
214
|
leftblock allow ID [ID ...] --reason "Why this is not sensitive"
|
|
@@ -157,10 +225,14 @@ allow:
|
|
|
157
225
|
- id: "3f2a9c1e7b4d5a60"
|
|
158
226
|
rule: "leftblock-personal-email"
|
|
159
227
|
file: "apps/web/privacy/page.tsx"
|
|
228
|
+
kind: "Personal email address"
|
|
160
229
|
reason: "Public role address required in the privacy notice."
|
|
161
230
|
added: 2026-10-20
|
|
162
231
|
```
|
|
163
232
|
|
|
233
|
+
- **Category for reviewers.** `kind` is the rule's description, written so a
|
|
234
|
+
reviewer of the pull request sees what kind of finding was approved. It is
|
|
235
|
+
informational: it is not part of matching and may be omitted.
|
|
164
236
|
- **Scoped to line content.** The id hashes the rule, the file, and the exact
|
|
165
237
|
staged content of the flagged lines. Editing that line, or adding another
|
|
166
238
|
finding anywhere, blocks again.
|
|
@@ -177,10 +249,34 @@ allow:
|
|
|
177
249
|
policy when present, otherwise `.leftblock.yml`; pass the same `--config`
|
|
178
250
|
you use for `check` if it differs.
|
|
179
251
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
Gitleaks
|
|
183
|
-
|
|
252
|
+
#### Gitleaks' own suppressions
|
|
253
|
+
|
|
254
|
+
Gitleaks skips a line carrying its inline allow comment and any entry in a
|
|
255
|
+
`.gitleaksignore` file, with no recorded reason. LeftBlock closes the silent
|
|
256
|
+
paths it can see: it reports, as ordinary findings that carry an id and can be
|
|
257
|
+
overridden only through a recorded reason as above,
|
|
258
|
+
|
|
259
|
+
- `leftblock-gitleaks-allow-marker`: any staged added line containing the
|
|
260
|
+
Gitleaks allow comment (the word `gitleaks`, a colon, then `allow`); and
|
|
261
|
+
- `leftblock-gitleaksignore-change`: any staged addition or edit of a
|
|
262
|
+
`.gitleaksignore` file at any path (removing one is not a finding).
|
|
263
|
+
|
|
264
|
+
Both are read from the staged diff (the merge-scoped index during a merge).
|
|
265
|
+
Removing a suppression is the fix, not a finding.
|
|
266
|
+
|
|
267
|
+
**Residual risk.** Tested with Gitleaks 8.30: a `.gitleaksignore` in the
|
|
268
|
+
repository root that already exists, committed or only in the working tree, is
|
|
269
|
+
still honoured by `protect --staged`, and no option turns that off. Passing
|
|
270
|
+
`--gitleaks-ignore-path` with an empty file or directory adds to the sources it
|
|
271
|
+
reads; it does not replace the root file. Ignore files in subdirectories and
|
|
272
|
+
ones that exist only in the index are not read. LeftBlock therefore cannot
|
|
273
|
+
stop an ignore file that was already there before the check ran, but every
|
|
274
|
+
`leftblock check` warns when a root `.gitleaksignore` exists, so its skips are
|
|
275
|
+
never silent. Review it (`git ls-files '*.gitleaksignore'`), and treat a file
|
|
276
|
+
that is untracked or edited outside the index as outside this check. The
|
|
277
|
+
installed policy passes `--ignore-gitleaks-allow`, so Gitleaks still scans
|
|
278
|
+
lines that carry the inline comment: a real finding on such a line is reported
|
|
279
|
+
alongside the marker finding.
|
|
184
280
|
|
|
185
281
|
During an in-progress merge, command checkers skip only regular files whose
|
|
186
282
|
complete blob and mode already exist at the same path in a parent. Changed
|
|
@@ -224,6 +320,12 @@ Python environment or Gitleaks executable. For manual composition, run
|
|
|
224
320
|
`leftblock hook "$1"` in the `commit-msg` flow when receipts are needed.
|
|
225
321
|
Preserve existing behavior and propagate any failure's exit status.
|
|
226
322
|
|
|
323
|
+
A receipt records `Inspection-result: clean` when nothing was found, and
|
|
324
|
+
`Inspection-result: allowed:N` when every finding was covered by `N` staged,
|
|
325
|
+
reasoned overrides (see [Overriding a false alarm](#overriding-a-false-alarm)).
|
|
326
|
+
Neither needs a passphrase. An interactive passphrase override of unrecorded
|
|
327
|
+
findings is still recorded as `override:N-checkers`.
|
|
328
|
+
|
|
227
329
|
Receipts use HMAC trailers, not Git GPG/SSH signatures; they do not appear
|
|
228
330
|
in `git log --show-signature`. Every machine running `leftblock verify`
|
|
229
331
|
needs the same secret, stored by default at `~/.config/leftblock/secret`
|
|
@@ -273,6 +375,15 @@ PEM private keys, and other standard secret formats.
|
|
|
273
375
|
| `leftblock-wif-key` | Bitcoin WIF private keys (compressed `K/L/M…`, uncompressed `5…`) |
|
|
274
376
|
| `leftblock-bip39-mnemonic` | BIP-39 seed phrases (12–24 words) in seed/mnemonic/recovery context |
|
|
275
377
|
|
|
378
|
+
A blocked check prints a one-line hint after each rule's findings (once per
|
|
379
|
+
rule): use a role or placeholder address for personal emails; a relative path,
|
|
380
|
+
`$HOME` or `~` for home paths; drop or describe session and actor identifiers;
|
|
381
|
+
and for API, private and wallet keys, remove the value **and rotate the key**,
|
|
382
|
+
since it may already be exposed. Findings in Kanbus board data
|
|
383
|
+
(`project/issues/*.json`, `project/events/*.json`) also say that Kanbus writes
|
|
384
|
+
those files and that the content should be fixed through `kbs` (for example
|
|
385
|
+
`kbs update` or `kbs comment`) rather than by hand.
|
|
386
|
+
|
|
276
387
|
Kanbus exceptions apply to complete identifier property lines in
|
|
277
388
|
`project/issues/*.json` and `project/events/*.json`. Event actor attribution
|
|
278
389
|
must occupy a root property line in the canonical two-space format.
|
|
@@ -19,7 +19,8 @@ ALLOW_FILE = ".leftblock-allow.yml"
|
|
|
19
19
|
MIN_REASON = 10
|
|
20
20
|
ALLOW_HEADER = (
|
|
21
21
|
"# LeftBlock overrides: each entry approves one finding (one rule on one exact\n"
|
|
22
|
-
"# line content in one file). Changing that line re-blocks it
|
|
22
|
+
"# line content in one file). Changing that line re-blocks it. `kind` is the\n"
|
|
23
|
+
"# rule's category, shown for reviewers; it is not part of matching.\n"
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
|
|
@@ -52,12 +53,14 @@ def finding_id(repo_root, rule, file, start, end):
|
|
|
52
53
|
|
|
53
54
|
|
|
54
55
|
def attach_ids(repo_root, results):
|
|
55
|
-
"""Replace StartLine/EndLine on findings with an id (when computable)."""
|
|
56
|
+
"""Replace StartLine/EndLine on findings with `line` and an id (when computable)."""
|
|
56
57
|
for result in results:
|
|
57
58
|
if result.error or not isinstance(result.findings, list):
|
|
58
59
|
continue
|
|
59
60
|
for finding in result.findings:
|
|
60
61
|
start, end = finding.pop("StartLine", None), finding.pop("EndLine", None)
|
|
62
|
+
if type(start) is int and start >= 1:
|
|
63
|
+
finding["line"] = start
|
|
61
64
|
if finding.get("File") == ALLOW_FILE:
|
|
62
65
|
continue # the allow file can never approve itself
|
|
63
66
|
ident = finding_id(
|
|
@@ -145,6 +148,8 @@ def _render(entries):
|
|
|
145
148
|
out.append(f" - id: {_quote(e['id'])}\n")
|
|
146
149
|
out.append(f" rule: {_quote(e['rule'])}\n")
|
|
147
150
|
out.append(f" file: {_quote(e['file'])}\n")
|
|
151
|
+
if e.get("kind"):
|
|
152
|
+
out.append(f" kind: {_quote(e['kind'])}\n")
|
|
148
153
|
out.append(f" reason: {_quote(' '.join(e['reason'].split()))}\n")
|
|
149
154
|
if added:
|
|
150
155
|
plain = re.fullmatch(r"\d{4}-\d{2}-\d{2}", added)
|
|
@@ -153,7 +158,7 @@ def _render(entries):
|
|
|
153
158
|
|
|
154
159
|
|
|
155
160
|
def record_overrides(repo_root, findings, reason):
|
|
156
|
-
"""Append entries for findings (dicts with id/RuleID/File) to the allow file.
|
|
161
|
+
"""Append entries for findings (dicts with id/RuleID/File, optional kind) to the allow file.
|
|
157
162
|
|
|
158
163
|
Returns the findings newly recorded. The working-tree file must be valid.
|
|
159
164
|
"""
|
|
@@ -169,10 +174,13 @@ def record_overrides(repo_root, findings, reason):
|
|
|
169
174
|
if finding["id"] in known:
|
|
170
175
|
continue
|
|
171
176
|
known.add(finding["id"])
|
|
172
|
-
|
|
177
|
+
entry = {
|
|
173
178
|
"id": finding["id"], "rule": finding["RuleID"], "file": finding["File"],
|
|
174
179
|
"reason": reason, "added": today,
|
|
175
|
-
}
|
|
180
|
+
}
|
|
181
|
+
if finding.get("kind"):
|
|
182
|
+
entry["kind"] = finding["kind"] # informational, for reviewers only
|
|
183
|
+
entries.append(entry)
|
|
176
184
|
added.append(finding)
|
|
177
185
|
with open(path, "w", encoding="utf-8") as f:
|
|
178
186
|
f.write(_render(entries))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Findings for the two ways Gitleaks can be told to skip content silently.
|
|
2
|
+
|
|
3
|
+
Gitleaks honours an inline allow comment and `.gitleaksignore`
|
|
4
|
+
files without any recorded reason. LeftBlock reports staged additions of
|
|
5
|
+
either as findings of its own, so they are blocked by default and can be
|
|
6
|
+
overridden only through a recorded, reviewed override like any other finding.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
# Split so this repository does not trip its own check.
|
|
13
|
+
MARKER = "gitleaks" + ":allow"
|
|
14
|
+
IGNORE_FILE = ".gitleaksignore"
|
|
15
|
+
MARKER_RULE = "leftblock-gitleaks-allow-marker"
|
|
16
|
+
IGNORE_RULE = "leftblock-gitleaksignore-change"
|
|
17
|
+
# Descriptions are written into the allow file as `kind`, so they must not
|
|
18
|
+
# contain the marker themselves.
|
|
19
|
+
RULE_DESCRIPTIONS = {
|
|
20
|
+
MARKER_RULE: "Inline Gitleaks allow comment (skips the scanner with no recorded reason)",
|
|
21
|
+
IGNORE_RULE: "Change to .gitleaksignore (skips the scanner with no recorded reason)",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_HUNK = re.compile(r"^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
25
|
+
_ESCAPES = {"a": 7, "b": 8, "f": 12, "n": 10, "r": 13, "t": 9, "v": 11, '"': 34, "\\": 92}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BypassScanError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _git(repo_root, env, *args):
|
|
33
|
+
proc = subprocess.run(
|
|
34
|
+
["git", *args], cwd=repo_root, env=env, capture_output=True, check=False,
|
|
35
|
+
stdin=subprocess.DEVNULL,
|
|
36
|
+
)
|
|
37
|
+
if proc.returncode:
|
|
38
|
+
raise BypassScanError("git " + args[0] + " failed")
|
|
39
|
+
return proc.stdout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _unquote(path):
|
|
43
|
+
"""Decode a path as Git prints it (C-style quoting when it has odd bytes)."""
|
|
44
|
+
if not (path.startswith(b'"') and path.endswith(b'"') and len(path) >= 2):
|
|
45
|
+
return path
|
|
46
|
+
out, i, body = bytearray(), 0, path[1:-1]
|
|
47
|
+
while i < len(body):
|
|
48
|
+
c = body[i]
|
|
49
|
+
if c != 0x5C:
|
|
50
|
+
out.append(c)
|
|
51
|
+
i += 1
|
|
52
|
+
elif body[i + 1:i + 2].isdigit() and re.fullmatch(rb"[0-7]{3}", body[i + 1:i + 4]):
|
|
53
|
+
out.append(int(body[i + 1:i + 4], 8))
|
|
54
|
+
i += 4
|
|
55
|
+
else:
|
|
56
|
+
out.append(_ESCAPES.get(chr(body[i + 1]), body[i + 1]))
|
|
57
|
+
i += 2
|
|
58
|
+
return bytes(out)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _added_lines(diff):
|
|
62
|
+
"""Yield (path, line_number, text) for each added line in a -U0 patch.
|
|
63
|
+
|
|
64
|
+
Hunk line counts decide which lines are content, so an added line that
|
|
65
|
+
itself starts with "++" or "@@" cannot be mistaken for a header.
|
|
66
|
+
"""
|
|
67
|
+
path, line, old_left, new_left = None, 0, 0, 0
|
|
68
|
+
for raw in diff.split(b"\n"):
|
|
69
|
+
if old_left or new_left:
|
|
70
|
+
if raw.startswith(b"-"):
|
|
71
|
+
old_left -= 1
|
|
72
|
+
elif raw.startswith(b"+"):
|
|
73
|
+
new_left -= 1
|
|
74
|
+
if path is not None:
|
|
75
|
+
yield path, line, raw[1:]
|
|
76
|
+
line += 1
|
|
77
|
+
continue
|
|
78
|
+
if raw.startswith(b"+++ "):
|
|
79
|
+
target = _unquote(raw[4:])
|
|
80
|
+
path = None if target == b"/dev/null" else target[2:].decode("utf-8", "replace")
|
|
81
|
+
elif raw.startswith(b"@@"):
|
|
82
|
+
hunk = _HUNK.match(raw.decode("utf-8", "replace"))
|
|
83
|
+
if hunk:
|
|
84
|
+
old_left = int(hunk.group(1) or 1)
|
|
85
|
+
line = int(hunk.group(2))
|
|
86
|
+
new_left = int(hunk.group(3) or 1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def bypass_findings(repo_root, env=None):
|
|
90
|
+
"""Findings (File, RuleID, StartLine, EndLine, description) for staged bypasses.
|
|
91
|
+
|
|
92
|
+
Reads the staged diff through `env`, which carries the merge-scoped index
|
|
93
|
+
when one is in use. Raises BypassScanError when the diff cannot be read.
|
|
94
|
+
"""
|
|
95
|
+
diff = _git(
|
|
96
|
+
repo_root, env, "-c", "core.quotePath=false", "diff", "--cached", "-U0",
|
|
97
|
+
"--no-color", "--no-ext-diff", "--no-renames", "--text",
|
|
98
|
+
)
|
|
99
|
+
findings = []
|
|
100
|
+
for path, number, text in _added_lines(diff):
|
|
101
|
+
if MARKER.encode() in text:
|
|
102
|
+
findings.append({
|
|
103
|
+
"File": path, "RuleID": MARKER_RULE, "StartLine": number, "EndLine": number,
|
|
104
|
+
"description": RULE_DESCRIPTIONS[MARKER_RULE],
|
|
105
|
+
})
|
|
106
|
+
names = _git(
|
|
107
|
+
repo_root, env, "diff", "--cached", "--name-only", "-z", "--no-renames",
|
|
108
|
+
"--diff-filter=AMT",
|
|
109
|
+
)
|
|
110
|
+
for raw in names.split(b"\0"):
|
|
111
|
+
path = raw.decode("utf-8", "replace")
|
|
112
|
+
if path and (path == IGNORE_FILE or path.endswith("/" + IGNORE_FILE)):
|
|
113
|
+
blob = _git(repo_root, env, "show", ":" + path)
|
|
114
|
+
lines = len(blob.decode("utf-8", "replace").split("\n"))
|
|
115
|
+
findings.append({
|
|
116
|
+
"File": path, "RuleID": IGNORE_RULE, "StartLine": 1, "EndLine": lines,
|
|
117
|
+
"description": RULE_DESCRIPTIONS[IGNORE_RULE],
|
|
118
|
+
})
|
|
119
|
+
return findings
|