Skip to content

Commit 538c8da

Browse files
SK-3156-addressed-review-comments
1 parent 158007a commit 538c8da

2 files changed

Lines changed: 120 additions & 16 deletions

File tree

‎.githooks/pre-commit‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Tier 1: auto-redact gitleaks findings inside the Fern-generated code
44
# trees (driven live by .gitleaks.toml via
55
# scripts/patch_generated_secrets.py, not a hand-maintained list)
6-
# and re-stage those directories. Never blocks the commit by
6+
# and re-stage the files it touched. Never blocks the commit by
77
# itself - it either fixes generated code or leaves it untouched
88
# for tier 2 to catch.
99
# Tier 2: run the real gitleaks scan against the staged diff and block on
@@ -21,6 +21,20 @@ GENERATED_DIRS=(
2121
"flowvault/skyflow/generated"
2222
)
2323

24+
# Snapshot what's already staged in the generated dirs before tier 1 runs,
25+
# so an intentionally-unstaged, in-progress change there (e.g. mid-way
26+
# through testing a Fern regen) isn't unconditionally swept into this
27+
# commit. Tier 1 only edits working-tree files, never the index, so this
28+
# snapshot stays accurate regardless of what it does next.
29+
already_staged_in_generated="$(git diff --cached --name-only -- "${GENERATED_DIRS[@]}" || true)"
30+
31+
git_dir="$(git rev-parse --git-dir)"
32+
touched_file_list="$git_dir/leak-guard-touched-files.txt"
33+
# A list from a previous commit's tier 1 run must never be reused here - if
34+
# tier 1 doesn't run this time (python3 missing, below), stale entries from
35+
# that earlier run would otherwise get staged again.
36+
rm -f "$touched_file_list"
37+
2438
# git invokes hooks with a leaner PATH than your interactive shell, so a
2539
# Python install managed by pyenv/conda/asdf (rather than a system package)
2640
# is often invisible here even though `python3` works fine in your
@@ -46,12 +60,24 @@ else
4660
fi
4761
fi
4862

49-
# Stages the whole generated-code directories rather than just the files
50-
# tier 1 touched, since we don't get that list back from the script. Safe
51-
# in practice: these directories are machine-owned (Fern-generated, never
52-
# hand edited), so there's no legitimate "leave part of it unstaged" case to
53-
# worry about disturbing.
54-
git add -- "${GENERATED_DIRS[@]}"
63+
# Stages only what tier 1 actually touched this run (recorded to
64+
# $touched_file_list - see record_touched_files in the script) plus
65+
# whatever was already staged above. A file sitting in one of
66+
# GENERATED_DIRS that tier 1 didn't touch and the caller hadn't staged is
67+
# left alone rather than force-added.
68+
to_stage=()
69+
if [ -f "$touched_file_list" ]; then
70+
while IFS= read -r f; do
71+
[ -n "$f" ] && to_stage+=("$f")
72+
done < "$touched_file_list"
73+
fi
74+
while IFS= read -r f; do
75+
[ -n "$f" ] && to_stage+=("$f")
76+
done <<< "$already_staged_in_generated"
77+
78+
if [ "${#to_stage[@]}" -gt 0 ]; then
79+
git add -- "${to_stage[@]}"
80+
fi
5581

5682
if ! command -v gitleaks >/dev/null 2>&1; then
5783
echo "[leak-guard] tier 2 skipped: 'gitleaks' binary not found locally."

‎scripts/patch_generated_secrets.py‎

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,49 @@ def placeholder_for(rule_id: str) -> str:
163163
return f"<REDACTED_{re.sub(r'[^A-Z0-9]+', '_', rule_id.upper())}>"
164164

165165

