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 +3 -0
- token_hunt/allowlist.py +68 -0
- token_hunt/cli.py +194 -0
- token_hunt/engine.py +52 -0
- token_hunt/entropy.py +52 -0
- token_hunt/escape.py +91 -0
- token_hunt/patterns/builtin.toml +371 -0
- token_hunt/patterns/rotate-urls.toml +204 -0
- token_hunt/patterns_loader.py +47 -0
- token_hunt/regex_scanner.py +49 -0
- token_hunt/store.py +98 -0
- token_hunt/types.py +34 -0
- token_hunt-0.1.0.data/data/token_hunt/patterns/builtin.toml +371 -0
- token_hunt-0.1.0.data/data/token_hunt/patterns/rotate-urls.toml +204 -0
- token_hunt-0.1.0.dist-info/METADATA +193 -0
- token_hunt-0.1.0.dist-info/RECORD +19 -0
- token_hunt-0.1.0.dist-info/WHEEL +4 -0
- token_hunt-0.1.0.dist-info/entry_points.txt +2 -0
- token_hunt-0.1.0.dist-info/licenses/LICENSE +113 -0
token_hunt/__init__.py
ADDED
token_hunt/allowlist.py
ADDED
|
@@ -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
|