token-hunt 0.1.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.
token_hunt/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """token-hunt: catch secrets at chat time, not commit time."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+ import re
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ if sys.version_info >= (3, 11):
7
+ import tomllib
8
+ else:
9
+ import tomli as tomllib
10
+
11
+ from token_hunt.types import Match, ScanContext
12
+
13
+ # Path patterns that commonly contain realistic fake keys (test fixtures etc.)
14
+ # Mirrors Jordan's existing ~/.gitleaks-config.toml allowlist.
15
+ ALLOWED_PATH_PATTERNS = [
16
+ re.compile(p) for p in [
17
+ r'(^|/)node_modules/.*',
18
+ r'(^|/)vendor/.*',
19
+ r'(^|/)third_party/.*',
20
+ r'(^|/)tests?/fixtures?/.*',
21
+ r'(^|/)tests?/.*\.example$',
22
+ r'(^|/)examples?/.*',
23
+ r'(^|/)samples?/.*',
24
+ r'(^|/)demo/.*',
25
+ r'.*\.example$',
26
+ r'.*\.sample$',
27
+ r'.*\.env\.sample$',
28
+ r'.*\.env\.example$',
29
+ r'.*_test\..*',
30
+ r'.*\.test\..*',
31
+ r'.*\.spec\..*',
32
+ ]
33
+ ]
34
+
35
+
36
+ def is_path_allowed(path: str) -> bool:
37
+ return any(p.search(path) for p in ALLOWED_PATH_PATTERNS)
38
+
39
+
40
+ def load_fingerprint_allowlist() -> set[str]:
41
+ """Load SHA-256 fingerprints from ~/.token-hunt/allowlist.toml.
42
+ Missing file = empty allowlist."""
43
+ home = Path.home()
44
+ allowlist_file = home / ".token-hunt" / "allowlist.toml"
45
+ if not allowlist_file.is_file():
46
+ return set()
47
+ try:
48
+ data = tomllib.loads(allowlist_file.read_text(encoding="utf-8"))
49
+ except tomllib.TOMLDecodeError:
50
+ return set()
51
+ return set(data.get("fingerprints", []))
52
+
53
+
54
+ def apply_allowlist(matches: list[Match], ctx: ScanContext) -> list[Match]:
55
+ """Filter out allowlisted matches."""
56
+ if not matches:
57
+ return matches
58
+
59
+ # Path-based allowlist (only applies to file-backed scans)
60
+ if ctx.file_path and is_path_allowed(ctx.file_path):
61
+ return []
62
+
63
+ # Fingerprint-based allowlist
64
+ allowed_fps = load_fingerprint_allowlist()
65
+ if not allowed_fps:
66
+ return matches
67
+
68
+ return [m for m in matches if m.fingerprint not in allowed_fps]
token_hunt/cli.py ADDED
@@ -0,0 +1,194 @@
1
+ """token-hunt CLI entry point."""
2
+ from __future__ import annotations
3
+ import sys
4
+
5
+ import click
6
+
7
+ from token_hunt import __version__
8
+
9
+
10
+ @click.group()
11
+ @click.version_option(__version__, "--version")
12
+ def main() -> None:
13
+ """token-hunt: catch secrets at chat time, not commit time."""
14
+
15
+
16
+ @main.command()
17
+ @click.argument("path", required=False, type=click.Path(exists=True))
18
+ @click.option("--stdin", is_flag=True, help="Read text from stdin instead of file.")
19
+ @click.option("--context", default="UserPromptSubmit",
20
+ help="Detection point (UserPromptSubmit, PreToolUse:Bash, etc.)")
21
+ @click.option("--format", "fmt", default="text", type=click.Choice(["text", "json"]))
22
+ @click.option("--no-log", is_flag=True, help="Don't write to rotations.log.")
23
+ def scan(path, stdin, context, fmt, no_log):
24
+ """Scan a file or stdin for secret leaks."""
25
+ import json as _json
26
+ from pathlib import Path as _Path
27
+
28
+ from token_hunt.engine import scan as scan_engine
29
+ from token_hunt.store import LeakRecord, Store
30
+ from token_hunt.types import ScanContext
31
+ from dataclasses import asdict
32
+
33
+ if stdin:
34
+ text = sys.stdin.read()
35
+ file_path = None
36
+ elif path:
37
+ text = _Path(path).read_text(encoding="utf-8", errors="replace")
38
+ file_path = str(path)
39
+ else:
40
+ click.echo("error: provide a PATH or use --stdin", err=True)
41
+ sys.exit(2)
42
+
43
+ ctx = ScanContext(detection_point=context, file_path=file_path)
44
+ result = scan_engine(text, ctx)
45
+
46
+ if not no_log and result.matches:
47
+ store = Store()
48
+ for m in result.matches:
49
+ store.append(LeakRecord(
50
+ id=None,
51
+ ts=__import__("datetime").datetime.now(__import__("datetime").timezone.utc)
52
+ .isoformat(timespec="seconds"),
53
+ kind=m.kind,
54
+ source=m.detection_point,
55
+ fingerprint=m.fingerprint,
56
+ rotate_url=m.rotate_url,
57
+ status="open",
58
+ ))
59
+
60
+ if fmt == "json":
61
+ click.echo(_json.dumps({
62
+ "count": result.count,
63
+ "matches": [asdict(m) for m in result.matches],
64
+ }, indent=2))
65
+ else:
66
+ if not result.matches:
67
+ click.echo("no leaks detected")
68
+ else:
69
+ click.echo(f"⚠️ {result.count} potential leak(s) detected:")
70
+ for m in result.matches:
71
+ fp_preview = m.fingerprint[:8] if m.fingerprint else "n/a"
72
+ click.echo(f" - {m.kind} (fp={fp_preview}) @ line {m.line}")
73
+ if m.rotate_url:
74
+ click.echo(f" rotate: {m.rotate_url}")
75
+
76
+
77
+ @main.command(name="list")
78
+ @click.option("--all", "show_all", is_flag=True, help="Show closed leaks too.")
79
+ def list_cmd(show_all):
80
+ """List open leaks in rotations.log."""
81
+ from token_hunt.store import Store
82
+
83
+ store = Store()
84
+ if show_all:
85
+ records = []
86
+ for line in store.path.read_text(encoding="utf-8").splitlines():
87
+ if line.strip():
88
+ import json as _j
89
+ from token_hunt.store import LeakRecord
90
+ records.append(LeakRecord(**_j.loads(line)))
91
+ else:
92
+ records = store.list_open()
93
+
94
+ if not records:
95
+ click.echo("no open leaks" if not show_all else "no leaks recorded")
96
+ return
97
+
98
+ click.echo(f"{'ID':<5} {'KIND':<22} {'SOURCE':<22} {'WHEN':<20} {'STATUS':<10} URL")
99
+ for r in records:
100
+ ts_short = r.ts[:19].replace("T", " ")
101
+ status = r.status.upper()
102
+ if r.is_overdue:
103
+ status = "OVERDUE"
104
+ click.echo(f"{r.id:<5} {r.kind:<22} {r.source:<22} {ts_short:<20} {status:<10} {r.rotate_url}")
105
+
106
+
107
+ @main.command()
108
+ @click.argument("leak_id", type=int)
109
+ @click.option("--note", default="", help="Optional rotation note.")
110
+ def close(leak_id, note):
111
+ """Mark a leak as closed after rotation."""
112
+ from token_hunt.store import Store
113
+ store = Store()
114
+ if store.close(leak_id, note=note):
115
+ click.echo(f"leak {leak_id} marked closed")
116
+ else:
117
+ click.echo(f"error: leak {leak_id} not found", err=True)
118
+ raise click.exceptions.Exit(1)
119
+
120
+
121
+ @main.command()
122
+ @click.argument("path", type=click.Path(exists=True, file_okay=False))
123
+ @click.option("--context", default="PostToolUse:Read")
124
+ @click.option("--exit-after", type=int, default=0,
125
+ help="Exit after N seconds (0 = run forever). Test helper.")
126
+ def watch(path, context, exit_after):
127
+ """Watch a directory for new transcript files and scan them."""
128
+ import time as _time
129
+ from pathlib import Path as _Path
130
+
131
+ from watchdog.events import FileSystemEventHandler
132
+ from watchdog.observers import Observer
133
+
134
+ from token_hunt.engine import scan as scan_engine
135
+ from token_hunt.store import LeakRecord, Store
136
+ from token_hunt.types import ScanContext
137
+ import datetime as _dt
138
+
139
+ watched = _Path(path)
140
+ click.echo(f"watching {watched} for new files...")
141
+
142
+ class Handler(FileSystemEventHandler):
143
+ def on_created(self, event):
144
+ if event.is_directory:
145
+ return
146
+ self._scan_file(event.src_path)
147
+
148
+ def on_modified(self, event):
149
+ if event.is_directory:
150
+ return
151
+ self._scan_file(event.src_path)
152
+
153
+ def _scan_file(self, file_path):
154
+ try:
155
+ text = _Path(file_path).read_text(encoding="utf-8", errors="replace")
156
+ except OSError:
157
+ return
158
+ ctx = ScanContext(detection_point=context, file_path=file_path)
159
+ result = scan_engine(text, ctx)
160
+ if result.matches:
161
+ store = Store()
162
+ for m in result.matches:
163
+ store.append(LeakRecord(
164
+ id=None,
165
+ ts=_dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
166
+ kind=m.kind,
167
+ source=m.detection_point,
168
+ fingerprint=m.fingerprint,
169
+ rotate_url=m.rotate_url,
170
+ status="open",
171
+ ))
172
+ click.echo(f"⚠️ {file_path}: {result.count} leak(s) detected")
173
+ else:
174
+ click.echo(f"✓ {file_path}: clean")
175
+
176
+ observer = Observer()
177
+ observer.schedule(Handler(), str(watched), recursive=True)
178
+ observer.start()
179
+
180
+ try:
181
+ if exit_after > 0:
182
+ _time.sleep(exit_after)
183
+ else:
184
+ while True:
185
+ _time.sleep(1)
186
+ except KeyboardInterrupt:
187
+ pass
188
+ finally:
189
+ observer.stop()
190
+ observer.join()
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
token_hunt/engine.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from token_hunt import regex_scanner
6
+ from token_hunt.allowlist import apply_allowlist
7
+ from token_hunt.entropy import scan_entropy
8
+ from token_hunt.escape import scan_escape
9
+ from token_hunt.patterns_loader import load_builtin_rules, load_user_rules
10
+ from token_hunt.types import Match, ScanContext
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ScanResult:
15
+ matches: list[Match]
16
+ count: int
17
+ highest_confidence: float
18
+
19
+
20
+ def _load_all_rules(ctx: ScanContext):
21
+ """Load builtin + machine-local + repo-local rules. Later extends earlier."""
22
+ rules = list(load_builtin_rules())
23
+ home = Path.home()
24
+ machine_local = home / ".token-hunt" / "patterns.local.toml"
25
+ repo_local = Path(ctx.cwd) / ".token-hunt" / "patterns.local.toml"
26
+ rules.extend(load_user_rules([machine_local, repo_local]))
27
+ return rules
28
+
29
+
30
+ def scan(text: str, ctx: ScanContext) -> ScanResult:
31
+ """Run all detection passes against text. Fail-closed: any internal
32
+ exception propagates to caller (hook layer handles user-facing bypass)."""
33
+ if not text:
34
+ return ScanResult(matches=[], count=0, highest_confidence=0.0)
35
+
36
+ rules = _load_all_rules(ctx)
37
+ matches: list[Match] = []
38
+
39
+ # Pass 1: regex rules (call via module so tests can monkeypatch)
40
+ matches.extend(regex_scanner.scan_regex(text, rules, ctx))
41
+
42
+ # Pass 2: bash escape hatch (only fires in PreToolUse:Bash)
43
+ matches.extend(scan_escape(text, ctx))
44
+
45
+ # Pass 3: entropy candidates (skipped for Bash)
46
+ matches.extend(scan_entropy(text, ctx))
47
+
48
+ # Apply allowlist filters
49
+ matches = apply_allowlist(matches, ctx)
50
+
51
+ highest = max((m.confidence for m in matches), default=0.0)
52
+ return ScanResult(matches=matches, count=len(matches), highest_confidence=highest)
token_hunt/entropy.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+ import math
3
+ import re
4
+ from collections import Counter
5
+
6
+ from token_hunt.types import Match, ScanContext
7
+
8
+ MIN_LENGTH = 20
9
+ MIN_ENTROPY = 3.5 # hex keys are ~3.9; threshold below that keeps english prose out
10
+
11
+ # Word-boundary-ish token extraction. We look for runs of "secret-shaped" chars.
12
+ TOKEN_RE = re.compile(r'[A-Za-z0-9_+/=-]{20,}')
13
+
14
+
15
+ def shannon_entropy(s: str) -> float:
16
+ """Shannon entropy in bits per character."""
17
+ if not s:
18
+ return 0.0
19
+ counts = Counter(s)
20
+ length = len(s)
21
+ return -sum((c / length) * math.log2(c / length) for c in counts.values())
22
+
23
+
24
+ def scan_entropy(text: str, ctx: ScanContext) -> list[Match]:
25
+ """Flag long high-entropy strings that didn't match a regex rule.
26
+ Skipped for PreToolUse:Bash (commands have legit high-entropy args)."""
27
+ if ctx.detection_point == "PreToolUse:Bash":
28
+ return []
29
+
30
+ matches: list[Match] = []
31
+ for line_no, line in enumerate(text.splitlines(), start=1):
32
+ for m in TOKEN_RE.finditer(line):
33
+ candidate = m.group(0)
34
+ if len(candidate) < MIN_LENGTH:
35
+ continue
36
+ ent = shannon_entropy(candidate)
37
+ if ent < MIN_ENTROPY:
38
+ continue
39
+ # Confidence scales with entropy above threshold
40
+ confidence = min(0.4, 0.1 + (ent - MIN_ENTROPY) * 0.05)
41
+ matches.append(Match(
42
+ kind="entropy-candidate",
43
+ value=candidate,
44
+ fingerprint="", # don't fingerprint unknowns
45
+ rotate_url="",
46
+ detection_point=ctx.detection_point,
47
+ confidence=confidence,
48
+ rule_id="entropy",
49
+ line=line_no,
50
+ column=m.start() + 1,
51
+ ))
52
+ return matches
token_hunt/escape.py ADDED
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+ import re
3
+
4
+ from token_hunt.types import Match, ScanContext
5
+
6
+ # Patterns where bash evaluates a secret-bearing env var into emitted text.
7
+ # These leak the value even when the literal secret isn't in the input.
8
+ ESCAPE_PATTERNS = [
9
+ # ${VAR:+...} and ${VAR:-...} — Jordan's bug class. Both branches can run.
10
+ (re.compile(r'\$\{([A-Z][A-Z0-9_]{2,}):[+-][^\}]*\}'), "brace-eval"),
11
+ # echo $TOKEN — direct echo of var
12
+ (re.compile(r'\becho\s+["\']?\$([A-Z][A-Z0-9_]{2,})'), "echo-var"),
13
+ # printenv TOKEN
14
+ (re.compile(r'\bprintenv\s+([A-Z][A-Z0-9_]{2,})'), "printenv"),
15
+ # env | grep X (likely hunting for a secret)
16
+ (re.compile(r'\benv\b\s*\|\s*grep'), "env-pipe-grep"),
17
+ # grep $VAR (variable expands before grep runs)
18
+ (re.compile(r'\bgrep\s+.*\$([A-Z][A-Z0-9_]{2,})'), "grep-var"),
19
+ # $(VAR) command substitution
20
+ (re.compile(r'\$\(\s*([A-Z][A-Z0-9_]{2,})\s*\)'), "cmd-substitution"),
21
+ # `VAR` backtick substitution
22
+ (re.compile(r'`([A-Z][A-Z0-9_]{2,})`'), "backtick-substitution"),
23
+ # printf with %s and secret arg
24
+ (re.compile(r'\bprintf\s+.*%s.*\$([A-Z][A-Z0-9_]{2,})'), "printf-secret"),
25
+ # awk ENVIRON access
26
+ (re.compile(r'\bawk\b.*ENVIRON\['), "awk-environ"),
27
+ ]
28
+
29
+ # Legit status-check patterns. If the SAME LINE has one of these, the escape
30
+ # pattern is treated as a safe check (not a leak).
31
+ SAFE_STATUS_PATTERNS = [
32
+ re.compile(r'\[\s*-n\s'), # [ -n "$VAR" ]
33
+ re.compile(r'\[\s*-z\s'), # [ -z "$VAR" ]
34
+ re.compile(r'\bwc\s+-c\b'), # wc -c <<< "$VAR"
35
+ re.compile(r'\bwc\s+-m\b'), # wc -m
36
+ re.compile(r'\[\s*-n\s.*\]\s*&&\s*echo\s+(SET|UNSET|set|unset)'),
37
+ ]
38
+
39
+ # Env var name patterns that mark a var as secret-bearing.
40
+ SECRET_NAME_SUFFIXES = (
41
+ "_TOKEN", "_KEY", "_SECRET", "_API_KEY", "_PASSWORD", "_PASS",
42
+ "_DBROOT", "_PAT", "_CREDENTIAL", "_CREDS", "_AUTH", "_ACCESS_KEY",
43
+ )
44
+ SECRET_NAME_PREFIXES = (
45
+ "ANTHROPIC_", "OPENAI_", "GITHUB_", "ZCODE_", "Z_AI_", "FIGMA_",
46
+ "LINEAR_", "STRIPE_", "SLACK_", "NPM_", "PYPI_", "HEROKU_",
47
+ "AWS_", "GCP_", "AZURE_", "DBROOT", "TOKEN", "SECRET",
48
+ )
49
+
50
+
51
+ def _is_secret_var_name(name: str) -> bool:
52
+ upper = name.upper()
53
+ if any(upper.endswith(s) for s in SECRET_NAME_SUFFIXES):
54
+ return True
55
+ if any(upper.startswith(p) for p in SECRET_NAME_PREFIXES):
56
+ return True
57
+ return False
58
+
59
+
60
+ def _is_legit_status_check(line: str) -> bool:
61
+ return any(p.search(line) for p in SAFE_STATUS_PATTERNS)
62
+
63
+
64
+ def scan_escape(text: str, ctx: ScanContext) -> list[Match]:
65
+ """Detect bash patterns that evaluate secrets into emitted text.
66
+ Only fires in PreToolUse:Bash context — escape patterns are bash-specific."""
67
+ if ctx.detection_point != "PreToolUse:Bash":
68
+ return []
69
+
70
+ matches: list[Match] = []
71
+ for line_no, line in enumerate(text.splitlines(), start=1):
72
+ if _is_legit_status_check(line):
73
+ continue
74
+ for pattern, pattern_name in ESCAPE_PATTERNS:
75
+ for m in pattern.finditer(line):
76
+ var_name = m.group(1) if m.groups() else ""
77
+ if var_name and not _is_secret_var_name(var_name):
78
+ continue
79
+ kind = f"escape:{var_name}" if var_name else f"escape:{pattern_name}"
80
+ matches.append(Match(
81
+ kind=kind,
82
+ value=line.strip(), # the whole line, not the literal secret
83
+ fingerprint="", # no literal secret to fingerprint
84
+ rotate_url="",
85
+ detection_point=ctx.detection_point,
86
+ confidence=0.85,
87
+ rule_id=f"escape:{pattern_name}",
88
+ line=line_no,
89
+ column=m.start() + 1,
90
+ ))
91
+ return matches