nodeprobe 0.1.3__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.
Files changed (53) hide show
  1. nodeprobe/__init__.py +3 -0
  2. nodeprobe/__main__.py +4 -0
  3. nodeprobe/chains.py +87 -0
  4. nodeprobe/cli.py +440 -0
  5. nodeprobe/contract/__init__.py +18 -0
  6. nodeprobe/contract/address.py +21 -0
  7. nodeprobe/contract/bytecode.py +77 -0
  8. nodeprobe/contract/interfaces.py +72 -0
  9. nodeprobe/contract/proxy.py +104 -0
  10. nodeprobe/contract/sourcify.py +98 -0
  11. nodeprobe/contract_engine.py +485 -0
  12. nodeprobe/data/__init__.py +1 -0
  13. nodeprobe/data/chains_mini.json +1 -0
  14. nodeprobe/engine.py +193 -0
  15. nodeprobe/escalation.py +706 -0
  16. nodeprobe/escalation_contract.py +304 -0
  17. nodeprobe/escalation_web.py +379 -0
  18. nodeprobe/http_client.py +168 -0
  19. nodeprobe/killswitch.py +46 -0
  20. nodeprobe/models.py +114 -0
  21. nodeprobe/multichain/__init__.py +230 -0
  22. nodeprobe/multichain/aptos_engine.py +455 -0
  23. nodeprobe/multichain/common.py +162 -0
  24. nodeprobe/multichain/cosmos_engine.py +348 -0
  25. nodeprobe/multichain/detect.py +110 -0
  26. nodeprobe/multichain/near_engine.py +459 -0
  27. nodeprobe/multichain/solana_engine.py +371 -0
  28. nodeprobe/multichain/starknet_engine.py +418 -0
  29. nodeprobe/multichain/substrate_engine.py +246 -0
  30. nodeprobe/multichain/sui_engine.py +451 -0
  31. nodeprobe/profiles.py +72 -0
  32. nodeprobe/providers.py +41 -0
  33. nodeprobe/report.py +625 -0
  34. nodeprobe/rpc.py +151 -0
  35. nodeprobe/rules/__init__.py +36 -0
  36. nodeprobe/rules/base.py +73 -0
  37. nodeprobe/rules/client.py +187 -0
  38. nodeprobe/rules/identity.py +147 -0
  39. nodeprobe/rules/namespaces.py +336 -0
  40. nodeprobe/rules/tls_http.py +157 -0
  41. nodeprobe/rules/web/__init__.py +23 -0
  42. nodeprobe/rules/web/headers.py +500 -0
  43. nodeprobe/rules/web/tls.py +99 -0
  44. nodeprobe/rules/web/well_known.py +117 -0
  45. nodeprobe/safety.py +133 -0
  46. nodeprobe/scoring.py +42 -0
  47. nodeprobe/web_engine.py +154 -0
  48. nodeprobe-0.1.3.dist-info/METADATA +95 -0
  49. nodeprobe-0.1.3.dist-info/RECORD +53 -0
  50. nodeprobe-0.1.3.dist-info/WHEEL +5 -0
  51. nodeprobe-0.1.3.dist-info/entry_points.txt +2 -0
  52. nodeprobe-0.1.3.dist-info/licenses/LICENSE +21 -0
  53. nodeprobe-0.1.3.dist-info/top_level.txt +1 -0
