refhound 0.1.0b1__py3-none-any.whl

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 (94) hide show
  1. refhound/__init__.py +5 -0
  2. refhound/__main__.py +6 -0
  3. refhound/_version.py +7 -0
  4. refhound/analysis/__init__.py +1 -0
  5. refhound/analysis/chronology.py +108 -0
  6. refhound/analysis/commit_anomalies.py +130 -0
  7. refhound/analysis/correlations.py +150 -0
  8. refhound/analysis/data.py +120 -0
  9. refhound/analysis/deletion_analysis.py +157 -0
  10. refhound/analysis/force_push_analysis.py +107 -0
  11. refhound/analysis/identity_analysis.py +101 -0
  12. refhound/analysis/scoring.py +96 -0
  13. refhound/api.py +90 -0
  14. refhound/baseline.py +78 -0
  15. refhound/cache.py +73 -0
  16. refhound/cli.py +1436 -0
  17. refhound/cli_cache.py +84 -0
  18. refhound/config.py +199 -0
  19. refhound/detectors/__init__.py +1 -0
  20. refhound/detectors/base.py +125 -0
  21. refhound/detectors/cloud.py +100 -0
  22. refhound/detectors/database.py +66 -0
  23. refhound/detectors/entropy.py +80 -0
  24. refhound/detectors/generic.py +42 -0
  25. refhound/detectors/github.py +33 -0
  26. refhound/detectors/gitlab.py +45 -0
  27. refhound/detectors/jwt.py +68 -0
  28. refhound/detectors/passwords.py +98 -0
  29. refhound/detectors/private_keys.py +41 -0
  30. refhound/detectors/registry.py +54 -0
  31. refhound/errors.py +63 -0
  32. refhound/git/__init__.py +1 -0
  33. refhound/git/command.py +289 -0
  34. refhound/git/commits.py +196 -0
  35. refhound/git/diff.py +87 -0
  36. refhound/git/fsck.py +70 -0
  37. refhound/git/graph.py +110 -0
  38. refhound/git/lfs.py +47 -0
  39. refhound/git/notes.py +37 -0
  40. refhound/git/objects.py +161 -0
  41. refhound/git/reflog.py +90 -0
  42. refhound/git/refs.py +84 -0
  43. refhound/git/repository.py +188 -0
  44. refhound/git/submodules.py +117 -0
  45. refhound/git/tags.py +52 -0
  46. refhound/models/__init__.py +1 -0
  47. refhound/models/anomaly.py +67 -0
  48. refhound/models/commit.py +62 -0
  49. refhound/models/diagnostic.py +20 -0
  50. refhound/models/finding.py +119 -0
  51. refhound/models/object.py +96 -0
  52. refhound/models/repository.py +87 -0
  53. refhound/models/secret.py +45 -0
  54. refhound/models/statistics.py +79 -0
  55. refhound/providers/__init__.py +1 -0
  56. refhound/providers/base.py +71 -0
  57. refhound/providers/generic.py +20 -0
  58. refhound/providers/github.py +56 -0
  59. refhound/providers/gitlab.py +55 -0
  60. refhound/reporting/__init__.py +1 -0
  61. refhound/reporting/console.py +106 -0
  62. refhound/reporting/json.py +166 -0
  63. refhound/reporting/markdown.py +209 -0
  64. refhound/reporting/sarif.py +114 -0
  65. refhound/reporting/statistics.py +46 -0
  66. refhound/repository_key.py +40 -0
  67. refhound/scanners/__init__.py +1 -0
  68. refhound/scanners/base.py +79 -0
  69. refhound/scanners/ci.py +57 -0
  70. refhound/scanners/engine.py +514 -0
  71. refhound/scanners/files.py +48 -0
  72. refhound/scanners/history.py +46 -0
  73. refhound/scanners/identity.py +46 -0
  74. refhound/scanners/refs.py +34 -0
  75. refhound/scanners/secrets.py +202 -0
  76. refhound/scanners/timeline.py +66 -0
  77. refhound/scanners/unreachable.py +120 -0
  78. refhound/snapshots.py +155 -0
  79. refhound/storage/__init__.py +1 -0
  80. refhound/storage/database.py +322 -0
  81. refhound/storage/migrations/__init__.py +1 -0
  82. refhound/storage/schema.py +120 -0
  83. refhound/util/__init__.py +1 -0
  84. refhound/util/dates.py +97 -0
  85. refhound/util/hashing.py +71 -0
  86. refhound/util/output.py +34 -0
  87. refhound/util/paths.py +207 -0
  88. refhound/util/sanitize.py +60 -0
  89. refhound/util/sorting.py +26 -0
  90. refhound-0.1.0b1.dist-info/METADATA +164 -0
  91. refhound-0.1.0b1.dist-info/RECORD +94 -0
  92. refhound-0.1.0b1.dist-info/WHEEL +4 -0
  93. refhound-0.1.0b1.dist-info/entry_points.txt +2 -0
  94. refhound-0.1.0b1.dist-info/licenses/LICENSE +21 -0
