crypttrace 0.32.0__tar.gz → 0.33.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.
- {crypttrace-0.32.0/src/crypttrace.egg-info → crypttrace-0.33.0}/PKG-INFO +22 -1
- {crypttrace-0.32.0 → crypttrace-0.33.0}/README.md +21 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/__init__.py +1 -1
- crypttrace-0.33.0/src/crypttrace/case.py +187 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/cli.py +119 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0/src/crypttrace.egg-info}/PKG-INFO +22 -1
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/LICENSE +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/pyproject.toml +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/setup.cfg +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/chains.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/drain.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/evidence.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/export.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/ton.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/gascluster.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/names.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/tonapi.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/tronscan.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/spam.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/store.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/web/index.html +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.33.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -329,6 +329,26 @@ entities — that's the OSINT handoff point. `--direction out` (default) answers
|
|
|
329
329
|
money come from", which is how you vet a suspicious address or find a victim's
|
|
330
330
|
source of funds.
|
|
331
331
|
|
|
332
|
+
### Cases — an investigation kept as a file
|
|
333
|
+
|
|
334
|
+
An investigation rarely fits one address. `case` keeps the addresses (any
|
|
335
|
+
chain, each with a role: victim, suspect, intermediary, exchange, mixer, bridge
|
|
336
|
+
or other), a log of notes, and the last snapshot of each address. `refresh`
|
|
337
|
+
reads them all again and says what changed: a balance that went down, new
|
|
338
|
+
activity since the last look.
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
crypttrace case new ronin --note "Ronin bridge exploit, March 2022"
|
|
342
|
+
crypttrace case add ronin 0x098b…2f96 --chain eth --role suspect --note "exploiter"
|
|
343
|
+
crypttrace case note ronin "asked Binance for KYC on the first-gas withdrawal"
|
|
344
|
+
crypttrace case refresh ronin # what moved since the last refresh
|
|
345
|
+
crypttrace case show ronin # addresses, current labels, last snapshot, the log
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
A case is one JSON file in `~/.crypttrace/cases/`. Every `case` command also
|
|
349
|
+
takes a path to a `.json` file, so handing a case to a colleague is sending the
|
|
350
|
+
file.
|
|
351
|
+
|
|
332
352
|
### Watch & alerts — catching the cash-out
|
|
333
353
|
|
|
334
354
|
The only window to freeze stolen funds is the moment they reach an exchange
|
|
@@ -727,6 +747,7 @@ src/crypttrace/
|
|
|
727
747
|
offramp.py # exchange-deposit detection
|
|
728
748
|
bridges.py # cross-chain matching
|
|
729
749
|
watch.py # watchlist + alerts
|
|
750
|
+
case.py # investigations as files: addresses, roles, notes, snapshots
|
|
730
751
|
report.py # Markdown + JSON reports
|
|
731
752
|
export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
|
|
732
753
|
prices.py # USD valuation
|
|
@@ -295,6 +295,26 @@ entities — that's the OSINT handoff point. `--direction out` (default) answers
|
|
|
295
295
|
money come from", which is how you vet a suspicious address or find a victim's
|
|
296
296
|
source of funds.
|
|
297
297
|
|
|
298
|
+
### Cases — an investigation kept as a file
|
|
299
|
+
|
|
300
|
+
An investigation rarely fits one address. `case` keeps the addresses (any
|
|
301
|
+
chain, each with a role: victim, suspect, intermediary, exchange, mixer, bridge
|
|
302
|
+
or other), a log of notes, and the last snapshot of each address. `refresh`
|
|
303
|
+
reads them all again and says what changed: a balance that went down, new
|
|
304
|
+
activity since the last look.
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
crypttrace case new ronin --note "Ronin bridge exploit, March 2022"
|
|
308
|
+
crypttrace case add ronin 0x098b…2f96 --chain eth --role suspect --note "exploiter"
|
|
309
|
+
crypttrace case note ronin "asked Binance for KYC on the first-gas withdrawal"
|
|
310
|
+
crypttrace case refresh ronin # what moved since the last refresh
|
|
311
|
+
crypttrace case show ronin # addresses, current labels, last snapshot, the log
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
A case is one JSON file in `~/.crypttrace/cases/`. Every `case` command also
|
|
315
|
+
takes a path to a `.json` file, so handing a case to a colleague is sending the
|
|
316
|
+
file.
|
|
317
|
+
|
|
298
318
|
### Watch & alerts — catching the cash-out
|
|
299
319
|
|
|
300
320
|
The only window to freeze stolen funds is the moment they reach an exchange
|
|
@@ -693,6 +713,7 @@ src/crypttrace/
|
|
|
693
713
|
offramp.py # exchange-deposit detection
|
|
694
714
|
bridges.py # cross-chain matching
|
|
695
715
|
watch.py # watchlist + alerts
|
|
716
|
+
case.py # investigations as files: addresses, roles, notes, snapshots
|
|
696
717
|
report.py # Markdown + JSON reports
|
|
697
718
|
export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
|
|
698
719
|
prices.py # USD valuation
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.33.0"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""An investigation kept as a file: its addresses, their roles, notes, and what
|
|
2
|
+
changed since you last looked.
|
|
3
|
+
|
|
4
|
+
A case is one JSON file — by default ~/.crypttrace/cases/NAME.json, or any path
|
|
5
|
+
— so handing a case to a colleague is sending them the file. It holds:
|
|
6
|
+
|
|
7
|
+
- addresses across chains, each with a role (victim, suspect, exchange, …)
|
|
8
|
+
and a note;
|
|
9
|
+
- a log of timestamped notes;
|
|
10
|
+
- for each address, the last snapshot taken by `refresh`: native balance
|
|
11
|
+
and the time of the latest transfer (tokens included where the chain has
|
|
12
|
+
them). The next `refresh` compares against it, which is how a case tells
|
|
13
|
+
you the money moved again.
|
|
14
|
+
"""
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable, List, Optional
|
|
21
|
+
|
|
22
|
+
from crypttrace import addresses, chains, config
|
|
23
|
+
|
|
24
|
+
CASES_DIR = config.DATA_DIR / "cases"
|
|
25
|
+
ROLES = ("victim", "suspect", "intermediary", "exchange", "mixer", "bridge", "other")
|
|
26
|
+
FORMAT = 1
|
|
27
|
+
_ALL_TOKENS = {"contract": None, "symbol": "*"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CaseError(ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def path_for(name_or_path: str) -> Path:
|
|
35
|
+
"""A case named in the cases folder, or a .json file anywhere."""
|
|
36
|
+
p = Path(name_or_path)
|
|
37
|
+
if p.suffix.lower() == ".json" or p.parent != Path("."):
|
|
38
|
+
return p
|
|
39
|
+
if not re.fullmatch(r"[\w.-]{1,64}", name_or_path):
|
|
40
|
+
raise CaseError("a case name is letters, digits, '.', '-' or '_' (up to 64)")
|
|
41
|
+
return CASES_DIR / f"{name_or_path}.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _now() -> int:
|
|
45
|
+
return int(time.time())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def new(name: str, note: str = "") -> Path:
|
|
49
|
+
p = path_for(name)
|
|
50
|
+
if p.exists():
|
|
51
|
+
raise CaseError(f"case already exists: {p}")
|
|
52
|
+
case = {"crypttrace_case": FORMAT, "name": p.stem, "created": _now(), "updated": _now(),
|
|
53
|
+
"addresses": [], "notes": []}
|
|
54
|
+
if note:
|
|
55
|
+
case["notes"].append({"at": _now(), "text": note})
|
|
56
|
+
save(case, p)
|
|
57
|
+
return p
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load(name: str) -> dict:
|
|
61
|
+
p = path_for(name)
|
|
62
|
+
try:
|
|
63
|
+
case = json.loads(p.read_text(encoding="utf-8"))
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
raise CaseError(f"no such case: {p}")
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
raise CaseError(f"{p} is not a readable case file: {e}")
|
|
68
|
+
if not isinstance(case, dict) or "crypttrace_case" not in case:
|
|
69
|
+
raise CaseError(f"{p} is not a crypttrace case file")
|
|
70
|
+
case["_path"] = str(p)
|
|
71
|
+
return case
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save(case: dict, p: Optional[Path] = None) -> Path:
|
|
75
|
+
p = Path(p or case["_path"])
|
|
76
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
data = {k: v for k, v in case.items() if not k.startswith("_")}
|
|
78
|
+
data["updated"] = _now()
|
|
79
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
80
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
81
|
+
tmp.replace(p) # a half-written case never replaces a good one
|
|
82
|
+
return p
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def all_cases() -> List[dict]:
|
|
86
|
+
out = []
|
|
87
|
+
for p in sorted(CASES_DIR.glob("*.json")):
|
|
88
|
+
try:
|
|
89
|
+
c = load(str(p))
|
|
90
|
+
except CaseError:
|
|
91
|
+
continue
|
|
92
|
+
out.append({"name": c.get("name", p.stem), "path": str(p), "addresses": len(c.get("addresses", [])),
|
|
93
|
+
"notes": len(c.get("notes", [])), "updated": c.get("updated")})
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def add(name: str, address: str, chain: str, role: str = "other", note: str = "") -> dict:
|
|
98
|
+
chains.check(chain)
|
|
99
|
+
if role not in ROLES:
|
|
100
|
+
raise CaseError(f"role must be one of: {', '.join(ROLES)}")
|
|
101
|
+
ok, why = addresses.validate(address, chain)
|
|
102
|
+
if not ok:
|
|
103
|
+
raise CaseError(f"not a valid {chain} address: {why}")
|
|
104
|
+
case = load(name)
|
|
105
|
+
key = chains.norm_addr(address, chain)
|
|
106
|
+
for a in case["addresses"]:
|
|
107
|
+
if a["chain"] == chain and a["address"] == key:
|
|
108
|
+
a["role"] = role # adding it again updates it
|
|
109
|
+
if note:
|
|
110
|
+
a["note"] = note
|
|
111
|
+
save(case)
|
|
112
|
+
return a
|
|
113
|
+
entry = {"address": key, "chain": chain, "role": role, "note": note, "added": _now(), "snapshot": None}
|
|
114
|
+
case["addresses"].append(entry)
|
|
115
|
+
save(case)
|
|
116
|
+
return entry
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def remove(name: str, address: str, chain: Optional[str] = None) -> bool:
|
|
120
|
+
case = load(name)
|
|
121
|
+
before = len(case["addresses"])
|
|
122
|
+
case["addresses"] = [a for a in case["addresses"]
|
|
123
|
+
if not (a["address"] == chains.norm_addr(address, a["chain"])
|
|
124
|
+
and (chain is None or a["chain"] == chain))]
|
|
125
|
+
if len(case["addresses"]) == before:
|
|
126
|
+
return False
|
|
127
|
+
save(case)
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def note(name: str, text: str) -> dict:
|
|
132
|
+
case = load(name)
|
|
133
|
+
entry = {"at": _now(), "text": text}
|
|
134
|
+
case["notes"].append(entry)
|
|
135
|
+
save(case)
|
|
136
|
+
return entry
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def snapshot(address: str, chain: str) -> dict:
|
|
140
|
+
"""Native balance and the time of the latest transfer, read fresh."""
|
|
141
|
+
snap = {"at": _now(), "balance": None, "last_seen": None, "error": None}
|
|
142
|
+
try:
|
|
143
|
+
snap["balance"] = chains.balance(address, chain)
|
|
144
|
+
rows = list(chains.transfers(address, chain, 50, fresh=True))
|
|
145
|
+
if not chains.is_utxo(chain):
|
|
146
|
+
try:
|
|
147
|
+
rows += chains.transfers(address, chain, 50, asset=_ALL_TOKENS, fresh=True)
|
|
148
|
+
except chains.ChainError:
|
|
149
|
+
pass
|
|
150
|
+
stamps = [int(r.get("timestamp") or 0) for r in rows if r.get("timestamp")]
|
|
151
|
+
snap["last_seen"] = max(stamps) if stamps else None
|
|
152
|
+
except chains.ChainError as e:
|
|
153
|
+
snap["error"] = str(e)
|
|
154
|
+
return snap
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def changes(prev: Optional[dict], cur: dict) -> List[str]:
|
|
158
|
+
"""What is different from the last snapshot, in words."""
|
|
159
|
+
if cur.get("error"):
|
|
160
|
+
return [f"could not be read: {cur['error']}"]
|
|
161
|
+
if not prev or prev.get("error"):
|
|
162
|
+
return ["first snapshot"]
|
|
163
|
+
out = []
|
|
164
|
+
a, b = prev.get("balance"), cur.get("balance")
|
|
165
|
+
if a is not None and b is not None and abs(b - a) > max(1e-9, abs(a) * 1e-9):
|
|
166
|
+
out.append(f"balance {a:,.8f} -> {b:,.8f} ({b - a:+,.8f})")
|
|
167
|
+
if (cur.get("last_seen") or 0) > (prev.get("last_seen") or 0):
|
|
168
|
+
when = dt.datetime.fromtimestamp(cur["last_seen"], dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
169
|
+
out.append(f"new activity, latest {when}")
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def refresh(name: str, take: Callable[[str, str], dict] = snapshot) -> List[dict]:
|
|
174
|
+
"""Snapshot every address; report what changed since the last refresh."""
|
|
175
|
+
case = load(name)
|
|
176
|
+
report = []
|
|
177
|
+
for a in case["addresses"]:
|
|
178
|
+
cur = take(a["address"], a["chain"])
|
|
179
|
+
diff = changes(a.get("snapshot"), cur)
|
|
180
|
+
report.append({**{k: a[k] for k in ("address", "chain", "role")}, "changes": diff,
|
|
181
|
+
"moved": any(not d.startswith(("first", "could not")) for d in diff),
|
|
182
|
+
"balance": cur.get("balance"), "previous": a.get("snapshot")})
|
|
183
|
+
if not cur.get("error"):
|
|
184
|
+
a["snapshot"] = cur
|
|
185
|
+
case.setdefault("refreshes", []).append(_now())
|
|
186
|
+
save(case)
|
|
187
|
+
return report
|
|
@@ -1034,5 +1034,124 @@ def watch_run(
|
|
|
1034
1034
|
console.print("\n[dim]Stopped.[/dim]")
|
|
1035
1035
|
|
|
1036
1036
|
|
|
1037
|
+
# ---------------------------------------------------------------- cases
|
|
1038
|
+
case_app = typer.Typer(help="Keep an investigation as a file: addresses, roles, notes, what changed.")
|
|
1039
|
+
app.add_typer(case_app, name="case")
|
|
1040
|
+
CASE_ARG = typer.Argument(..., help="Case name (kept in ~/.crypttrace/cases) or a path to a .json case file")
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _case_call(fn, *a, **kw):
|
|
1044
|
+
from crypttrace import case as case_mod
|
|
1045
|
+
try:
|
|
1046
|
+
return fn(*a, **kw)
|
|
1047
|
+
except (case_mod.CaseError, chains_mod.ChainError) as e:
|
|
1048
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1049
|
+
raise typer.Exit(1)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
@case_app.command("new")
|
|
1053
|
+
def case_new(name: str = CASE_ARG,
|
|
1054
|
+
note: str = typer.Option("", "--note", "-n", help="What the case is about")):
|
|
1055
|
+
"""Start a case."""
|
|
1056
|
+
from crypttrace import case as case_mod
|
|
1057
|
+
p = _case_call(case_mod.new, name, note)
|
|
1058
|
+
console.print(f"[green]✓ Case created[/green] {p}\n[dim]Add addresses with "
|
|
1059
|
+
f"`crypttrace case add {name} ADDRESS --chain … --role suspect`[/dim]")
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
@case_app.command("list")
|
|
1063
|
+
def case_list():
|
|
1064
|
+
"""Cases in ~/.crypttrace/cases."""
|
|
1065
|
+
from crypttrace import case as case_mod
|
|
1066
|
+
cases = case_mod.all_cases()
|
|
1067
|
+
if not cases:
|
|
1068
|
+
console.print("[dim]No cases yet. Start one with `crypttrace case new NAME`.[/dim]")
|
|
1069
|
+
return
|
|
1070
|
+
t = render.Table(header_style="bold")
|
|
1071
|
+
for c in ("Case", "Addresses", "Notes", "Updated (UTC)"):
|
|
1072
|
+
t.add_column(c)
|
|
1073
|
+
for c in cases:
|
|
1074
|
+
t.add_row(c["name"], str(c["addresses"]), str(c["notes"]), render._ts(str(c["updated"] or 0)))
|
|
1075
|
+
console.print(t)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@case_app.command("add")
|
|
1079
|
+
def case_add(name: str = CASE_ARG,
|
|
1080
|
+
address: str = typer.Argument(..., help="Address to add"),
|
|
1081
|
+
chain: str = CHAIN_OPT,
|
|
1082
|
+
role: str = typer.Option("other", "--role", "-r",
|
|
1083
|
+
help="victim, suspect, intermediary, exchange, mixer, bridge or other"),
|
|
1084
|
+
note: str = typer.Option("", "--note", "-n", help="Why it is in the case")):
|
|
1085
|
+
"""Add an address to a case (adding it again updates its role and note)."""
|
|
1086
|
+
from crypttrace import case as case_mod
|
|
1087
|
+
a = _case_call(case_mod.add, name, address, chain, role, note)
|
|
1088
|
+
console.print(f"[green]✓[/green] {a['address']} ({a['chain']}) as {a['role']}"
|
|
1089
|
+
+ (f" — {a['note']}" if a.get("note") else ""))
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
@case_app.command("remove")
|
|
1093
|
+
def case_remove(name: str = CASE_ARG, address: str = typer.Argument(..., help="Address to drop"),
|
|
1094
|
+
chain: Optional[str] = typer.Option(None, "--chain", "-c", help="Only on this chain")):
|
|
1095
|
+
"""Drop an address from a case."""
|
|
1096
|
+
from crypttrace import case as case_mod
|
|
1097
|
+
if _case_call(case_mod.remove, name, address, chain):
|
|
1098
|
+
console.print(f"[green]✓ Removed[/green] {address}")
|
|
1099
|
+
else:
|
|
1100
|
+
console.print("[dim]That address is not in the case.[/dim]")
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
@case_app.command("note")
|
|
1104
|
+
def case_note(name: str = CASE_ARG, text: str = typer.Argument(..., help="The note")):
|
|
1105
|
+
"""Add a timestamped note to the case log."""
|
|
1106
|
+
from crypttrace import case as case_mod
|
|
1107
|
+
_case_call(case_mod.note, name, text)
|
|
1108
|
+
console.print("[green]✓ Noted[/green]")
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
@case_app.command("show")
|
|
1112
|
+
def case_show(name: str = CASE_ARG):
|
|
1113
|
+
"""The case: its addresses with current labels, the last snapshot, the log."""
|
|
1114
|
+
from crypttrace import case as case_mod
|
|
1115
|
+
c = _case_call(case_mod.load, name)
|
|
1116
|
+
console.print(f"[bold]{c['name']}[/bold] [dim]{c['_path']}[/dim]")
|
|
1117
|
+
if c["addresses"]:
|
|
1118
|
+
t = render.Table(header_style="bold")
|
|
1119
|
+
for col in ("Role", "Chain", "Address", "Label", "Note", "Last snapshot"):
|
|
1120
|
+
t.add_column(col)
|
|
1121
|
+
for a in c["addresses"]:
|
|
1122
|
+
s = a.get("snapshot") or {}
|
|
1123
|
+
snap = "—" if not s else (
|
|
1124
|
+
(f"{s['balance']:,.8f}" if s.get("balance") is not None else "?") + " "
|
|
1125
|
+
f"{chains_mod.symbol(a['chain'])} · seen "
|
|
1126
|
+
+ (render._ts(str(s['last_seen'])) if s.get("last_seen") else "never")
|
|
1127
|
+
+ f" · taken {render._ts(str(s['at']))}")
|
|
1128
|
+
t.add_row(a["role"], a["chain"], a["address"], labels.label_of(a["address"]) or "",
|
|
1129
|
+
a.get("note") or "", snap)
|
|
1130
|
+
console.print(t)
|
|
1131
|
+
else:
|
|
1132
|
+
console.print("[dim]No addresses yet.[/dim]")
|
|
1133
|
+
for n in c.get("notes", []):
|
|
1134
|
+
console.print(f" [dim]{render._ts(str(n['at']))}[/dim] {n['text']}")
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
@case_app.command("refresh")
|
|
1138
|
+
def case_refresh(name: str = CASE_ARG):
|
|
1139
|
+
"""Read every address again and say what changed since the last refresh."""
|
|
1140
|
+
from crypttrace import case as case_mod
|
|
1141
|
+
rep = _case_call(case_mod.refresh, name)
|
|
1142
|
+
if not rep:
|
|
1143
|
+
console.print("[dim]The case has no addresses.[/dim]")
|
|
1144
|
+
return
|
|
1145
|
+
moved = [r for r in rep if r["moved"]]
|
|
1146
|
+
for r in rep:
|
|
1147
|
+
mark = "[bold yellow]●[/bold yellow]" if r["moved"] else "[dim]·[/dim]"
|
|
1148
|
+
console.print(f"{mark} {r['role']:<12} {r['chain']:<8} {r['address']}")
|
|
1149
|
+
for d in r["changes"]:
|
|
1150
|
+
console.print(f" {d}")
|
|
1151
|
+
console.print(f"\n[bold]{len(moved)}[/bold] of {len(rep)} address(es) changed since the last refresh."
|
|
1152
|
+
if any(r["previous"] for r in rep) else "\nFirst snapshot taken; the next refresh "
|
|
1153
|
+
"will say what changed.")
|
|
1154
|
+
|
|
1155
|
+
|
|
1037
1156
|
if __name__ == "__main__":
|
|
1038
1157
|
app()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.33.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -329,6 +329,26 @@ entities — that's the OSINT handoff point. `--direction out` (default) answers
|
|
|
329
329
|
money come from", which is how you vet a suspicious address or find a victim's
|
|
330
330
|
source of funds.
|
|
331
331
|
|
|
332
|
+
### Cases — an investigation kept as a file
|
|
333
|
+
|
|
334
|
+
An investigation rarely fits one address. `case` keeps the addresses (any
|
|
335
|
+
chain, each with a role: victim, suspect, intermediary, exchange, mixer, bridge
|
|
336
|
+
or other), a log of notes, and the last snapshot of each address. `refresh`
|
|
337
|
+
reads them all again and says what changed: a balance that went down, new
|
|
338
|
+
activity since the last look.
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
crypttrace case new ronin --note "Ronin bridge exploit, March 2022"
|
|
342
|
+
crypttrace case add ronin 0x098b…2f96 --chain eth --role suspect --note "exploiter"
|
|
343
|
+
crypttrace case note ronin "asked Binance for KYC on the first-gas withdrawal"
|
|
344
|
+
crypttrace case refresh ronin # what moved since the last refresh
|
|
345
|
+
crypttrace case show ronin # addresses, current labels, last snapshot, the log
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
A case is one JSON file in `~/.crypttrace/cases/`. Every `case` command also
|
|
349
|
+
takes a path to a `.json` file, so handing a case to a colleague is sending the
|
|
350
|
+
file.
|
|
351
|
+
|
|
332
352
|
### Watch & alerts — catching the cash-out
|
|
333
353
|
|
|
334
354
|
The only window to freeze stolen funds is the moment they reach an exchange
|
|
@@ -727,6 +747,7 @@ src/crypttrace/
|
|
|
727
747
|
offramp.py # exchange-deposit detection
|
|
728
748
|
bridges.py # cross-chain matching
|
|
729
749
|
watch.py # watchlist + alerts
|
|
750
|
+
case.py # investigations as files: addresses, roles, notes, snapshots
|
|
730
751
|
report.py # Markdown + JSON reports
|
|
731
752
|
export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
|
|
732
753
|
prices.py # USD valuation
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|