@clear-capabilities/agentic-security-scanner 0.79.0 → 0.80.0
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.
- package/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +90 -1
- package/dist/agentic-security.mjs +83 -83
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/.agentic-security/findings.json +104638 -0
- package/src/.agentic-security/last-scan.json +104638 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12562 -0
- package/src/.agentic-security/streak.json +21 -0
- package/src/dataflow/.agentic-security/findings.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +250 -0
- package/src/dataflow/.agentic-security/streak.json +21 -0
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/formal-verify.js +204 -0
- package/src/dataflow/ifds-precise.js +222 -0
- package/src/dataflow/k2-summary-cache.js +153 -0
- package/src/dataflow/lib-taint-summaries.js +198 -0
- package/src/dataflow/privacy-taint.js +205 -0
- package/src/dataflow/smt-feasibility.js +189 -0
- package/src/engine.js +784 -127
- package/src/ir/.agentic-security/findings.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +193 -0
- package/src/ir/.agentic-security/streak.json +20 -0
- package/src/ir/cpp-preprocessor.js +142 -0
- package/src/ir/csharp-ir.js +604 -0
- package/src/ir/universal-ir.js +403 -0
- package/src/mcp/.agentic-security/findings.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +143 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/tools.js +90 -1
- package/src/posture/.agentic-security/findings.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +7162 -0
- package/src/posture/.agentic-security/streak.json +21 -0
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/runtime-correlation.js +174 -0
- package/src/posture/sbom-diff.js +171 -0
- package/src/posture/sca-policy.js +235 -0
- package/src/posture/sca-upgrade.js +259 -0
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage.js +26 -1
- package/src/sast/.agentic-security/findings.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +941 -0
- package/src/sast/.agentic-security/streak.json +22 -0
- package/src/sast/_secret-entropy.js +145 -0
- package/src/sast/cloud-iam.js +312 -0
- package/src/sast/cpp.js +138 -4
- package/src/sast/crypto-protocol.js +388 -0
- package/src/sast/csharp-tokenizer.js +392 -0
- package/src/sast/csharp.js +924 -138
- package/src/sast/dapp-frontend.js +200 -0
- package/src/sast/k8s-admission.js +271 -0
- package/src/sast/llm-app.js +272 -0
- package/src/sast/ml-supply-chain.js +259 -0
- package/src/sast/mobile.js +224 -0
- package/src/sast/post-quantum-crypto.js +348 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json.sig +1 -0
- package/src/sca/.agentic-security/scan-history.json +113 -0
- package/src/sca/.agentic-security/streak.json +21 -0
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +37 -15
- package/src/sca/sigstore-verify.js +215 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Web3 advanced SAST — Item #3 of the world-class+3 plan.
|
|
2
|
+
//
|
|
3
|
+
// Fills the gap between solidity.js (canonical CWE patterns) and defi-deep.js
|
|
4
|
+
// (AMM / vault / swap) with the bug classes that have dominated 2024-2026
|
|
5
|
+
// Web3 incident reports:
|
|
6
|
+
//
|
|
7
|
+
// 1. UPGRADEABLE_NO_DISABLE_INIT — implementation contract leaves
|
|
8
|
+
// initialize() callable; missing _disableInitializers() in constructor
|
|
9
|
+
// 2. UPGRADEABLE_NO_GAP — UUPS/Transparent contracts without
|
|
10
|
+
// __gap or storage-slot pinning; future upgrades brick storage
|
|
11
|
+
// 3. SIGREPLAY_NO_NONCE_CHAINID — signed messages without nonce + chainId
|
|
12
|
+
// + domain separator (cross-chain replay)
|
|
13
|
+
// 4. ECDSA_S_MALLEABILITY — raw ecrecover without checks for
|
|
14
|
+
// s in lower half (EIP-2; OpenZeppelin's ECDSA fixes this)
|
|
15
|
+
// 5. ORACLE_NO_STALENESS — Chainlink latestRoundData() / answer
|
|
16
|
+
// consumed without checking updatedAt freshness
|
|
17
|
+
// 6. ERC4337_NO_VALIDATION — UserOperation entry without sig +
|
|
18
|
+
// nonce verification, or paymaster missing prefund check
|
|
19
|
+
// 7. RO_REENTRANCY — view function returns state that mutates
|
|
20
|
+
// during reentrancy window (Curve-style read-only reentrancy bug)
|
|
21
|
+
// 8. MULTICALL_DELEGATECALL — Multicall with delegatecall that
|
|
22
|
+
// enables cross-function reentrancy / sig-replay
|
|
23
|
+
// 9. NFT_RECEIVER_UNTRUSTED — _mint to address triggers
|
|
24
|
+
// onERC721Received before state finalize (callback reentrancy)
|
|
25
|
+
// 10. FEE_ON_TRANSFER_VAULT — deposit assumes amountIn = amountReceived
|
|
26
|
+
// without balBefore/balAfter pair
|
|
27
|
+
// 11. SOLANA_NO_OWNER_CHECK — Anchor Rust handler reads an account
|
|
28
|
+
// without verifying owner == program_id (classic Anchor pitfall)
|
|
29
|
+
// 12. VYPER_RAW_CALL_UNSAFE — Vyper raw_call without max_outsize,
|
|
30
|
+
// gas, value sanity checks
|
|
31
|
+
//
|
|
32
|
+
// Each detector is family-tagged so reachability / calibration can be
|
|
33
|
+
// applied per-class. Files are routed by extension:
|
|
34
|
+
// .sol → detectors 1-10
|
|
35
|
+
// .vy → detector 12
|
|
36
|
+
// .rs → detector 11 (Anchor)
|
|
37
|
+
//
|
|
38
|
+
// Opt-out: AGENTIC_SECURITY_NO_WEB3_ADV=1 disables the whole module.
|
|
39
|
+
|
|
40
|
+
import { blankComments } from './_comment-strip.js';
|
|
41
|
+
|
|
42
|
+
function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
|
|
43
|
+
function _snip(raw, line) { return (raw.split('\n')[line - 1] || '').trim().slice(0, 200); }
|
|
44
|
+
|
|
45
|
+
function _shape(file, line, ruleId, vuln, fam, sev, cwe, remediation, description) {
|
|
46
|
+
return {
|
|
47
|
+
id: `${ruleId}:${file}:${line}`,
|
|
48
|
+
file, line, vuln, severity: sev, cwe,
|
|
49
|
+
family: fam, parser: 'WEB3-ADV',
|
|
50
|
+
confidence: 0.8,
|
|
51
|
+
stride: fam.includes('reentrancy') || fam.includes('upgradeable') || fam.includes('replay') ? 'Tampering'
|
|
52
|
+
: fam.includes('oracle') || fam.includes('staleness') ? 'Spoofing'
|
|
53
|
+
: 'Elevation of Privilege',
|
|
54
|
+
description: description || vuln,
|
|
55
|
+
remediation,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Solidity detectors ─────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function detectUpgradeableNoDisableInit(file, raw, code, out, seen) {
|
|
62
|
+
// Looking for: inherits *Upgradeable contract OR uses `initializer` modifier,
|
|
63
|
+
// BUT the constructor does not call _disableInitializers().
|
|
64
|
+
const isUpgradeable = /\bcontract\s+\w+\s+is\s+[^{]*Upgradeable\b/.test(code) ||
|
|
65
|
+
/\b(?:Initializable|UUPSUpgradeable|TransparentUpgradeableProxy)\b/.test(code);
|
|
66
|
+
if (!isUpgradeable) return;
|
|
67
|
+
const hasInitializerMod = /\binitializer\s*\{|\bonlyInitializing\s*\{/.test(code);
|
|
68
|
+
if (!hasInitializerMod) return;
|
|
69
|
+
// Look for constructor that includes _disableInitializers
|
|
70
|
+
const ctor = /\bconstructor\s*\(\s*\)[^{]*\{([\s\S]*?)\}/.exec(code);
|
|
71
|
+
const hasDisable = ctor && /_disableInitializers\s*\(/.test(ctor[1]);
|
|
72
|
+
if (hasDisable) return;
|
|
73
|
+
const m = /\binitializer\s*\{|\bcontract\s+\w+\s+is\s+[^{]*Upgradeable/.exec(code);
|
|
74
|
+
const ln = _line(raw, m.index);
|
|
75
|
+
const id = `web3-upgradeable-no-disable-init:${file}:${ln}`;
|
|
76
|
+
if (seen.has(id)) return;
|
|
77
|
+
seen.add(id);
|
|
78
|
+
out.push(_shape(file, ln, 'web3-upgradeable-no-disable-init',
|
|
79
|
+
'Upgradeable contract — implementation does not call _disableInitializers() in constructor',
|
|
80
|
+
'upgradeable-init', 'high', 'CWE-665',
|
|
81
|
+
'Add `constructor() { _disableInitializers(); }` to the implementation contract. Without it, anyone can call initialize() on the implementation contract itself and (in some configurations) take control of the proxy via selfdestruct delegatecall.',
|
|
82
|
+
'OpenZeppelin warns: every upgradeable implementation MUST disable initializers in its constructor. The Wormhole and Audius incidents both stemmed from initialize() being callable post-deploy on an unfinished implementation.'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function detectUpgradeableNoGap(file, raw, code, out, seen) {
|
|
86
|
+
// Upgradeable contract without __gap variable or unstructured storage hint.
|
|
87
|
+
if (!/Upgradeable\b|\bInitializable\b/.test(code)) return;
|
|
88
|
+
if (/\b__gap\b|\bERC7201\b|\b@custom:storage-location\b/.test(code)) return;
|
|
89
|
+
// Contract that declares state variables but no gap.
|
|
90
|
+
const stateMatch = /\bcontract\s+(\w+)\s+is\s+[^{]*(?:Upgradeable|Initializable)[\s\S]*?\{/m.exec(code);
|
|
91
|
+
if (!stateMatch) return;
|
|
92
|
+
const ln = _line(raw, stateMatch.index);
|
|
93
|
+
const id = `web3-upgradeable-no-gap:${file}:${ln}`;
|
|
94
|
+
if (seen.has(id)) return;
|
|
95
|
+
seen.add(id);
|
|
96
|
+
out.push(_shape(file, ln, 'web3-upgradeable-no-gap',
|
|
97
|
+
`Upgradeable contract ${stateMatch[1]} has no __gap or ERC-7201 storage namespace`,
|
|
98
|
+
'upgradeable-storage', 'medium', 'CWE-665',
|
|
99
|
+
'Add `uint256[50] private __gap;` at the end of every upgradeable contract, OR adopt ERC-7201 namespaced storage (`@custom:storage-location erc7201:...`). Without one, adding a state variable in a future version shifts every subsequent slot — corrupting all stored data.',
|
|
100
|
+
'Storage layout drift across upgrades is the #1 cause of bricked proxy contracts. Compound III, OpenZeppelin Defender, and ERC-7201 all enforce explicit gap or namespacing.'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function detectSigReplayNoNonceChainId(file, raw, code, out, seen) {
|
|
104
|
+
// ecrecover(...) used in a function whose body does NOT reference both
|
|
105
|
+
// nonce/nonces AND chainid/block.chainid AND a domain-separator hash.
|
|
106
|
+
const re = /\becrecover\s*\(/g;
|
|
107
|
+
let m;
|
|
108
|
+
while ((m = re.exec(code))) {
|
|
109
|
+
const ln = _line(raw, m.index);
|
|
110
|
+
// Find enclosing function body
|
|
111
|
+
const before = raw.slice(0, m.index);
|
|
112
|
+
const fnStart = before.lastIndexOf('function ');
|
|
113
|
+
if (fnStart < 0) continue;
|
|
114
|
+
const bodyStart = raw.indexOf('{', fnStart);
|
|
115
|
+
let depth = 1, bodyEnd = bodyStart + 1;
|
|
116
|
+
for (let i = bodyStart + 1; i < Math.min(bodyStart + 6000, raw.length); i++) {
|
|
117
|
+
if (raw[i] === '{') depth++;
|
|
118
|
+
else if (raw[i] === '}') { depth--; if (depth === 0) { bodyEnd = i; break; } }
|
|
119
|
+
}
|
|
120
|
+
const body = raw.slice(bodyStart, bodyEnd + 1);
|
|
121
|
+
const hasNonce = /\bnonces?\b|\busedSignatures?\b/.test(body);
|
|
122
|
+
const hasChain = /\bblock\.chainid\b|\bchainid\(\)/.test(body) || /\bDOMAIN_SEPARATOR\b|\b_hashTypedDataV4\b/.test(body);
|
|
123
|
+
if (hasNonce && hasChain) continue;
|
|
124
|
+
const id = `web3-sig-replay:${file}:${ln}`;
|
|
125
|
+
if (seen.has(id)) continue;
|
|
126
|
+
seen.add(id);
|
|
127
|
+
out.push(_shape(file, ln, 'web3-sig-replay',
|
|
128
|
+
`ecrecover used without ${hasNonce ? '' : 'nonce '}${hasChain ? '' : 'chainId/domain-separator '}— signature replay surface`,
|
|
129
|
+
'signature-replay', 'high', 'CWE-294',
|
|
130
|
+
'Bind signed messages to (a) a per-signer nonce, (b) the chainId, and (c) the contract address (EIP-712 domain separator). Use `_hashTypedDataV4()` from OpenZeppelin EIP712 to handle all three.',
|
|
131
|
+
'Signature replay attacks have drained NFT marketplaces (LooksRare, OpenSea Wyvern), bridges, and meta-tx relayers. Cross-chain replay is the most common — same signed message accepted on Ethereum + every L2.'));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function detectEcdsaSMalleability(file, raw, code, out, seen) {
|
|
136
|
+
// Raw ecrecover without OpenZeppelin ECDSA wrapper AND no s ≤ secp256k1n/2 check.
|
|
137
|
+
if (!/\becrecover\s*\(/.test(code)) return;
|
|
138
|
+
if (/\bECDSA\.recover\b|\bECDSA\.tryRecover\b/.test(code)) return;
|
|
139
|
+
// If we don't see the canonical low-s check, flag.
|
|
140
|
+
if (/0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0/i.test(code)) return;
|
|
141
|
+
const m = /\becrecover\s*\(/.exec(code);
|
|
142
|
+
const ln = _line(raw, m.index);
|
|
143
|
+
const id = `web3-ecdsa-malleability:${file}:${ln}`;
|
|
144
|
+
if (seen.has(id)) return;
|
|
145
|
+
seen.add(id);
|
|
146
|
+
out.push(_shape(file, ln, 'web3-ecdsa-malleability',
|
|
147
|
+
'Raw ecrecover without ECDSA s-malleability check — duplicate-signature attack',
|
|
148
|
+
'ecdsa-malleability', 'medium', 'CWE-347',
|
|
149
|
+
'Use `ECDSA.recover(...)` from `@openzeppelin/contracts/utils/cryptography/ECDSA.sol`. It rejects high-s signatures per EIP-2 and prevents the (v, r, s) ↔ (v ^ 1, r, secp256k1n - s) twin-signature problem.',
|
|
150
|
+
'Without the lower-s check, every valid signature has a malleable twin — breaking any application that uses signatures as unique IDs (hash maps of signatures, signature-based replay protection).'));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function detectOracleNoStaleness(file, raw, code, out, seen) {
|
|
154
|
+
// latestRoundData() destructured without `updatedAt` being checked.
|
|
155
|
+
const re = /\.latestRoundData\s*\(\s*\)/g;
|
|
156
|
+
let m;
|
|
157
|
+
while ((m = re.exec(code))) {
|
|
158
|
+
const ln = _line(raw, m.index);
|
|
159
|
+
// Look at the next ~400 chars for a freshness check.
|
|
160
|
+
const after = raw.slice(m.index, m.index + 400);
|
|
161
|
+
const hasFresh = /\b(?:updatedAt|timestamp)\b[^;]{0,80}>\s*block\.timestamp\s*-/.test(after) ||
|
|
162
|
+
/require\s*\(\s*[^,)]*updatedAt[^)]*\)/.test(after) ||
|
|
163
|
+
/staleAfter|MAX_DELAY|HEARTBEAT/.test(after);
|
|
164
|
+
if (hasFresh) continue;
|
|
165
|
+
const id = `web3-oracle-staleness:${file}:${ln}`;
|
|
166
|
+
if (seen.has(id)) continue;
|
|
167
|
+
seen.add(id);
|
|
168
|
+
out.push(_shape(file, ln, 'web3-oracle-staleness',
|
|
169
|
+
'Chainlink latestRoundData() consumed without checking updatedAt freshness',
|
|
170
|
+
'oracle-staleness', 'high', 'CWE-672',
|
|
171
|
+
'After destructuring latestRoundData(), assert `require(block.timestamp - updatedAt < HEARTBEAT, "Stale oracle");` where HEARTBEAT matches the feed (e.g. 3600 for ETH/USD on mainnet). Also check answeredInRound >= roundId and answer > 0.',
|
|
172
|
+
'Stale oracle data has caused liquidation cascades and bad-debt accumulation across DeFi (Mango, Inverse Finance). A stuck price feed remains "fresh enough" to drain a protocol when reality has moved.'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function detectErc4337NoValidation(file, raw, code, out, seen) {
|
|
177
|
+
// validateUserOp(...) without signature recovery + nonce check.
|
|
178
|
+
const re = /\bvalidateUserOp\s*\(/g;
|
|
179
|
+
let m;
|
|
180
|
+
while ((m = re.exec(code))) {
|
|
181
|
+
const ln = _line(raw, m.index);
|
|
182
|
+
const after = raw.slice(m.index, m.index + 2000);
|
|
183
|
+
const hasSig = /\b(?:ecrecover|ECDSA|isValidSignature)\b/.test(after);
|
|
184
|
+
const hasNonce = /\bnonce\b/.test(after);
|
|
185
|
+
if (hasSig && hasNonce) continue;
|
|
186
|
+
const id = `web3-4337-no-validation:${file}:${ln}`;
|
|
187
|
+
if (seen.has(id)) continue;
|
|
188
|
+
seen.add(id);
|
|
189
|
+
out.push(_shape(file, ln, 'web3-4337-no-validation',
|
|
190
|
+
`validateUserOp missing ${hasSig ? '' : 'signature '}${hasNonce ? '' : 'nonce '}validation`,
|
|
191
|
+
'erc4337-validation', 'critical', 'CWE-287',
|
|
192
|
+
'A ERC-4337 account MUST verify the userOp signature against its owning key and increment a per-account nonce inside validateUserOp. Without sig verification anyone executes ops on the account; without nonce the same op replays forever.',
|
|
193
|
+
'ERC-4337 (account abstraction) is the foundation of every smart-account wallet. A flawed validateUserOp is the equivalent of "ECDSA verification removed" for an EOA.'));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function detectReadOnlyReentrancy(file, raw, code, out, seen) {
|
|
198
|
+
// view/pure function that returns a value derived from a state var that
|
|
199
|
+
// mutates inside an external-call function. Heuristic: view function reads
|
|
200
|
+
// `totalSupply()` / `balance` / `reserves` AND there's a write to the same
|
|
201
|
+
// state in another function that contains `.call{value:` or `.transfer(`.
|
|
202
|
+
const viewFns = [...code.matchAll(/\bfunction\s+(\w+)\s*\([^)]*\)\s+(?:external|public)\s+view\s+[^{]*\{([\s\S]*?)\}/g)];
|
|
203
|
+
if (!viewFns.length) return;
|
|
204
|
+
const writingFns = code.match(/\.call\s*\{[^}]*value\s*:|\.transfer\s*\(|\.send\s*\(/g);
|
|
205
|
+
if (!writingFns) return;
|
|
206
|
+
for (const v of viewFns) {
|
|
207
|
+
const body = v[2];
|
|
208
|
+
if (/\btotalSupply\s*\(\)|\bgetPricePerShare\s*\(|\bpricePerShare\s*\(|\bbalanceOf\s*\(/.test(body) ||
|
|
209
|
+
/\breserves?\b|\bvirtualPrice\b/.test(body)) {
|
|
210
|
+
const ln = _line(raw, v.index);
|
|
211
|
+
const id = `web3-ro-reentrancy:${file}:${ln}`;
|
|
212
|
+
if (seen.has(id)) continue;
|
|
213
|
+
seen.add(id);
|
|
214
|
+
out.push(_shape(file, ln, 'web3-ro-reentrancy',
|
|
215
|
+
`view function ${v[1]}() exposes mid-mutation state — read-only reentrancy surface`,
|
|
216
|
+
'read-only-reentrancy', 'high', 'CWE-841',
|
|
217
|
+
'View functions that return computed price / share / balance values must be wrapped in the same nonReentrant guard as the mutating functions, OR must read from a checkpointed snapshot that updates atomically. Use a `nonReentrantView` modifier (Curve / Yearn pattern).',
|
|
218
|
+
'Read-only reentrancy bug pattern: integrator contracts read your view function during a mid-mutation external call (e.g. inside an ERC-777 callback) and get a stale or inflated value. Curve LP token pricing has been exploited via this exact pattern (multiple incidents).'));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function detectMulticallDelegatecall(file, raw, code, out, seen) {
|
|
224
|
+
// Multicall implementation that delegatecalls each call.
|
|
225
|
+
const re = /\bfunction\s+multicall\s*\([^)]*\)\s*[^{]*\{([\s\S]*?)\}/g;
|
|
226
|
+
let m;
|
|
227
|
+
while ((m = re.exec(code))) {
|
|
228
|
+
const body = m[1];
|
|
229
|
+
if (/\bdelegatecall\s*\(/.test(body)) {
|
|
230
|
+
const ln = _line(raw, m.index);
|
|
231
|
+
const id = `web3-multicall-delegatecall:${file}:${ln}`;
|
|
232
|
+
if (seen.has(id)) continue;
|
|
233
|
+
seen.add(id);
|
|
234
|
+
out.push(_shape(file, ln, 'web3-multicall-delegatecall',
|
|
235
|
+
'multicall() uses delegatecall — enables msg.sender forwarding + sig-replay attacks',
|
|
236
|
+
'multicall-delegatecall', 'high', 'CWE-863',
|
|
237
|
+
'Multicall + delegatecall lets users batch arbitrary functions that read msg.sender — including transferFrom on tokens with a stale approval. Use the OpenZeppelin Multicall (which uses Address.functionDelegateCall on the same contract) and avoid combining it with any function that triggers external behavior on msg.sender.',
|
|
238
|
+
'Critical bugs in Uniswap V3 SwapRouter and 0x protocol traced to multicall + delegatecall enabling unintended message-sender semantics.'));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function detectNftReceiverUntrusted(file, raw, code, out, seen) {
|
|
244
|
+
// _mint or _safeMint to an arbitrary address before state finalize.
|
|
245
|
+
// Pattern: `_safeMint(to, ...)` followed by `state_var = ...` in the same function.
|
|
246
|
+
const re = /\b_(?:safeMint|safeTransferFrom)\s*\([^)]*\)\s*;\s*\n[^\n}]*\b\w+\s*\[[^\]]+\]\s*=/g;
|
|
247
|
+
let m;
|
|
248
|
+
while ((m = re.exec(code))) {
|
|
249
|
+
const ln = _line(raw, m.index);
|
|
250
|
+
const id = `web3-nft-receiver-untrusted:${file}:${ln}`;
|
|
251
|
+
if (seen.has(id)) continue;
|
|
252
|
+
seen.add(id);
|
|
253
|
+
out.push(_shape(file, ln, 'web3-nft-receiver-untrusted',
|
|
254
|
+
'_safeMint / _safeTransferFrom triggers onERC721Received callback BEFORE state finalize',
|
|
255
|
+
'nft-receiver-reentrancy', 'high', 'CWE-841',
|
|
256
|
+
'Move all state updates BEFORE _safeMint/_safeTransferFrom. The receiver hook can re-enter your contract and observe partial state (CEI violation in NFT context). HashMasks, Meebits, and several NFT staking contracts have shipped this bug.',
|
|
257
|
+
'ERC-721 safeTransfer triggers a callback on the receiver — if the receiver is itself a contract, it can call back into your contract before you finish updating local state. Same anti-pattern as ERC-777 hooks for ERC-20.'));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function detectFeeOnTransferVault(file, raw, code, out, seen) {
|
|
262
|
+
// deposit() / mint() that uses transferFrom amount as shares-in directly.
|
|
263
|
+
const re = /\bfunction\s+(?:deposit|mint)\s*\([^)]*\)\s*[^{]*\{([\s\S]*?)\}/g;
|
|
264
|
+
let m;
|
|
265
|
+
while ((m = re.exec(code))) {
|
|
266
|
+
const body = m[1];
|
|
267
|
+
const usesTransferFrom = /\btransferFrom\s*\(/.test(body);
|
|
268
|
+
const usesBalanceCheckpoint = /balBefore|balanceBefore|balPre|_before\b/.test(body) ||
|
|
269
|
+
/balanceOf\s*\([^)]+\)\s*-\s*\w+Before/.test(body);
|
|
270
|
+
if (usesTransferFrom && !usesBalanceCheckpoint) {
|
|
271
|
+
const ln = _line(raw, m.index);
|
|
272
|
+
const id = `web3-fee-on-transfer-vault:${file}:${ln}`;
|
|
273
|
+
if (seen.has(id)) continue;
|
|
274
|
+
seen.add(id);
|
|
275
|
+
out.push(_shape(file, ln, 'web3-fee-on-transfer-vault',
|
|
276
|
+
'Vault deposit/mint assumes amountIn == amountReceived (fee-on-transfer token mishandling)',
|
|
277
|
+
'fee-on-transfer-vault', 'medium', 'CWE-682',
|
|
278
|
+
'Bracket transferFrom with balBefore = balanceOf(...) / balAfter = balanceOf(...); use the delta as the real amountReceived for share math. Otherwise tokens like USDT (mainnet) or PAXG fees will silently mis-mint shares.',
|
|
279
|
+
'Fee-on-transfer tokens (some L2 wrapped USDT, deflationary tokens) deduct a fee on transferFrom — leaving the vault under-collateralized for every deposit. The Beanstalk Wells V0 and several yield aggregators have shipped this.'));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Solana / Anchor detector (Rust) ────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
function detectSolanaNoOwnerCheck(file, raw, code, out, seen) {
|
|
287
|
+
// Anchor handler reads an AccountInfo without #[account(owner = ...)] or
|
|
288
|
+
// a runtime owner check.
|
|
289
|
+
if (!/\buse\s+anchor_lang\b/.test(code) && !/\bAccountInfo\b/.test(code)) return;
|
|
290
|
+
// Look for handlers (Anchor): pub fn handler(ctx: Context<...>, ...)
|
|
291
|
+
const re = /pub\s+fn\s+(\w+)\s*\(\s*ctx\s*:\s*Context<(\w+)>[^)]*\)[^{]*\{([\s\S]*?)\bOk\s*\(\s*\(\s*\)\s*\)/g;
|
|
292
|
+
let m;
|
|
293
|
+
while ((m = re.exec(code))) {
|
|
294
|
+
const body = m[3];
|
|
295
|
+
const accountsReferenced = body.match(/\bctx\.accounts\.(\w+)/g) || [];
|
|
296
|
+
if (!accountsReferenced.length) continue;
|
|
297
|
+
// Look for the matching Accounts struct.
|
|
298
|
+
const accountsStruct = new RegExp(`#\\[derive\\(Accounts\\)\\][\\s\\S]*?struct\\s+${m[2]}\\b[\\s\\S]*?\\}`, 'g').exec(code);
|
|
299
|
+
if (!accountsStruct) continue;
|
|
300
|
+
const structBody = accountsStruct[0];
|
|
301
|
+
if (/#\[account\s*\([^)]*owner\s*=/.test(structBody)) continue;
|
|
302
|
+
if (/has_one\s*=/.test(structBody)) continue;
|
|
303
|
+
if (/\bsigner\s*:|Signer<'info>/.test(structBody) && body.length < 500) continue;
|
|
304
|
+
const ln = _line(raw, m.index);
|
|
305
|
+
const id = `web3-solana-no-owner-check:${file}:${ln}`;
|
|
306
|
+
if (seen.has(id)) continue;
|
|
307
|
+
seen.add(id);
|
|
308
|
+
out.push(_shape(file, ln, 'web3-solana-no-owner-check',
|
|
309
|
+
`Anchor handler ${m[1]} reads accounts without owner-check constraints`,
|
|
310
|
+
'solana-anchor-no-owner', 'high', 'CWE-862',
|
|
311
|
+
'Add `#[account(owner = program_id)]` or `has_one = X` constraints to the Accounts struct. Without explicit ownership checks Anchor will deserialize a malicious account that happens to match the struct shape — the canonical Solana "type confusion" attack class.',
|
|
312
|
+
'Many high-profile Solana exploits (Wormhole bridge, Cashio, Audius governance) reduce to "account did not match the expected program owner". Anchor 0.20+ enforces this when you use the Account<\'info, T> type with constraints.'));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Vyper detector ─────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
function detectVyperRawCallUnsafe(file, raw, code, out, seen) {
|
|
319
|
+
// raw_call(...) without max_outsize specified or with value > 0 inside @view.
|
|
320
|
+
const re = /\braw_call\s*\(([^)]*)\)/g;
|
|
321
|
+
let m;
|
|
322
|
+
while ((m = re.exec(code))) {
|
|
323
|
+
const args = m[1];
|
|
324
|
+
const hasMax = /\bmax_outsize\s*=/.test(args);
|
|
325
|
+
if (hasMax) continue;
|
|
326
|
+
const ln = _line(raw, m.index);
|
|
327
|
+
const id = `web3-vyper-raw-call:${file}:${ln}`;
|
|
328
|
+
if (seen.has(id)) continue;
|
|
329
|
+
seen.add(id);
|
|
330
|
+
out.push(_shape(file, ln, 'web3-vyper-raw-call',
|
|
331
|
+
'Vyper raw_call without explicit max_outsize — unbounded returndata',
|
|
332
|
+
'vyper-raw-call', 'medium', 'CWE-1284',
|
|
333
|
+
'Always pass `max_outsize=<expected_bytes>` to raw_call. Without it, callees can return arbitrarily large data and force gas-exhaustion DoS on the caller. Also gate `is_static_call=True` for read-only paths.',
|
|
334
|
+
'Vyper raw_call exposes the same low-level primitives as Solidity .call; the safe default is to constrain return-data size and to enforce read-only semantics where applicable.'));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Entry points ───────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
export function scanWeb3Advanced(fp, raw) {
|
|
341
|
+
if (process.env.AGENTIC_SECURITY_NO_WEB3_ADV === '1') return [];
|
|
342
|
+
if (!raw || raw.length > 500_000) return [];
|
|
343
|
+
const out = [];
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
if (/\.sol$/i.test(fp)) {
|
|
346
|
+
const code = blankComments(raw);
|
|
347
|
+
try { detectUpgradeableNoDisableInit(fp, raw, code, out, seen); } catch {}
|
|
348
|
+
try { detectUpgradeableNoGap(fp, raw, code, out, seen); } catch {}
|
|
349
|
+
try { detectSigReplayNoNonceChainId(fp, raw, code, out, seen); } catch {}
|
|
350
|
+
try { detectEcdsaSMalleability(fp, raw, code, out, seen); } catch {}
|
|
351
|
+
try { detectOracleNoStaleness(fp, raw, code, out, seen); } catch {}
|
|
352
|
+
try { detectErc4337NoValidation(fp, raw, code, out, seen); } catch {}
|
|
353
|
+
try { detectReadOnlyReentrancy(fp, raw, code, out, seen); } catch {}
|
|
354
|
+
try { detectMulticallDelegatecall(fp, raw, code, out, seen); } catch {}
|
|
355
|
+
try { detectNftReceiverUntrusted(fp, raw, code, out, seen); } catch {}
|
|
356
|
+
try { detectFeeOnTransferVault(fp, raw, code, out, seen); } catch {}
|
|
357
|
+
} else if (/\.vy$/i.test(fp)) {
|
|
358
|
+
const code = blankComments(raw, 'py');
|
|
359
|
+
try { detectVyperRawCallUnsafe(fp, raw, code, out, seen); } catch {}
|
|
360
|
+
} else if (/\.rs$/i.test(fp) && /\banchor_lang\b/.test(raw)) {
|
|
361
|
+
const code = raw;
|
|
362
|
+
try { detectSolanaNoOwnerCheck(fp, raw, code, out, seen); } catch {}
|
|
363
|
+
}
|
|
364
|
+
for (const f of out) f.file = fp;
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export const _internals = {
|
|
369
|
+
detectUpgradeableNoDisableInit, detectUpgradeableNoGap,
|
|
370
|
+
detectSigReplayNoNonceChainId, detectEcdsaSMalleability,
|
|
371
|
+
detectOracleNoStaleness, detectErc4337NoValidation,
|
|
372
|
+
detectReadOnlyReentrancy, detectMulticallDelegatecall,
|
|
373
|
+
detectNftReceiverUntrusted, detectFeeOnTransferVault,
|
|
374
|
+
detectSolanaNoOwnerCheck, detectVyperRawCallUnsafe,
|
|
375
|
+
};
|