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.
- nodeprobe/__init__.py +3 -0
- nodeprobe/__main__.py +4 -0
- nodeprobe/chains.py +87 -0
- nodeprobe/cli.py +440 -0
- nodeprobe/contract/__init__.py +18 -0
- nodeprobe/contract/address.py +21 -0
- nodeprobe/contract/bytecode.py +77 -0
- nodeprobe/contract/interfaces.py +72 -0
- nodeprobe/contract/proxy.py +104 -0
- nodeprobe/contract/sourcify.py +98 -0
- nodeprobe/contract_engine.py +485 -0
- nodeprobe/data/__init__.py +1 -0
- nodeprobe/data/chains_mini.json +1 -0
- nodeprobe/engine.py +193 -0
- nodeprobe/escalation.py +706 -0
- nodeprobe/escalation_contract.py +304 -0
- nodeprobe/escalation_web.py +379 -0
- nodeprobe/http_client.py +168 -0
- nodeprobe/killswitch.py +46 -0
- nodeprobe/models.py +114 -0
- nodeprobe/multichain/__init__.py +230 -0
- nodeprobe/multichain/aptos_engine.py +455 -0
- nodeprobe/multichain/common.py +162 -0
- nodeprobe/multichain/cosmos_engine.py +348 -0
- nodeprobe/multichain/detect.py +110 -0
- nodeprobe/multichain/near_engine.py +459 -0
- nodeprobe/multichain/solana_engine.py +371 -0
- nodeprobe/multichain/starknet_engine.py +418 -0
- nodeprobe/multichain/substrate_engine.py +246 -0
- nodeprobe/multichain/sui_engine.py +451 -0
- nodeprobe/profiles.py +72 -0
- nodeprobe/providers.py +41 -0
- nodeprobe/report.py +625 -0
- nodeprobe/rpc.py +151 -0
- nodeprobe/rules/__init__.py +36 -0
- nodeprobe/rules/base.py +73 -0
- nodeprobe/rules/client.py +187 -0
- nodeprobe/rules/identity.py +147 -0
- nodeprobe/rules/namespaces.py +336 -0
- nodeprobe/rules/tls_http.py +157 -0
- nodeprobe/rules/web/__init__.py +23 -0
- nodeprobe/rules/web/headers.py +500 -0
- nodeprobe/rules/web/tls.py +99 -0
- nodeprobe/rules/web/well_known.py +117 -0
- nodeprobe/safety.py +133 -0
- nodeprobe/scoring.py +42 -0
- nodeprobe/web_engine.py +154 -0
- nodeprobe-0.1.3.dist-info/METADATA +95 -0
- nodeprobe-0.1.3.dist-info/RECORD +53 -0
- nodeprobe-0.1.3.dist-info/WHEEL +5 -0
- nodeprobe-0.1.3.dist-info/entry_points.txt +2 -0
- nodeprobe-0.1.3.dist-info/licenses/LICENSE +21 -0
- nodeprobe-0.1.3.dist-info/top_level.txt +1 -0
nodeprobe/__init__.py
ADDED
nodeprobe/__main__.py
ADDED
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
|