leftblock 0.3.4__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.
Files changed (30) hide show
  1. {leftblock-0.3.4 → leftblock-0.4.0}/PKG-INFO +119 -8
  2. {leftblock-0.3.4 → leftblock-0.4.0}/README.md +118 -7
  3. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock/allow.py +13 -5
  4. leftblock-0.4.0/leftblock/bypass.py +119 -0
  5. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock/cli.py +287 -33
  6. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock/core.py +7 -4
  7. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock/hooks.py +102 -4
  8. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock.egg-info/PKG-INFO +119 -8
  9. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock.egg-info/SOURCES.txt +3 -0
  10. leftblock-0.4.0/pyproject.toml +47 -0
  11. {leftblock-0.3.4 → leftblock-0.4.0}/tests/test_branding.py +7 -2
  12. leftblock-0.4.0/tests/test_bypasses.py +183 -0
  13. {leftblock-0.3.4 → leftblock-0.4.0}/tests/test_commit_gate.py +20 -6
  14. {leftblock-0.3.4 → leftblock-0.4.0}/tests/test_false_positives.py +1 -1
  15. leftblock-0.4.0/tests/test_kanbus_advice.py +96 -0
  16. {leftblock-0.3.4 → leftblock-0.4.0}/tests/test_local_install.py +87 -1
  17. {leftblock-0.3.4 → leftblock-0.4.0}/tests/test_recorded_overrides.py +141 -2
  18. leftblock-0.3.4/pyproject.toml +0 -24
  19. {leftblock-0.3.4 → leftblock-0.4.0}/LICENSE +0 -0
  20. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock/__init__.py +0 -0
  21. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock/data/gitleaks.toml +0 -0
  22. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock/merge.py +0 -0
  23. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock.egg-info/dependency_links.txt +0 -0
  24. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock.egg-info/entry_points.txt +0 -0
  25. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock.egg-info/requires.txt +0 -0
  26. {leftblock-0.3.4 → leftblock-0.4.0}/leftblock.egg-info/top_level.txt +0 -0
  27. {leftblock-0.3.4 → leftblock-0.4.0}/setup.cfg +0 -0
  28. {leftblock-0.3.4 → leftblock-0.4.0}/tests/test_default_config.py +0 -0
  29. {leftblock-0.3.4 → leftblock-0.4.0}/tests/test_install.py +0 -0
  30. {leftblock-0.3.4 → 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.4
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.3.4
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 an `id` for each finding, never the matched value, and
176
- the exact command to run:
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
- This mechanism is separate from Gitleaks' own suppressions: inline
214
- `gitleaks:allow` markers and a root `.gitleaksignore` are still honoured by
215
- Gitleaks itself, and they are outside this mechanism's reason, scope, and
216
- review trail.
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.3.4
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 an `id` for each finding, never the matched value, and
143
- the exact command to run:
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
- This mechanism is separate from Gitleaks' own suppressions: inline
181
- `gitleaks:allow` markers and a root `.gitleaksignore` are still honoured by
182
- Gitleaks itself, and they are outside this mechanism's reason, scope, and
183
- review trail.
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.\n"
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
- entries.append({
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