Skip to main content

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}