gitghost-scanner 1.2.0__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.2.0/LICENSE +21 -0
- gitghost_scanner-1.2.0/PKG-INFO +99 -0
- gitghost_scanner-1.2.0/README.md +79 -0
- gitghost_scanner-1.2.0/gitghost/__init__.py +1 -0
- gitghost_scanner-1.2.0/gitghost/__main__.py +2 -0
- gitghost_scanner-1.2.0/gitghost/banner.py +38 -0
- gitghost_scanner-1.2.0/gitghost/cli.py +123 -0
- gitghost_scanner-1.2.0/gitghost/ghost.py +81 -0
- gitghost_scanner-1.2.0/gitghost/github.py +80 -0
- gitghost_scanner-1.2.0/gitghost/metadata.py +52 -0
- gitghost_scanner-1.2.0/gitghost/report.py +272 -0
- gitghost_scanner-1.2.0/gitghost/rules.py +178 -0
- gitghost_scanner-1.2.0/gitghost/scanner.py +53 -0
- gitghost_scanner-1.2.0/gitghost/score.py +86 -0
- gitghost_scanner-1.2.0/gitghost_scanner.egg-info/PKG-INFO +99 -0
- gitghost_scanner-1.2.0/gitghost_scanner.egg-info/SOURCES.txt +20 -0
- gitghost_scanner-1.2.0/gitghost_scanner.egg-info/dependency_links.txt +1 -0
- gitghost_scanner-1.2.0/gitghost_scanner.egg-info/entry_points.txt +2 -0
- gitghost_scanner-1.2.0/gitghost_scanner.egg-info/top_level.txt +1 -0
- gitghost_scanner-1.2.0/pyproject.toml +36 -0
- gitghost_scanner-1.2.0/setup.cfg +4 -0
- gitghost_scanner-1.2.0/tests/test_detection.py +116 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CyberM
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: gitghost-scanner
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Find secrets in a GitHub account's public repos — including the ones still hiding in git history after being 'deleted'.
|
|
5
|
+
Author: cy3erm
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cy3erm/gitghost
|
|
8
|
+
Project-URL: Repository, https://github.com/cy3erm/gitghost
|
|
9
|
+
Project-URL: Issues, https://github.com/cy3erm/gitghost/issues
|
|
10
|
+
Keywords: security,secrets-detection,git,osint,devsecops,bug-bounty
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
|
|
21
|
+
# gitghost
|
|
22
|
+

