gitghost-scanner 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitghost/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.2.0"
gitghost/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ main()
gitghost/banner.py ADDED
@@ -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)
gitghost/cli.py ADDED
@@ -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()
gitghost/ghost.py ADDED
@@ -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
gitghost/github.py ADDED
@@ -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
gitghost/metadata.py ADDED
@@ -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
gitghost/report.py ADDED
@@ -0,0 +1,272 @@
1
+ import html
2
+ from datetime import datetime, timezone
3
+
4
+ from .metadata import MetadataReport
5
+ from .rules import Finding
6
+ from .score import ScoreCard
7
+
8
+ _BAND_ANGLE = {"CRITICAL": -8, "HIGH": -6, "MODERATE": -4, "LOW": -3, "MINIMAL": -2}
9
+
10
+
11
+ def _esc(s: str) -> str:
12
+ return html.escape(str(s))
13
+
14
+
15
+ def _link(url: str, text: str) -> str:
16
+ return f'<a href="{_esc(url)}" target="_blank" rel="noopener">{text}</a>'
17
+
18
+
19
+ def _finding_row(f: Finding) -> str:
20
+ tone = "ghost" if f.is_ghost else "live"
21
+ repo_tag = f'<span class="repo">{_esc(f.repo)}</span> ' if f.repo else ""
22
+
23
+ if f.is_ghost:
24
+ loc_text = _esc(f.path) if f.path else ""
25
+ if f.repo_url and f.commit and f.commit != "dangling":
26
+ commit_html = _link(f"{f.repo_url}/commit/{f.commit}", _esc(f.commit))
27
+ elif f.commit:
28
+ commit_html = f'<span class="commit">{_esc(f.commit)}</span>'
29
+ else:
30
+ commit_html = ""
31
+ loc = f"{loc_text} {commit_html}"
32
+ else:
33
+ pathline = f"{_esc(f.path)}:{f.line_no}"
34
+ if f.repo_url and f.path:
35
+
36
+ loc = _link(f"{f.repo_url}/blob/HEAD/{f.path}#L{f.line_no}", pathline)
37
+ else:
38
+ loc = pathline
39
+
40
+ return f"""
41
+ <tr class="frow {tone}">
42
+ <td class="sev"><span class="sev-dot s{f.severity}">{f.severity}</span></td>
43
+ <td class="lbl">{_esc(f.label)}</td>
44
+ <td class="val"><code class="redaction">{_esc(f.redacted)}</code></td>
45
+ <td class="loc">{repo_tag}{loc}<div class="rem">{_esc(f.remediation)}</div></td>
46
+ </tr>"""
47
+
48
+
49
+ def render_report(identity: str, card: ScoreCard, findings: list[Finding],
50
+ meta: MetadataReport, repos_scanned: int) -> str:
51
+ live = sorted((f for f in findings if f.kind == "secret" and not f.is_ghost),
52
+ key=lambda f: -f.severity)
53
+ ghosts = sorted((f for f in findings if f.is_ghost), key=lambda f: -f.severity)
54
+ infra = sorted((f for f in findings if f.kind == "infra"), key=lambda f: -f.severity)
55
+
56
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
57
+ angle = _BAND_ANGLE.get(card.band, -6)
58
+
59
+ meter_pct = card.score
60
+ drivers = "".join(f"<li>{_esc(d)}</li>" for d in card.drivers) or "<li>Nothing notable surfaced. Rare, and good.</li>"
61
+
62
+ ghost_section = ""
63
+ if ghosts:
64
+ ghost_section = f"""
65
+ <section class="block ghost-block">
66
+ <div class="block-head">
67
+ <span class="eyebrow">Recovered from history</span>
68
+ <h2>The secrets you deleted didn't leave.</h2>
69
+ <p class="note">{len(ghosts)} secret{'s' if len(ghosts)!=1 else ''} removed from the current code but still reachable in git history. To anyone who clones the repo, these are one command away.</p>
70
+ </div>
71
+ <table class="findings"><tbody>{''.join(_finding_row(f) for f in ghosts)}</tbody></table>
72
+ </section>"""
73
+
74
+ live_section = ""
75
+ if live:
76
+ live_section = f"""
77
+ <section class="block">
78
+ <div class="block-head">
79
+ <span class="eyebrow hot">Live in current code</span>
80
+ <h2>{len(live)} secret{'s' if len(live)!=1 else ''} sitting in HEAD right now.</h2>
81
+ </div>
82
+ <table class="findings"><tbody>{''.join(_finding_row(f) for f in live)}</tbody></table>
83
+ </section>"""
84
+
85
+ infra_section = ""
86
+ if infra:
87
+ infra_section = f"""
88
+ <section class="block">
89
+ <div class="block-head"><span class="eyebrow">Infrastructure breadcrumbs</span></div>
90
+ <table class="findings"><tbody>{''.join(_finding_row(f) for f in infra)}</tbody></table>
91
+ </section>"""
92
+
93
+ emails = ", ".join(_esc(e) for e in meta.emails[:3]) or "none exposed"
94
+ meta_section = f"""
95
+ <section class="block">
96
+ <div class="block-head"><span class="eyebrow">What your commits reveal about you</span></div>
97
+ <div class="meta-grid">
98
+ <div class="meta-cell"><div class="mk">Author email</div><div class="mv">{emails}</div></div>
99
+ <div class="meta-cell"><div class="mk">Timezone (inferred)</div><div class="mv">{_esc(meta.dominant_utc_offset or '—')}</div></div>
100
+ <div class="meta-cell"><div class="mk">Active hours (inferred)</div><div class="mv">{_esc(meta.likely_active_hours or '—')}</div></div>
101
+ <div class="meta-cell"><div class="mk">Commits analyzed</div><div class="mv">{meta.commit_count:,}</div></div>
102
+ </div>
103
+ </section>"""
104
+
105
+ return f"""<!DOCTYPE html>
106
+ <html lang="en"><head>
107
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
108
+ <title>gitghost dossier — {_esc(identity)}</title>
109
+ <link rel="preconnect" href="https://fonts.googleapis.com">
110
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
111
+ <style>
112
+ :root {{
113
+ --paper:#E7E4DB; --panel:#F1EEE7; --ink:#17150F; --muted:#8A8577;
114
+ --hazard:#E4491C; --ghost:#3E5C6B; --line:rgba(23,21,15,.16);
115
+ }}
116
+ * {{ box-sizing:border-box; }}
117
+ body {{ margin:0; background:var(--paper); color:var(--ink);
118
+ font-family:'Archivo',system-ui,sans-serif; -webkit-font-smoothing:antialiased; }}
119
+ .sheet {{ max-width:940px; margin:0 auto; padding:40px 28px 80px; }}
120
+ code, .mono {{ font-family:'Space Mono',monospace; }}
121
+
122
+ .top {{ display:flex; justify-content:space-between; align-items:baseline;
123
+ border-bottom:2px solid var(--ink); padding-bottom:10px; gap:16px; flex-wrap:wrap; }}
124
+ .brand {{ font-weight:900; letter-spacing:-.02em; font-size:20px; }}
125
+ .brand b {{ color:var(--hazard); }}
126
+ .filed {{ font-family:'Space Mono',monospace; font-size:12px; color:var(--muted); text-align:right; }}
127
+
128
+ .hero {{ display:grid; grid-template-columns:1.1fr .9fr; gap:28px; margin:34px 0 10px; align-items:center; }}
129
+ @media(max-width:720px){{ .hero{{ grid-template-columns:1fr; }} }}
130
+ .target-eyebrow {{ font-family:'Space Mono',monospace; font-size:12px; letter-spacing:.14em;
131
+ text-transform:uppercase; color:var(--muted); }}
132
+ .target {{ font-size:clamp(34px,7vw,58px); font-weight:900; letter-spacing:-.03em; line-height:.98; margin:6px 0 14px; word-break:break-word; }}
133
+ .target span {{ color:var(--hazard); }}
134
+ .scope {{ font-family:'Space Mono',monospace; font-size:12.5px; color:var(--muted); max-width:40ch; }}
135
+
136
+ .scorebox {{ position:relative; background:var(--panel); border:2px solid var(--ink);
137
+ padding:22px 24px 20px; }}
138
+ .scorebox .k {{ font-family:'Space Mono',monospace; font-size:11px; letter-spacing:.14em;
139
+ text-transform:uppercase; color:var(--muted); }}
140
+ .bignum {{ font-size:96px; font-weight:900; line-height:.86; letter-spacing:-.04em; margin:2px 0 0; }}
141
+ .bignum small {{ font-size:26px; color:var(--muted); font-weight:600; }}
142
+ .meter {{ height:12px; background:repeating-linear-gradient(90deg,var(--line) 0 1px,transparent 1px 5%);
143
+ border:1px solid var(--ink); margin:14px 0 6px; position:relative; }}
144
+ .meter i {{ position:absolute; inset:0 auto 0 0; width:{meter_pct}%;
145
+ background:var(--hazard); mix-blend-mode:multiply; }}
146
+ .stamp {{ position:absolute; top:14px; right:16px; transform:rotate({angle}deg);
147
+ border:3px double var(--hazard); color:var(--hazard); font-weight:800;
148
+ font-family:'Space Mono',monospace; letter-spacing:.06em; padding:4px 10px;
149
+ font-size:15px; opacity:.9; }}
150
+ .grade {{ font-family:'Space Mono',monospace; font-size:13px; color:var(--muted); margin-top:2px; }}
151
+ .grade b {{ color:var(--ink); }}
152
+
153
+ .drivers {{ background:var(--ink); color:var(--paper); padding:20px 24px; margin:26px 0 8px; }}
154
+ .drivers h3 {{ margin:0 0 10px; font-size:12px; letter-spacing:.14em; text-transform:uppercase;
155
+ font-family:'Space Mono',monospace; color:#c9c4b6; font-weight:400; }}
156
+ .drivers ul {{ margin:0; padding-left:18px; }}
157
+ .drivers li {{ margin:5px 0; font-size:15px; }}
158
+
159
+ .block {{ margin:40px 0 0; }}
160
+ .block-head {{ border-bottom:1px solid var(--line); padding-bottom:8px; margin-bottom:6px; }}
161
+ .eyebrow {{ font-family:'Space Mono',monospace; font-size:12px; letter-spacing:.14em;
162
+ text-transform:uppercase; color:var(--ghost); }}
163
+ .eyebrow.hot {{ color:var(--hazard); }}
164
+ .block-head h2 {{ font-size:clamp(21px,3.4vw,30px); font-weight:800; letter-spacing:-.02em; margin:6px 0 2px; }}
165
+ .note {{ font-size:14px; color:var(--muted); max-width:62ch; margin:2px 0 0; }}
166
+ .ghost-block .block-head {{ border-color:var(--ghost); }}
167
+
168
+ table.findings {{ width:100%; border-collapse:collapse; margin-top:4px; }}
169
+ .frow td {{ border-bottom:1px solid var(--line); padding:12px 8px; vertical-align:top; }}
170
+ .sev {{ width:34px; }}
171
+ .sev-dot {{ display:inline-grid; place-items:center; width:26px; height:26px; border-radius:50%;
172
+ font-family:'Space Mono',monospace; font-weight:700; font-size:13px; color:var(--paper); background:var(--muted); }}
173
+ .sev-dot.s10,.sev-dot.s9 {{ background:var(--hazard); }}
174
+ .sev-dot.s8,.sev-dot.s7 {{ background:#C4531F; }}
175
+ .sev-dot.s6,.sev-dot.s5 {{ background:#B8862B; }}
176
+ .lbl {{ font-weight:600; width:210px; font-size:15px; }}
177
+ .frow.ghost .lbl {{ color:var(--ghost); }}
178
+ .val {{ min-width:180px; }}
179
+ code.redaction {{ background:var(--ink); color:var(--paper); padding:3px 8px; font-size:13px;
180
+ display:inline-block; letter-spacing:.02em; }}
181
+ .frow.ghost code.redaction {{ background:var(--ghost); }}
182
+ .loc {{ font-family:'Space Mono',monospace; font-size:12px; color:var(--muted); }}
183
+ .commit {{ color:var(--ghost); }}
184
+ .fixsteps {{ margin:14px 0 0; padding-left:20px; }}
185
+ .fixsteps li {{ margin:12px 0; font-size:15px; line-height:1.5; max-width:70ch; }}
186
+ .cmd {{ display:block; margin:8px 0; background:var(--ink); color:var(--paper);
187
+ font-family:'Space Mono',monospace; font-size:12.5px; padding:8px 12px; overflow-x:auto; }}
188
+ .repo {{ display:inline-block; background:var(--ink); color:var(--paper);
189
+ font-family:'Space Mono',monospace; font-size:11px; padding:1px 6px; margin-right:6px; }}
190
+ .loc a {{ color:var(--ink); text-decoration:underline; text-underline-offset:2px; }}
191
+ .frow.ghost .loc a {{ color:var(--ghost); }}
192
+ .rem {{ margin-top:6px; font-family:'Archivo',sans-serif; font-size:13px; color:var(--ink); max-width:52ch; }}
193
+
194
+ .meta-grid {{ display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line);
195
+ border:1px solid var(--line); margin-top:12px; }}
196
+ @media(max-width:640px){{ .meta-grid{{ grid-template-columns:repeat(2,1fr); }} }}
197
+ .meta-cell {{ background:var(--panel); padding:16px; }}
198
+ .mk {{ font-family:'Space Mono',monospace; font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); }}
199
+ .mv {{ font-family:'Space Mono',monospace; font-size:14px; margin-top:6px; word-break:break-word; }}
200
+
201
+ .foot {{ margin-top:56px; border-top:2px solid var(--ink); padding-top:16px;
202
+ font-family:'Space Mono',monospace; font-size:12px; color:var(--muted); line-height:1.6; }}
203
+ .foot b {{ color:var(--ink); }}
204
+ </style></head>
205
+ <body><div class="sheet">
206
+
207
+ <div class="top">
208
+ <div class="brand">git<b>ghost</b> // exposure dossier</div>
209
+ <div class="filed">FILED {ts}<br>{repos_scanned} public repo{'s' if repos_scanned!=1 else ''} scanned</div>
210
+ </div>
211
+
212
+ <div class="hero">
213
+ <div>
214
+ <div class="target-eyebrow">Subject of report</div>
215
+ <div class="target">@<span>{_esc(identity)}</span></div>
216
+ <div class="scope">Public repositories only. Detection-only: no discovered
217
+ credential was ever tested against its provider.</div>
218
+ </div>
219
+ <div class="scorebox">
220
+ <div class="stamp">{_esc(card.band)}</div>
221
+ <div class="k">Exposure Score</div>
222
+ <div class="bignum">{card.score}<small>/100</small></div>
223
+ <div class="meter"><i></i></div>
224
+ <div class="grade">Grade <b>{_esc(card.grade)}</b> · higher is worse · worst find: {_esc(card.worst)}</div>
225
+ </div>
226
+ </div>
227
+
228
+ <div class="drivers">
229
+ <h3>What's driving this score</h3>
230
+ <ul>{drivers}</ul>
231
+ </div>
232
+
233
+ {ghost_section}
234
+ {live_section}
235
+ {infra_section}
236
+
237
+ <section class="block fixguide">
238
+ <div class="block-head"><span class="eyebrow">How to actually fix these</span>
239
+ <h2>Rotate first. Deleting the repo doesn't help.</h2></div>
240
+ <ol class="fixsteps">
241
+ <li><b>Rotate the credential at the provider.</b> This is the real fix. Anyone
242
+ who cloned the repo already has the key, so revoking or rotating it is the
243
+ only thing that truly closes the exposure. Each finding above says where to
244
+ do this. Do this <i>first</i>.</li>
245
+ <li><b>Then purge it from history.</b> Deleting the line in a new commit isn't
246
+ enough — the old commit still holds the secret (that's what the "ghost"
247
+ findings are). Strip the file from all history and force-push:
248
+ <code class="cmd">git filter-repo --path PATH/TO/FILE --invert-paths</code>
249
+ then <code class="cmd">git push --force</code>. (Install <code>git-filter-repo</code>
250
+ first; it's safer than the old filter-branch.)</li>
251
+ <li><b>Don't just delete the repo or the commit.</b> That doesn't un-leak
252
+ anything already public, and it throws away your history for no security
253
+ benefit. Rotate the key, then do the history surgery.</li>
254
+ </ol>
255
+ </section>
256
+
257
+ {meta_section}
258
+
259
+ <div class="foot">
260
+ <b>gitghost</b> — an exposure audit built from public data. It surfaces and
261
+ scores what a GitHub identity already published, including material believed
262
+ deleted but still recoverable from git history.<br>
263
+ It is <b>detection-only by design</b>: it reports that a string matches a
264
+ credential format and stops. It never authenticates with a discovered secret,
265
+ never touches private repositories, and is meant for auditing your own
266
+ identity or one you're authorized to assess.<br>
267
+ Every finding is shown as a non-reversible fingerprint (a safe prefix, a
268
+ length, and a partial hash) — never recoverable key material — so this report
269
+ is safe to share. gitghost does not retain the raw secret past the match.
270
+ </div>
271
+
272
+ </div></body></html>"""
gitghost/rules.py ADDED
@@ -0,0 +1,178 @@
1
+ import hashlib
2
+ import math
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Rule:
9
+ id: str
10
+ label: str
11
+ pattern: re.Pattern
12
+ severity: int
13
+ kind: str = "secret"
14
+ remediation: str = ""
15
+
16
+
17
+ PROVIDER_RULES: list[Rule] = [
18
+ Rule("aws-access-key-id", "AWS Access Key ID",
19
+ re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), 9,
20
+ remediation="Deactivate the key in IAM immediately, then rotate. Assume it is compromised the moment it touched a public commit."),
21
+ Rule("aws-secret-key", "AWS Secret Access Key",
22
+ re.compile(r"(?i)aws.{0,20}?(secret|sk).{0,30}?['\"=:\s]([A-Za-z0-9/+=]{40})\b"), 10,
23
+ remediation="Rotate the secret key and audit CloudTrail for use since the commit date."),
24
+ Rule("gcp-service-account", "GCP Service Account Key",
25
+ re.compile(r"\"type\":\s*\"service_account\""), 9,
26
+ remediation="Delete and regenerate the service-account key in GCP IAM."),
27
+ Rule("github-pat", "GitHub Personal Access Token",
28
+ re.compile(r"\bghp_[A-Za-z0-9]{36}\b"), 8,
29
+ remediation="Revoke the token under GitHub Settings > Developer settings > Tokens."),
30
+ Rule("github-oauth", "GitHub OAuth / App Token",
31
+ re.compile(r"\b(gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"), 8,
32
+ remediation="Revoke the token and rotate the associated OAuth app secret."),
33
+ Rule("stripe-secret", "Stripe Secret Key",
34
+ re.compile(r"\bsk_live_[A-Za-z0-9]{24,}\b"), 9,
35
+ remediation="Roll the key in the Stripe dashboard; check for unexpected charges."),
36
+ Rule("slack-token", "Slack Token",
37
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), 6,
38
+ remediation="Revoke the token in the Slack admin console."),
39
+ Rule("slack-webhook", "Slack Incoming Webhook",
40
+ re.compile(r"https://hooks\.slack\.com/services/T[A-Za-z0-9_/]+"), 4,
41
+ remediation="Delete the webhook; anyone with the URL can post to the channel."),
42
+ Rule("google-api-key", "Google API Key",
43
+ re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b"), 6,
44
+ remediation="Restrict or regenerate the key in the Google Cloud console."),
45
+ Rule("openai-key", "OpenAI API Key",
46
+ re.compile(r"\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b"), 7,
47
+ remediation="Revoke the key at platform.openai.com; you are billed for its usage."),
48
+ Rule("private-key", "Private Key Block",
49
+ re.compile(r"-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----"), 9,
50
+ remediation="Treat the key pair as burned. Generate a new pair and rotate every place the public key was trusted."),
51
+ Rule("jwt", "JSON Web Token",
52
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), 5,
53
+ remediation="If this is a signing secret or long-lived token, rotate it. Decode (don't trust) to confirm scope."),
54
+ Rule("db-connection", "Database Connection String",
55
+ re.compile(r"\b(postgres|postgresql|mysql|mongodb(\+srv)?|redis)://[^\s:@/]+:[^\s:@/]+@[^\s/]+"), 8,
56
+ remediation="Rotate the database password; the credential and host are both exposed."),
57
+ ]
58
+
59
+
60
+ INFRA_RULES: list[Rule] = [
61
+ Rule("internal-host", "Internal Hostname", kind="infra", severity=2,
62
+ pattern=re.compile(r"\b[a-z0-9-]+(\.[a-z0-9-]+)*\.(internal|corp|intranet|lan)\b"),
63
+ remediation="Scrub internal DNS names from committed config; they reveal network topology."),
64
+ Rule("private-ip", "Hardcoded Private IP", kind="infra", severity=1,
65
+ pattern=re.compile(r"\b(10\.\d{1,3}|192\.168|172\.(1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b"),
66
+ remediation="Move host addresses to environment config rather than source."),
67
+ ]
68
+
69
+ SECRETISH_ASSIGN = re.compile(
70
+ r"""(?ix)
71
+ (?P<name>\w*(secret|token|passwd|password|api[_-]?key|apikey|access[_-]?key|private[_-]?key|auth)\w*)
72
+ \s*[:=]\s*
73
+ ['"]?(?P<val>[A-Za-z0-9/+_=.\-]{16,})['"]?
74
+ """
75
+ )
76
+
77
+
78
+ def shannon_entropy(s: str) -> float:
79
+ if not s:
80
+ return 0.0
81
+ freq = {c: s.count(c) for c in set(s)}
82
+ n = len(s)
83
+ return -sum((c / n) * math.log2(c / n) for c in freq.values())
84
+
85
+
86
+ @dataclass
87
+ class Finding:
88
+ rule_id: str
89
+ label: str
90
+ kind: str
91
+ severity: int
92
+ line_no: int
93
+ redacted: str
94
+ entropy: float = 0.0
95
+ remediation: str = ""
96
+
97
+ repo: str = ""
98
+ path: str = ""
99
+ commit: str = ""
100
+ is_ghost: bool = False
101
+ repo_url: str = ""
102
+
103
+
104
+ _SAFE_PREFIXES = ("AKIA", "ASIA", "ghp_", "gho_", "ghu_", "ghs_", "ghr_",
105
+ "sk_live_", "sk-", "AIza", "xoxb-", "xoxp-", "eyJ")
106
+
107
+
108
+ def _fingerprint(value: str) -> str:
109
+ v = value.strip().strip("'\"")
110
+ if not v:
111
+ return ""
112
+ digest = hashlib.sha256(v.encode("utf-8", "ignore")).hexdigest()[:10]
113
+ prefix = ""
114
+ for p in _SAFE_PREFIXES:
115
+ if v.startswith(p):
116
+ prefix = f"{p}… "
117
+ break
118
+ return f"{prefix}{len(v)} chars · fp:{digest}"
119
+
120
+
121
+ def scan_text(text: str) -> list[Finding]:
122
+ findings: list[Finding] = []
123
+ lines = text.splitlines()
124
+ for i, line in enumerate(lines, 1):
125
+ if len(line) > 4000:
126
+ continue
127
+ matched_spans: list[tuple[int, int]] = []
128
+ for rule in (*PROVIDER_RULES, *INFRA_RULES):
129
+ for m in rule.pattern.finditer(line):
130
+ raw = m.group(0)
131
+ matched_spans.append(m.span())
132
+ findings.append(Finding(
133
+ rule_id=rule.id, label=rule.label, kind=rule.kind,
134
+ severity=rule.severity, line_no=i, redacted=_fingerprint(raw),
135
+ entropy=round(shannon_entropy(raw), 2),
136
+ remediation=rule.remediation,
137
+ ))
138
+
139
+
140
+ for m in SECRETISH_ASSIGN.finditer(line):
141
+ val = m.group("val")
142
+ vstart = m.start("val")
143
+ already = any(s <= vstart < e for s, e in matched_spans)
144
+ if not already and _looks_like_secret_value(val):
145
+ findings.append(Finding(
146
+ rule_id="generic-high-entropy",
147
+ label="High-Entropy Secret (generic)", kind="secret",
148
+ severity=5, line_no=i, redacted=_fingerprint(val),
149
+ entropy=round(shannon_entropy(val), 2),
150
+ remediation="Confirm whether this is a real credential; if so rotate and move it to a secret manager.",
151
+ ))
152
+ return findings
153
+
154
+
155
+ _PLACEHOLDER = re.compile(r"(?i)(xxx|placeholder|example|changeme|your[_-]?|dummy|sample|<.*>|\.\.\.|test1234|000000|insecure)")
156
+
157
+
158
+ def _looks_like_placeholder(v: str) -> bool:
159
+ return bool(_PLACEHOLDER.search(v)) or len(set(v)) <= 4
160
+
161
+
162
+ _CODE_PUNCT = re.compile(r"[\s.()\[\]{}<>/\\:;,]")
163
+ _KEYISH = re.compile(r"^[A-Za-z0-9_\-+=]+$")
164
+
165
+
166
+ def _looks_like_secret_value(val: str) -> bool:
167
+ v = val.strip().strip("'\"")
168
+ if len(v) < 20 or _looks_like_placeholder(v):
169
+ return False
170
+ if _CODE_PUNCT.search(v):
171
+ return False
172
+ if not _KEYISH.fullmatch(v):
173
+ return False
174
+ has_digit = any(c.isdigit() for c in v)
175
+ has_alpha = any(c.isalpha() for c in v)
176
+ if not (has_digit and has_alpha):
177
+ return False
178
+ return shannon_entropy(v) >= 3.5
gitghost/scanner.py ADDED
@@ -0,0 +1,53 @@
1
+ import os
2
+ from dataclasses import dataclass, field
3
+
4
+ from .rules import Finding, scan_text
5
+
6
+
7
+ SKIP_DIRS = {".git", "node_modules", "vendor", "dist", "build", ".venv",
8
+ "venv", "__pycache__", ".next", "target", ".gradle"}
9
+ SKIP_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip",
10
+ ".gz", ".tar", ".woff", ".woff2", ".ttf", ".mp4", ".mp3", ".lock",
11
+ ".min.js", ".map", ".so", ".dll", ".class", ".pyc"}
12
+ MAX_BYTES = 2_000_000
13
+
14
+
15
+ @dataclass
16
+ class RepoScan:
17
+ repo: str
18
+ findings: list[Finding] = field(default_factory=list)
19
+ files_scanned: int = 0
20
+
21
+
22
+ def _readable(path: str) -> str | None:
23
+ _, ext = os.path.splitext(path)
24
+ if ext.lower() in SKIP_EXT:
25
+ return None
26
+ try:
27
+ if os.path.getsize(path) > MAX_BYTES:
28
+ return None
29
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
30
+ return f.read()
31
+ except (OSError, ValueError):
32
+ return None
33
+
34
+
35
+ def scan_repo(root: str, repo_name: str) -> RepoScan:
36
+ result = RepoScan(repo=repo_name)
37
+ for dirpath, dirnames, filenames in os.walk(root):
38
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
39
+ for fn in filenames:
40
+ full = os.path.join(dirpath, fn)
41
+ text = _readable(full)
42
+ if text is None:
43
+ continue
44
+ result.files_scanned += 1
45
+ rel = os.path.relpath(full, root)
46
+ for f in scan_text(text):
47
+ f.repo = repo_name
48
+ f.path = rel
49
+
50
+ if os.path.basename(rel).startswith(".env") and f.kind == "secret":
51
+ f.severity = min(10, f.severity + 1)
52
+ result.findings.append(f)
53
+ return result
gitghost/score.py ADDED
@@ -0,0 +1,86 @@
1
+ import math
2
+ from dataclasses import dataclass, field
3
+
4
+ from .metadata import MetadataReport
5
+ from .rules import Finding
6
+
7
+ BANDS = [
8
+ (80, "CRITICAL", "F"),
9
+ (60, "HIGH", "D"),
10
+ (40, "MODERATE", "C"),
11
+ (20, "LOW", "B"),
12
+ (0, "MINIMAL", "A"),
13
+ ]
14
+
15
+
16
+ @dataclass
17
+ class ScoreCard:
18
+ score: int
19
+ band: str
20
+ grade: str
21
+ live_secrets: int = 0
22
+ ghost_secrets: int = 0
23
+ pii_hits: int = 0
24
+ infra_hits: int = 0
25
+ worst: str = ""
26
+ drivers: list[str] = field(default_factory=list)
27
+
28
+
29
+ def _band(score: int) -> tuple[str, str]:
30
+ for threshold, band, grade in BANDS:
31
+ if score >= threshold:
32
+ return band, grade
33
+ return "MINIMAL", "A"
34
+
35
+
36
+ def compute_score(findings: list[Finding], meta: MetadataReport) -> ScoreCard:
37
+ meta = meta or MetadataReport()
38
+ secrets = [f for f in findings if f.kind == "secret"]
39
+ infra = [f for f in findings if f.kind == "infra"]
40
+ live = [f for f in secrets if not f.is_ghost]
41
+ ghost = [f for f in secrets if f.is_ghost]
42
+
43
+
44
+ raw = 0.0
45
+ for f in secrets:
46
+ raw += f.severity * (0.9 if f.is_ghost else 1.0)
47
+ for f in infra:
48
+ raw += f.severity * 0.5
49
+ pii = 0
50
+ if meta.emails:
51
+ raw += 6
52
+ pii += 1
53
+ if meta.dominant_utc_offset:
54
+ raw += 3
55
+ pii += 1
56
+ aggregate = 100 * (1 - math.exp(-raw / 26))
57
+
58
+
59
+ floor = 0.0
60
+ worst_label = "—"
61
+ if secrets:
62
+ worst = max(secrets, key=lambda f: f.severity)
63
+ worst_label = worst.label + (" (ghost)" if worst.is_ghost else " (live)")
64
+ floor = worst.severity * 8 * (0.9 if worst.is_ghost else 1.0)
65
+
66
+ score = int(round(min(100, max(floor, aggregate))))
67
+ band, grade = _band(score)
68
+
69
+ drivers: list[str] = []
70
+ if live:
71
+ drivers.append(f"{len(live)} live secret{'s' if len(live) != 1 else ''} in current code")
72
+ if ghost:
73
+ drivers.append(f"{len(ghost)} 'deleted' secret{'s' if len(ghost) != 1 else ''} still recoverable from history")
74
+ if meta.emails:
75
+ drivers.append(f"real author email exposed ({meta.emails[0]})")
76
+ if meta.dominant_utc_offset:
77
+ drivers.append(f"timezone inferable from commit times (UTC{meta.dominant_utc_offset})")
78
+ if infra:
79
+ drivers.append(f"{len(infra)} internal infrastructure breadcrumb{'s' if len(infra) != 1 else ''}")
80
+
81
+ return ScoreCard(
82
+ score=score, band=band, grade=grade,
83
+ live_secrets=len(live), ghost_secrets=len(ghost),
84
+ pii_hits=pii, infra_hits=len(infra),
85
+ worst=worst_label, drivers=drivers,
86
+ )
@@ -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
+ ![gitghost exposure dossier](preview.png)
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,17 @@
1
+ gitghost/__init__.py,sha256=MpAT5hgNoHnTtG1XRD_GV_A7QrHVU6vJjGSw_8qMGA4,22
2
+ gitghost/__main__.py,sha256=z3OQolCKD8rUd3pKYp_FISq2VctFhFl1mV-DVnaoNOM,29
3
+ gitghost/banner.py,sha256=n-q2IKMqnyBPZiDzgU_60vNn0Nnt7YPHMmKSpSIt2H8,976
4
+ gitghost/cli.py,sha256=zsSA7SYmVHSFGa3uPwTMNQ2OUOnyEtuKUH7mZBJhGq4,4552
5
+ gitghost/ghost.py,sha256=YOCQKon_vxKDZUAn34w8zYaVIkSzWdXD69W4g9q-6Iw,2615
6
+ gitghost/github.py,sha256=iw4lZCwg5qDgor6pLt0v9-JipgqtWwzH6O9xcTD73cQ,2310
7
+ gitghost/metadata.py,sha256=EtAIXsHBhCfX569VnATfzWeuL3t37-gAA-ZzpVDyeGM,1560
8
+ gitghost/report.py,sha256=S5maXwP8hfHrfPksATfPj2On0_IyNC4WFr0enOvDHjM,14038
9
+ gitghost/rules.py,sha256=VLQZYe_nx7aLDAH3BkEDkKO3bGPh4--0lzY0_HVleug,7142
10
+ gitghost/scanner.py,sha256=FWVBhI3UplRI7UpQgl4xM3yz-N5AiFjhdaItZK9vnTY,1729
11
+ gitghost/score.py,sha256=nHsntzReBb15Q8XyQwbtVfZ2HIvnqh2DUP2aZNSaB6s,2519
12
+ gitghost_scanner-1.2.0.dist-info/LICENSE,sha256=tjrNXBeGFejjQVj8bxB-d8pegRqxpAV_dsJIN1hqIiQ,1063
13
+ gitghost_scanner-1.2.0.dist-info/METADATA,sha256=b_NUeXJBoYQDhgK_Rl_PrFNRf9YRluNsyM_buW3Ryfk,5473
14
+ gitghost_scanner-1.2.0.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
15
+ gitghost_scanner-1.2.0.dist-info/entry_points.txt,sha256=RqWCch6nQ7UQ-PNhlXoeM1ioE7iZdRHG6MUloz1ibcc,47
16
+ gitghost_scanner-1.2.0.dist-info/top_level.txt,sha256=mojEQo6pt-3lkeJxbiwqWxsm0Z7PMngvymEH863d73g,9
17
+ gitghost_scanner-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (76.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitghost = gitghost.cli:main
@@ -0,0 +1 @@
1
+ gitghost