gitghost-scanner 1.4.0__tar.gz → 1.4.1__tar.gz
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.
- {gitghost_scanner-1.4.0/gitghost_scanner.egg-info → gitghost_scanner-1.4.1}/PKG-INFO +27 -27
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/README.md +26 -26
- gitghost_scanner-1.4.1/gitghost/__init__.py +1 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/cli.py +26 -5
- gitghost_scanner-1.4.1/gitghost/metadata.py +108 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/report.py +6 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1/gitghost_scanner.egg-info}/PKG-INFO +27 -27
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/tests/test_detection.py +66 -0
- gitghost_scanner-1.4.0/gitghost/__init__.py +0 -1
- gitghost_scanner-1.4.0/gitghost/metadata.py +0 -52
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/LICENSE +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/__main__.py +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/banner.py +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/console.py +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/ghost.py +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/github.py +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/rules.py +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/scanner.py +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/score.py +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/ui.py +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/SOURCES.txt +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/dependency_links.txt +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/entry_points.txt +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/top_level.txt +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/pyproject.toml +0 -0
- {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: gitghost-scanner
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: Find secrets in a GitHub account's public repos — including the ones still hiding in git history after being 'deleted'.
|
|
5
5
|
Author: cy3erm
|
|
6
6
|
License: MIT
|
|
@@ -29,22 +29,22 @@ License-File: LICENSE
|
|
|
29
29
|
|
|
30
30
|
**The secrets you deleted are still in git history.** gitghost finds them.
|
|
31
31
|
|
|
32
|
-
Finds live credentials in a GitHub account's public repos
|
|
32
|
+
Finds live credentials in a GitHub account's public repos, *and* the ones that were committed, "deleted," and are still sitting one `git log` away for anyone who clones.
|
|
33
33
|
|
|
34
|
-
I built this after noticing how often the real leak isn't in someone's current code
|
|
34
|
+
I built this after noticing how often the real leak isn't in someone's current code. It's in a commit from eight months ago that they thought they'd cleaned up. You paste an API key, catch it, delete the line, and move on. The latest version looks fine. But the old commit still has the key, and anyone who clones the repo gets it. Most scanners only look at your current files and miss this entirely. gitghost digs through history for exactly those, then rolls everything into a single **exposure score** so you can tell how bad things are at a glance.
|
|
35
35
|
|
|
36
36
|
## Highlights
|
|
37
37
|
|
|
38
|
-
- **Ghost recovery
|
|
39
|
-
- **Force-push recovery
|
|
40
|
-
- **Gist revision history
|
|
41
|
-
- **40+ credential formats
|
|
42
|
-
- **Generic high-entropy detection
|
|
43
|
-
- **Exposure score (0–100)
|
|
44
|
-
- **Metadata leaks
|
|
45
|
-
- **HTML dossier output
|
|
46
|
-
- **Interactive console** or one-shot CLI
|
|
47
|
-
- **Zero dependencies
|
|
38
|
+
- **Ghost recovery:** walks the full git history of every repo (including unreachable objects) to recover secrets that were removed from HEAD but never purged
|
|
39
|
+
- **Force-push recovery:** even if you rewrote history, your recent push events still list the orphaned commit SHAs; gitghost fetches those commits by SHA and scans what the force-push tried to bury
|
|
40
|
+
- **Gist revision history:** secrets you edited out of a gist stay readable at old revision URLs; gitghost scans every revision and flags the ones that were "deleted"
|
|
41
|
+
- **40+ credential formats:** AWS, GCP, GitHub, OpenAI, Anthropic, Stripe, Slack, Google, Azure, Telegram, Discord, Notion, Shopify, Vault, Postman, Square, and many more (full list below)
|
|
42
|
+
- **Generic high-entropy detection:** catches secrets that don't match any known format, with false-positive filtering tuned against real codebases
|
|
43
|
+
- **Exposure score (0–100):** one number that weights severity, age of the leak, cross-repo reuse, and whether a "deleted" secret is *still in use* somewhere
|
|
44
|
+
- **Metadata leaks:** the author email in your commits, plus inferred timezone and working hours; profile enrichment pulls public name, company, location, blog, and account age
|
|
45
|
+
- **HTML dossier output:** every finding links to the exact file+line or commit; findings are shown as irreversible fingerprints so reports are safe to share
|
|
46
|
+
- **Interactive console** or one-shot CLI, your choice
|
|
47
|
+
- **Zero dependencies:** pure Python 3.10+ standard library
|
|
48
48
|
|
|
49
49
|
## Install
|
|
50
50
|
|
|
@@ -63,7 +63,7 @@ cd gitghost
|
|
|
63
63
|
python3 -m gitghost
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
> Set `GITHUB_TOKEN` (any token works
|
|
66
|
+
> Set `GITHUB_TOKEN` (any token works, it doesn't need scopes) to raise GitHub's 60-requests/hour anonymous limit before scanning more than an account or two.
|
|
67
67
|
|
|
68
68
|
## Usage
|
|
69
69
|
|
|
@@ -78,7 +78,7 @@ $ gitghost
|
|
|
78
78
|
/ /_/ / / /_/ /_/ / / / /_/ (__ ) /_
|
|
79
79
|
\__, /_/\__/\__, /_/ /_/\____/____/\__/
|
|
80
80
|
/____/ /____/
|
|
81
|
-
the secrets you deleted are still in git history v1.
|
|
81
|
+
the secrets you deleted are still in git history v1.4.1
|
|
82
82
|
by cy3erm · github.com/cy3erm/gitghost
|
|
83
83
|
|
|
84
84
|
1) Scan a GitHub user public repos + gists
|
|
@@ -117,7 +117,7 @@ gitghost --jobs 8 # parallel clones/scans (default 4)
|
|
|
117
117
|
gitghost --debug # full traceback if something breaks
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
Try the demo first if you'd rather not point this at a real person
|
|
120
|
+
Try the demo first if you'd rather not point this at a real person. It builds a tiny repo with fake credentials, including one that gets "deleted" a few commits in:
|
|
121
121
|
|
|
122
122
|
```bash
|
|
123
123
|
bash demo/make_demo_repo.sh /tmp/demo
|
|
@@ -140,32 +140,32 @@ OpenAI (classic + project keys) · Anthropic · Groq · Perplexity · xAI · Hug
|
|
|
140
140
|
**Communication & SaaS**
|
|
141
141
|
Slack (tokens, webhooks, app tokens) · Discord (webhooks, bot tokens) · Telegram bot tokens · Twilio · SendGrid · Mailgun · Notion integration secrets · Shopify · Square · Razorpay · DigitalOcean · Doppler · Firebase Cloud Messaging server keys · Google API keys and OAuth refresh tokens
|
|
142
142
|
|
|
143
|
-
**Everything else
|
|
143
|
+
**Everything else**: a generic high-entropy scanner looks for values assigned to suspiciously-named variables (`api_key = "..."`) even when no provider pattern matches, filtered through placeholder/entropy checks to keep false positives near zero.
|
|
144
144
|
|
|
145
|
-
Missing a provider you care about? Rules are four lines each in [`gitghost/rules.py`](gitghost/rules.py)
|
|
145
|
+
Missing a provider you care about? Rules are four lines each in [`gitghost/rules.py`](gitghost/rules.py): name, regex, severity, remediation advice. PRs welcome.
|
|
146
146
|
|
|
147
147
|
## The score
|
|
148
148
|
|
|
149
149
|
One number, 0–100, higher is worse:
|
|
150
150
|
|
|
151
|
-
- **Severity-weighted findings
|
|
152
|
-
- **Ghost weighting
|
|
153
|
-
- **Reuse correlation
|
|
154
|
-
- **Metadata exposure
|
|
151
|
+
- **Severity-weighted findings:** a live AWS secret key counts far more than a webhook URL
|
|
152
|
+
- **Ghost weighting:** recovered-from-history secrets scale with age: something exposed for 2 years hurts more than something from last week
|
|
153
|
+
- **Reuse correlation:** the same secret across multiple repos, and the nastiest case: a "deleted" secret whose fingerprint still matches something *live*
|
|
154
|
+
- **Metadata exposure:** real email in commits, inferable timezone
|
|
155
155
|
|
|
156
|
-
A single live cloud key is enough to put an account in the red by itself. Run it, clean up, run again
|
|
156
|
+
A single live cloud key is enough to put an account in the red by itself. Run it, clean up, run again, and the number tells you if it worked.
|
|
157
157
|
|
|
158
158
|
## Where it draws the line (legal & ethics)
|
|
159
159
|
|
|
160
|
-
It only ever reads public repositories
|
|
160
|
+
It only ever reads public repositories (things the account already chose to publish), and it's detection-only. It'll tell you a string *looks like* a credential and leave it at that. It won't try the key against the actual service to see if it still works, because quietly logging into someone else's account isn't the tool's job, and honestly it's not yours either. Use it on your own identity, or one you're authorized to assess.
|
|
161
161
|
|
|
162
|
-
Findings are shown as non-reversible fingerprints (a safe prefix, a length, a partial hash)
|
|
162
|
+
Findings are shown as non-reversible fingerprints (a safe prefix, a length, a partial hash), never recoverable key material, so reports are safe to share. gitghost does not retain raw secrets past the match.
|
|
163
163
|
|
|
164
|
-
Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about
|
|
164
|
+
Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about. I did.
|
|
165
165
|
|
|
166
166
|
## Leaked something? The fix is rotate, not delete.
|
|
167
167
|
|
|
168
|
-
Deleting the file or the repo doesn't un-leak anything
|
|
168
|
+
Deleting the file or the repo doesn't un-leak anything: anyone who cloned already has the key. Rotate the credential first, then purge history with `git filter-repo`. The end of every dossier walks through both steps.
|
|
169
169
|
|
|
170
170
|
## Development
|
|
171
171
|
|
|
@@ -9,22 +9,22 @@
|
|
|
9
9
|
|
|
10
10
|
**The secrets you deleted are still in git history.** gitghost finds them.
|
|
11
11
|
|
|
12
|
-
Finds live credentials in a GitHub account's public repos
|
|
12
|
+
Finds live credentials in a GitHub account's public repos, *and* the ones that were committed, "deleted," and are still sitting one `git log` away for anyone who clones.
|
|
13
13
|
|
|
14
|
-
I built this after noticing how often the real leak isn't in someone's current code
|
|
14
|
+
I built this after noticing how often the real leak isn't in someone's current code. It's in a commit from eight months ago that they thought they'd cleaned up. You paste an API key, catch it, delete the line, and move on. The latest version looks fine. But the old commit still has the key, and anyone who clones the repo gets it. Most scanners only look at your current files and miss this entirely. gitghost digs through history for exactly those, then rolls everything into a single **exposure score** so you can tell how bad things are at a glance.
|
|
15
15
|
|
|
16
16
|
## Highlights
|
|
17
17
|
|
|
18
|
-
- **Ghost recovery
|
|
19
|
-
- **Force-push recovery
|
|
20
|
-
- **Gist revision history
|
|
21
|
-
- **40+ credential formats
|
|
22
|
-
- **Generic high-entropy detection
|
|
23
|
-
- **Exposure score (0–100)
|
|
24
|
-
- **Metadata leaks
|
|
25
|
-
- **HTML dossier output
|
|
26
|
-
- **Interactive console** or one-shot CLI
|
|
27
|
-
- **Zero dependencies
|
|
18
|
+
- **Ghost recovery:** walks the full git history of every repo (including unreachable objects) to recover secrets that were removed from HEAD but never purged
|
|
19
|
+
- **Force-push recovery:** even if you rewrote history, your recent push events still list the orphaned commit SHAs; gitghost fetches those commits by SHA and scans what the force-push tried to bury
|
|
20
|
+
- **Gist revision history:** secrets you edited out of a gist stay readable at old revision URLs; gitghost scans every revision and flags the ones that were "deleted"
|
|
21
|
+
- **40+ credential formats:** AWS, GCP, GitHub, OpenAI, Anthropic, Stripe, Slack, Google, Azure, Telegram, Discord, Notion, Shopify, Vault, Postman, Square, and many more (full list below)
|
|
22
|
+
- **Generic high-entropy detection:** catches secrets that don't match any known format, with false-positive filtering tuned against real codebases
|
|
23
|
+
- **Exposure score (0–100):** one number that weights severity, age of the leak, cross-repo reuse, and whether a "deleted" secret is *still in use* somewhere
|
|
24
|
+
- **Metadata leaks:** the author email in your commits, plus inferred timezone and working hours; profile enrichment pulls public name, company, location, blog, and account age
|
|
25
|
+
- **HTML dossier output:** every finding links to the exact file+line or commit; findings are shown as irreversible fingerprints so reports are safe to share
|
|
26
|
+
- **Interactive console** or one-shot CLI, your choice
|
|
27
|
+
- **Zero dependencies:** pure Python 3.10+ standard library
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
@@ -43,7 +43,7 @@ cd gitghost
|
|
|
43
43
|
python3 -m gitghost
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
> Set `GITHUB_TOKEN` (any token works
|
|
46
|
+
> Set `GITHUB_TOKEN` (any token works, it doesn't need scopes) to raise GitHub's 60-requests/hour anonymous limit before scanning more than an account or two.
|
|
47
47
|
|
|
48
48
|
## Usage
|
|
49
49
|
|
|
@@ -58,7 +58,7 @@ $ gitghost
|
|
|
58
58
|
/ /_/ / / /_/ /_/ / / / /_/ (__ ) /_
|
|
59
59
|
\__, /_/\__/\__, /_/ /_/\____/____/\__/
|
|
60
60
|
/____/ /____/
|
|
61
|
-
the secrets you deleted are still in git history v1.
|
|
61
|
+
the secrets you deleted are still in git history v1.4.1
|
|
62
62
|
by cy3erm · github.com/cy3erm/gitghost
|
|
63
63
|
|
|
64
64
|
1) Scan a GitHub user public repos + gists
|
|
@@ -97,7 +97,7 @@ gitghost --jobs 8 # parallel clones/scans (default 4)
|
|
|
97
97
|
gitghost --debug # full traceback if something breaks
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
Try the demo first if you'd rather not point this at a real person
|
|
100
|
+
Try the demo first if you'd rather not point this at a real person. It builds a tiny repo with fake credentials, including one that gets "deleted" a few commits in:
|
|
101
101
|
|
|
102
102
|
```bash
|
|
103
103
|
bash demo/make_demo_repo.sh /tmp/demo
|
|
@@ -120,32 +120,32 @@ OpenAI (classic + project keys) · Anthropic · Groq · Perplexity · xAI · Hug
|
|
|
120
120
|
**Communication & SaaS**
|
|
121
121
|
Slack (tokens, webhooks, app tokens) · Discord (webhooks, bot tokens) · Telegram bot tokens · Twilio · SendGrid · Mailgun · Notion integration secrets · Shopify · Square · Razorpay · DigitalOcean · Doppler · Firebase Cloud Messaging server keys · Google API keys and OAuth refresh tokens
|
|
122
122
|
|
|
123
|
-
**Everything else
|
|
123
|
+
**Everything else**: a generic high-entropy scanner looks for values assigned to suspiciously-named variables (`api_key = "..."`) even when no provider pattern matches, filtered through placeholder/entropy checks to keep false positives near zero.
|
|
124
124
|
|
|
125
|
-
Missing a provider you care about? Rules are four lines each in [`gitghost/rules.py`](gitghost/rules.py)
|
|
125
|
+
Missing a provider you care about? Rules are four lines each in [`gitghost/rules.py`](gitghost/rules.py): name, regex, severity, remediation advice. PRs welcome.
|
|
126
126
|
|
|
127
127
|
## The score
|
|
128
128
|
|
|
129
129
|
One number, 0–100, higher is worse:
|
|
130
130
|
|
|
131
|
-
- **Severity-weighted findings
|
|
132
|
-
- **Ghost weighting
|
|
133
|
-
- **Reuse correlation
|
|
134
|
-
- **Metadata exposure
|
|
131
|
+
- **Severity-weighted findings:** a live AWS secret key counts far more than a webhook URL
|
|
132
|
+
- **Ghost weighting:** recovered-from-history secrets scale with age: something exposed for 2 years hurts more than something from last week
|
|
133
|
+
- **Reuse correlation:** the same secret across multiple repos, and the nastiest case: a "deleted" secret whose fingerprint still matches something *live*
|
|
134
|
+
- **Metadata exposure:** real email in commits, inferable timezone
|
|
135
135
|
|
|
136
|
-
A single live cloud key is enough to put an account in the red by itself. Run it, clean up, run again
|
|
136
|
+
A single live cloud key is enough to put an account in the red by itself. Run it, clean up, run again, and the number tells you if it worked.
|
|
137
137
|
|
|
138
138
|
## Where it draws the line (legal & ethics)
|
|
139
139
|
|
|
140
|
-
It only ever reads public repositories
|
|
140
|
+
It only ever reads public repositories (things the account already chose to publish), and it's detection-only. It'll tell you a string *looks like* a credential and leave it at that. It won't try the key against the actual service to see if it still works, because quietly logging into someone else's account isn't the tool's job, and honestly it's not yours either. Use it on your own identity, or one you're authorized to assess.
|
|
141
141
|
|
|
142
|
-
Findings are shown as non-reversible fingerprints (a safe prefix, a length, a partial hash)
|
|
142
|
+
Findings are shown as non-reversible fingerprints (a safe prefix, a length, a partial hash), never recoverable key material, so reports are safe to share. gitghost does not retain raw secrets past the match.
|
|
143
143
|
|
|
144
|
-
Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about
|
|
144
|
+
Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about. I did.
|
|
145
145
|
|
|
146
146
|
## Leaked something? The fix is rotate, not delete.
|
|
147
147
|
|
|
148
|
-
Deleting the file or the repo doesn't un-leak anything
|
|
148
|
+
Deleting the file or the repo doesn't un-leak anything: anyone who cloned already has the key. Rotate the credential first, then purge history with `git filter-repo`. The end of every dossier walks through both steps.
|
|
149
149
|
|
|
150
150
|
## Development
|
|
151
151
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.4.1"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import os
|
|
3
|
+
import subprocess
|
|
3
4
|
import sys
|
|
4
5
|
import tempfile
|
|
5
6
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -7,7 +8,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
7
8
|
from . import __version__, github
|
|
8
9
|
from .banner import print_banner
|
|
9
10
|
from .ghost import recover_force_pushed, recover_ghosts
|
|
10
|
-
from .metadata import MetadataReport, analyze_metadata
|
|
11
|
+
from .metadata import MetadataReport, _NOREPLY, analyze_metadata, attribute_emails
|
|
11
12
|
from .report import render_report
|
|
12
13
|
from .rules import Finding, scan_text
|
|
13
14
|
from .scanner import HOT_FILES, scan_repo
|
|
@@ -48,8 +49,19 @@ def run_local(path: str, name: str, out: str) -> None:
|
|
|
48
49
|
if not os.path.isdir(path):
|
|
49
50
|
die(f"no such directory: {path!r}",
|
|
50
51
|
"pass the path to a checkout on disk (or use --repo/--org for GitHub repos)")
|
|
52
|
+
real = os.path.realpath(path)
|
|
53
|
+
top = subprocess.run(["git", "-C", path, "rev-parse", "--show-toplevel"],
|
|
54
|
+
capture_output=True, text=True).stdout.strip()
|
|
55
|
+
if not top:
|
|
56
|
+
die(f"{path!r} is not a git repository",
|
|
57
|
+
"pass the root of a checkout (it must contain a .git), "
|
|
58
|
+
"or use --repo/--org for GitHub repos")
|
|
59
|
+
if os.path.realpath(top) != real:
|
|
60
|
+
die(f"{path!r} is inside a bigger repo rooted at {top!r}",
|
|
61
|
+
"git commands would silently scan the parent repo — pass the repo root itself")
|
|
51
62
|
info(f"scanning local repo: {name}")
|
|
52
63
|
findings, meta = _scan_one(path, name)
|
|
64
|
+
attribute_emails(meta)
|
|
53
65
|
card = compute_score(findings, meta)
|
|
54
66
|
_finish(name, card, findings, meta, 1, out)
|
|
55
67
|
|
|
@@ -68,6 +80,7 @@ def run_repo(url: str, out: str) -> None:
|
|
|
68
80
|
findings, meta = _scan_one(dest, repo.name)
|
|
69
81
|
for finding in findings:
|
|
70
82
|
finding.repo_url = repo.html_url
|
|
83
|
+
attribute_emails(meta, login=repo.full_name.split("/", 1)[0])
|
|
71
84
|
card = compute_score(findings, meta)
|
|
72
85
|
_finish(repo.full_name, card, findings, meta, 1, out)
|
|
73
86
|
|
|
@@ -234,8 +247,7 @@ def run_identity(identity: str, limit: int, out: str, jobs: int = 4,
|
|
|
234
247
|
all_findings += gist_findings
|
|
235
248
|
|
|
236
249
|
merged = merge_metas(metas)
|
|
237
|
-
|
|
238
|
-
merged = merge_metas(metas)
|
|
250
|
+
attribute_emails(merged, profile, login=identity)
|
|
239
251
|
card = compute_score(all_findings, merged)
|
|
240
252
|
_finish(identity, card, all_findings, merged, repos_scanned, out,
|
|
241
253
|
profile=profile)
|
|
@@ -300,6 +312,7 @@ def run_org(org: str, limit: int, out: str, jobs: int = 4,
|
|
|
300
312
|
metas += mm
|
|
301
313
|
|
|
302
314
|
merged = merge_metas(metas)
|
|
315
|
+
attribute_emails(merged)
|
|
303
316
|
card = compute_score(all_findings, merged)
|
|
304
317
|
_finish(f"org:{org}", card, all_findings, merged, len(repos), out)
|
|
305
318
|
|
|
@@ -308,8 +321,16 @@ def merge_metas(metas: list[MetadataReport]) -> MetadataReport:
|
|
|
308
321
|
"""Fold per-repo metadata reports into one."""
|
|
309
322
|
merged = MetadataReport()
|
|
310
323
|
for m in metas:
|
|
311
|
-
|
|
312
|
-
|
|
324
|
+
for e in m.emails + m.noreply_emails:
|
|
325
|
+
known = merged.emails + merged.noreply_emails
|
|
326
|
+
if e not in known:
|
|
327
|
+
(merged.noreply_emails if _NOREPLY.search(e) else merged.emails).append(e)
|
|
328
|
+
authors = m.email_authors.get(e)
|
|
329
|
+
if authors:
|
|
330
|
+
merged.email_authors.setdefault(e, set()).update(authors)
|
|
331
|
+
merged.email_repos[e] = max(merged.email_repos.get(e, 0),
|
|
332
|
+
m.email_repos.get(e, 0))
|
|
333
|
+
merged.other_emails += [e for e in m.other_emails if e not in merged.other_emails]
|
|
313
334
|
merged.commit_count += m.commit_count
|
|
314
335
|
merged.dominant_utc_offset = merged.dominant_utc_offset or m.dominant_utc_offset
|
|
315
336
|
merged.likely_active_hours = merged.likely_active_hours or m.likely_active_hours
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class MetadataReport:
|
|
9
|
+
emails: list[str] = field(default_factory=list)
|
|
10
|
+
noreply_emails: list[str] = field(default_factory=list)
|
|
11
|
+
other_emails: list[str] = field(default_factory=list) # co-authors', not the subject's
|
|
12
|
+
email_authors: dict[str, set[str]] = field(default_factory=dict)
|
|
13
|
+
email_repos: dict[str, int] = field(default_factory=dict)
|
|
14
|
+
dominant_utc_offset: str | None = None
|
|
15
|
+
likely_active_hours: str | None = None
|
|
16
|
+
commit_count: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_NOREPLY = re.compile(r"noreply|users\.noreply\.github\.com", re.I)
|
|
20
|
+
# GitHub privacy email embeds the login: "123456+octocat@users.noreply.github.com"
|
|
21
|
+
_NOREPLY_LOGIN = re.compile(r"^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$", re.I)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def analyze_metadata(root: str) -> MetadataReport:
|
|
25
|
+
out = subprocess.run(
|
|
26
|
+
["git", "-C", root, "log", "--all", "--format=%an|%ae|%ai"],
|
|
27
|
+
capture_output=True, text=True, errors="ignore",
|
|
28
|
+
).stdout
|
|
29
|
+
|
|
30
|
+
emails: Counter[str] = Counter()
|
|
31
|
+
offsets: Counter[str] = Counter()
|
|
32
|
+
hours: Counter[int] = Counter()
|
|
33
|
+
authors: dict[str, set[str]] = {}
|
|
34
|
+
count = 0
|
|
35
|
+
|
|
36
|
+
for line in out.splitlines():
|
|
37
|
+
if line.count("|") < 2:
|
|
38
|
+
continue
|
|
39
|
+
name, email, ts = line.split("|", 2)
|
|
40
|
+
count += 1
|
|
41
|
+
email = email.strip()
|
|
42
|
+
emails[email] += 1
|
|
43
|
+
authors.setdefault(email, set()).add(name.strip())
|
|
44
|
+
|
|
45
|
+
m = re.search(r"(\d{2}):\d{2}:\d{2}\s([+-]\d{4})", ts)
|
|
46
|
+
if m:
|
|
47
|
+
hours[int(m.group(1))] += 1
|
|
48
|
+
offsets[m.group(2)] += 1
|
|
49
|
+
|
|
50
|
+
report = MetadataReport(commit_count=count, email_authors=authors,
|
|
51
|
+
email_repos={e: 1 for e in emails})
|
|
52
|
+
for email, _ in emails.most_common():
|
|
53
|
+
(report.noreply_emails if _NOREPLY.search(email) else report.emails).append(email)
|
|
54
|
+
|
|
55
|
+
if offsets:
|
|
56
|
+
report.dominant_utc_offset = offsets.most_common(1)[0][0]
|
|
57
|
+
if hours:
|
|
58
|
+
top = [h for h, _ in hours.most_common(6)]
|
|
59
|
+
lo, hi = min(top), max(top)
|
|
60
|
+
report.likely_active_hours = f"{lo:02d}:00–{hi:02d}:00 (local)"
|
|
61
|
+
return report
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _looks_mine(email: str, meta: MetadataReport,
|
|
65
|
+
profile=None, login: str = "") -> bool:
|
|
66
|
+
"""Does this commit email actually belong to the scanned identity?
|
|
67
|
+
|
|
68
|
+
A repo owner's commits are not the only commits in their repos —
|
|
69
|
+
contributors push with their own identities. Flagging every author
|
|
70
|
+
address as the subject's email produced false attributions.
|
|
71
|
+
"""
|
|
72
|
+
e = email.casefold()
|
|
73
|
+
if profile and profile.email and e == profile.email.casefold():
|
|
74
|
+
return True
|
|
75
|
+
m = _NOREPLY_LOGIN.match(email)
|
|
76
|
+
if m and login and m.group(1).casefold() == login.casefold():
|
|
77
|
+
return True # github's privacy email literally contains the login
|
|
78
|
+
names = {n.casefold() for n in meta.email_authors.get(email, ())}
|
|
79
|
+
targets = set()
|
|
80
|
+
if profile:
|
|
81
|
+
targets |= {profile.name.casefold(), profile.login.casefold()}
|
|
82
|
+
if login:
|
|
83
|
+
targets.add(login.casefold())
|
|
84
|
+
if names & targets:
|
|
85
|
+
return True
|
|
86
|
+
# strong signal without any name match: the same address spans several
|
|
87
|
+
# of the subject's repos — one-off contributors rarely do that
|
|
88
|
+
return meta.email_repos.get(email, 0) >= 2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def attribute_emails(meta: MetadataReport,
|
|
92
|
+
profile=None, login: str = "") -> None:
|
|
93
|
+
"""Re-sort meta.emails into the subject's vs co-authors', in place.
|
|
94
|
+
|
|
95
|
+
With no identity signals (no profile, no login — e.g. --local) there is
|
|
96
|
+
nothing to attribute against, so everything stays as before.
|
|
97
|
+
"""
|
|
98
|
+
if not profile and not login:
|
|
99
|
+
return
|
|
100
|
+
mine: list[str] = []
|
|
101
|
+
others: list[str] = []
|
|
102
|
+
for email in meta.emails:
|
|
103
|
+
if _looks_mine(email, meta, profile, login):
|
|
104
|
+
mine.append(email)
|
|
105
|
+
else:
|
|
106
|
+
others.append(email)
|
|
107
|
+
meta.emails = mine
|
|
108
|
+
meta.other_emails = others
|
|
@@ -114,6 +114,11 @@ def render_report(identity: str, card: ScoreCard, findings: list[Finding],
|
|
|
114
114
|
</section>"""
|
|
115
115
|
|
|
116
116
|
emails = ", ".join(_esc(e) for e in meta.emails[:3]) or "none exposed"
|
|
117
|
+
if getattr(meta, "other_emails", None):
|
|
118
|
+
others = ", ".join(_esc(e) for e in meta.other_emails[:3])
|
|
119
|
+
more = f" (+{len(meta.other_emails) - 3} more)" if len(meta.other_emails) > 3 else ""
|
|
120
|
+
else:
|
|
121
|
+
others, more = "", ""
|
|
117
122
|
if profile is not None:
|
|
118
123
|
who = " · ".join(x for x in (profile.name, profile.company, profile.location) if x) or "—"
|
|
119
124
|
contact = " · ".join(x for x in (profile.email, profile.blog, profile.twitter) if x) or "—"
|
|
@@ -130,6 +135,7 @@ def render_report(identity: str, card: ScoreCard, findings: list[Finding],
|
|
|
130
135
|
<div class="block-head"><span class="eyebrow">What your commits reveal about you</span></div>
|
|
131
136
|
<div class="meta-grid">{profile_cells}
|
|
132
137
|
<div class="meta-cell"><div class="mk">Author email</div><div class="mv">{emails}</div></div>
|
|
138
|
+
{'<div class="meta-cell"><div class="mk">Co-author emails (not the subject)</div><div class="mv">' + others + more + '</div></div>' if others else ''}
|
|
133
139
|
<div class="meta-cell"><div class="mk">Timezone (inferred)</div><div class="mv">{_esc(meta.dominant_utc_offset or '—')}</div></div>
|
|
134
140
|
<div class="meta-cell"><div class="mk">Active hours (inferred)</div><div class="mv">{_esc(meta.likely_active_hours or '—')}</div></div>
|
|
135
141
|
<div class="meta-cell"><div class="mk">Commits analyzed</div><div class="mv">{meta.commit_count:,}</div></div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: gitghost-scanner
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: Find secrets in a GitHub account's public repos — including the ones still hiding in git history after being 'deleted'.
|
|
5
5
|
Author: cy3erm
|
|
6
6
|
License: MIT
|
|
@@ -29,22 +29,22 @@ License-File: LICENSE
|
|
|
29
29
|
|
|
30
30
|
**The secrets you deleted are still in git history.** gitghost finds them.
|
|
31
31
|
|
|
32
|
-
Finds live credentials in a GitHub account's public repos
|
|
32
|
+
Finds live credentials in a GitHub account's public repos, *and* the ones that were committed, "deleted," and are still sitting one `git log` away for anyone who clones.
|
|
33
33
|
|
|
34
|
-
I built this after noticing how often the real leak isn't in someone's current code
|
|
34
|
+
I built this after noticing how often the real leak isn't in someone's current code. It's in a commit from eight months ago that they thought they'd cleaned up. You paste an API key, catch it, delete the line, and move on. The latest version looks fine. But the old commit still has the key, and anyone who clones the repo gets it. Most scanners only look at your current files and miss this entirely. gitghost digs through history for exactly those, then rolls everything into a single **exposure score** so you can tell how bad things are at a glance.
|
|
35
35
|
|
|
36
36
|
## Highlights
|
|
37
37
|
|
|
38
|
-
- **Ghost recovery
|
|
39
|
-
- **Force-push recovery
|
|
40
|
-
- **Gist revision history
|
|
41
|
-
- **40+ credential formats
|
|
42
|
-
- **Generic high-entropy detection
|
|
43
|
-
- **Exposure score (0–100)
|
|
44
|
-
- **Metadata leaks
|
|
45
|
-
- **HTML dossier output
|
|
46
|
-
- **Interactive console** or one-shot CLI
|
|
47
|
-
- **Zero dependencies
|
|
38
|
+
- **Ghost recovery:** walks the full git history of every repo (including unreachable objects) to recover secrets that were removed from HEAD but never purged
|
|
39
|
+
- **Force-push recovery:** even if you rewrote history, your recent push events still list the orphaned commit SHAs; gitghost fetches those commits by SHA and scans what the force-push tried to bury
|
|
40
|
+
- **Gist revision history:** secrets you edited out of a gist stay readable at old revision URLs; gitghost scans every revision and flags the ones that were "deleted"
|
|
41
|
+
- **40+ credential formats:** AWS, GCP, GitHub, OpenAI, Anthropic, Stripe, Slack, Google, Azure, Telegram, Discord, Notion, Shopify, Vault, Postman, Square, and many more (full list below)
|
|
42
|
+
- **Generic high-entropy detection:** catches secrets that don't match any known format, with false-positive filtering tuned against real codebases
|
|
43
|
+
- **Exposure score (0–100):** one number that weights severity, age of the leak, cross-repo reuse, and whether a "deleted" secret is *still in use* somewhere
|
|
44
|
+
- **Metadata leaks:** the author email in your commits, plus inferred timezone and working hours; profile enrichment pulls public name, company, location, blog, and account age
|
|
45
|
+
- **HTML dossier output:** every finding links to the exact file+line or commit; findings are shown as irreversible fingerprints so reports are safe to share
|
|
46
|
+
- **Interactive console** or one-shot CLI, your choice
|
|
47
|
+
- **Zero dependencies:** pure Python 3.10+ standard library
|
|
48
48
|
|
|
49
49
|
## Install
|
|
50
50
|
|
|
@@ -63,7 +63,7 @@ cd gitghost
|
|
|
63
63
|
python3 -m gitghost
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
> Set `GITHUB_TOKEN` (any token works
|
|
66
|
+
> Set `GITHUB_TOKEN` (any token works, it doesn't need scopes) to raise GitHub's 60-requests/hour anonymous limit before scanning more than an account or two.
|
|
67
67
|
|
|
68
68
|
## Usage
|
|
69
69
|
|
|
@@ -78,7 +78,7 @@ $ gitghost
|
|
|
78
78
|
/ /_/ / / /_/ /_/ / / / /_/ (__ ) /_
|
|
79
79
|
\__, /_/\__/\__, /_/ /_/\____/____/\__/
|
|
80
80
|
/____/ /____/
|
|
81
|
-
the secrets you deleted are still in git history v1.
|
|
81
|
+
the secrets you deleted are still in git history v1.4.1
|
|
82
82
|
by cy3erm · github.com/cy3erm/gitghost
|
|
83
83
|
|
|
84
84
|
1) Scan a GitHub user public repos + gists
|
|
@@ -117,7 +117,7 @@ gitghost --jobs 8 # parallel clones/scans (default 4)
|
|
|
117
117
|
gitghost --debug # full traceback if something breaks
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
Try the demo first if you'd rather not point this at a real person
|
|
120
|
+
Try the demo first if you'd rather not point this at a real person. It builds a tiny repo with fake credentials, including one that gets "deleted" a few commits in:
|
|
121
121
|
|
|
122
122
|
```bash
|
|
123
123
|
bash demo/make_demo_repo.sh /tmp/demo
|
|
@@ -140,32 +140,32 @@ OpenAI (classic + project keys) · Anthropic · Groq · Perplexity · xAI · Hug
|
|
|
140
140
|
**Communication & SaaS**
|
|
141
141
|
Slack (tokens, webhooks, app tokens) · Discord (webhooks, bot tokens) · Telegram bot tokens · Twilio · SendGrid · Mailgun · Notion integration secrets · Shopify · Square · Razorpay · DigitalOcean · Doppler · Firebase Cloud Messaging server keys · Google API keys and OAuth refresh tokens
|
|
142
142
|
|
|
143
|
-
**Everything else
|
|
143
|
+
**Everything else**: a generic high-entropy scanner looks for values assigned to suspiciously-named variables (`api_key = "..."`) even when no provider pattern matches, filtered through placeholder/entropy checks to keep false positives near zero.
|
|
144
144
|
|
|
145
|
-
Missing a provider you care about? Rules are four lines each in [`gitghost/rules.py`](gitghost/rules.py)
|
|
145
|
+
Missing a provider you care about? Rules are four lines each in [`gitghost/rules.py`](gitghost/rules.py): name, regex, severity, remediation advice. PRs welcome.
|
|
146
146
|
|
|
147
147
|
## The score
|
|
148
148
|
|
|
149
149
|
One number, 0–100, higher is worse:
|
|
150
150
|
|
|
151
|
-
- **Severity-weighted findings
|
|
152
|
-
- **Ghost weighting
|
|
153
|
-
- **Reuse correlation
|
|
154
|
-
- **Metadata exposure
|
|
151
|
+
- **Severity-weighted findings:** a live AWS secret key counts far more than a webhook URL
|
|
152
|
+
- **Ghost weighting:** recovered-from-history secrets scale with age: something exposed for 2 years hurts more than something from last week
|
|
153
|
+
- **Reuse correlation:** the same secret across multiple repos, and the nastiest case: a "deleted" secret whose fingerprint still matches something *live*
|
|
154
|
+
- **Metadata exposure:** real email in commits, inferable timezone
|
|
155
155
|
|
|
156
|
-
A single live cloud key is enough to put an account in the red by itself. Run it, clean up, run again
|
|
156
|
+
A single live cloud key is enough to put an account in the red by itself. Run it, clean up, run again, and the number tells you if it worked.
|
|
157
157
|
|
|
158
158
|
## Where it draws the line (legal & ethics)
|
|
159
159
|
|
|
160
|
-
It only ever reads public repositories
|
|
160
|
+
It only ever reads public repositories (things the account already chose to publish), and it's detection-only. It'll tell you a string *looks like* a credential and leave it at that. It won't try the key against the actual service to see if it still works, because quietly logging into someone else's account isn't the tool's job, and honestly it's not yours either. Use it on your own identity, or one you're authorized to assess.
|
|
161
161
|
|
|
162
|
-
Findings are shown as non-reversible fingerprints (a safe prefix, a length, a partial hash)
|
|
162
|
+
Findings are shown as non-reversible fingerprints (a safe prefix, a length, a partial hash), never recoverable key material, so reports are safe to share. gitghost does not retain raw secrets past the match.
|
|
163
163
|
|
|
164
|
-
Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about
|
|
164
|
+
Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about. I did.
|
|
165
165
|
|
|
166
166
|
## Leaked something? The fix is rotate, not delete.
|
|
167
167
|
|
|
168
|
-
Deleting the file or the repo doesn't un-leak anything
|
|
168
|
+
Deleting the file or the repo doesn't un-leak anything: anyone who cloned already has the key. Rotate the credential first, then purge history with `git filter-repo`. The end of every dossier walks through both steps.
|
|
169
169
|
|
|
170
170
|
## Development
|
|
171
171
|
|
|
@@ -13,6 +13,7 @@ import subprocess
|
|
|
13
13
|
import tempfile
|
|
14
14
|
import time
|
|
15
15
|
from pathlib import Path
|
|
16
|
+
from types import SimpleNamespace
|
|
16
17
|
|
|
17
18
|
from gitghost.rules import scan_text, _looks_like_secret_value
|
|
18
19
|
from gitghost.ghost import recover_ghosts
|
|
@@ -205,6 +206,57 @@ def test_parse_push_commits_extracts_shas_per_repo():
|
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
|
|
209
|
+
# ---- email attribution: co-author emails must not be pinned on the subject ----
|
|
210
|
+
|
|
211
|
+
def test_coauthor_email_not_attributed_to_subject():
|
|
212
|
+
from gitghost.metadata import MetadataReport, attribute_emails
|
|
213
|
+
meta = MetadataReport(
|
|
214
|
+
emails=["contributor@example.com"],
|
|
215
|
+
noreply_emails=["123456+owner@users.noreply.github.com"],
|
|
216
|
+
email_authors={
|
|
217
|
+
"contributor@example.com": {"Some Contributor"},
|
|
218
|
+
"123456+owner@users.noreply.github.com": {"Owner Name"},
|
|
219
|
+
},
|
|
220
|
+
email_repos={"contributor@example.com": 1,
|
|
221
|
+
"123456+owner@users.noreply.github.com": 2},
|
|
222
|
+
)
|
|
223
|
+
profile = SimpleNamespace(login="owner", name="Owner Name", email="")
|
|
224
|
+
attribute_emails(meta, profile, login="owner")
|
|
225
|
+
# the contributor's address must NOT be reported as the subject's exposure
|
|
226
|
+
assert meta.emails == []
|
|
227
|
+
assert meta.other_emails == ["contributor@example.com"]
|
|
228
|
+
# ...and it must not fire the score driver either
|
|
229
|
+
from gitghost.score import compute_score
|
|
230
|
+
card = compute_score([], meta)
|
|
231
|
+
assert not any("real author email" in d for d in card.drivers)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_subject_email_attributed_via_profile_and_name_match():
|
|
235
|
+
from gitghost.metadata import MetadataReport, attribute_emails
|
|
236
|
+
meta = MetadataReport(
|
|
237
|
+
emails=["real.person@gmail.com"],
|
|
238
|
+
email_authors={"real.person@gmail.com": {"Real P. Erson"}},
|
|
239
|
+
email_repos={"real.person@gmail.com": 1},
|
|
240
|
+
)
|
|
241
|
+
attribute_emails(meta, SimpleNamespace(login="rperson", name="Real P. Erson",
|
|
242
|
+
email=""), login="rperson")
|
|
243
|
+
assert meta.emails == ["real.person@gmail.com"]
|
|
244
|
+
assert meta.other_emails == []
|
|
245
|
+
|
|
246
|
+
# no signals at all, single repo: honest fallback keeps it listed
|
|
247
|
+
meta2 = MetadataReport(emails=["x@y.z"], email_authors={"x@y.z": {"X"}},
|
|
248
|
+
email_repos={"x@y.z": 1})
|
|
249
|
+
attribute_emails(meta2)
|
|
250
|
+
assert meta2.emails == ["x@y.z"]
|
|
251
|
+
|
|
252
|
+
# spanning two repos is ownership evidence even without names
|
|
253
|
+
meta3 = MetadataReport(emails=["spanning@gmail.com"],
|
|
254
|
+
email_authors={"spanning@gmail.com": {"???"}},
|
|
255
|
+
email_repos={"spanning@gmail.com": 2})
|
|
256
|
+
attribute_emails(meta3)
|
|
257
|
+
assert meta3.emails == ["spanning@gmail.com"]
|
|
258
|
+
|
|
259
|
+
|
|
208
260
|
def test_ghost_carries_commit_context():
|
|
209
261
|
with tempfile.TemporaryDirectory() as d:
|
|
210
262
|
_run(["git", "init", "-q"], d)
|
|
@@ -280,3 +332,17 @@ def test_score_within_bounds_and_empty_is_minimal():
|
|
|
280
332
|
def test_score_handles_none_meta():
|
|
281
333
|
card = compute_score([], None) # the all-clones-failed path
|
|
282
334
|
assert 0 <= card.score <= 100
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_local_scan_rejects_subdir_of_repo():
|
|
338
|
+
"""A subdirectory of a checkout must not silently scan the parent repo."""
|
|
339
|
+
import gitghost.cli as cli
|
|
340
|
+
with tempfile.TemporaryDirectory() as d:
|
|
341
|
+
_run(["git", "init", "-q"], d)
|
|
342
|
+
sub = Path(d) / "demo"
|
|
343
|
+
sub.mkdir()
|
|
344
|
+
try:
|
|
345
|
+
cli.run_local(str(sub), "demo", "/dev/null")
|
|
346
|
+
raise AssertionError("expected SystemExit")
|
|
347
|
+
except SystemExit:
|
|
348
|
+
pass
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.4.0"
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import subprocess
|
|
3
|
-
from collections import Counter
|
|
4
|
-
from dataclasses import dataclass, field
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@dataclass
|
|
8
|
-
class MetadataReport:
|
|
9
|
-
emails: list[str] = field(default_factory=list)
|
|
10
|
-
noreply_emails: list[str] = field(default_factory=list)
|
|
11
|
-
dominant_utc_offset: str | None = None
|
|
12
|
-
likely_active_hours: str | None = None
|
|
13
|
-
commit_count: int = 0
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
_NOREPLY = re.compile(r"noreply|users\.noreply\.github\.com", re.I)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def analyze_metadata(root: str) -> MetadataReport:
|
|
20
|
-
out = subprocess.run(
|
|
21
|
-
["git", "-C", root, "log", "--all", "--format=%ae|%ai"],
|
|
22
|
-
capture_output=True, text=True, errors="ignore",
|
|
23
|
-
).stdout
|
|
24
|
-
|
|
25
|
-
emails: Counter[str] = Counter()
|
|
26
|
-
offsets: Counter[str] = Counter()
|
|
27
|
-
hours: Counter[int] = Counter()
|
|
28
|
-
count = 0
|
|
29
|
-
|
|
30
|
-
for line in out.splitlines():
|
|
31
|
-
if "|" not in line:
|
|
32
|
-
continue
|
|
33
|
-
email, ts = line.split("|", 1)
|
|
34
|
-
count += 1
|
|
35
|
-
emails[email.strip()] += 1
|
|
36
|
-
|
|
37
|
-
m = re.search(r"(\d{2}):\d{2}:\d{2}\s([+-]\d{4})", ts)
|
|
38
|
-
if m:
|
|
39
|
-
hours[int(m.group(1))] += 1
|
|
40
|
-
offsets[m.group(2)] += 1
|
|
41
|
-
|
|
42
|
-
report = MetadataReport(commit_count=count)
|
|
43
|
-
for email, _ in emails.most_common():
|
|
44
|
-
(report.noreply_emails if _NOREPLY.search(email) else report.emails).append(email)
|
|
45
|
-
|
|
46
|
-
if offsets:
|
|
47
|
-
report.dominant_utc_offset = offsets.most_common(1)[0][0]
|
|
48
|
-
if hours:
|
|
49
|
-
top = [h for h, _ in hours.most_common(6)]
|
|
50
|
-
lo, hi = min(top), max(top)
|
|
51
|
-
report.likely_active_hours = f"{lo:02d}:00–{hi:02d}:00 (local)"
|
|
52
|
-
return report
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|