keyhog_sources/git/source.rs
1//! Git repository source: scans repository commits and extracts text blobs with
2//! `gix`, stopping once the in-memory byte cap is reached.
3
4use std::collections::{HashSet, VecDeque};
5use std::io::BufRead;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use gix::objs::Kind;
10use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
11
12/// Maximum total in-memory bytes for all git blob content.
13/// 256 MiB covers large monorepos without OOM.
14const MAX_GIT_TOTAL_BYTES: usize = 256 * 1024 * 1024;
15
16/// Maximum size of a single git blob. Larger objects (binaries, vendor bundles)
17/// are skipped entirely - secrets almost never appear in 10+ MiB files.
18const MAX_GIT_BLOB_BYTES: u64 = 10 * 1024 * 1024;
19
20/// Maximum number of chunks the git source can produce.
21/// Guards against repos with millions of tiny files where the byte limit alone
22/// wouldn't cap memory: each chunk carries ~200 bytes of metadata overhead,
23/// so 500K chunks × 200B = ~100 MB metadata ceiling.
24const MAX_GIT_CHUNKS: usize = 500_000;
25
26/// Scans git history: traverses commits and extracts text blob contents.
27///
28/// # Examples
29///
30/// ```rust
31/// use keyhog_core::Source;
32/// use keyhog_sources::GitSource;
33/// use std::path::PathBuf;
34///
35/// let source = GitSource::new(PathBuf::from(".")).with_max_commits(10);
36/// assert_eq!(source.name(), "git");
37/// ```
38pub struct GitSource {
39 repo_path: PathBuf,
40 max_commits: Option<usize>,
41}
42
43impl GitSource {
44 /// Create a source that traverses a git repository.
45 ///
46 /// # Examples
47 ///
48 /// ```rust
49 /// use keyhog_core::Source;
50 /// use keyhog_sources::GitSource;
51 /// use std::path::PathBuf;
52 ///
53 /// let source = GitSource::new(PathBuf::from("."));
54 /// assert_eq!(source.name(), "git");
55 /// ```
56 pub fn new(repo_path: PathBuf) -> Self {
57 Self {
58 repo_path,
59 max_commits: None,
60 }
61 }
62
63 /// Limit how many commits are traversed from `HEAD`.
64 ///
65 /// # Examples
66 ///
67 /// ```rust
68 /// use keyhog_core::Source;
69 /// use keyhog_sources::GitSource;
70 /// use std::path::PathBuf;
71 ///
72 /// let source = GitSource::new(PathBuf::from(".")).with_max_commits(5);
73 /// assert_eq!(source.name(), "git");
74 /// ```
75 pub fn with_max_commits(mut self, n: usize) -> Self {
76 self.max_commits = Some(n);
77 self
78 }
79}
80
81impl Source for GitSource {
82 fn name(&self) -> &str {
83 "git"
84 }
85
86 fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
87 match stream_git_blobs(&self.repo_path, self.max_commits) {
88 Ok(iter) => Box::new(iter),
89 Err(e) => Box::new(std::iter::once(Err(e))),
90 }
91 }
92 fn as_any(&self) -> &dyn std::any::Any {
93 self
94 }
95}
96
97fn stream_git_blobs(
98 repo_path: &Path,
99 max_commits: Option<usize>,
100) -> Result<impl Iterator<Item = Result<Chunk, SourceError>>, SourceError> {
101 let repo_arg = super::validate_repo_path(repo_path)?;
102
103 // Get commit hashes from ALL refs - branches, tags, dangling commits.
104 // The previous version walked HEAD ancestry only, silently missing
105 // secrets in feature branches, deleted-but-tagged history, and merge-only
106 // commits. See audit release-2026-04-26 sources/git/source.rs:104.
107 let mut log_cmd = Command::new(super::git_bin()?);
108 log_cmd.args([
109 "-C",
110 &repo_arg,
111 "log",
112 "--all",
113 "--branches",
114 "--tags",
115 "-m", // emit patches for merge commits ("evil merges")
116 "--format=%H %an",
117 ]);
118 if let Some(limit) = max_commits {
119 log_cmd.args(["--max-count", &limit.to_string()]);
120 }
121 log_cmd.arg("--end-of-options");
122
123 log_cmd.stdout(std::process::Stdio::piped());
124 let mut log_child = log_cmd.spawn().map_err(SourceError::Io)?;
125 let log_stdout = log_child
126 .stdout
127 .take()
128 .ok_or_else(|| SourceError::Io(std::io::Error::other("missing log stdout")))?;
129 let mut log_lines = std::io::BufReader::new(log_stdout).lines();
130
131 // Open the gix repo ONCE and reuse it for every commit. The previous
132 // version called `gix::open(&repo_owned)` per-commit which on a 10k-commit
133 // repo opened the repo 10k times - fd churn + IO amplification.
134 let repo_owned = repo_path.to_path_buf();
135 let repo_handle = gix::open(&repo_owned)
136 .map_err(|e| SourceError::Io(std::io::Error::other(format!("gix open: {e}"))))?;
137 // Snapshot every blob OID reachable from HEAD's tree. Used to label
138 // emitted chunks as "git/head" (live in HEAD) vs "git/history"
139 // (only present in older commits). The downstream scorer downgrades
140 // the severity of `git/history` findings - a credential a developer
141 // already removed from HEAD is still a leak, but less urgent than
142 // one currently grep-able from main. Cheap: one tree walk at most.
143 // If the HEAD blob walk fails (corrupt object, unborn HEAD, partial
144 // clone without the tree objects) we fall back to an empty set,
145 // which labels every chunk as `git/history`. The downstream scorer
146 // downgrades that bucket, so a silent failure here would
147 // systematically deflate severity for findings that are actually
148 // live in HEAD. Surface the missing set so the operator sees the
149 // cause and can fall back to `keyhog scan --git-staged` or
150 // `--git-diff` instead.
151 let head_blobs = match collect_head_blob_set(&repo_handle) {
152 Some(set) => set,
153 None => {
154 tracing::warn!(
155 "git: HEAD blob walk produced no set; all findings will be \
156 labelled git/history (lower severity). The scan continues \
157 but you may underweight live-in-HEAD leaks. Common causes: \
158 unborn HEAD, partial clone without tree objects, \
159 corrupt ref."
160 );
161 HashSet::new()
162 }
163 };
164 let mut current_tree_blobs: VecDeque<Chunk> = VecDeque::new();
165 let mut seen_blobs: HashSet<gix::ObjectId> = HashSet::new();
166 let mut seen_commits: HashSet<gix::ObjectId> = HashSet::new();
167 let mut total_bytes = 0usize;
168 let mut chunk_count = 0usize;
169 let mut done = false;
170
171 Ok(std::iter::from_fn(move || {
172 if done {
173 return None;
174 }
175
176 loop {
177 if let Some(chunk) = current_tree_blobs.pop_front() {
178 return Some(Ok(chunk));
179 }
180
181 if total_bytes >= MAX_GIT_TOTAL_BYTES || chunk_count >= MAX_GIT_CHUNKS {
182 done = true;
183 return None;
184 }
185
186 let line = match log_lines.next() {
187 Some(Ok(l)) => l,
188 Some(Err(e)) => {
189 done = true;
190 return Some(Err(SourceError::Io(e)));
191 }
192 None => {
193 done = true;
194 return None;
195 }
196 };
197
198 let parts: Vec<&str> = line.splitn(2, ' ').collect();
199 if parts.len() < 2 {
200 continue;
201 }
202 let commit_id = parts[0];
203 let author = parts[1];
204
205 let repo = &repo_handle;
206 let Ok(id) = gix::ObjectId::from_hex(commit_id.as_bytes()) else {
207 continue;
208 };
209 // Cache visited Git commit OIDs in a fast set to avoid traversing duplicate merge commits (KH-56)
210 if !seen_commits.insert(id) {
211 continue;
212 }
213 let Ok(obj) = repo.find_object(id) else {
214 continue;
215 };
216 let Ok(commit) = obj.try_into_commit() else {
217 continue;
218 };
219 let Ok(tree) = commit.tree() else {
220 continue;
221 };
222
223 let mut blob_metadata = Vec::new();
224 collect_tree_blobs_metadata(repo, &tree, &mut seen_blobs, &mut blob_metadata, b"");
225
226 if !blob_metadata.is_empty() {
227 let repo_cloned = repo.clone();
228 let commit_id_str = commit_id.to_string();
229 let author_str = author.to_string();
230 let head_blobs_ref = &head_blobs;
231
232 // Serial blob decompression. This was `into_par_iter()` (KH-58),
233 // but `gix::Repository` (gix 0.77) holds RefCell-backed object/
234 // pack caches: it is `Send` but NOT `Sync`, so sharing one across
235 // Rayon worker threads does not compile. A fresh
236 // `cargo build -p keyhog-sources --features git` failed with 7
237 // `RefCell<…> cannot be shared between threads safely` errors -
238 // which is why clean CI builds went red while cached local builds
239 // still passed. Correct re-parallelization needs a per-thread
240 // `gix::open(git_dir)` via `map_init` (each worker owns its own
241 // Repository); tracked as a follow-up. Serial is correct and
242 // keeps git history scanning working.
243 //
244 // Memory bound (M16): enforce the aggregate byte/chunk caps
245 // INSIDE this per-commit loop rather than only between commits.
246 // The previous `.collect()` materialized every unique blob in
247 // the commit's reachable tree at once - for the initial commit
248 // of a large monorepo that is the entire tree (multi-GiB),
249 // blowing past MAX_GIT_TOTAL_BYTES before a single chunk drained.
250 // Accumulate into `total_bytes`/`chunk_count` as each blob is
251 // decoded and stop collecting the moment a cap is crossed.
252 for (oid, filepath) in blob_metadata {
253 if total_bytes >= MAX_GIT_TOTAL_BYTES || chunk_count >= MAX_GIT_CHUNKS {
254 break;
255 }
256 // Reject blobs larger than max_file_size immediately by reading only the Git object header metadata (KH-66)
257 let Ok(header) = repo_cloned.find_header(oid) else {
258 continue;
259 };
260 if header.kind() != Kind::Blob || header.size() > MAX_GIT_BLOB_BYTES {
261 continue;
262 }
263 let Ok(obj) = repo_cloned.find_object(oid) else {
264 continue;
265 };
266 // Decode contract mirrors the filesystem source (C4): a
267 // single non-UTF-8 byte must NOT discard the whole blob, or
268 // `keyhog scan --git` silently under-recalls vs.
269 // `keyhog scan <dir>`. Skip only true binary; otherwise
270 // decode losslessly.
271 let Some(file_text) = decode_git_blob(&obj.data) else {
272 continue;
273 };
274 let path = String::from_utf8_lossy(&filepath).to_string();
275 let in_head = head_blobs_ref.contains(&oid);
276 let chunk = Chunk {
277 data: file_text.into(),
278 metadata: ChunkMetadata {
279 base_offset: 0,
280 base_line: 0,
281 source_type: if in_head { "git/head" } else { "git/history" }.into(),
282 path: Some(path),
283 commit: Some(commit_id_str.clone()),
284 author: Some(author_str.clone()),
285 date: None,
286 mtime_ns: None,
287 size_bytes: Some(header.size()),
288 },
289 };
290 total_bytes = total_bytes.saturating_add(chunk.data.len());
291 chunk_count += 1;
292 current_tree_blobs.push_back(chunk);
293 }
294
295 if let Some(chunk) = current_tree_blobs.pop_front() {
296 return Some(Ok(chunk));
297 }
298 }
299 }
300 }))
301}
302
303/// Decode a git blob into scannable text using the same recall-preserving
304/// contract as the filesystem source (`crate::filesystem::read::decode`).
305///
306/// The previous implementation used `std::str::from_utf8(&data).ok()?`, which
307/// dropped the ENTIRE blob on a single non-UTF-8 byte - so a credential next to
308/// a stray `0x80` (latin-1 config, a `.env` with a smart quote, a key beside
309/// binary data) was found by `keyhog scan <dir>` but MISSED by
310/// `keyhog scan --git` on the same content (audit C4). The filesystem path
311/// instead falls back to `String::from_utf8_lossy` after a binary-density
312/// check; mirror that here so non-UTF-8 blobs are still scanned.
313///
314/// Returns `None` only for true binary (recognized magic header or a high
315/// density of C0 control bytes), matching `decode_text_file`'s rejects - those
316/// inputs cannot carry a grep-able credential and would only add noise.
317fn decode_git_blob(data: &[u8]) -> Option<String> {
318 if data.is_empty() {
319 return Some(String::new());
320 }
321 // Reject genuine binary FIRST. A NUL byte (and other C0 controls) is valid
322 // UTF-8, so the fast path below would otherwise emit a NUL- or control-heavy
323 // blob (ELF/PNG/sqlite/object files) as "text" and pay a full detector scan
324 // on bytes that cannot carry a grep-able credential. Doing the cheap
325 // first-1KiB heuristic up front mirrors the filesystem `decode_text_file`
326 // ordering and skips that waste (precision + throughput).
327 if looks_binary_blob(data) {
328 return None;
329 }
330 // Valid-UTF-8 fast path (the common case for source trees): one validation
331 // pass, owned copy on success.
332 if let Ok(s) = std::str::from_utf8(data) {
333 return Some(s.to_owned());
334 }
335 // Not strictly valid UTF-8 but not binary: partial corruption / latin-1 / a
336 // stray high byte / UTF-16 — decode lossily to preserve recall (audit C4).
337 Some(String::from_utf8_lossy(data).into_owned())
338}
339
340/// Cheap binary heuristic for git blobs, kept byte-compatible with the
341/// filesystem `looks_binary` verdict: recognized magic headers, a NUL near the
342/// start that isn't UTF-16's alternating pattern, or >5% C0 control density.
343/// Lives here (rather than reaching into `crate::filesystem::read`, whose
344/// helpers are module-private) so the git decode path stays self-contained.
345fn looks_binary_blob(data: &[u8]) -> bool {
346 // Common executable / archive / image / serialized-data magic bytes whose
347 // contents cannot be a credential. Mirrors `has_binary_magic`.
348 const MAGIC_HEADERS: &[&[u8]] = &[
349 b"%PDF-",
350 b"PK\x03\x04",
351 b"\x89PNG\r\n\x1a\n",
352 b"\xD0\xCF\x11\xE0",
353 b"\x7fELF",
354 b"\xfe\xed\xfa\xce",
355 b"\xfe\xed\xfa\xcf",
356 b"\xcf\xfa\xed\xfe",
357 b"\xca\xfe\xba\xbe",
358 b"MZ",
359 b"\x1f\x8b",
360 b"BZh",
361 b"\xfd7zXZ\x00",
362 b"7z\xbc\xaf\x27\x1c",
363 b"Rar!\x1a\x07",
364 b"GIF87a",
365 b"GIF89a",
366 b"\xff\xd8\xff",
367 b"\x00\x00\x01\x00",
368 b"OggS",
369 b"ID3",
370 b"fLaC",
371 b"\x00asm",
372 b"!<arch>\n",
373 b"\x80\x02",
374 ];
375 if MAGIC_HEADERS.iter().any(|h| data.starts_with(h)) {
376 return true;
377 }
378 // UTF-16 BOM: alternating-NUL text, not binary.
379 let utf16_bom = data.len() >= 4
380 && ((data[0] == 0xFF && data[1] == 0xFE) || (data[0] == 0xFE && data[1] == 0xFF));
381 if utf16_bom {
382 return true;
383 }
384 // A NUL near the start usually means binary, unless it's headerless UTF-16
385 // (alternating NULs), which is real text.
386 if let Some(first_nul) = data.iter().position(|&b| b == 0) {
387 if first_nul < 1024 {
388 let is_utf16 = data.len() >= 4
389 && ((data[0] == 0 && data[1] != 0) || (data[0] != 0 && data[1] == 0));
390 if !is_utf16 {
391 return true;
392 }
393 }
394 }
395 // C0-control density: >5% suspicious bytes (matching `looks_binary`'s
396 // `suspicious * 20 > total` threshold).
397 let total = data.len() as u64;
398 if total == 0 {
399 return false;
400 }
401 let mut suspicious: u64 = 0;
402 for &byte in data {
403 if byte < 0x20 && !matches!(byte, b'\n' | b'\r' | b'\t' | 0x0C) {
404 suspicious += 1;
405 if suspicious * 20 > total {
406 return true;
407 }
408 }
409 }
410 false
411}
412
413fn collect_tree_blobs_metadata(
414 repo: &gix::Repository,
415 tree: &gix::Tree<'_>,
416 seen_blobs: &mut HashSet<gix::ObjectId>,
417 blob_metadata: &mut Vec<(gix::ObjectId, Vec<u8>)>,
418 prefix: &[u8],
419) {
420 for entry_ref in tree.iter() {
421 let entry = match entry_ref {
422 Ok(e) => e,
423 Err(error) => {
424 tracing::debug!(%error, "git tree entry read failed; skipping");
425 continue;
426 }
427 };
428
429 // Skip Git trees containing only excluded paths without reading individual blob OIDs (KH-59)
430 let name = entry.filename();
431 if name == b"node_modules"
432 || name == b"target"
433 || name == b".git"
434 || name == b"__pycache__"
435 || name == b"dist"
436 || name == b"build"
437 || name == b"vendor"
438 {
439 continue;
440 }
441
442 let oid = entry.oid().to_owned();
443
444 let filepath = if prefix.is_empty() {
445 entry.filename().to_vec()
446 } else {
447 let mut p = prefix.to_vec();
448 p.push(b'/');
449 p.extend_from_slice(entry.filename());
450 p
451 };
452
453 let mode = entry.mode();
454
455 if mode.is_tree() {
456 if let Ok(obj) = repo.find_object(oid) {
457 if let Ok(subtree) = obj.try_into_tree() {
458 collect_tree_blobs_metadata(
459 repo,
460 &subtree,
461 seen_blobs,
462 blob_metadata,
463 &filepath,
464 );
465 }
466 }
467 continue;
468 }
469
470 if !mode.is_blob() {
471 continue;
472 }
473
474 if seen_blobs.insert(oid) {
475 blob_metadata.push((oid, filepath));
476 }
477 }
478}
479
480/// Walk HEAD's tree and collect every blob OID reachable from it.
481///
482/// Returns an empty set if HEAD doesn't resolve (detached, empty repo, or
483/// transient I/O error). The caller's behavior in that case: every blob is
484/// labeled `git/history` since we cannot prove it sits in HEAD - safer than
485/// the inverse, which would suppress severity downgrades for genuine
486/// historical leaks.
487fn collect_head_blob_set(repo: &gix::Repository) -> Option<HashSet<gix::ObjectId>> {
488 let head = repo.head().ok()?;
489 let head_id = head.try_into_peeled_id().ok().flatten()?;
490 let commit = repo.find_object(head_id).ok()?.try_into_commit().ok()?;
491 let tree = commit.tree().ok()?;
492 let mut out = HashSet::new();
493 walk_tree_for_blobs(repo, &tree, &mut out);
494 Some(out)
495}
496
497fn walk_tree_for_blobs(
498 repo: &gix::Repository,
499 tree: &gix::Tree<'_>,
500 out: &mut HashSet<gix::ObjectId>,
501) {
502 for entry_ref in tree.iter() {
503 let Ok(entry) = entry_ref else { continue };
504 let oid = entry.oid().to_owned();
505 let mode = entry.mode();
506 if mode.is_tree() {
507 if let Ok(obj) = repo.find_object(oid) {
508 if let Ok(subtree) = obj.try_into_tree() {
509 walk_tree_for_blobs(repo, &subtree, out);
510 }
511 }
512 } else if mode.is_blob() {
513 out.insert(oid);
514 }
515 }
516}