166+
def _git_dir() -> Path:
167+
output = subprocess.run(
168+
["git", "rev-parse", "--git-dir"],
169+
cwd=REPO_ROOT,
170+
capture_output=True,
171+
text=True,
172+
check=True,
173+
).stdout.strip()
174+
return (REPO_ROOT / output).resolve()
175+
176+
177+
def record_touched_files(relative_files) -> None:
178+
"""Records exactly which files this run modified (an empty list if none).
179+
180+
Lets the pre-commit hook stage only those files - plus whatever was
181+
already staged - instead of the entire generated-code directories,
182+
which would otherwise sweep in unrelated or intentionally-unstaged
183+
in-progress changes sitting in those same directories. Called at every
184+
exit point so the hook never reads a stale list left over from a prior
185+
run. Best-effort: if this can't be written, the hook simply finds no
186+
list and stages nothing beyond what was already staged.
187+
"""
188+
try:
189+
list_path = _git_dir() / "leak-guard-touched-files.txt"
190+
list_path.write_text(
191+
"".join(f"{f}\n" for f in relative_files), encoding="utf-8"
192+
)
193+
except (OSError, subprocess.CalledProcessError):
194+
pass
195+
196+
197+
def _rollback(original_contents: dict) -> None:
198+
"""Restores every file this run has written so far back to what it read
199+
at the start. Called from every failure path after files start getting
200+
written, so a build break, a final-scan tool failure, or leftover
201+
findings after redaction never leaves a partially-redacted, unverified
202+
file sitting in the working tree.
203+
"""
204+
for file_path, content in original_contents.items():
205+
file_path.write_text(content, encoding="utf-8")
206+
record_touched_files([])
207+
208+
166209
def _line_start_offsets(text: str) -> list:
167210
"""Absolute char offset where each 1-indexed line starts in `text`.
168211
@@ -213,6 +256,7 @@ def main() -> int:
213256
"generated code. CI will still scan for this.",
214257
file=sys.stderr,
215258
)
259+
record_touched_files([])
216260
return 0
217261

218262
if not self_test_allowlist_support():
@@ -225,11 +269,13 @@ def main() -> int:
225269
"zricethezav/gitleaks:latest) and try again.",
226270
file=sys.stderr,
227271
)
272+
record_touched_files([])
228273
return 1
229274

230275
findings = scan_generated_dirs()
231276
if not findings:
232277
print("No gitleaks findings in generated code.")
278+
record_touched_files([])
233279
return 0
234280

235281
allowed_prefixes = tuple(GENERATED_DIRS)
@@ -243,6 +289,7 @@ def main() -> int:
243289
+ "\n".join(f" - {f['File']}" for f in out_of_scope),
244290
file=sys.stderr,
245291
)
292+
record_touched_files([])
246293
return 1
247294

248295
by_file = {}
@@ -284,11 +331,21 @@ def main() -> int:
284331
continue
285332
spans.append((span[0], span[1], finding["RuleID"]))
286333

287-
# Deduplicate by (start, end): if two rules flag the exact same
288-
# span, it must only be redacted once. Applied back-to-front
289-
# (highest offset first) so replacing a later span never shifts
290-
# the offsets of an earlier one still waiting to be processed.
291-
unique_spans_by_file[file_path] = sorted(set(spans), reverse=True)
334+
# Deduplicate by (start, end) only - not the full (start, end,
335+
# rule_id) triple, which wouldn't collapse two different rules
336+
# flagging the exact same span; a stale second replacement at an
337+
# already-redacted offset would then corrupt the file or eat
338+
# adjacent text. Keeps whichever rule_id was seen first for that
339+
# span. Applied back-to-front (highest offset first) so replacing
340+
# a later span never shifts the offsets of an earlier one still
341+
# waiting to be processed.
342+
spans_by_range = {}
343+
for start, end, rule_id in spans:
344+
spans_by_range.setdefault((start, end), rule_id)
345+
unique_spans_by_file[file_path] = sorted(
346+
((start, end, rule_id) for (start, end), rule_id in spans_by_range.items()),
347+
reverse=True,
348+
)
292349

293350
if unresolved:
294351
print(
@@ -300,9 +357,11 @@ def main() -> int:
300357
),
301358
file=sys.stderr,
302359
)
360+
record_touched_files([])
303361
return 1
304362

305363
original_contents = dict(file_contents)
364+
touched_files = set()
306365
redacted_count = 0
307366
for file_path, content in file_contents.items():
308367
by_rule = {}
@@ -317,6 +376,8 @@ def main() -> int:
317376
print(f"[{rule_id}] redacted in {file_path.relative_to(REPO_ROOT)} -> {placeholder}")
318377

319378
file_path.write_text(content, encoding="utf-8")
379+
if content != original_contents[file_path]:
380+
touched_files.add(str(file_path.relative_to(REPO_ROOT)))
320381

321382
build_ok = True
322383
for file_path in original_contents:
@@ -330,26 +391,43 @@ def main() -> int:
330391
)
331392

332393
if not build_ok:
333-
for file_path, content in original_contents.items():
334-
file_path.write_text(content, encoding="utf-8")
394+
_rollback(original_contents)
335395
print(
336396
"Rolled back all changes from this run - redaction needs manual review.",
337397
file=sys.stderr,
338398
)
339399
return 1
340400

341-
remaining = scan_generated_dirs()
401+
# The final re-scan can itself fail for reasons other than "still
402+
# leaks" (e.g. the gitleaks binary crashing mid-run) -
403+
# run_gitleaks_detect (via scan_generated_dirs) raises in that case
404+
# rather than returning a findings list. Either way, an unverified
405+
# redaction must never be left on disk: roll back exactly as the
406+
# build-failure path above does.
407+
try:
408+
remaining = scan_generated_dirs()
409+
except RuntimeError as err:
410+
_rollback(original_contents)
411+
print(
412+
f"Final gitleaks re-scan failed to run ({err}) - rolled back all "
413+
"changes from this run. Redaction needs manual review.",
414+
file=sys.stderr,
415+
)
416+
return 1
342417
if remaining:
418+
_rollback(original_contents)
343419
print(
344420
f"Redacted {redacted_count} secret(s), but {len(remaining)} finding(s) "
345-
"remain after re-scanning. Manual review needed:\n"
421+
"remain after re-scanning. Rolled back all changes from this run. "
422+
"Manual review needed:\n"
346423
+ "\n".join(
347424
f" - [{f['RuleID']}] {f['File']}:{f['StartLine']}" for f in remaining
348425
),
349426
file=sys.stderr,
350427
)
351428
return 1
352429

430+
record_touched_files(sorted(touched_files))
353431
print(
354432
f"\nDone. Redacted {redacted_count} secret(s) across {len(by_file)} file(s). "
355433
"Build and gitleaks re-scan both clean."

0 commit comments

Comments
 (0)