|
|
23
|
+
Finds secrets in a GitHub account's public repos — including the ones that got committed, then "deleted," but are still sitting in the git history where anyone can read them.
|
|
24
|
+
|
|
25
|
+
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 `git log` hands it to anyone who clones the repo. Most scanners only look at your current files and miss this entirely. gitghost goes digging through history for exactly those, and then rolls everything up into a single exposure score so you can actually tell how bad things are at a glance.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
$ gitghost cy3erm
|
|
29
|
+
|
|
30
|
+
EXPOSURE SCORE: 96/100 [CRITICAL] grade F
|
|
31
|
+
· 7 live secrets in current code
|
|
32
|
+
· 3 "deleted" secrets still recoverable from history
|
|
33
|
+
· author email exposed in commit metadata
|
|
34
|
+
· timezone inferable from commit times (UTC+0530)
|
|
35
|
+
|
|
36
|
+
dossier written to gitghost-dossier.html
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
You get an HTML report you can open in a browser, not just a wall of terminal text. Every finding links straight to the spot — the exact file and line for live secrets, or the commit for the ones recovered from history — so you're not hunting for where the key actually is. And the report ends with a short how-to-fix guide, because the instinct when you see a leaked key (delete the file, nuke the repo) doesn't actually fix anything: the key's already been cloned. The real fix is to rotate it, then purge it from history, and the report walks you through both.
|
|
40
|
+
|
|
41
|
+
## Running it
|
|
42
|
+
|
|
43
|
+
You'll need Python 3.10+ and git. There are no runtime dependencies — it's all standard library.
|
|
44
|
+
|
|
45
|
+
Install it as a command:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pipx install git+https://github.com/cy3erm/gitghost
|
|
49
|
+
gitghost <username>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or just clone and run it without installing:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/cy3erm/gitghost
|
|
56
|
+
cd gitghost
|
|
57
|
+
python3 -m gitghost <username>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If you'd rather not point it at a real person first, there's a demo. It builds a little repo with fake credentials — including one that gets "deleted" a few commits in — so you can watch the history recovery dig it back out:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bash demo/make_demo_repo.sh /tmp/demo
|
|
64
|
+
python3 -m gitghost --local /tmp/demo --name demo
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A few other flags:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python3 -m gitghost <username> --limit 50 # cap how many repos
|
|
71
|
+
python3 -m gitghost <username> --out report.html # where to write the report
|
|
72
|
+
python3 -m gitghost --local ./some-repo # scan a checkout you have locally
|
|
73
|
+
python3 -m gitghost --repo owner/name # scan just one repo (URL or owner/name)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Scanning more than a couple of accounts? Set `GITHUB_TOKEN` (any token works, it doesn't need any scopes) so you don't run into GitHub's 60-requests-an-hour limit for anonymous calls.
|
|
77
|
+
|
|
78
|
+
## What it looks for
|
|
79
|
+
|
|
80
|
+
- Cloud and service keys — AWS, GCP, Stripe, GitHub, OpenAI, Slack, private keys, database URLs
|
|
81
|
+
- The same keys pulled back out of history after they were removed from the current code
|
|
82
|
+
- Metadata you might not realize you're sharing — the email in your commits, and a rough guess at your timezone and working hours from commit timestamps
|
|
83
|
+
- Committed `.env` files, internal hostnames, hardcoded private IPs
|
|
84
|
+
|
|
85
|
+
The score is deliberately one number so you can watch it move — run it, clean things up, run it again. A single live cloud key is enough to put an account in the red by itself; a pile of smaller stuff pushes it up from there.
|
|
86
|
+
|
|
87
|
+
## Where it draws the line
|
|
88
|
+
|
|
89
|
+
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. Findings in the report are shown as fingerprints, not the raw values, so you can share a report without leaking anything.
|
|
90
|
+
|
|
91
|
+
Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about — I did. (The first time I pushed this repo, GitHub's own secret scanner blocked me because the demo's fake keys looked real enough to trip it. Which is about the best proof of the premise I could ask for.)
|
|
92
|
+
|
|
93
|
+
## Adding your own detections
|
|
94
|
+
|
|
95
|
+
The patterns live in `gitghost/rules.py`. Each one is just a name, a regex, a severity, and a line of advice for fixing it — easy to add. If you write a good one, send a PR.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# gitghost
|
|
2
|
+