nodeprobe/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Nodeprobe EVM RPC security scanner."""
2
+
3
+ __version__ = "0.1.3"
nodeprobe/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from nodeprobe.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
nodeprobe/chains.py ADDED
@@ -0,0 +1,87 @@
1
+ """EVM chain registry — any chain ID is accepted; names from Chainlist when known."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+ from importlib import resources
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ChainInfo:
13
+ chain_id: int
14
+ name: str
15
+ short_name: str
16
+ is_testnet: bool = False
17
+ listed: bool = True
18
+
19
+
20
+ # Backward-compatible alias used by older imports/tests.
21
+ SUPPORTED_CHAINS: dict[int, ChainInfo] = {}
22
+
23
+
24
+ class UnsupportedChainError(ValueError):
25
+ """Deprecated: scans no longer abort on unknown chains.
26
+
27
+ Kept so older callers that catch this exception continue to work.
28
+ """
29
+
30
+ def __init__(self, chain_id: int | None, message: str | None = None):
31
+ self.chain_id = chain_id
32
+ super().__init__(
33
+ message or f"Unsupported or unknown chain ID: {chain_id}."
34
+ )
35
+
36
+
37
+ @lru_cache(maxsize=1)
38
+ def _load_registry() -> dict[int, ChainInfo]:
39
+ text = resources.files("nodeprobe").joinpath("data/chains_mini.json").read_text(
40
+ encoding="utf-8"
41
+ )
42
+ raw = json.loads(text)
43
+ registry: dict[int, ChainInfo] = {}
44
+ for key, value in raw.items():
45
+ chain_id = int(key)
46
+ registry[chain_id] = ChainInfo(
47
+ chain_id=chain_id,
48
+ name=str(value["name"]),
49
+ short_name=str(value.get("short_name") or f"chain-{chain_id}"),
50
+ is_testnet=bool(value.get("is_testnet")),
51
+ listed=True,
52
+ )
53
+ # Populate alias for introspection / tests that inspect the map.
54
+ SUPPORTED_CHAINS.clear()
55
+ SUPPORTED_CHAINS.update(registry)
56
+ return registry
57
+
58
+
59
+ def known_chains() -> dict[int, ChainInfo]:
60
+ return dict(_load_registry())
61
+
62
+
63
+ def resolve_chain(chain_id: int) -> ChainInfo:
64
+ """Resolve any EVM chain ID. Unknown IDs get a generic name and continue scanning."""
65
+ if not isinstance(chain_id, int):
66
+ raise TypeError(f"chain_id must be int, got {type(chain_id)!r}")
67
+ if chain_id < 0:
68
+ raise ValueError(f"chain_id must be non-negative, got {chain_id}")
69
+ info = _load_registry().get(chain_id)
70
+ if info is not None:
71
+ return info
72
+ return ChainInfo(
73
+ chain_id=chain_id,
74
+ name=f"Chain {chain_id}",
75
+ short_name=f"chain-{chain_id}",
76
+ is_testnet=False,
77
+ listed=False,
78
+ )
79
+
80
+
81
+ def parse_hex_or_int(value: str | int) -> int:
82
+ if isinstance(value, int):
83
+ return value
84
+ text = str(value).strip()
85
+ if text.startswith("0x") or text.startswith("0X"):
86
+ return int(text, 16)
87
+ return int(text)
nodeprobe/cli.py ADDED
@@ -0,0 +1,440 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from nodeprobe.contract_engine import ContractScannerEngine
8
+ from nodeprobe.engine import ScannerEngine
9
+ from nodeprobe.multichain import MultichainRpcEngine
10
+ from nodeprobe.multichain.aptos_engine import APTOS_RULE_CATALOG, AptosScannerEngine
11
+ from nodeprobe.multichain.cosmos_engine import COSMOS_RULE_CATALOG, CosmosScannerEngine
12
+ from nodeprobe.multichain.near_engine import NEAR_RULE_CATALOG, NearScannerEngine
13
+ from nodeprobe.multichain.solana_engine import SOLANA_RULE_CATALOG, SolanaScannerEngine
14
+ from nodeprobe.multichain.starknet_engine import STARKNET_RULE_CATALOG, StarknetScannerEngine
15
+ from nodeprobe.multichain.substrate_engine import (
16
+ SUBSTRATE_RULE_CATALOG,
17
+ SubstrateScannerEngine,
18
+ )
19
+ from nodeprobe.multichain.sui_engine import SUI_RULE_CATALOG, SuiScannerEngine
20
+ from nodeprobe.profiles import PROFILES
21
+ from nodeprobe.report import format_html_report, format_human_report
22
+ from nodeprobe.rules import all_rules
23
+ from nodeprobe.rules.web import web_rules
24
+ from nodeprobe.web_engine import WebScannerEngine
25
+
26
+ CONTRACT_RULE_CATALOG = [
27
+ {"rule_id": "SC-IDENT-001", "title": "RPC chain ID mismatch", "category": "Identity"},
28
+ {"rule_id": "SC-IDENT-002", "title": "Chain not in local name registry", "category": "Identity"},
29
+ {"rule_id": "SC-CODE-001", "title": "Contract code presence", "category": "Code"},
30
+ {"rule_id": "SC-PROXY-001", "title": "Proxy pattern detection", "category": "Proxy"},
31
+ {"rule_id": "SC-BYTE-001", "title": "SELFDESTRUCT presence", "category": "Bytecode"},
32
+ {"rule_id": "SC-BYTE-002", "title": "DELEGATECALL presence", "category": "Bytecode"},
33
+ {"rule_id": "SC-SRC-001", "title": "Sourcify verification", "category": "Source"},
34
+ {"rule_id": "SC-IFACE-001", "title": "Interface hints", "category": "Interfaces"},
35
+ {"rule_id": "SC-IFACE-002", "title": "Ownable-style ownership surface", "category": "Access Control"},
36
+ ]
37
+
38
+
39
+ _PROFILE_HELP = (
40
+ "Scan profile: Quick | Standard | Deep "
41
+ "(aliases: Free→Quick, Outbound→Standard, Authorized-Full→Deep; default: Standard)"
42
+ )
43
+ _FAMILY_HELP = (
44
+ "RPC family: auto | evm | solana | substrate | cosmos | aptos | sui | starknet | near "
45
+ "(default: auto)"
46
+ )
47
+
48
+
49
+ def _add_output_flags(parser: argparse.ArgumentParser) -> None:
50
+ fmt = parser.add_mutually_exclusive_group()
51
+ fmt.add_argument(
52
+ "--json",
53
+ action="store_true",
54
+ help="Emit JSON instead of a human-readable report",
55
+ )
56
+ fmt.add_argument(
57
+ "--html",
58
+ action="store_true",
59
+ help="Emit a self-contained HTML report",
60
+ )
61
+ parser.add_argument(
62
+ "--pretty",
63
+ action="store_true",
64
+ help="With --json: indent JSON. Without --json: human report (default).",
65
+ )
66
+ parser.add_argument(
67
+ "-o",
68
+ "--output",
69
+ metavar="PATH",
70
+ help="Write the report to PATH instead of stdout",
71
+ )
72
+ parser.add_argument(
73
+ "-v",
74
+ "--verbose",
75
+ action="store_true",
76
+ help="Human report: include evidence and full detail for Low/Info findings",
77
+ )
78
+ color = parser.add_mutually_exclusive_group()
79
+ color.add_argument(
80
+ "--color",
81
+ action="store_true",
82
+ help="Force ANSI colors in the human report",
83
+ )
84
+ color.add_argument(
85
+ "--no-color",
86
+ action="store_true",
87
+ help="Disable ANSI colors in the human report",
88
+ )
89
+
90
+
91
+ def _add_rpc_flags(parser: argparse.ArgumentParser, *, include_family: bool = False) -> None:
92
+ parser.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
93
+ if include_family:
94
+ parser.add_argument("--family", default="auto", help=_FAMILY_HELP)
95
+ parser.add_argument(
96
+ "--block-providers",
97
+ action="store_true",
98
+ help="Block known third-party provider hostnames (EVM only)",
99
+ )
100
+ _add_output_flags(parser)
101
+
102
+
103
+ def build_parser() -> argparse.ArgumentParser:
104
+ parser = argparse.ArgumentParser(
105
+ prog="nodeprobe",
106
+ description=(
107
+ "Nodeprobe — website, multi-chain RPC, and EVM contract scanner"
108
+ ),
109
+ )
110
+ sub = parser.add_subparsers(dest="command", required=True)
111
+
112
+ rpc = sub.add_parser(
113
+ "rpc",
114
+ help="Scan an RPC endpoint (auto-detect family, or set --family)",
115
+ )
116
+ rpc.add_argument("url", help="HTTP(S) RPC endpoint URL")
117
+ _add_rpc_flags(rpc, include_family=True)
118
+
119
+ scan = sub.add_parser("scan", help="Scan an EVM JSON-RPC endpoint (alias of rpc --family evm)")
120
+ scan.add_argument("url", help="HTTP(S) JSON-RPC endpoint URL")
121
+ _add_rpc_flags(scan)
122
+
123
+ solana = sub.add_parser("solana", help="Scan a Solana JSON-RPC endpoint")
124
+ solana.add_argument("url", help="HTTP(S) Solana RPC URL")
125
+ solana.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
126
+ _add_output_flags(solana)
127
+
128
+ substrate = sub.add_parser(
129
+ "substrate",
130
+ help="Scan a Substrate / Polkadot JSON-RPC endpoint",
131
+ )
132
+ substrate.add_argument("url", help="HTTP(S) Substrate RPC URL")
133
+ substrate.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
134
+ _add_output_flags(substrate)
135
+
136
+ cosmos = sub.add_parser("cosmos", help="Scan a Cosmos / Tendermint RPC endpoint")
137
+ cosmos.add_argument("url", help="HTTP(S) Tendermint / Cosmos RPC URL")
138
+ cosmos.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
139
+ _add_output_flags(cosmos)
140
+
141
+ aptos = sub.add_parser("aptos", help="Scan an Aptos fullnode REST API (/v1)")
142
+ aptos.add_argument("url", help="HTTP(S) Aptos fullnode URL (with or without /v1)")
143
+ aptos.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
144
+ _add_output_flags(aptos)
145
+
146
+ sui = sub.add_parser("sui", help="Scan a Sui GraphQL RPC endpoint")
147
+ sui.add_argument("url", help="HTTP(S) Sui GraphQL RPC URL")
148
+ sui.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
149
+ _add_output_flags(sui)
150
+
151
+ starknet = sub.add_parser("starknet", help="Scan a Starknet JSON-RPC endpoint")
152
+ starknet.add_argument("url", help="HTTP(S) Starknet JSON-RPC URL")
153
+ starknet.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
154
+ _add_output_flags(starknet)
155
+
156
+ near = sub.add_parser("near", help="Scan a NEAR JSON-RPC endpoint")
157
+ near.add_argument("url", help="HTTP(S) NEAR JSON-RPC URL")
158
+ near.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
159
+ _add_output_flags(near)
160
+
161
+ web = sub.add_parser("web", help="Scan a website (HTTP/TLS surface)")
162
+ web.add_argument("url", help="HTTP(S) website URL")
163
+ web.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
164
+ _add_output_flags(web)
165
+
166
+ contract = sub.add_parser("contract", help="Scan an EVM smart contract via read-only RPC")
167
+ contract.add_argument("address", help="Contract address (0x…)")
168
+ contract.add_argument("--rpc", required=True, help="HTTP(S) JSON-RPC URL for the chain")
169
+ contract.add_argument("--chain", type=int, default=None, help="Expected chain ID")
170
+ contract.add_argument("--profile", default="Standard", help=_PROFILE_HELP)
171
+ contract.add_argument(
172
+ "--no-sourcify",
173
+ action="store_true",
174
+ help="Skip Sourcify verification lookup",
175
+ )
176
+ _add_output_flags(contract)
177
+
178
+ sub.add_parser("profiles", help="List scan profiles and budgets")
179
+ rules = sub.add_parser("rules", help="List registered rules")
180
+ rules.add_argument(
181
+ "--module",
182
+ choices=(
183
+ "rpc",
184
+ "evm",
185
+ "web",
186
+ "contract",
187
+ "solana",
188
+ "substrate",
189
+ "cosmos",
190
+ "aptos",
191
+ "sui",
192
+ "starknet",
193
+ "near",
194
+ "all",
195
+ ),
196
+ default="all",
197
+ help="Rule module to list (default: all)",
198
+ )
199
+
200
+ return parser
201
+
202
+
203
+ def _color_flag(args: argparse.Namespace) -> bool | None:
204
+ if getattr(args, "no_color", False):
205
+ return False
206
+ if getattr(args, "color", False):
207
+ return True
208
+ return None
209
+
210
+
211
+ def _print_result(
212
+ result,
213
+ *,
214
+ as_json: bool,
215
+ as_html: bool,
216
+ pretty: bool,
217
+ color: bool | None,
218
+ verbose: bool = False,
219
+ output: str | None = None,
220
+ ) -> int:
221
+ if as_json:
222
+ payload = json.dumps(result.to_dict(), indent=2 if pretty else None)
223
+ elif as_html:
224
+ payload = format_html_report(result)
225
+ else:
226
+ payload = format_human_report(result, color=color, verbose=verbose)
227
+
228
+ if output:
229
+ with open(output, "w", encoding="utf-8") as handle:
230
+ handle.write(payload if payload.endswith("\n") else payload + "\n")
231
+ print(f"Wrote report to {output}", file=sys.stderr)
232
+ else:
233
+ print(payload, end="" if payload.endswith("\n") else "\n")
234
+
235
+ if result.aborted and result.abort_reason in {
236
+ "unsafe_target",
237
+ "third_party_provider",
238
+ "kill_switch",
239
+ "invalid_address",
240
+ "unknown_family",
241
+ }:
242
+ return 2
243
+ return 0
244
+
245
+
246
+ def main(argv: list[str] | None = None) -> int:
247
+ parser = build_parser()
248
+ args = parser.parse_args(argv)
249
+
250
+ if args.command == "profiles":
251
+ payload = {
252
+ name.value: {
253
+ "max_requests": limits.max_requests,
254
+ "max_rps": limits.max_rps,
255
+ "max_duration_seconds": limits.max_duration_seconds,
256
+ "allow_expensive_namespace_calls": limits.allow_expensive_namespace_calls,
257
+ "allow_rate_limit_stress": limits.allow_rate_limit_stress,
258
+ }
259
+ for name, limits in PROFILES.items()
260
+ }
261
+ print(json.dumps(payload, indent=2))
262
+ return 0
263
+
264
+ if args.command == "rules":
265
+ module = args.module
266
+ payload = []
267
+ if module in {"rpc", "evm", "all"}:
268
+ payload.extend(
269
+ {
270
+ "rule_id": r.meta.rule_id,
271
+ "title": r.meta.title,
272
+ "category": r.meta.category,
273
+ "severity": r.meta.severity.value,
274
+ "kind": r.meta.kind.value,
275
+ "profiles": [p.value for p in r.meta.allowed_profiles],
276
+ "module": "evm",
277
+ }
278
+ for r in all_rules()
279
+ )
280
+ if module in {"web", "all"}:
281
+ payload.extend(
282
+ {
283
+ "rule_id": r.meta.rule_id,
284
+ "title": r.meta.title,
285
+ "category": r.meta.category,
286
+ "severity": r.meta.severity.value,
287
+ "kind": r.meta.kind.value,
288
+ "profiles": [p.value for p in r.meta.allowed_profiles],
289
+ "module": "web",
290
+ }
291
+ for r in web_rules()
292
+ )
293
+ if module in {"contract", "all"}:
294
+ payload.extend(
295
+ {
296
+ **item,
297
+ "severity": "varies",
298
+ "kind": "finding",
299
+ "profiles": ["Quick", "Standard", "Deep"],
300
+ "module": "contract",
301
+ }
302
+ for item in CONTRACT_RULE_CATALOG
303
+ )
304
+ if module in {"solana", "rpc", "all"}:
305
+ payload.extend(
306
+ {**item, "severity": "varies", "kind": "finding", "profiles": ["Quick", "Standard", "Deep"], "module": "solana"}
307
+ for item in SOLANA_RULE_CATALOG
308
+ )
309
+ if module in {"substrate", "rpc", "all"}:
310
+ payload.extend(
311
+ {**item, "severity": "varies", "kind": "finding", "profiles": ["Quick", "Standard", "Deep"], "module": "substrate"}
312
+ for item in SUBSTRATE_RULE_CATALOG
313
+ )
314
+ if module in {"cosmos", "rpc", "all"}:
315
+ payload.extend(
316
+ {**item, "severity": "varies", "kind": "finding", "profiles": ["Quick", "Standard", "Deep"], "module": "cosmos"}
317
+ for item in COSMOS_RULE_CATALOG
318
+ )
319
+ if module in {"aptos", "rpc", "all"}:
320
+ payload.extend(
321
+ {
322
+ **item,
323
+ "severity": "varies",
324
+ "kind": "finding",
325
+ "profiles": ["Quick", "Standard", "Deep"],
326
+ "module": "aptos",
327
+ }
328
+ for item in APTOS_RULE_CATALOG
329
+ )
330
+ if module in {"sui", "rpc", "all"}:
331
+ payload.extend(
332
+ {
333
+ **item,
334
+ "severity": "varies",
335
+ "kind": "finding",
336
+ "profiles": ["Quick", "Standard", "Deep"],
337
+ "module": "sui",
338
+ }
339
+ for item in SUI_RULE_CATALOG
340
+ )
341
+ if module in {"starknet", "rpc", "all"}:
342
+ payload.extend(
343
+ {
344
+ **item,
345
+ "severity": "varies",
346
+ "kind": "finding",
347
+ "profiles": ["Quick", "Standard", "Deep"],
348
+ "module": "starknet",
349
+ }
350
+ for item in STARKNET_RULE_CATALOG
351
+ )
352
+ if module in {"near", "rpc", "all"}:
353
+ payload.extend(
354
+ {
355
+ **item,
356
+ "severity": "varies",
357
+ "kind": "finding",
358
+ "profiles": ["Quick", "Standard", "Deep"],
359
+ "module": "near",
360
+ }
361
+ for item in NEAR_RULE_CATALOG
362
+ )
363
+ print(json.dumps(payload, indent=2))
364
+ return 0
365
+
366
+ color = _color_flag(args)
367
+ out_kwargs = {
368
+ "as_json": bool(getattr(args, "json", False)),
369
+ "as_html": bool(getattr(args, "html", False)),
370
+ "pretty": bool(getattr(args, "pretty", False)),
371
+ "color": color,
372
+ "verbose": bool(getattr(args, "verbose", False)),
373
+ "output": getattr(args, "output", None),
374
+ }
375
+
376
+ if args.command == "rpc":
377
+ result = MultichainRpcEngine(
378
+ args.url,
379
+ args.profile,
380
+ family=args.family,
381
+ block_providers=args.block_providers,
382
+ ).run()
383
+ return _print_result(result, **out_kwargs)
384
+
385
+ if args.command == "scan":
386
+ result = ScannerEngine(
387
+ args.url,
388
+ args.profile,
389
+ block_providers=args.block_providers,
390
+ ).run()
391
+ return _print_result(result, **out_kwargs)
392
+
393
+ if args.command == "solana":
394
+ result = SolanaScannerEngine(args.url, args.profile).run()
395
+ return _print_result(result, **out_kwargs)
396
+
397
+ if args.command == "substrate":
398
+ result = SubstrateScannerEngine(args.url, args.profile).run()
399
+ return _print_result(result, **out_kwargs)
400
+
401
+ if args.command == "cosmos":
402
+ result = CosmosScannerEngine(args.url, args.profile).run()
403
+ return _print_result(result, **out_kwargs)
404
+
405
+ if args.command == "aptos":
406
+ result = AptosScannerEngine(args.url, args.profile).run()
407
+ return _print_result(result, **out_kwargs)
408
+
409
+ if args.command == "sui":
410
+ result = SuiScannerEngine(args.url, args.profile).run()
411
+ return _print_result(result, **out_kwargs)
412
+
413
+ if args.command == "starknet":
414
+ result = StarknetScannerEngine(args.url, args.profile).run()
415
+ return _print_result(result, **out_kwargs)
416
+
417
+ if args.command == "near":
418
+ result = NearScannerEngine(args.url, args.profile).run()
419
+ return _print_result(result, **out_kwargs)
420
+
421
+ if args.command == "web":
422
+ result = WebScannerEngine(args.url, args.profile).run()
423
+ return _print_result(result, **out_kwargs)
424
+
425
+ if args.command == "contract":
426
+ result = ContractScannerEngine(
427
+ args.address,
428
+ rpc_url=args.rpc,
429
+ chain_id=args.chain,
430
+ profile=args.profile,
431
+ fetch_verification=not args.no_sourcify,
432
+ ).run()
433
+ return _print_result(result, **out_kwargs)
434
+
435
+ parser.error(f"Unknown command: {args.command}")
436
+ return 1
437
+
438
+
439
+ if __name__ == "__main__":
440
+ sys.exit(main())
@@ -0,0 +1,18 @@
1
+ from nodeprobe.contract.address import is_empty_code, normalize_address
2
+ from nodeprobe.contract.bytecode import analyze_bytecode, extract_selectors
3
+ from nodeprobe.contract.interfaces import interfaces_from_abi, interfaces_from_selectors
4
+ from nodeprobe.contract.proxy import detect_eip1167, detect_proxies_from_slots
5
+ from nodeprobe.contract.sourcify import SourcifyMatch, fetch_sourcify
6
+
7
+ __all__ = [
8
+ "SourcifyMatch",
9
+ "analyze_bytecode",
10
+ "detect_eip1167",
11
+ "detect_proxies_from_slots",
12
+ "extract_selectors",
13
+ "fetch_sourcify",
14
+ "interfaces_from_abi",
15
+ "interfaces_from_selectors",
16
+ "is_empty_code",
17
+ "normalize_address",
18
+ ]
@@ -0,0 +1,21 @@
1
+ """Address helpers for contract scans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _ADDR_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
8
+
9
+
10
+ def normalize_address(value: str) -> str:
11
+ text = (value or "").strip()
12
+ if not _ADDR_RE.match(text):
13
+ raise ValueError(f"Invalid EVM address: {value!r}")
14
+ return text.lower()
15
+
16
+
17
+ def is_empty_code(code: str | None) -> bool:
18
+ if code is None:
19
+ return True
20
+ cleaned = code.strip().lower()
21
+ return cleaned in {"", "0x", "0x0"}
@@ -0,0 +1,77 @@
1
+ """Bytecode opcode heuristics (presence-only, no symbolic execution)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class OpcodeHit:
10
+ name: str
11
+ opcode: str
12
+ count: int
13
+
14
+
15
+ # Single-byte opcodes of interest. Presence is informational / low-severity —
16
+ # many legitimate contracts use DELEGATECALL (proxies) and rarely SELFDESTRUCT.
17
+ _INTERESTING = {
18
+ "f4": "DELEGATECALL",
19
+ "ff": "SELFDESTRUCT",
20
+ "f1": "CALL",
21
+ "f2": "CALLCODE",
22
+ "55": "SSTORE",
23
+ }
24
+
25
+
26
+ def analyze_bytecode(code_hex: str) -> list[OpcodeHit]:
27
+ raw = code_hex.lower().removeprefix("0x")
28
+ if len(raw) % 2:
29
+ raw = raw[:-1]
30
+ counts: dict[str, int] = {k: 0 for k in _INTERESTING}
31
+ i = 0
32
+ while i < len(raw) - 1:
33
+ op = raw[i : i + 2]
34
+ # PUSH1..PUSH32 skip immediate data
35
+ try:
36
+ op_int = int(op, 16)
37
+ except ValueError:
38
+ i += 2
39
+ continue
40
+ if 0x60 <= op_int <= 0x7F:
41
+ n = op_int - 0x5F
42
+ i += 2 + (n * 2)
43
+ continue
44
+ if op in counts:
45
+ counts[op] += 1
46
+ i += 2
47
+ return [
48
+ OpcodeHit(name=_INTERESTING[op], opcode="0x" + op, count=count)
49
+ for op, count in counts.items()
50
+ if count > 0
51
+ ]
52
+
53
+
54
+ def extract_selectors(code_hex: str) -> set[str]:
55
+ """Best-effort 4-byte selector harvest from PUSH4 immediates."""
56
+ raw = code_hex.lower().removeprefix("0x")
57
+ selectors: set[str] = set()
58
+ i = 0
59
+ while i < len(raw) - 1:
60
+ op = raw[i : i + 2]
61
+ try:
62
+ op_int = int(op, 16)
63
+ except ValueError:
64
+ i += 2
65
+ continue
66
+ if op_int == 0x63: # PUSH4
67
+ sel = raw[i + 2 : i + 10]
68
+ if len(sel) == 8:
69
+ selectors.add("0x" + sel)
70
+ i += 10
71
+ continue
72
+ if 0x60 <= op_int <= 0x7F:
73
+ n = op_int - 0x5F
74
+ i += 2 + (n * 2)
75
+ continue
76
+ i += 2
77
+ return selectors