refhound/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """RefHound - Git repository security and forensic analysis tool."""
2
+
3
+ from refhound._version import __version__
4
+
5
+ __all__ = ["__version__"]
refhound/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """RefHound entry point: ``python -m refhound``."""
2
+
3
+ from refhound.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
refhound/_version.py ADDED
@@ -0,0 +1,7 @@
1
+ """Version information.
2
+
3
+ This file is the single source of truth for the package version. The build
4
+ backend reads it via ``[tool.hatch.version]`` and the documentation imports it.
5
+ """
6
+
7
+ __version__ = "0.1.0b1"
@@ -0,0 +1 @@
1
+ """RefHound analysis package."""
@@ -0,0 +1,108 @@
1
+ """Temporal anomaly detection.
2
+
3
+ We only describe observable patterns and never attribute intent or actor.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import itertools
9
+ from datetime import UTC, datetime, timedelta
10
+
11
+ from refhound.models.anomaly import TemporalAnomaly
12
+ from refhound.models.commit import CommitInfo
13
+
14
+
15
+ def detect_temporal_anomalies(
16
+ commits: list[CommitInfo],
17
+ *,
18
+ min_burst: int = 5,
19
+ future_tolerance_minutes: int = 5,
20
+ ) -> list[TemporalAnomaly]:
21
+ """Find unusual timestamp patterns.
22
+
23
+ Heuristics:
24
+ * bursts of commits within a few seconds
25
+ * committer date earlier than author date
26
+ * huge author/committer gap (>= 7 days)
27
+ * future timestamps (beyond tolerance)
28
+ * timestamps before repository era (handled by caller via first commit)
29
+ * timezone jumps (>= 6h offset delta between consecutive commits)
30
+ """
31
+ anomalies: list[TemporalAnomaly] = []
32
+ dated = [c for c in commits if c.committer_date is not None]
33
+ ordered = sorted(dated, key=lambda c: c.committer_date or datetime.min)
34
+
35
+ bursts: dict[int, list[CommitInfo]] = {}
36
+ for info in ordered:
37
+ if info.committer_date is None or info.author_date is None:
38
+ continue
39
+ if info.committer_date < info.author_date:
40
+ anomalies.append(
41
+ TemporalAnomaly(
42
+ kind="committer_before_author",
43
+ commit_sha=info.sha,
44
+ description="Committer timestamp predates author timestamp.",
45
+ metadata={
46
+ "author": info.author_date.isoformat(),
47
+ "committer": info.committer_date.isoformat(),
48
+ },
49
+ )
50
+ )
51
+ gap = info.committer_date - info.author_date
52
+ if gap > timedelta(days=7):
53
+ anomalies.append(
54
+ TemporalAnomaly(
55
+ kind="large_author_committer_gap",
56
+ commit_sha=info.sha,
57
+ description=f"Author and committer timestamps differ by {gap.days}d.",
58
+ metadata={
59
+ "author": info.author_date.isoformat(),
60
+ "committer": info.committer_date.isoformat(),
61
+ },
62
+ )
63
+ )
64
+ now = datetime.now(tz=UTC)
65
+ if info.committer_date > now + timedelta(minutes=future_tolerance_minutes):
66
+ anomalies.append(
67
+ TemporalAnomaly(
68
+ kind="future_timestamp",
69
+ commit_sha=info.sha,
70
+ description="Committer timestamp is in the future.",
71
+ metadata={"committer": info.committer_date.isoformat()},
72
+ )
73
+ )
74
+ bucket = int(info.committer_date.timestamp()) // 30
75
+ bursts.setdefault(bucket, []).append(info)
76
+
77
+ for bucket, items in bursts.items():
78
+ if len(items) >= min_burst:
79
+ window = datetime.fromtimestamp(bucket * 30, tz=UTC)
80
+ anomalies.append(
81
+ TemporalAnomaly(
82
+ kind="commit_burst",
83
+ commit_sha=items[0].sha,
84
+ description=f"{len(items)} commits within a 30 second window starting {window.isoformat()}.",
85
+ metadata={"count": str(len(items)), "window": window.isoformat()},
86
+ )
87
+ )
88
+
89
+ # Timezone jumps between consecutive commits.
90
+ for prev, current in itertools.pairwise(ordered):
91
+ if prev.committer_date is None or current.committer_date is None:
92
+ continue
93
+ prev_tz = prev.committer_date.utcoffset() or timedelta(0)
94
+ curr_tz = current.committer_date.utcoffset() or timedelta(0)
95
+ jump = abs((curr_tz - prev_tz).total_seconds())
96
+ if jump >= 6 * 3600:
97
+ anomalies.append(
98
+ TemporalAnomaly(
99
+ kind="timezone_jump",
100
+ commit_sha=current.sha,
101
+ description="Sudden shift in committer timezone between consecutive commits.",
102
+ metadata={
103
+ "prev_offset": str(prev_tz),
104
+ "current_offset": str(curr_tz),
105
+ },
106
+ )
107
+ )
108
+ return anomalies
@@ -0,0 +1,130 @@
1
+ """Commit-level interest and anomaly heuristics.
2
+
3
+ These feed the ``interesting`` command and ``explain``. Nothing here asserts
4
+ intent; each reason is a plain observable fact.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from refhound.models.anomaly import InterestingCommit
10
+ from refhound.models.commit import CommitInfo
11
+ from refhound.util.paths import classify_path_category, is_interesting_path
12
+
13
+ _SUSPICIOUS_NAMES = frozenset(
14
+ {
15
+ ".env",
16
+ ".env.production",
17
+ ".env.local",
18
+ "secrets",
19
+ "credentials",
20
+ "id_rsa",
21
+ "id_ed25519",
22
+ "kubeconfig",
23
+ "terraform.tfstate",
24
+ ".npmrc",
25
+ "settings.py",
26
+ }
27
+ )
28
+
29
+ _REVIEW_KEYWORDS = (
30
+ "remove secret",
31
+ "cleanup credentials",
32
+ "temporary",
33
+ "debug",
34
+ "hotfix",
35
+ "revert",
36
+ "backup",
37
+ "old config",
38
+ "production",
39
+ )
40
+
41
+
42
+ def _reason(score: int, text: str) -> tuple[int, str]:
43
+ return score, text
44
+
45
+
46
+ def score_commit(
47
+ info: CommitInfo,
48
+ *,
49
+ has_secret_added: bool = False,
50
+ has_secret_removed: bool = False,
51
+ added_paths: list[str] | None = None,
52
+ removed_paths: list[str] | None = None,
53
+ unreachable: bool = False,
54
+ chain_id: str | None = None,
55
+ unusual_author: bool = False,
56
+ temporal_anomaly: bool = False,
57
+ ci_changed: bool = False,
58
+ ) -> InterestingCommit:
59
+ """Compute an explainable interest score for a commit."""
60
+ added = added_paths or []
61
+ removed = removed_paths or []
62
+ reasons: list[tuple[int, str]] = []
63
+ score = 0
64
+
65
+ if has_secret_added:
66
+ reasons.append(_reason(30, "introduces secret material"))
67
+ score += 30
68
+ if has_secret_removed:
69
+ reasons.append(_reason(20, "removes secret material"))
70
+ score += 20
71
+ if unreachable and chain_id:
72
+ reasons.append(_reason(20, f"commit is unreachable (chain {chain_id})"))
73
+ score += 20
74
+ elif unreachable:
75
+ reasons.append(_reason(20, "commit is unreachable from known refs"))
76
+ score += 20
77
+ if chain_id:
78
+ reasons.append(_reason(5, "part of a lost commit chain"))
79
+ score += 5
80
+
81
+ for path in added[:20]:
82
+ if is_interesting_path(path):
83
+ reasons.append(_reason(15, "adds file under an interesting path"))
84
+ score += 15
85
+ break
86
+ for path in added[:20]:
87
+ if classify_path_category(path) == "credential":
88
+ reasons.append(_reason(25, "adds a credential/config file"))
89
+ score += 25
90
+ break
91
+ for path in removed[:20]:
92
+ if is_interesting_path(path):
93
+ reasons.append(_reason(10, "removes file under an interesting path"))
94
+ score += 10
95
+ break
96
+
97
+ if unusual_author:
98
+ reasons.append(_reason(9, "unusual author/committer identity"))
99
+ score += 9
100
+ if temporal_anomaly:
101
+ reasons.append(_reason(7, "temporal timestamp anomaly"))
102
+ score += 7
103
+ if ci_changed:
104
+ reasons.append(_reason(12, "CI/CD configuration changed"))
105
+ score += 12
106
+
107
+ subject_lower = info.subject.lower()
108
+ for keyword in _REVIEW_KEYWORDS:
109
+ if keyword in subject_lower:
110
+ reasons.append(_reason(6, f"subject matches review keyword '{keyword}'"))
111
+ score += 6
112
+ break
113
+
114
+ if info.is_merge:
115
+ reasons.append(_reason(2, "merge commit"))
116
+ score += 2
117
+ if info.deleted >= 5000:
118
+ reasons.append(_reason(12, "unusually large deletion (heuristic)"))
119
+ score += 12
120
+
121
+ return InterestingCommit(
122
+ sha=info.sha,
123
+ score=min(100, score),
124
+ date=info.committer_date,
125
+ subject=info.subject,
126
+ author=info.author_email,
127
+ reasons=reasons,
128
+ added_files=added[:50],
129
+ removed_files=removed[:50],
130
+ )
@@ -0,0 +1,150 @@
1
+ """Correlation: combine weak signals into prioritized findings.
2
+
3
+ Rather than emitting thousands of isolated rows, we consolidate evidence
4
+ across unreachable history, secret state, and removal windows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from refhound.models.anomaly import ChurnFinding
10
+ from refhound.models.finding import (
11
+ Finding,
12
+ FindingCategory,
13
+ SecretRecord,
14
+ Severity,
15
+ SourceState,
16
+ )
17
+ from refhound.models.object import LostCommitChain
18
+
19
+
20
+ def churn_to_findings(churn: list[ChurnFinding], repo: str) -> list[Finding]:
21
+ """Promote churn results with secret involvement to findings."""
22
+ findings: list[Finding] = []
23
+ for item in churn:
24
+ if not item.secret_found:
25
+ continue
26
+ severity = Severity.HIGH
27
+ findings.append(
28
+ Finding(
29
+ id=f"RH-{item.added_commit[:8]}-{item.removed_commit[:8]}-churn",
30
+ category=FindingCategory.SECRET,
31
+ title="Secret introduced and removed within a short window",
32
+ description=(
33
+ f"{item.path.split('/')[-1]} was added in {item.added_commit[:8]} "
34
+ f"and removed in {item.removed_commit[:8]} ({item.lifetime_seconds:.0f}s "
35
+ f"if lifetime data available)."
36
+ ),
37
+ severity=severity,
38
+ score=0,
39
+ repository=repo,
40
+ commit_sha=item.added_commit,
41
+ path=item.path,
42
+ source_state=SourceState.HISTORICAL,
43
+ introduced_commit=item.added_commit,
44
+ removed_commit=item.removed_commit,
45
+ introduced_at=item.added_at,
46
+ removed_at=item.removed_at,
47
+ metadata={"lifetime_seconds": f"{int(item.lifetime_seconds or -1)}"},
48
+ provenance=["git-object-db"],
49
+ )
50
+ )
51
+ return findings
52
+
53
+
54
+ def cluster_secrets_into_findings(
55
+ secrets: list[SecretRecord], repo: str, interesting_paths: set[str]
56
+ ) -> list[Finding]:
57
+ """Build one Finding per unique secret with occurrence aggregation."""
58
+ findings: list[Finding] = []
59
+ for secret in secrets:
60
+ state = (
61
+ SourceState.CURRENT
62
+ if secret.current
63
+ else SourceState.HISTORICAL
64
+ if secret.historical
65
+ else SourceState.UNREACHABLE
66
+ if secret.unreachable
67
+ else SourceState.HISTORICAL
68
+ )
69
+ first = secret.occurrences[0] if secret.occurrences else None
70
+ path = (
71
+ first.path if first else (secret.occurrences[-1].path if secret.occurrences else None)
72
+ )
73
+ interesting = bool(path and path in interesting_paths)
74
+ severity = (
75
+ Severity.HIGH
76
+ if secret.detector in {"private-key"}
77
+ else Severity.HIGH
78
+ if secret.current or secret.unreachable
79
+ else Severity.MEDIUM
80
+ )
81
+ finding = Finding(
82
+ id=f"RH-sec-{secret.fingerprint[-12:]}",
83
+ category=(
84
+ FindingCategory.PRIVATE_KEY
85
+ if secret.detector == "private-key"
86
+ else FindingCategory.CREDENTIAL
87
+ ),
88
+ title=f"{secret.detector} credential",
89
+ severity=severity,
90
+ score=0,
91
+ repository=repo,
92
+ commit_sha=secret.introduced_commit,
93
+ path=path,
94
+ detector=secret.detector,
95
+ source_state=state,
96
+ introduced_commit=secret.introduced_commit,
97
+ removed_commit=secret.removed_commit,
98
+ introduced_at=secret.first_seen,
99
+ removed_at=secret.last_seen,
100
+ first_seen=secret.first_seen,
101
+ last_seen=secret.last_seen,
102
+ occurrence_count=secret.occurrence_count,
103
+ secret_fingerprint=secret.fingerprint,
104
+ metadata={
105
+ "secret_prefix": secret.prefix,
106
+ "secret_suffix": secret.suffix,
107
+ "interesting_path": "yes" if interesting else "no",
108
+ **(
109
+ {"lifetime_seconds": f"{int(secret.lifetime_seconds or 0)}"}
110
+ if secret.lifetime_seconds
111
+ else {}
112
+ ),
113
+ },
114
+ provenance=sorted({o.source_state.value for o in secret.occurrences}),
115
+ remediation="Rotate the credential and verify it is not in use. Review the commits listed in this finding.",
116
+ )
117
+ findings.append(finding)
118
+ return findings
119
+
120
+
121
+ def merge_chain_into_finding(
122
+ chain: LostCommitChain, secrets_in_chain: list[SecretRecord], repo: str
123
+ ) -> Finding | None:
124
+ """A high-value consolidated finding for a lost chain containing secrets."""
125
+ if not secrets_in_chain:
126
+ return None
127
+ severity = Severity.HIGH if any(s.unreachable for s in secrets_in_chain) else Severity.MEDIUM
128
+ return Finding(
129
+ id=f"RH-chain-{chain.chain_id}",
130
+ category=FindingCategory.LOST_HISTORY,
131
+ title="Secret present in unreachable (lost) history",
132
+ description=(
133
+ f"Lost chain {chain.chain_id} ({chain.commit_count} commits, "
134
+ f"{chain.root[:8]}..{chain.tip[:8]}) contains secret material. "
135
+ "The commits are unreachable from known refs."
136
+ ),
137
+ severity=severity,
138
+ score=0,
139
+ repository=repo,
140
+ commit_sha=chain.tip,
141
+ source_state=SourceState.UNREACHABLE,
142
+ chain_id=chain.chain_id,
143
+ metadata={
144
+ "chain_root": chain.root,
145
+ "chain_tip": chain.tip,
146
+ "commit_count": str(chain.commit_count),
147
+ },
148
+ provenance=["git-object-db", "unreachable-analysis"],
149
+ remediation="Review the lost chain commits; export them to a report for manual inspection.",
150
+ )
@@ -0,0 +1,120 @@
1
+ """Shared analysis state passed between pipeline stages.
2
+
3
+ This is intentionally a plain dataclass (not pydantic) — it is scratch space
4
+ during a scan, not part of the persisted/reported surface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from refhound.analysis.force_push_analysis import RefTransition
12
+ from refhound.models.anomaly import (
13
+ ChurnFinding,
14
+ IdentityAnomaly,
15
+ InterestingCommit,
16
+ TemporalAnomaly,
17
+ TimelineRow,
18
+ )
19
+ from refhound.models.commit import CommitInfo, IdentitySet
20
+ from refhound.models.diagnostic import ScanDiagnostic
21
+ from refhound.models.finding import Finding, SecretRecord
22
+ from refhound.models.object import BlobRecord, DanglingObject, LostCommitChain
23
+ from refhound.models.repository import RepoRef, RepositoryInfo
24
+ from refhound.models.statistics import ObjectStats, RepositoryStatistics, SecretStats, SeverityStats
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class AnalysisData:
29
+ repo: RepositoryInfo | None = None
30
+ refs: list[RepoRef] = field(default_factory=list)
31
+ commit_graph: dict[str, CommitInfo] = field(default_factory=dict)
32
+ reachable_oids: set[str] = field(default_factory=set)
33
+ unreachable_oids: set[str] = field(default_factory=set)
34
+ dangling: list[DanglingObject] = field(default_factory=list)
35
+ all_commit_oids: list[str] = field(default_factory=list)
36
+ object_stats: ObjectStats = field(default_factory=ObjectStats)
37
+ blobs: dict[str, BlobRecord] = field(default_factory=dict)
38
+ tip_trees: dict[str, dict[str, str]] = field(default_factory=dict)
39
+ blob_commits: dict[str, set[str]] = field(default_factory=dict)
40
+ secrets: list[SecretRecord] = field(default_factory=list)
41
+ lost_chains: list[LostCommitChain] = field(default_factory=list)
42
+ timeline: list[TimelineRow] = field(default_factory=list)
43
+ interesting: dict[str, InterestingCommit] = field(default_factory=dict)
44
+ churn: list[ChurnFinding] = field(default_factory=list)
45
+ identities: list[IdentitySet] = field(default_factory=list)
46
+ deleted_files: list[str] = field(default_factory=list)
47
+ renamed_files: list[str] = field(default_factory=list)
48
+ merge_commit_count: int = 0
49
+ signed_count: int = 0
50
+ unsigned_count: int = 0
51
+ history_components: int = 0
52
+ findings: list[Finding] = field(default_factory=list)
53
+ notes: dict[str, bytes] = field(default_factory=dict)
54
+ fingerprint: object | None = None
55
+ scan_warnings: list[str] = field(default_factory=list)
56
+ complete: bool = True
57
+ diagnostics: list[ScanDiagnostic] = field(default_factory=list)
58
+ failed_detectors: dict[str, int] = field(default_factory=dict)
59
+ temporal_anomalies: list[TemporalAnomaly] = field(default_factory=list)
60
+ identity_anomalies: list[IdentityAnomaly] = field(default_factory=list)
61
+ ref_transitions: list[RefTransition] = field(default_factory=list)
62
+ scan_id: str = ""
63
+ scan_timestamp: str = ""
64
+ cached_statistics: RepositoryStatistics | None = None
65
+
66
+ @property
67
+ def statistics(self) -> RepositoryStatistics:
68
+ if self.cached_statistics is not None:
69
+ return self.cached_statistics
70
+ return RepositoryStatistics(
71
+ total_commits=len(self.commit_graph),
72
+ reachable_commits=len(self.reachable_oids),
73
+ unreachable_commits=len(self.unreachable_oids),
74
+ branches=sum(1 for r in self.refs if r.ref_name.startswith("refs/heads/")),
75
+ tags=sum(1 for r in self.refs if r.ref_name.startswith("refs/tags/")),
76
+ authors=len({i.email for i in self.identities}),
77
+ files=len(self.blobs),
78
+ blobs=len(self.blobs),
79
+ deleted_files=len(self.deleted_files),
80
+ renamed_files=len(self.renamed_files),
81
+ merge_commits=self.merge_commit_count,
82
+ signed_commits=self.signed_count,
83
+ unsigned_commits=self.unsigned_count,
84
+ objects=ObjectStats(
85
+ commits=self.object_stats.commits,
86
+ trees=self.object_stats.trees,
87
+ blobs=self.object_stats.blobs,
88
+ tags=self.object_stats.tags,
89
+ unreachable_commits=len(self.unreachable_oids),
90
+ lost_chains=len(self.lost_chains),
91
+ ),
92
+ history_components=self.history_components,
93
+ )
94
+
95
+ @property
96
+ def severity_stats(self) -> SeverityStats:
97
+ stats = SeverityStats()
98
+ for finding in self.findings:
99
+ stats.add(finding.severity.value)
100
+ return stats
101
+
102
+ @property
103
+ def secret_stats(self) -> SecretStats:
104
+ stats = SecretStats(unique_secrets=len(self.secrets))
105
+ for secret in self.secrets:
106
+ if secret.current:
107
+ stats.current += 1
108
+ if secret.historical:
109
+ stats.historical += 1
110
+ if secret.unreachable:
111
+ stats.unreachable += 1
112
+ if secret.detector == "private-key":
113
+ stats.private_keys += 1
114
+ elif secret.detector in {"database-uri"}:
115
+ stats.connection_strings += 1
116
+ elif secret.detector == "generic-password":
117
+ stats.passwords += 1
118
+ else:
119
+ stats.tokens += 1
120
+ return stats
@@ -0,0 +1,157 @@
1
+ """File deletion / introduction analysis.
2
+
3
+ Tracks the lifecycle of files that are interesting from a security
4
+ perspective (added, removed, reintroduced) and computes the additive/removal
5
+ windows used by churn analysis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from refhound.git.command import GitRunner, validate_oid
15
+ from refhound.git.fsck import list_tree_blobs
16
+ from refhound.models.anomaly import ChurnFinding
17
+
18
+ _HEADER_MARK = "\x1e"
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class PathEvent:
23
+ commit: str
24
+ status: str # A/M/D/R
25
+ date: str = ""
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class ChangeRecord:
30
+ commit: str
31
+ timestamp: str
32
+ added: list[str]
33
+ removed: list[str]
34
+
35
+
36
+ def path_history(git: GitRunner, cwd: str | Path, path: str) -> list[PathEvent]:
37
+ """Chronological (oldest-first) list of changes touching ``path``."""
38
+ out = git.run(
39
+ "log",
40
+ "--all",
41
+ "--reverse",
42
+ "--format=%H%x1e%ct%x1e",
43
+ "--name-status",
44
+ "--no-renames",
45
+ "--",
46
+ path,
47
+ cwd=cwd,
48
+ check=False,
49
+ )
50
+ if out.returncode != 0:
51
+ return []
52
+ events: list[PathEvent] = []
53
+ current_commit = ""
54
+ current_date = ""
55
+ for line in out.stdout.split("\n"):
56
+ if _HEADER_MARK in line:
57
+ fields = line.split(_HEADER_MARK)
58
+ current_commit = fields[0]
59
+ current_date = fields[1] if len(fields) > 1 else ""
60
+ continue
61
+ if not current_commit:
62
+ continue
63
+ status = line[0] if line else "?"
64
+ if status in {"A", "M", "D", "R", "C", "T"}:
65
+ events.append(PathEvent(commit=current_commit, status=status, date=current_date))
66
+ return events
67
+
68
+
69
+ def changed_file_status_iter(git: GitRunner, cwd: str | Path) -> Iterator[ChangeRecord]:
70
+ """Yield per-commit added/removed path lists for all reachable commits.
71
+
72
+ Single ``git log`` invocation, no renames (renames decompose into
73
+ add+delete), unit-separated format. Oldest first.
74
+ """
75
+ out = git.run(
76
+ "log",
77
+ "--all",
78
+ "--reverse",
79
+ "--format=%H%x1e%ct%x1e",
80
+ "--name-status",
81
+ "--no-renames",
82
+ "--no-merges",
83
+ cwd=cwd,
84
+ timeout=900.0,
85
+ ).stdout
86
+ current_commit = ""
87
+ current_ts = ""
88
+ added: list[str] = []
89
+ removed: list[str] = []
90
+ for line in out.split("\n"):
91
+ if _HEADER_MARK in line:
92
+ if current_commit:
93
+ yield ChangeRecord(
94
+ commit=current_commit, timestamp=current_ts, added=added, removed=removed
95
+ )
96
+ fields = line.split(_HEADER_MARK)
97
+ current_commit = fields[0]
98
+ current_ts = fields[1] if len(fields) > 1 else ""
99
+ added, removed = [], []
100
+ continue
101
+ if not current_commit or "\t" not in line:
102
+ continue
103
+ status = line[0]
104
+ path = line.split("\t")[-1]
105
+ if status == "A":
106
+ added.append(path)
107
+ elif status == "D":
108
+ removed.append(path)
109
+ if current_commit:
110
+ yield ChangeRecord(
111
+ commit=current_commit, timestamp=current_ts, added=added, removed=removed
112
+ )
113
+
114
+
115
+ def interesting_lifetimes(
116
+ git: GitRunner,
117
+ cwd: str | Path,
118
+ interesting_paths: list[str],
119
+ *,
120
+ max_window_seconds: int = 3600,
121
+ ) -> list[ChurnFinding]:
122
+ """Find files that were added then removed within a short window.
123
+
124
+ Facts only: reports the observable add/remove pair. Never claims intent.
125
+ """
126
+ findings: list[ChurnFinding] = []
127
+ for path in interesting_paths:
128
+ events = path_history(git, cwd, path)
129
+ if len(events) < 2:
130
+ continue
131
+ additions: list[PathEvent] = [e for e in events if e.status == "A"]
132
+ removals: list[PathEvent] = [e for e in events if e.status == "D"]
133
+ if not additions or not removals:
134
+ continue
135
+ for added in additions:
136
+ for removed in removals:
137
+ added_ts = int(added.date) if added.date.isdigit() else 0
138
+ removed_ts = int(removed.date) if removed.date.isdigit() else 0
139
+ if added_ts and removed_ts and 0 <= removed_ts - added_ts <= max_window_seconds:
140
+ findings.append(
141
+ ChurnFinding(
142
+ path=path,
143
+ added_commit=added.commit,
144
+ removed_commit=removed.commit,
145
+ lifetime_seconds=float(removed_ts - added_ts),
146
+ )
147
+ )
148
+ break
149
+ findings.sort(key=lambda f: (f.lifetime_seconds or 0, f.path))
150
+ return findings
151
+
152
+
153
+ def file_state_at(git: GitRunner, cwd: str | Path, commit: str, path: str) -> str | None:
154
+ """Blob oid of ``path`` at ``commit``, or None if absent."""
155
+ commit = validate_oid(commit)
156
+ blobs = dict(list_tree_blobs(git, cwd, commit))
157
+ return blobs.get(path)