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.
Files changed (26) hide show
  1. {gitghost_scanner-1.4.0/gitghost_scanner.egg-info → gitghost_scanner-1.4.1}/PKG-INFO +27 -27
  2. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/README.md +26 -26
  3. gitghost_scanner-1.4.1/gitghost/__init__.py +1 -0
  4. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/cli.py +26 -5
  5. gitghost_scanner-1.4.1/gitghost/metadata.py +108 -0
  6. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/report.py +6 -0
  7. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1/gitghost_scanner.egg-info}/PKG-INFO +27 -27
  8. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/tests/test_detection.py +66 -0
  9. gitghost_scanner-1.4.0/gitghost/__init__.py +0 -1
  10. gitghost_scanner-1.4.0/gitghost/metadata.py +0 -52
  11. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/LICENSE +0 -0
  12. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/__main__.py +0 -0
  13. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/banner.py +0 -0
  14. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/console.py +0 -0
  15. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/ghost.py +0 -0
  16. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/github.py +0 -0
  17. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/rules.py +0 -0
  18. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/scanner.py +0 -0
  19. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/score.py +0 -0
  20. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost/ui.py +0 -0
  21. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/SOURCES.txt +0 -0
  22. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/dependency_links.txt +0 -0
  23. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/entry_points.txt +0 -0
  24. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/gitghost_scanner.egg-info/top_level.txt +0 -0
  25. {gitghost_scanner-1.4.0 → gitghost_scanner-1.4.1}/pyproject.toml +0 -0
  26. {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.0
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 — *and* the ones that were committed, "deleted," and are still sitting one `git log` away for anyone who clones.
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 — 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.
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** — 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
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 — it doesn't need scopes) to raise GitHub's 60-requests/hour anonymous limit before scanning more than an account or two.
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.3.0
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 — it builds a tiny repo with fake credentials, including one that gets "deleted" a few commits in:
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** — 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.
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) — name, regex, severity, remediation advice. PRs welcome.
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** — 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
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 — the number tells you if it worked.
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 — 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.
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) — never recoverable key material — so reports are safe to share. gitghost does not retain raw secrets past the match.
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 — I did.
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 — 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.
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 — *and* the ones that were committed, "deleted," and are still sitting one `git log` away for anyone who clones.
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 — 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.
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** — 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
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 — it doesn't need scopes) to raise GitHub's 60-requests/hour anonymous limit before scanning more than an account or two.
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.3.0
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 — it builds a tiny repo with fake credentials, including one that gets "deleted" a few commits in:
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** — 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.
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) — name, regex, severity, remediation advice. PRs welcome.
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** — 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
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 — the number tells you if it worked.
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 — 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.
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) — never recoverable key material — so reports are safe to share. gitghost does not retain raw secrets past the match.
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 — I did.
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 — 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.
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
- card = compute_score(all_findings, merged)
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
- merged.emails += [e for e in m.emails if e not in merged.emails]
312
- merged.noreply_emails += [e for e in m.noreply_emails if e not in merged.noreply_emails]
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.0
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 — *and* the ones that were committed, "deleted," and are still sitting one `git log` away for anyone who clones.
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 — 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.
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** — 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
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 — it doesn't need scopes) to raise GitHub's 60-requests/hour anonymous limit before scanning more than an account or two.
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.3.0
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 — it builds a tiny repo with fake credentials, including one that gets "deleted" a few commits in:
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** — 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.
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) — name, regex, severity, remediation advice. PRs welcome.
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** — 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
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 — the number tells you if it worked.
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 — 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.
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) — never recoverable key material — so reports are safe to share. gitghost does not retain raw secrets past the match.
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 — I did.
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 — 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.
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