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.
- refhound/__init__.py +5 -0
- refhound/__main__.py +6 -0
- refhound/_version.py +7 -0
- refhound/analysis/__init__.py +1 -0
- refhound/analysis/chronology.py +108 -0
- refhound/analysis/commit_anomalies.py +130 -0
- refhound/analysis/correlations.py +150 -0
- refhound/analysis/data.py +120 -0
- refhound/analysis/deletion_analysis.py +157 -0
- refhound/analysis/force_push_analysis.py +107 -0
- refhound/analysis/identity_analysis.py +101 -0
- refhound/analysis/scoring.py +96 -0
- refhound/api.py +90 -0
- refhound/baseline.py +78 -0
- refhound/cache.py +73 -0
- refhound/cli.py +1436 -0
- refhound/cli_cache.py +84 -0
- refhound/config.py +199 -0
- refhound/detectors/__init__.py +1 -0
- refhound/detectors/base.py +125 -0
- refhound/detectors/cloud.py +100 -0
- refhound/detectors/database.py +66 -0
- refhound/detectors/entropy.py +80 -0
- refhound/detectors/generic.py +42 -0
- refhound/detectors/github.py +33 -0
- refhound/detectors/gitlab.py +45 -0
- refhound/detectors/jwt.py +68 -0
- refhound/detectors/passwords.py +98 -0
- refhound/detectors/private_keys.py +41 -0
- refhound/detectors/registry.py +54 -0
- refhound/errors.py +63 -0
- refhound/git/__init__.py +1 -0
- refhound/git/command.py +289 -0
- refhound/git/commits.py +196 -0
- refhound/git/diff.py +87 -0
- refhound/git/fsck.py +70 -0
- refhound/git/graph.py +110 -0
- refhound/git/lfs.py +47 -0
- refhound/git/notes.py +37 -0
- refhound/git/objects.py +161 -0
- refhound/git/reflog.py +90 -0
- refhound/git/refs.py +84 -0
- refhound/git/repository.py +188 -0
- refhound/git/submodules.py +117 -0
- refhound/git/tags.py +52 -0
- refhound/models/__init__.py +1 -0
- refhound/models/anomaly.py +67 -0
- refhound/models/commit.py +62 -0
- refhound/models/diagnostic.py +20 -0
- refhound/models/finding.py +119 -0
- refhound/models/object.py +96 -0
- refhound/models/repository.py +87 -0
- refhound/models/secret.py +45 -0
- refhound/models/statistics.py +79 -0
- refhound/providers/__init__.py +1 -0
- refhound/providers/base.py +71 -0
- refhound/providers/generic.py +20 -0
- refhound/providers/github.py +56 -0
- refhound/providers/gitlab.py +55 -0
- refhound/reporting/__init__.py +1 -0
- refhound/reporting/console.py +106 -0
- refhound/reporting/json.py +166 -0
- refhound/reporting/markdown.py +209 -0
- refhound/reporting/sarif.py +114 -0
- refhound/reporting/statistics.py +46 -0
- refhound/repository_key.py +40 -0
- refhound/scanners/__init__.py +1 -0
- refhound/scanners/base.py +79 -0
- refhound/scanners/ci.py +57 -0
- refhound/scanners/engine.py +514 -0
- refhound/scanners/files.py +48 -0
- refhound/scanners/history.py +46 -0
- refhound/scanners/identity.py +46 -0
- refhound/scanners/refs.py +34 -0
- refhound/scanners/secrets.py +202 -0
- refhound/scanners/timeline.py +66 -0
- refhound/scanners/unreachable.py +120 -0
- refhound/snapshots.py +155 -0
- refhound/storage/__init__.py +1 -0
- refhound/storage/database.py +322 -0
- refhound/storage/migrations/__init__.py +1 -0
- refhound/storage/schema.py +120 -0
- refhound/util/__init__.py +1 -0
- refhound/util/dates.py +97 -0
- refhound/util/hashing.py +71 -0
- refhound/util/output.py +34 -0
- refhound/util/paths.py +207 -0
- refhound/util/sanitize.py +60 -0
- refhound/util/sorting.py +26 -0
- refhound-0.1.0b1.dist-info/METADATA +164 -0
- refhound-0.1.0b1.dist-info/RECORD +94 -0
- refhound-0.1.0b1.dist-info/WHEEL +4 -0
- refhound-0.1.0b1.dist-info/entry_points.txt +2 -0
- refhound-0.1.0b1.dist-info/licenses/LICENSE +21 -0
refhound/__init__.py
ADDED
refhound/__main__.py
ADDED
refhound/_version.py
ADDED
|
@@ -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)
|