|
|
3
|
+
Finds secrets in a GitHub account's public repos — including the ones that got committed, then "deleted," but are still sitting in the git history where anyone can read them.
|
|
4
|
+
|
|
5
|
+
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 `git log` hands it to anyone who clones the repo. Most scanners only look at your current files and miss this entirely. gitghost goes digging through history for exactly those, and then rolls everything up into a single exposure score so you can actually tell how bad things are at a glance.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
$ gitghost cy3erm
|
|
9
|
+
|
|
10
|
+
EXPOSURE SCORE: 96/100 [CRITICAL] grade F
|
|
11
|
+
· 7 live secrets in current code
|
|
12
|
+
· 3 "deleted" secrets still recoverable from history
|
|
13
|
+
· author email exposed in commit metadata
|
|
14
|
+
· timezone inferable from commit times (UTC+0530)
|
|
15
|
+
|
|
16
|
+
dossier written to gitghost-dossier.html
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
You get an HTML report you can open in a browser, not just a wall of terminal text. Every finding links straight to the spot — the exact file and line for live secrets, or the commit for the ones recovered from history — so you're not hunting for where the key actually is. And the report ends with a short how-to-fix guide, because the instinct when you see a leaked key (delete the file, nuke the repo) doesn't actually fix anything: the key's already been cloned. The real fix is to rotate it, then purge it from history, and the report walks you through both.
|
|
20
|
+
|
|
21
|
+
## Running it
|
|
22
|
+
|
|
23
|
+
You'll need Python 3.10+ and git. There are no runtime dependencies — it's all standard library.
|
|
24
|
+
|
|
25
|
+
Install it as a command:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pipx install git+https://github.com/cy3erm/gitghost
|
|
29
|
+
gitghost <username>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or just clone and run it without installing:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/cy3erm/gitghost
|
|
36
|
+
cd gitghost
|
|
37
|
+
python3 -m gitghost <username>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If you'd rather not point it at a real person first, there's a demo. It builds a little repo with fake credentials — including one that gets "deleted" a few commits in — so you can watch the history recovery dig it back out:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bash demo/make_demo_repo.sh /tmp/demo
|
|
44
|
+
python3 -m gitghost --local /tmp/demo --name demo
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A few other flags:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python3 -m gitghost <username> --limit 50 # cap how many repos
|
|
51
|
+
python3 -m gitghost <username> --out report.html # where to write the report
|
|
52
|
+
python3 -m gitghost --local ./some-repo # scan a checkout you have locally
|
|
53
|
+
python3 -m gitghost --repo owner/name # scan just one repo (URL or owner/name)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Scanning more than a couple of accounts? Set `GITHUB_TOKEN` (any token works, it doesn't need any scopes) so you don't run into GitHub's 60-requests-an-hour limit for anonymous calls.
|
|
57
|
+
|
|
58
|
+
## What it looks for
|
|
59
|
+
|
|
60
|
+
- Cloud and service keys — AWS, GCP, Stripe, GitHub, OpenAI, Slack, private keys, database URLs
|
|
61
|
+
- The same keys pulled back out of history after they were removed from the current code
|
|
62
|
+
- Metadata you might not realize you're sharing — the email in your commits, and a rough guess at your timezone and working hours from commit timestamps
|
|
63
|
+
- Committed `.env` files, internal hostnames, hardcoded private IPs
|
|
64
|
+
|
|
65
|
+
The score is deliberately one number so you can watch it move — run it, clean things up, run it again. A single live cloud key is enough to put an account in the red by itself; a pile of smaller stuff pushes it up from there.
|
|
66
|
+
|
|
67
|
+
## Where it draws the line
|
|
68
|
+
|
|
69
|
+
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. Findings in the report are shown as fingerprints, not the raw values, so you can share a report without leaking anything.
|
|
70
|
+
|
|
71
|
+
Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about — I did. (The first time I pushed this repo, GitHub's own secret scanner blocked me because the demo's fake keys looked real enough to trip it. Which is about the best proof of the premise I could ask for.)
|
|
72
|
+
|
|
73
|
+
## Adding your own detections
|
|
74
|
+
|
|
75
|
+
The patterns live in `gitghost/rules.py`. Each one is just a name, a regex, a severity, and a line of advice for fixing it — easy to add. If you write a good one, send a PR.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.0"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from . import __version__
|
|
4
|
+
|
|
5
|
+
_ART = r"""
|
|
6
|
+
____ _(_) /_____ _/ /_ ____ _____/ /_
|
|
7
|
+
/ __ `/ / __/ __ `/ __ \/ __ \/ ___/ __/
|
|
8
|
+
/ /_/ / / /_/ /_/ / / / / /_/ (__ ) /_
|
|
9
|
+
\__, /_/\__/\__, /_/ /_/\____/____/\__/
|
|
10
|
+
/____/ /____/
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
_GHOST = r""" .-.
|
|
14
|
+
(o o)
|
|
15
|
+
| O |
|
|
16
|
+
\_/
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def banner():
|
|
21
|
+
if not sys.stdout.isatty():
|
|
22
|
+
return ""
|
|
23
|
+
orange, dim, ghost_c, reset = "\033[38;5;208m", "\033[2m", "\033[38;5;250m", "\033[0m"
|
|
24
|
+
art = _ART.strip("\n").splitlines()
|
|
25
|
+
ghost = _GHOST.strip("\n").splitlines()
|
|
26
|
+
width = max(len(l) for l in art) + 4
|
|
27
|
+
out = []
|
|
28
|
+
for i, line in enumerate(art):
|
|
29
|
+
g = ghost[i] if i < len(ghost) else ""
|
|
30
|
+
out.append(orange + line.ljust(width) + reset + ghost_c + g + reset)
|
|
31
|
+
tag = f"{dim} the secrets you deleted are still in git history{reset} {orange}v{__version__}{reset}"
|
|
32
|
+
return "\n".join(out) + "\n" + tag + "\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_banner():
|
|
36
|
+
b = banner()
|
|
37
|
+
if b:
|
|
38
|
+
print(b)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
from . import github
|
|
6
|
+
from .ghost import recover_ghosts
|
|
7
|
+
from .metadata import analyze_metadata
|
|
8
|
+
from .report import render_report
|
|
9
|
+
from .rules import Finding
|
|
10
|
+
from .scanner import scan_repo
|
|
11
|
+
from .score import compute_score
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _scan_one(root: str, name: str) -> tuple[list[Finding], object]:
|
|
15
|
+
findings = list(scan_repo(root, name).findings)
|
|
16
|
+
findings += recover_ghosts(root, name)
|
|
17
|
+
meta = analyze_metadata(root)
|
|
18
|
+
return findings, meta
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_local(path: str, name: str, out: str) -> None:
|
|
22
|
+
print(f"[*] scanning local repo: {name}")
|
|
23
|
+
findings, meta = _scan_one(path, name)
|
|
24
|
+
card = compute_score(findings, meta)
|
|
25
|
+
_emit(name, card, findings, meta, 1, out)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_repo(url: str, out: str) -> None:
|
|
29
|
+
try:
|
|
30
|
+
repo = github.repo_from_url(url)
|
|
31
|
+
except ValueError as e:
|
|
32
|
+
sys.exit(f"[!] {e}")
|
|
33
|
+
print(f"[*] scanning single repo: {repo.full_name}")
|
|
34
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
35
|
+
dest = github.clone(repo, tmp)
|
|
36
|
+
if not dest:
|
|
37
|
+
sys.exit(f"[!] could not clone {repo.clone_url} (private, renamed, or network issue)")
|
|
38
|
+
findings, meta = _scan_one(dest, repo.name)
|
|
39
|
+
for finding in findings:
|
|
40
|
+
finding.repo_url = repo.html_url
|
|
41
|
+
card = compute_score(findings, meta)
|
|
42
|
+
_emit(repo.full_name, card, findings, meta, 1, out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_identity(identity: str, limit: int, out: str) -> None:
|
|
46
|
+
print(f"[*] enumerating public repos for @{identity} ...")
|
|
47
|
+
try:
|
|
48
|
+
repos = github.list_public_repos(identity, limit=limit)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
sys.exit(f"[!] could not reach GitHub API: {e}\n (set GITHUB_TOKEN to raise rate limits)")
|
|
51
|
+
if not repos:
|
|
52
|
+
sys.exit(f"[!] no public repos found for @{identity}")
|
|
53
|
+
print(f"[*] {len(repos)} repos. cloning + scanning (history included for ghost recovery)...")
|
|
54
|
+
|
|
55
|
+
all_findings: list[Finding] = []
|
|
56
|
+
merged_meta = None
|
|
57
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
58
|
+
for r in repos:
|
|
59
|
+
dest = github.clone(r, tmp)
|
|
60
|
+
if not dest:
|
|
61
|
+
print(f" - skip {r.name} (clone failed)")
|
|
62
|
+
continue
|
|
63
|
+
f, m = _scan_one(dest, r.name)
|
|
64
|
+
for finding in f:
|
|
65
|
+
finding.repo_url = r.html_url
|
|
66
|
+
all_findings += f
|
|
67
|
+
merged_meta = _merge_meta(merged_meta, m)
|
|
68
|
+
tag = f"{len([x for x in f if x.kind=='secret' and not x.is_ghost])} live / {len([x for x in f if x.is_ghost])} ghost"
|
|
69
|
+
print(f" - {r.name:<32} {tag}")
|
|
70
|
+
|
|
71
|
+
card = compute_score(all_findings, merged_meta)
|
|
72
|
+
_emit(identity, card, all_findings, merged_meta, len(repos), out)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _merge_meta(a, b):
|
|
76
|
+
if a is None:
|
|
77
|
+
return b
|
|
78
|
+
for e in b.emails:
|
|
79
|
+
if e not in a.emails:
|
|
80
|
+
a.emails.append(e)
|
|
81
|
+
a.commit_count += b.commit_count
|
|
82
|
+
a.dominant_utc_offset = a.dominant_utc_offset or b.dominant_utc_offset
|
|
83
|
+
a.likely_active_hours = a.likely_active_hours or b.likely_active_hours
|
|
84
|
+
return a
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _emit(identity, card, findings, meta, repos_scanned, out):
|
|
88
|
+
html = render_report(identity, card, findings, meta, repos_scanned)
|
|
89
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
90
|
+
f.write(html)
|
|
91
|
+
print(f"\n[=] EXPOSURE SCORE: {card.score}/100 [{card.band}] grade {card.grade}")
|
|
92
|
+
for d in card.drivers:
|
|
93
|
+
print(f" · {d}")
|
|
94
|
+
print(f"[=] dossier written to: {out}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main() -> None:
|
|
98
|
+
p = argparse.ArgumentParser(prog="gitghost", description="GitHub exposure dossier (detection-only).")
|
|
99
|
+
from . import __version__
|
|
100
|
+
p.add_argument("--version", action="version", version=f"gitghost {__version__}")
|
|
101
|
+
p.add_argument("identity", nargs="?", help="GitHub username or org")
|
|
102
|
+
p.add_argument("--repo", help="scan a single repo by URL or owner/name")
|
|
103
|
+
p.add_argument("--local", help="scan a repo already on disk instead of GitHub")
|
|
104
|
+
p.add_argument("--name", default="local-repo", help="label for --local scans")
|
|
105
|
+
p.add_argument("--limit", type=int, default=30, help="max repos to scan")
|
|
106
|
+
p.add_argument("--out", default="gitghost-dossier.html", help="output HTML path")
|
|
107
|
+
args = p.parse_args()
|
|
108
|
+
|
|
109
|
+
from .banner import print_banner
|
|
110
|
+
print_banner()
|
|
111
|
+
|
|
112
|
+
if args.local:
|
|
113
|
+
run_local(args.local, args.name, args.out)
|
|
114
|
+
elif args.repo:
|
|
115
|
+
run_repo(args.repo, args.out)
|
|
116
|
+
elif args.identity:
|
|
117
|
+
run_identity(args.identity, args.limit, args.out)
|
|
118
|
+
else:
|
|
119
|
+
p.error("provide a GitHub username, --repo <url>, or --local <path>")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from .rules import Finding, scan_text
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _git(root: str, *args: str) -> str:
|
|
9
|
+
return subprocess.run(
|
|
10
|
+
["git", "-C", root, *args],
|
|
11
|
+
capture_output=True, text=True, errors="ignore",
|
|
12
|
+
).stdout
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _head_blobs(root: str) -> set[str]:
|
|
16
|
+
out = _git(root, "ls-tree", "-r", "HEAD")
|
|
17
|
+
blobs = set()
|
|
18
|
+
for line in out.splitlines():
|
|
19
|
+
parts = line.split()
|
|
20
|
+
if len(parts) >= 3 and parts[1] == "blob":
|
|
21
|
+
blobs.add(parts[2])
|
|
22
|
+
return blobs
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _all_historical_blobs(root: str) -> list[tuple[str, str]]:
|
|
26
|
+
seen: dict[str, str] = {}
|
|
27
|
+
for line in _git(root, "rev-list", "--all", "--objects").splitlines():
|
|
28
|
+
parts = line.split(maxsplit=1)
|
|
29
|
+
if len(parts) == 2 and len(parts[0]) == 40:
|
|
30
|
+
seen.setdefault(parts[0], parts[1])
|
|
31
|
+
|
|
32
|
+
for line in _git(root, "fsck", "--unreachable", "--no-reflogs").splitlines():
|
|
33
|
+
parts = line.split()
|
|
34
|
+
if len(parts) == 3 and parts[1] == "blob":
|
|
35
|
+
seen.setdefault(parts[2], "")
|
|
36
|
+
return list(seen.items())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_VENDOR = re.compile(r"(^|/)(node_modules|vendor|dist|build|\.next|bower_components|"
|
|
40
|
+
r"third_party|site-packages|\.venv|venv)/|"
|
|
41
|
+
r"(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|"
|
|
42
|
+
r"\.min\.js|\.min\.css|\.map)$", re.I)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _introducing_commit(root: str, blob: str) -> tuple[str, str]:
|
|
46
|
+
out = _git(root, "log", "--all", "--format=%H|%ci", "--find-object", blob, "--reverse")
|
|
47
|
+
for line in out.splitlines():
|
|
48
|
+
if "|" in line:
|
|
49
|
+
h, date = line.split("|", 1)
|
|
50
|
+
return h[:10], date.strip()[:10]
|
|
51
|
+
return "dangling", "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def recover_ghosts(root: str, repo_name: str, max_blobs: int = 4000) -> list[Finding]:
|
|
55
|
+
head = _head_blobs(root)
|
|
56
|
+
ghosts: list[Finding] = []
|
|
57
|
+
seen_fp: set[tuple] = set()
|
|
58
|
+
checked = 0
|
|
59
|
+
for blob, path in _all_historical_blobs(root):
|
|
60
|
+
if blob in head:
|
|
61
|
+
continue
|
|
62
|
+
if path and _VENDOR.search(path):
|
|
63
|
+
continue
|
|
64
|
+
if checked >= max_blobs:
|
|
65
|
+
break
|
|
66
|
+
checked += 1
|
|
67
|
+
content = _git(root, "cat-file", "-p", blob)
|
|
68
|
+
if not content:
|
|
69
|
+
continue
|
|
70
|
+
for f in scan_text(content):
|
|
71
|
+
key = (f.rule_id, f.redacted)
|
|
72
|
+
if key in seen_fp:
|
|
73
|
+
continue
|
|
74
|
+
seen_fp.add(key)
|
|
75
|
+
commit, date = _introducing_commit(root, blob)
|
|
76
|
+
f.repo = repo_name
|
|
77
|
+
f.is_ghost = True
|
|
78
|
+
f.commit = commit
|
|
79
|
+
f.path = f"(history) entered {date}"
|
|
80
|
+
ghosts.append(f)
|
|
81
|
+
return ghosts
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import urllib.request
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
API = "https://api.github.com"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Repo:
|
|
14
|
+
name: str
|
|
15
|
+
full_name: str
|
|
16
|
+
clone_url: str
|
|
17
|
+
pushed_at: str
|
|
18
|
+
html_url: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get(url: str) -> list | dict:
|
|
22
|
+
req = urllib.request.Request(url, headers={
|
|
23
|
+
"Accept": "application/vnd.github+json",
|
|
24
|
+
"User-Agent": "gitghost",
|
|
25
|
+
})
|
|
26
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
27
|
+
if token:
|
|
28
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
29
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
30
|
+
return json.loads(r.read().decode())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_public_repos(identity: str, limit: int = 30) -> list[Repo]:
|
|
34
|
+
repos: list[Repo] = []
|
|
35
|
+
page = 1
|
|
36
|
+
while len(repos) < limit:
|
|
37
|
+
|
|
38
|
+
data = _get(f"{API}/users/{identity}/repos?per_page=100&page={page}&sort=pushed")
|
|
39
|
+
if not isinstance(data, list) or not data:
|
|
40
|
+
break
|
|
41
|
+
for d in data:
|
|
42
|
+
if d.get("fork"):
|
|
43
|
+
continue
|
|
44
|
+
repos.append(Repo(
|
|
45
|
+
name=d["name"], full_name=d["full_name"],
|
|
46
|
+
clone_url=d["clone_url"], pushed_at=d.get("pushed_at", ""),
|
|
47
|
+
html_url=d.get("html_url", ""),
|
|
48
|
+
))
|
|
49
|
+
if len(repos) >= limit:
|
|
50
|
+
break
|
|
51
|
+
page += 1
|
|
52
|
+
return repos
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def repo_from_url(url: str) -> Repo:
|
|
56
|
+
u = url.strip().rstrip("/")
|
|
57
|
+
if u.endswith(".git"):
|
|
58
|
+
u = u[:-4]
|
|
59
|
+
|
|
60
|
+
for prefix in ("https://github.com/", "http://github.com/", "git@github.com:", "github.com/"):
|
|
61
|
+
if u.startswith(prefix):
|
|
62
|
+
u = u[len(prefix):]
|
|
63
|
+
break
|
|
64
|
+
parts = u.split("/")
|
|
65
|
+
if len(parts) < 2:
|
|
66
|
+
raise ValueError(f"not a valid repo reference: {url!r} (expected owner/name or a GitHub URL)")
|
|
67
|
+
owner, name = parts[0], parts[1]
|
|
68
|
+
full = f"{owner}/{name}"
|
|
69
|
+
return Repo(name=name, full_name=full,
|
|
70
|
+
clone_url=f"https://github.com/{full}.git",
|
|
71
|
+
pushed_at="", html_url=f"https://github.com/{full}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clone(repo: Repo, dest_parent: str) -> str | None:
|
|
75
|
+
dest = os.path.join(dest_parent, repo.name)
|
|
76
|
+
r = subprocess.run(
|
|
77
|
+
["git", "clone", "--quiet", repo.clone_url, dest],
|
|
78
|
+
capture_output=True, text=True,
|
|
79
|
+
)
|
|
80
|
+
return dest if r.returncode == 0 else None
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|