diamond-detect 0.1.1 → 0.2.1
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/README.md +38 -21
- package/dist/cli.js +466 -119
- package/dist/index.js +496 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ npx diamond-detect .
|
|
|
8
8
|
|
|
9
9
|
## Why this tool exists
|
|
10
10
|
|
|
11
|
-
A Diamond proxy `delegatecall`s into many facet contracts, and **every facet shares the proxy's storage**. When two facets accidentally land at the same slot
|
|
11
|
+
A Diamond proxy `delegatecall`s into many facet contracts, and **every facet shares the proxy's storage**. When two facets accidentally land at the same slot, whether by reusing a Diamond Storage namespace string, by hardcoding the same precomputed slot, by computing the same ERC-7201 namespace inline, by drifting AppStorage layouts, by reusing an EIP-7201 id, or by writing a literal slot directly in inline assembly, the result is silent corruption where one facet's writes overwrite another's data with no error and no revert.
|
|
12
12
|
|
|
13
13
|
Slither catches general storage issues but doesn't speak Diamond. Most teams either hand-audit by spreadsheet or rely on a one-off script. `diamond-detect` is a focused, Diamond-specific analyzer you can drop into CI in three lines of YAML.
|
|
14
14
|
|
|
@@ -60,23 +60,40 @@ This populates `out/` with artifact JSON files that include the AST and storage
|
|
|
60
60
|
diamond-detect .
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
If everything is fine you'll see:
|
|
63
|
+
If everything is fine you'll see a confirmation, plus every storage region the tool verified so you can see it actually inspected each one and that they all sit on distinct slots:
|
|
64
64
|
|
|
65
65
|
```
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
✔ no storage collisions detected · 8 artifacts scanned
|
|
67
|
+
|
|
68
|
+
Verified 4 storage regions, each on its own slot:
|
|
69
|
+
|
|
70
|
+
• myapp.vaults erc7201 0x84d86c…b71bab LibVaults src/LibVaults.sol:12
|
|
71
|
+
• myapp.strategies namespace 0xa1b2c3…445566 LibStrategies src/LibStrategies.sol:9
|
|
72
|
+
• AAVE_STORAGE_SLOT precomputed 0x340080…215700 AaveFacet src/facets/AaveFacet.sol:28
|
|
73
|
+
• diamond.standard.diamond.storage namespace 0xc8fcad…2c131c LibDiamond src/libraries/LibDiamond.sol:8
|
|
74
|
+
|
|
75
|
+
Every facet keeps to its own namespace, and no two regions share a slot. Nicely done.
|
|
68
76
|
```
|
|
69
77
|
|
|
70
|
-
If something is wrong you'll
|
|
78
|
+
If something is wrong you'll get one diagnostic per collision, with a code frame pointing at the exact line in every colliding file, the shared slot, and a hint at the cause:
|
|
71
79
|
|
|
72
80
|
```
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
error[diamond-storage-namespace]: Diamond Storage namespace "myapp.strategies" is declared in 2 different sources, all resolving to the same slot.
|
|
82
|
+
╭─[src/LibStrategies.sol:5:5]
|
|
83
|
+
│
|
|
84
|
+
5 │ bytes32 internal constant POSITION = keccak256("myapp.strategies");
|
|
85
|
+
· ────────────────────────────────────────────────────────────────── slot 0x84d86c…b71bab
|
|
86
|
+
╰─
|
|
87
|
+
╭─[src/LibVaults.sol:8:5]
|
|
88
|
+
│
|
|
89
|
+
8 │ bytes32 internal constant POSITION = keccak256("myapp.strategies");
|
|
90
|
+
· ────────────────────────────────────────────────────────────────── same slot here
|
|
91
|
+
╰─
|
|
92
|
+
= facets: LibStrategies, LibVaults
|
|
93
|
+
= slot: 0x84d86c34a05b71953e57fe7dafea685384b33934d9ddaebd0cf7709e74b71bab
|
|
94
|
+
= help: give every facet a unique storage seed; never reuse a namespace string, precomputed slot, or formula across facets
|
|
78
95
|
|
|
79
|
-
1 error
|
|
96
|
+
✖ 1 error · 2 artifacts scanned
|
|
80
97
|
```
|
|
81
98
|
|
|
82
99
|
Exit code is `1` whenever a finding meets your `--severity` threshold (default `warn`), `0` otherwise, `2` on internal errors.
|
|
@@ -87,7 +104,7 @@ Run [`examples/`](./examples/) to see each one in action — every example ships
|
|
|
87
104
|
|
|
88
105
|
| Kind | Severity | What it catches |
|
|
89
106
|
|---|---|---|
|
|
90
|
-
| `diamond-storage-namespace` | error | Two
|
|
107
|
+
| `diamond-storage-namespace` | error | Two facets resolve to the same Diamond Storage slot, whether the slot comes from `keccak256("...")`, a hardcoded precomputed literal (`bytes32 constant S = 0x..`), the inline ERC-7201 formula written without an annotation, or a direct `assembly { x.slot := <literal> }`. All four representations are compared in one space, so a literal in one facet that matches a formula or namespace in another is caught too. ([01-namespace-collision](./examples/01-namespace-collision/)) |
|
|
91
108
|
| `appstorage-fingerprint` | error | The same fully-qualified struct (e.g. `struct LibAppStorage.AppStorage`) has different layouts across facets — the stale-artifact / forgot-to-rebuild bug. ([02-appstorage-shift](./examples/02-appstorage-shift/)) |
|
|
92
109
|
| `erc7201-namespace` | error | Two contracts annotate `@custom:storage-location erc7201:<id>` with the same id. ([03-erc7201-collision](./examples/03-erc7201-collision/)) |
|
|
93
110
|
| `inheritance-overlap` | warn | Two facets have state at the same slot whose `(label, type)` differ — e.g. `Ownable._owner` vs `MyOwnable.owner`. |
|
|
@@ -145,7 +162,7 @@ diamond-detect <path> Foundry project root or src/ folder
|
|
|
145
162
|
|
|
146
163
|
## Output formats
|
|
147
164
|
|
|
148
|
-
- **Terminal** (default):
|
|
165
|
+
- **Terminal** (default): a code-frame diagnostic per collision that underlines the exact slot declaration in every colliding file, with `= facets / = slot / = help` notes and a coloured summary footer. A clean run instead lists every storage region it verified, with its slot and location, so you can confirm nothing was skipped. Colour is auto-disabled when the output is piped or running in CI.
|
|
149
166
|
- **JSON** (`--json`): a stable shape suitable for piping into other tools.
|
|
150
167
|
|
|
151
168
|
```json
|
|
@@ -158,8 +175,8 @@ diamond-detect <path> Foundry project root or src/ folder
|
|
|
158
175
|
"slot": "0x...",
|
|
159
176
|
"message": "...",
|
|
160
177
|
"facets": ["LibStrategies", "LibVaults"],
|
|
161
|
-
"locations": [{ "file": "src/LibStrategies.sol" }],
|
|
162
|
-
"detail": { "namespaces": ["myapp.strategies"], "declarations": [...] }
|
|
178
|
+
"locations": [{ "file": "src/LibStrategies.sol", "line": 5, "src": "120:54:0" }],
|
|
179
|
+
"detail": { "namespaces": ["myapp.strategies"], "variableNames": ["POSITION"], "declarations": [...] }
|
|
163
180
|
}
|
|
164
181
|
]
|
|
165
182
|
}
|
|
@@ -206,13 +223,13 @@ Tighten with `--severity error` if you only want to fail CI on hard collisions.
|
|
|
206
223
|
|
|
207
224
|
## Comparison
|
|
208
225
|
|
|
209
|
-
| Tool | Diamond Storage namespaces | EIP-7201 ids | AppStorage drift | Hardcoded
|
|
210
|
-
|
|
211
|
-
| Slither | partial
|
|
212
|
-
| Hand-audit / spreadsheet | yes, manually | yes, manually | hard to spot | yes |
|
|
213
|
-
| `diamond-detect` | yes | yes | yes | yes |
|
|
226
|
+
| Tool | Diamond Storage namespaces | Precomputed / inline-formula slots | EIP-7201 ids | AppStorage drift | Hardcoded assembly slots |
|
|
227
|
+
|---|---|---|---|---|---|
|
|
228
|
+
| Slither | partial, a general slot detector that is not Diamond-aware | no | no | no | partial, raw `sstore` only |
|
|
229
|
+
| Hand-audit / spreadsheet | yes, manually | error-prone by hand | yes, manually | hard to spot | yes, manually |
|
|
230
|
+
| `diamond-detect` | yes | yes | yes | yes | yes |
|
|
214
231
|
|
|
215
|
-
Slither remains excellent for general Solidity static analysis
|
|
232
|
+
Slither's storage layout does not model Diamond namespaced storage, which lives at hashed slots reached through assembly, so it cannot see a Diamond storage collision at all. It remains excellent for general Solidity static analysis, so run both.
|
|
216
233
|
|
|
217
234
|
## Roadmap
|
|
218
235
|
|
package/dist/cli.js
CHANGED
|
@@ -72,6 +72,18 @@ function extractSourcePath(artifactPath, parsed) {
|
|
|
72
72
|
}
|
|
73
73
|
return path.basename(path.dirname(artifactPath));
|
|
74
74
|
}
|
|
75
|
+
async function loadRawSources(inputPath, sourcePaths) {
|
|
76
|
+
const { root } = await resolveFoundryRoot(inputPath);
|
|
77
|
+
const out = /* @__PURE__ */ new Map();
|
|
78
|
+
for (const sourcePath of new Set(sourcePaths)) {
|
|
79
|
+
if (out.has(sourcePath)) continue;
|
|
80
|
+
try {
|
|
81
|
+
out.set(sourcePath, await fs.readFile(path.join(root, sourcePath), "utf8"));
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
75
87
|
async function loadFoundryArtifacts(inputPath, opts = {}) {
|
|
76
88
|
const { outDir } = await resolveFoundryRoot(inputPath);
|
|
77
89
|
if (!await fileExists(outDir)) {
|
|
@@ -105,55 +117,58 @@ async function loadFoundryArtifacts(inputPath, opts = {}) {
|
|
|
105
117
|
return artifacts;
|
|
106
118
|
}
|
|
107
119
|
|
|
108
|
-
// src/detector/
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
function buildIgnore(userGlobs, noDefault) {
|
|
123
|
-
const globs = [
|
|
124
|
-
...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
|
|
125
|
-
...userGlobs ?? []
|
|
126
|
-
];
|
|
127
|
-
if (globs.length === 0) return void 0;
|
|
128
|
-
const patterns = compilePatterns(globs);
|
|
129
|
-
return (sourcePath) => patterns.some((p) => p.test(sourcePath));
|
|
120
|
+
// src/detector/analyzers/diamondStorage.ts
|
|
121
|
+
import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
|
|
122
|
+
|
|
123
|
+
// src/lib/eip7201.ts
|
|
124
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
125
|
+
var MASK_LAST_BYTE = (() => {
|
|
126
|
+
const m = new Uint8Array(32).fill(255);
|
|
127
|
+
m[31] = 0;
|
|
128
|
+
return m;
|
|
129
|
+
})();
|
|
130
|
+
function utf8(s) {
|
|
131
|
+
return new TextEncoder().encode(s);
|
|
130
132
|
}
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
return
|
|
133
|
+
function toHex(bytes) {
|
|
134
|
+
let out = "0x";
|
|
135
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
136
|
+
return out;
|
|
135
137
|
}
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
};
|
|
145
|
-
const findings = [];
|
|
146
|
-
for (const analyzer of analyzers) {
|
|
147
|
-
const out = await analyzer.run(ctx);
|
|
148
|
-
findings.push(...out);
|
|
138
|
+
function subOne(bytes) {
|
|
139
|
+
const out = new Uint8Array(bytes);
|
|
140
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
141
|
+
if (out[i] > 0) {
|
|
142
|
+
out[i] = out[i] - 1;
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
out[i] = 255;
|
|
149
146
|
}
|
|
150
|
-
return
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function maskLastByte(bytes) {
|
|
150
|
+
const out = new Uint8Array(32);
|
|
151
|
+
for (let i = 0; i < 32; i++) out[i] = bytes[i] & MASK_LAST_BYTE[i];
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
function erc7201Slot(namespaceId) {
|
|
155
|
+
const inner = keccak_256(utf8(namespaceId));
|
|
156
|
+
const decremented = subOne(inner);
|
|
157
|
+
const outer = keccak_256(decremented);
|
|
158
|
+
return toHex(maskLastByte(outer));
|
|
159
|
+
}
|
|
160
|
+
var ERC7201_PREFIX = "erc7201:";
|
|
161
|
+
function parseErc7201Annotation(text) {
|
|
162
|
+
const idx = text.indexOf(ERC7201_PREFIX);
|
|
163
|
+
if (idx === -1) return null;
|
|
164
|
+
const rest = text.slice(idx + ERC7201_PREFIX.length);
|
|
165
|
+
const match = rest.match(/^[A-Za-z0-9_.\-]+/);
|
|
166
|
+
return match ? match[0] : null;
|
|
151
167
|
}
|
|
152
168
|
|
|
153
169
|
// src/detector/analyzers/diamondStorage.ts
|
|
154
|
-
import { keccak_256 } from "@noble/hashes/sha3";
|
|
155
170
|
function keccak256Hex(input) {
|
|
156
|
-
const bytes =
|
|
171
|
+
const bytes = keccak_2562(new TextEncoder().encode(input));
|
|
157
172
|
let out = "0x";
|
|
158
173
|
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
159
174
|
return out;
|
|
@@ -176,6 +191,61 @@ function extractKeccakStringArg(value) {
|
|
|
176
191
|
if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
|
|
177
192
|
return typeof arg.value === "string" ? arg.value : null;
|
|
178
193
|
}
|
|
194
|
+
function extractBytes32HexLiteral(value) {
|
|
195
|
+
if (!value || typeof value !== "object") return null;
|
|
196
|
+
const v = value;
|
|
197
|
+
if (v.nodeType !== "Literal" || v.kind !== "number") return null;
|
|
198
|
+
if (typeof v.value !== "string") return null;
|
|
199
|
+
try {
|
|
200
|
+
const big = BigInt(v.value);
|
|
201
|
+
if (big < 0n) return null;
|
|
202
|
+
return "0x" + big.toString(16).padStart(64, "0");
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function findKeccakStringArg(node) {
|
|
208
|
+
if (!node || typeof node !== "object") return null;
|
|
209
|
+
const v = node;
|
|
210
|
+
if (v.nodeType !== "FunctionCall") return null;
|
|
211
|
+
const expr = v.expression;
|
|
212
|
+
if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
|
|
213
|
+
const args = v.arguments;
|
|
214
|
+
if (!Array.isArray(args) || args.length !== 1) return null;
|
|
215
|
+
const arg = args[0];
|
|
216
|
+
if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
|
|
217
|
+
return typeof arg.value === "string" ? arg.value : null;
|
|
218
|
+
}
|
|
219
|
+
function someNode(node, pred) {
|
|
220
|
+
if (!node || typeof node !== "object") return false;
|
|
221
|
+
const n = node;
|
|
222
|
+
if (typeof n.nodeType === "string" && pred(n)) return true;
|
|
223
|
+
for (const key of Object.keys(n)) {
|
|
224
|
+
const value = n[key];
|
|
225
|
+
if (Array.isArray(value)) {
|
|
226
|
+
if (value.some((c) => someNode(c, pred))) return true;
|
|
227
|
+
} else if (value && typeof value === "object") {
|
|
228
|
+
if (someNode(value, pred)) return true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
function extractErc7201FormulaNamespace(value) {
|
|
234
|
+
const strings = [];
|
|
235
|
+
someNode(value, (n) => {
|
|
236
|
+
const s = findKeccakStringArg(n);
|
|
237
|
+
if (s !== null) strings.push(s);
|
|
238
|
+
return false;
|
|
239
|
+
});
|
|
240
|
+
if (strings.length !== 1) return null;
|
|
241
|
+
const hasSubOne = someNode(
|
|
242
|
+
value,
|
|
243
|
+
(n) => n.nodeType === "BinaryOperation" && n.operator === "-" && n.rightExpression?.nodeType === "Literal" && n.rightExpression.value === "1"
|
|
244
|
+
);
|
|
245
|
+
const hasMask = someNode(value, (n) => n.nodeType === "BinaryOperation" && n.operator === "&");
|
|
246
|
+
if (!hasSubOne || !hasMask) return null;
|
|
247
|
+
return strings[0];
|
|
248
|
+
}
|
|
179
249
|
function lineFromSrc(src, sourceText) {
|
|
180
250
|
if (typeof src !== "string" || !sourceText) return void 0;
|
|
181
251
|
const [startStr] = src.split(":");
|
|
@@ -237,6 +307,105 @@ function collectSlotConstants(ctx) {
|
|
|
237
307
|
}
|
|
238
308
|
return out;
|
|
239
309
|
}
|
|
310
|
+
function collectLiteralSlotConstants(ctx) {
|
|
311
|
+
const seen = /* @__PURE__ */ new Set();
|
|
312
|
+
const out = [];
|
|
313
|
+
for (const artifact of ctx.artifacts) {
|
|
314
|
+
if (!artifact.ast) continue;
|
|
315
|
+
walkAst(artifact.ast, (node, parents) => {
|
|
316
|
+
if (!isBytes32Constant(node)) return;
|
|
317
|
+
if (extractKeccakStringArg(node.value) !== null) return;
|
|
318
|
+
const slot = extractBytes32HexLiteral(node.value);
|
|
319
|
+
if (slot === null) return;
|
|
320
|
+
const declarationId = typeof node.id === "number" ? node.id : -1;
|
|
321
|
+
const variableName = node.name ?? "<anon>";
|
|
322
|
+
const contract = declaringContract(parents) ?? artifact.contractName;
|
|
323
|
+
const src = node.src;
|
|
324
|
+
const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
|
|
325
|
+
if (seen.has(dedupeKey)) return;
|
|
326
|
+
seen.add(dedupeKey);
|
|
327
|
+
out.push({
|
|
328
|
+
declarationId,
|
|
329
|
+
variableName,
|
|
330
|
+
namespace: null,
|
|
331
|
+
slot,
|
|
332
|
+
contract,
|
|
333
|
+
sourcePath: artifact.sourcePath,
|
|
334
|
+
src
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
function collectFormulaSlotConstants(ctx) {
|
|
341
|
+
const seen = /* @__PURE__ */ new Set();
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const artifact of ctx.artifacts) {
|
|
344
|
+
if (!artifact.ast) continue;
|
|
345
|
+
walkAst(artifact.ast, (node, parents) => {
|
|
346
|
+
if (!isBytes32Constant(node)) return;
|
|
347
|
+
if (extractKeccakStringArg(node.value) !== null) return;
|
|
348
|
+
const namespace = extractErc7201FormulaNamespace(node.value);
|
|
349
|
+
if (namespace === null) return;
|
|
350
|
+
const declarationId = typeof node.id === "number" ? node.id : -1;
|
|
351
|
+
const variableName = node.name ?? "<anon>";
|
|
352
|
+
const contract = declaringContract(parents) ?? artifact.contractName;
|
|
353
|
+
const src = node.src;
|
|
354
|
+
const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
|
|
355
|
+
if (seen.has(dedupeKey)) return;
|
|
356
|
+
seen.add(dedupeKey);
|
|
357
|
+
out.push({
|
|
358
|
+
declarationId,
|
|
359
|
+
variableName,
|
|
360
|
+
namespace,
|
|
361
|
+
slot: erc7201Slot(namespace),
|
|
362
|
+
contract,
|
|
363
|
+
sourcePath: artifact.sourcePath,
|
|
364
|
+
src
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
function collectAssemblyLiteralSlots(artifacts) {
|
|
371
|
+
const seen = /* @__PURE__ */ new Set();
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const artifact of artifacts) {
|
|
374
|
+
if (!artifact.ast) continue;
|
|
375
|
+
walkAst(artifact.ast, (node, parents) => {
|
|
376
|
+
if (node.nodeType !== "YulAssignment") return;
|
|
377
|
+
const targets = node.variableNames;
|
|
378
|
+
const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
|
|
379
|
+
if (!targetsSlot) return;
|
|
380
|
+
const value = node.value;
|
|
381
|
+
if (value?.nodeType !== "YulLiteral" || value.kind !== "number") return;
|
|
382
|
+
if (typeof value.value !== "string") return;
|
|
383
|
+
let slot;
|
|
384
|
+
try {
|
|
385
|
+
const big = BigInt(value.value);
|
|
386
|
+
if (big < 0n) return;
|
|
387
|
+
slot = "0x" + big.toString(16).padStart(64, "0");
|
|
388
|
+
} catch {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const contract = declaringContract(parents) ?? artifact.contractName;
|
|
392
|
+
const src = node.src;
|
|
393
|
+
const dedupeKey = `${artifact.sourcePath}::${contract}::${slot}::${src ?? ""}`;
|
|
394
|
+
if (seen.has(dedupeKey)) return;
|
|
395
|
+
seen.add(dedupeKey);
|
|
396
|
+
out.push({
|
|
397
|
+
declarationId: -1,
|
|
398
|
+
variableName: "<assembly literal>",
|
|
399
|
+
namespace: null,
|
|
400
|
+
slot,
|
|
401
|
+
contract,
|
|
402
|
+
sourcePath: artifact.sourcePath,
|
|
403
|
+
src
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
240
409
|
function collectSlotAssignmentValueSrcs(yulNode, srcs) {
|
|
241
410
|
if (!yulNode || typeof yulNode !== "object") return;
|
|
242
411
|
const node = yulNode;
|
|
@@ -308,15 +477,23 @@ function isUsedAsSlot(constantId, slotUsedIds, aliases) {
|
|
|
308
477
|
}
|
|
309
478
|
return false;
|
|
310
479
|
}
|
|
480
|
+
function collectGatedSlotConstants(ctx) {
|
|
481
|
+
const constants = [
|
|
482
|
+
...collectSlotConstants(ctx),
|
|
483
|
+
...collectLiteralSlotConstants(ctx),
|
|
484
|
+
...collectFormulaSlotConstants(ctx)
|
|
485
|
+
];
|
|
486
|
+
const slotUsedIds = collectSlotUsedDeclarationIds(ctx.artifacts);
|
|
487
|
+
const aliases = collectAliases(ctx.artifacts);
|
|
488
|
+
return [
|
|
489
|
+
...constants.filter((c) => isUsedAsSlot(c.declarationId, slotUsedIds, aliases)),
|
|
490
|
+
...collectAssemblyLiteralSlots(ctx.artifacts)
|
|
491
|
+
];
|
|
492
|
+
}
|
|
311
493
|
var diamondStorageAnalyzer = {
|
|
312
494
|
name: "diamond-storage-namespace",
|
|
313
495
|
run(ctx) {
|
|
314
|
-
const
|
|
315
|
-
const slotUsedIds = collectSlotUsedDeclarationIds(ctx.artifacts);
|
|
316
|
-
const aliases = collectAliases(ctx.artifacts);
|
|
317
|
-
const slotConstants = constants.filter(
|
|
318
|
-
(c) => isUsedAsSlot(c.declarationId, slotUsedIds, aliases)
|
|
319
|
-
);
|
|
496
|
+
const slotConstants = collectGatedSlotConstants(ctx);
|
|
320
497
|
const bySlot = /* @__PURE__ */ new Map();
|
|
321
498
|
for (const c of slotConstants) {
|
|
322
499
|
const list = bySlot.get(c.slot) ?? [];
|
|
@@ -327,72 +504,38 @@ var diamondStorageAnalyzer = {
|
|
|
327
504
|
for (const [slot, group] of bySlot) {
|
|
328
505
|
const distinctSources = new Set(group.map((g) => g.sourcePath));
|
|
329
506
|
if (distinctSources.size < 2) continue;
|
|
330
|
-
const namespaces = Array.from(
|
|
507
|
+
const namespaces = Array.from(
|
|
508
|
+
new Set(group.map((g) => g.namespace).filter((n) => n !== null))
|
|
509
|
+
);
|
|
510
|
+
const variableNames = Array.from(new Set(group.map((g) => g.variableName)));
|
|
511
|
+
const hasLiteral = group.some((g) => g.namespace === null);
|
|
331
512
|
const facets = Array.from(new Set(group.map((g) => g.contract)));
|
|
332
513
|
const locations = group.map((g) => {
|
|
333
514
|
const sourceText = ctx.rawSources.get(g.sourcePath);
|
|
334
|
-
return { file: g.sourcePath, line: lineFromSrc(g.src, sourceText) };
|
|
515
|
+
return { file: g.sourcePath, line: lineFromSrc(g.src, sourceText), src: g.src };
|
|
335
516
|
});
|
|
517
|
+
let message;
|
|
518
|
+
if (!hasLiteral) {
|
|
519
|
+
message = namespaces.length === 1 ? `Diamond Storage namespace "${namespaces[0]}" is declared in ${distinctSources.size} different sources, all resolving to the same slot.` : `Distinct namespaces ${namespaces.map((n) => `"${n}"`).join(", ")} hash to the same slot.`;
|
|
520
|
+
} else if (namespaces.length === 0) {
|
|
521
|
+
message = `Hardcoded storage slot ${slot} is used as a Diamond Storage pointer by ${distinctSources.size} different sources (${variableNames.join(", ")}), so distinct facets share the same slot.`;
|
|
522
|
+
} else {
|
|
523
|
+
message = `Hardcoded slot ${slot} collides with namespace(s) ${namespaces.map((n) => `"${n}"`).join(", ")} \u2014 they resolve to the same storage slot.`;
|
|
524
|
+
}
|
|
336
525
|
findings.push({
|
|
337
526
|
kind: "diamond-storage-namespace",
|
|
338
527
|
severity: "error",
|
|
339
528
|
slot,
|
|
340
|
-
message
|
|
529
|
+
message,
|
|
341
530
|
facets,
|
|
342
531
|
locations,
|
|
343
|
-
detail: { namespaces, declarations: group }
|
|
532
|
+
detail: { namespaces, variableNames, declarations: group }
|
|
344
533
|
});
|
|
345
534
|
}
|
|
346
535
|
return findings;
|
|
347
536
|
}
|
|
348
537
|
};
|
|
349
538
|
|
|
350
|
-
// src/lib/eip7201.ts
|
|
351
|
-
import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
|
|
352
|
-
var MASK_LAST_BYTE = (() => {
|
|
353
|
-
const m = new Uint8Array(32).fill(255);
|
|
354
|
-
m[31] = 0;
|
|
355
|
-
return m;
|
|
356
|
-
})();
|
|
357
|
-
function utf8(s) {
|
|
358
|
-
return new TextEncoder().encode(s);
|
|
359
|
-
}
|
|
360
|
-
function toHex(bytes) {
|
|
361
|
-
let out = "0x";
|
|
362
|
-
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
363
|
-
return out;
|
|
364
|
-
}
|
|
365
|
-
function subOne(bytes) {
|
|
366
|
-
const out = new Uint8Array(bytes);
|
|
367
|
-
for (let i = out.length - 1; i >= 0; i--) {
|
|
368
|
-
if (out[i] > 0) {
|
|
369
|
-
out[i] = out[i] - 1;
|
|
370
|
-
return out;
|
|
371
|
-
}
|
|
372
|
-
out[i] = 255;
|
|
373
|
-
}
|
|
374
|
-
return out;
|
|
375
|
-
}
|
|
376
|
-
function maskLastByte(bytes) {
|
|
377
|
-
const out = new Uint8Array(32);
|
|
378
|
-
for (let i = 0; i < 32; i++) out[i] = bytes[i] & MASK_LAST_BYTE[i];
|
|
379
|
-
return out;
|
|
380
|
-
}
|
|
381
|
-
function erc7201Slot(namespaceId) {
|
|
382
|
-
const inner = keccak_2562(utf8(namespaceId));
|
|
383
|
-
const decremented = subOne(inner);
|
|
384
|
-
const outer = keccak_2562(decremented);
|
|
385
|
-
return toHex(maskLastByte(outer));
|
|
386
|
-
}
|
|
387
|
-
var ERC7201_PREFIX = "erc7201:";
|
|
388
|
-
function parseErc7201Annotation(text) {
|
|
389
|
-
const idx = text.indexOf(ERC7201_PREFIX);
|
|
390
|
-
if (idx === -1) return null;
|
|
391
|
-
const rest = text.slice(idx + ERC7201_PREFIX.length);
|
|
392
|
-
const match = rest.match(/^[A-Za-z0-9_.\-]+/);
|
|
393
|
-
return match ? match[0] : null;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
539
|
// src/detector/analyzers/erc7201.ts
|
|
397
540
|
var NAMED_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
398
541
|
"ContractDefinition",
|
|
@@ -480,7 +623,7 @@ var erc7201Analyzer = {
|
|
|
480
623
|
if (distinctSources.size < 2) continue;
|
|
481
624
|
const ids = Array.from(new Set(group.map((g) => g.namespaceId)));
|
|
482
625
|
const facets = Array.from(new Set(group.map((g) => g.contract)));
|
|
483
|
-
const locations = group.map((g) => ({ file: g.sourcePath }));
|
|
626
|
+
const locations = group.map((g) => ({ file: g.sourcePath, src: g.src }));
|
|
484
627
|
findings.push({
|
|
485
628
|
kind: "erc7201-namespace",
|
|
486
629
|
severity: "error",
|
|
@@ -495,6 +638,102 @@ var erc7201Analyzer = {
|
|
|
495
638
|
}
|
|
496
639
|
};
|
|
497
640
|
|
|
641
|
+
// src/detector/inventory.ts
|
|
642
|
+
function lineOf(src, text) {
|
|
643
|
+
if (!src || !text) return void 0;
|
|
644
|
+
const [startStr] = src.split(":");
|
|
645
|
+
const start = Number(startStr);
|
|
646
|
+
if (!Number.isFinite(start)) return void 0;
|
|
647
|
+
let line = 1;
|
|
648
|
+
for (let i = 0; i < start && i < text.length; i++) {
|
|
649
|
+
if (text.charCodeAt(i) === 10) line++;
|
|
650
|
+
}
|
|
651
|
+
return line;
|
|
652
|
+
}
|
|
653
|
+
var PRIORITY = { erc7201: 0, namespace: 1, hardcoded: 2 };
|
|
654
|
+
function buildInventory(ctx) {
|
|
655
|
+
const bySlot = /* @__PURE__ */ new Map();
|
|
656
|
+
const add = (region) => {
|
|
657
|
+
const existing = bySlot.get(region.slot);
|
|
658
|
+
if (!existing || PRIORITY[region.kind] < PRIORITY[existing.kind]) {
|
|
659
|
+
bySlot.set(region.slot, region);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
for (const c of collectGatedSlotConstants(ctx)) {
|
|
663
|
+
add({
|
|
664
|
+
slot: c.slot,
|
|
665
|
+
label: c.namespace ?? c.variableName,
|
|
666
|
+
kind: c.namespace ? "namespace" : "hardcoded",
|
|
667
|
+
contract: c.contract,
|
|
668
|
+
file: c.sourcePath,
|
|
669
|
+
line: lineOf(c.src, ctx.rawSources.get(c.sourcePath))
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
for (const a of collectErc7201Annotations(ctx.artifacts)) {
|
|
673
|
+
add({
|
|
674
|
+
slot: a.slot,
|
|
675
|
+
label: a.namespaceId,
|
|
676
|
+
kind: "erc7201",
|
|
677
|
+
contract: a.contract,
|
|
678
|
+
file: a.sourcePath,
|
|
679
|
+
line: lineOf(a.src, ctx.rawSources.get(a.sourcePath))
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
return [...bySlot.values()].sort(
|
|
683
|
+
(x, y) => x.file.localeCompare(y.file) || x.slot.localeCompare(y.slot)
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/detector/index.ts
|
|
688
|
+
var DEFAULT_IGNORE_GLOBS = [
|
|
689
|
+
"lib/**",
|
|
690
|
+
"test/**",
|
|
691
|
+
"script/**",
|
|
692
|
+
"**/*.t.sol",
|
|
693
|
+
"**/*.s.sol"
|
|
694
|
+
];
|
|
695
|
+
function compilePatterns(globs) {
|
|
696
|
+
return globs.map((g) => {
|
|
697
|
+
const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
|
|
698
|
+
return new RegExp(`^${escaped}$`);
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
function buildIgnore(userGlobs, noDefault) {
|
|
702
|
+
const globs = [
|
|
703
|
+
...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
|
|
704
|
+
...userGlobs ?? []
|
|
705
|
+
];
|
|
706
|
+
if (globs.length === 0) return void 0;
|
|
707
|
+
const patterns = compilePatterns(globs);
|
|
708
|
+
return (sourcePath) => patterns.some((p) => p.test(sourcePath));
|
|
709
|
+
}
|
|
710
|
+
function buildIsFacet(globs) {
|
|
711
|
+
if (!globs || globs.length === 0) return void 0;
|
|
712
|
+
const patterns = compilePatterns(globs);
|
|
713
|
+
return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
|
|
714
|
+
}
|
|
715
|
+
async function detect(options, analyzers) {
|
|
716
|
+
const artifacts = await loadFoundryArtifacts(options.path, {
|
|
717
|
+
ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
|
|
718
|
+
});
|
|
719
|
+
const rawSources = await loadRawSources(
|
|
720
|
+
options.path,
|
|
721
|
+
artifacts.map((a) => a.sourcePath)
|
|
722
|
+
);
|
|
723
|
+
const ctx = {
|
|
724
|
+
artifacts,
|
|
725
|
+
rawSources,
|
|
726
|
+
isFacet: buildIsFacet(options.facetGlobs)
|
|
727
|
+
};
|
|
728
|
+
const findings = [];
|
|
729
|
+
for (const analyzer of analyzers) {
|
|
730
|
+
const out = await analyzer.run(ctx);
|
|
731
|
+
findings.push(...out);
|
|
732
|
+
}
|
|
733
|
+
const inventory = buildInventory(ctx);
|
|
734
|
+
return { artifacts, findings, rawSources, inventory };
|
|
735
|
+
}
|
|
736
|
+
|
|
498
737
|
// src/detector/analyzers/appStorage.ts
|
|
499
738
|
function memberFingerprint(m) {
|
|
500
739
|
return { label: m.label, offset: m.offset, slot: m.slot, type: m.type };
|
|
@@ -659,7 +898,7 @@ var inlineAssemblyAnalyzer = {
|
|
|
659
898
|
slot: lit.slot,
|
|
660
899
|
message: `inline assembly writes to a hardcoded slot (sstore(${lit.rawValue}, \u2026)) \u2014 confirm no overlap with computed storage slots.`,
|
|
661
900
|
facets: [lit.artifact.contractName],
|
|
662
|
-
locations: [{ file: lit.artifact.sourcePath }],
|
|
901
|
+
locations: [{ file: lit.artifact.sourcePath, src: lit.src }],
|
|
663
902
|
detail: { rawValue: lit.rawValue, src: lit.src }
|
|
664
903
|
}));
|
|
665
904
|
}
|
|
@@ -750,37 +989,140 @@ var defaultAnalyzers = [
|
|
|
750
989
|
// src/reporter/terminal.ts
|
|
751
990
|
import pc from "picocolors";
|
|
752
991
|
var SEVERITY_RANK = { info: 0, warn: 1, error: 2 };
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
992
|
+
var SEV = {
|
|
993
|
+
error: { label: "error", glyph: "\u2716", paint: (s) => pc.red(s) },
|
|
994
|
+
warn: { label: "warning", glyph: "\u26A0", paint: (s) => pc.yellow(s) },
|
|
995
|
+
info: { label: "note", glyph: "\u25CF", paint: (s) => pc.cyan(s) }
|
|
996
|
+
};
|
|
997
|
+
var HELP = {
|
|
998
|
+
"diamond-storage-namespace": "give every facet a unique storage seed; never reuse a namespace string, precomputed slot, or formula across facets",
|
|
999
|
+
"erc7201-namespace": "use a distinct erc7201 namespace id per facet",
|
|
1000
|
+
"appstorage-fingerprint": "keep the shared struct layout identical across all facets; append fields, never reorder or insert",
|
|
1001
|
+
"inheritance-overlap": "facets must not declare sequential state variables; move state into namespaced Diamond Storage",
|
|
1002
|
+
"inline-assembly-slot": "confirm this hardcoded slot cannot overlap any namespaced storage region",
|
|
1003
|
+
"mapping-overlap": "ensure mapping base slots are derived from distinct, collision-resistant seeds"
|
|
1004
|
+
};
|
|
1005
|
+
function shortSlot(slot) {
|
|
1006
|
+
if (!slot.startsWith("0x") || slot.length <= 14) return slot;
|
|
1007
|
+
return `${slot.slice(0, 8)}\u2026${slot.slice(-6)}`;
|
|
1008
|
+
}
|
|
1009
|
+
function expandTabs(s) {
|
|
1010
|
+
return s.replace(/\t/g, " ");
|
|
1011
|
+
}
|
|
1012
|
+
function resolveSpan(loc, sourceText) {
|
|
1013
|
+
if (!sourceText || !loc.src) return null;
|
|
1014
|
+
const [offStr, lenStr] = loc.src.split(":");
|
|
1015
|
+
const offset = Number(offStr);
|
|
1016
|
+
const length = Number(lenStr);
|
|
1017
|
+
if (!Number.isFinite(offset) || offset < 0 || offset > sourceText.length) return null;
|
|
1018
|
+
let line = 1;
|
|
1019
|
+
let lineStart = 0;
|
|
1020
|
+
for (let i = 0; i < offset; i++) {
|
|
1021
|
+
if (sourceText.charCodeAt(i) === 10) {
|
|
1022
|
+
line++;
|
|
1023
|
+
lineStart = i + 1;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const nl = sourceText.indexOf("\n", lineStart);
|
|
1027
|
+
const lineEnd = nl === -1 ? sourceText.length : nl;
|
|
1028
|
+
const rawLine = sourceText.slice(lineStart, lineEnd);
|
|
1029
|
+
const rawPrefix = sourceText.slice(lineStart, offset);
|
|
1030
|
+
const column = expandTabs(rawPrefix).length + 1;
|
|
1031
|
+
const visibleLen = expandTabs(rawLine).length;
|
|
1032
|
+
const rawCaret = Number.isFinite(length) && length > 0 ? length : 1;
|
|
1033
|
+
const caretLen = Math.max(1, Math.min(rawCaret, visibleLen - (column - 1)));
|
|
1034
|
+
return { line, column, lineText: expandTabs(rawLine), caretLen };
|
|
1035
|
+
}
|
|
1036
|
+
function renderFrame(loc, span, sev, note) {
|
|
1037
|
+
const gutter = String(span.line);
|
|
1038
|
+
const pad = " ".repeat(gutter.length);
|
|
1039
|
+
const bar = pc.dim("\u2502");
|
|
1040
|
+
const arrow = pc.dim("\u256D\u2500[");
|
|
1041
|
+
const close = pc.dim("]");
|
|
1042
|
+
const caret = sev.paint("\u2500".repeat(span.caretLen));
|
|
1043
|
+
const caretPad = " ".repeat(span.column - 1);
|
|
1044
|
+
const label = note ? " " + sev.paint(note) : "";
|
|
1045
|
+
return [
|
|
1046
|
+
` ${arrow}${pc.cyan(`${loc.file}:${span.line}:${span.column}`)}${close}`,
|
|
1047
|
+
` ${pad} ${bar}`,
|
|
1048
|
+
` ${pc.dim(gutter)} ${bar} ${span.lineText}`,
|
|
1049
|
+
` ${pad} ${pc.dim("\xB7")} ${caretPad}${caret}${label}`,
|
|
1050
|
+
` ${pad} ${pc.dim("\u2570\u2500")}`
|
|
1051
|
+
];
|
|
757
1052
|
}
|
|
758
|
-
|
|
1053
|
+
var KIND_TAG = {
|
|
1054
|
+
erc7201: "erc7201",
|
|
1055
|
+
namespace: "namespace",
|
|
1056
|
+
hardcoded: "precomputed"
|
|
1057
|
+
};
|
|
1058
|
+
function renderInventory(inventory) {
|
|
759
1059
|
const lines = [];
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1060
|
+
const shown = inventory.slice(0, 60);
|
|
1061
|
+
const labelW = Math.max(...shown.map((r) => r.label.length), 0);
|
|
1062
|
+
const tagW = Math.max(...shown.map((r) => KIND_TAG[r.kind].length), 0);
|
|
1063
|
+
const contractW = Math.max(...shown.map((r) => r.contract.length), 0);
|
|
1064
|
+
lines.push("");
|
|
1065
|
+
lines.push(
|
|
1066
|
+
pc.bold(`Verified ${inventory.length} storage region${inventory.length === 1 ? "" : "s"}`) + pc.dim(", each on its own slot:")
|
|
1067
|
+
);
|
|
1068
|
+
lines.push("");
|
|
1069
|
+
for (const r of shown) {
|
|
1070
|
+
const where = r.line ? `${r.file}:${r.line}` : r.file;
|
|
1071
|
+
lines.push(
|
|
1072
|
+
` ${pc.green("\u2022")} ${pc.green(r.label.padEnd(labelW))} ${pc.dim(KIND_TAG[r.kind].padEnd(tagW))} ${pc.cyan(shortSlot(r.slot))} ${r.contract.padEnd(contractW)} ${pc.dim(where)}`
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
if (inventory.length > shown.length) {
|
|
1076
|
+
lines.push(` ${pc.dim(`\u2026 and ${inventory.length - shown.length} more`)}`);
|
|
764
1077
|
}
|
|
765
|
-
|
|
766
|
-
|
|
1078
|
+
lines.push("");
|
|
1079
|
+
lines.push(
|
|
1080
|
+
pc.green("Every facet keeps to its own namespace, and no two regions share a slot. ") + pc.green(pc.bold("Nicely done."))
|
|
767
1081
|
);
|
|
1082
|
+
return lines;
|
|
1083
|
+
}
|
|
1084
|
+
function renderTerminal(findings, facetCount, rawSources, inventory = []) {
|
|
1085
|
+
const artifactsNote = pc.dim(`${facetCount} artifact${facetCount === 1 ? "" : "s"} scanned`);
|
|
1086
|
+
if (findings.length === 0) {
|
|
1087
|
+
const header = `${pc.green(pc.bold("\u2714 no storage collisions detected"))} ${pc.dim("\xB7")} ${artifactsNote}`;
|
|
1088
|
+
if (inventory.length === 0) return header;
|
|
1089
|
+
return [header, ...renderInventory(inventory)].join("\n");
|
|
1090
|
+
}
|
|
1091
|
+
const sorted = [...findings].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
|
|
1092
|
+
const lines = [];
|
|
768
1093
|
for (const f of sorted) {
|
|
1094
|
+
const sev = SEV[f.severity];
|
|
1095
|
+
const tag = `${pc.bold(sev.paint(sev.label))}${pc.dim(`[${f.kind}]`)}`;
|
|
769
1096
|
lines.push("");
|
|
770
|
-
lines.push(`${
|
|
771
|
-
|
|
1097
|
+
lines.push(`${tag}: ${pc.bold(f.message)}`);
|
|
1098
|
+
f.locations.forEach((loc, i) => {
|
|
1099
|
+
const span = resolveSpan(loc, rawSources?.get(loc.file));
|
|
1100
|
+
const note = i === 0 ? `slot ${shortSlot(f.slot)}` : "same slot here";
|
|
1101
|
+
if (span) {
|
|
1102
|
+
lines.push(...renderFrame(loc, span, sev, f.slot === "n/a" ? "here" : note));
|
|
1103
|
+
} else {
|
|
1104
|
+
const where = loc.line ? `${loc.file}:${loc.line}` : loc.file;
|
|
1105
|
+
lines.push(` ${pc.dim("\u256D\u2500[")}${pc.cyan(where)}${pc.dim("]")}`);
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
772
1108
|
if (f.facets.length > 0) {
|
|
773
|
-
lines.push(` ${pc.dim("facets:")} ${f.facets.join(", ")}`);
|
|
1109
|
+
lines.push(` ${pc.dim("= facets:")} ${f.facets.join(pc.dim(", "))}`);
|
|
774
1110
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
lines.push(` ${pc.dim("at")} ${where}`);
|
|
1111
|
+
if (f.slot && f.slot !== "n/a") {
|
|
1112
|
+
lines.push(` ${pc.dim("= slot: ")} ${pc.dim(f.slot)}`);
|
|
778
1113
|
}
|
|
1114
|
+
lines.push(` ${pc.dim("= help: ")} ${pc.dim(HELP[f.kind] ?? "")}`);
|
|
779
1115
|
}
|
|
780
1116
|
const errors = findings.filter((f) => f.severity === "error").length;
|
|
781
1117
|
const warns = findings.filter((f) => f.severity === "warn").length;
|
|
1118
|
+
const notes = findings.filter((f) => f.severity === "info").length;
|
|
1119
|
+
const parts = [];
|
|
1120
|
+
if (errors > 0) parts.push(pc.red(`${SEV.error.glyph} ${errors} error${errors === 1 ? "" : "s"}`));
|
|
1121
|
+
if (warns > 0)
|
|
1122
|
+
parts.push(pc.yellow(`${SEV.warn.glyph} ${warns} warning${warns === 1 ? "" : "s"}`));
|
|
1123
|
+
if (notes > 0) parts.push(pc.cyan(`${SEV.info.glyph} ${notes} note${notes === 1 ? "" : "s"}`));
|
|
782
1124
|
lines.push("");
|
|
783
|
-
lines.push(pc.
|
|
1125
|
+
lines.push(`${parts.join(pc.dim(" \xB7 "))} ${pc.dim("\xB7")} ${artifactsNote}`);
|
|
784
1126
|
return lines.join("\n");
|
|
785
1127
|
}
|
|
786
1128
|
|
|
@@ -910,7 +1252,12 @@ async function run(target, opts) {
|
|
|
910
1252
|
)
|
|
911
1253
|
);
|
|
912
1254
|
}
|
|
913
|
-
const output = opts.json ? renderJson(result.findings, result.artifacts.length) : opts.markdown ? renderMarkdown(result.findings, result.artifacts.length) : renderTerminal(
|
|
1255
|
+
const output = opts.json ? renderJson(result.findings, result.artifacts.length) : opts.markdown ? renderMarkdown(result.findings, result.artifacts.length) : renderTerminal(
|
|
1256
|
+
result.findings,
|
|
1257
|
+
result.artifacts.length,
|
|
1258
|
+
result.rawSources,
|
|
1259
|
+
result.inventory
|
|
1260
|
+
);
|
|
914
1261
|
process.stdout.write(output + "\n");
|
|
915
1262
|
const threshold = SEVERITY_RANK3[opts.severity];
|
|
916
1263
|
const hit = result.findings.some((f) => SEVERITY_RANK3[f.severity] >= threshold);
|
package/dist/index.js
CHANGED
|
@@ -68,6 +68,18 @@ function extractSourcePath(artifactPath, parsed) {
|
|
|
68
68
|
}
|
|
69
69
|
return path.basename(path.dirname(artifactPath));
|
|
70
70
|
}
|
|
71
|
+
async function loadRawSources(inputPath, sourcePaths) {
|
|
72
|
+
const { root } = await resolveFoundryRoot(inputPath);
|
|
73
|
+
const out = /* @__PURE__ */ new Map();
|
|
74
|
+
for (const sourcePath of new Set(sourcePaths)) {
|
|
75
|
+
if (out.has(sourcePath)) continue;
|
|
76
|
+
try {
|
|
77
|
+
out.set(sourcePath, await fs.readFile(path.join(root, sourcePath), "utf8"));
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
71
83
|
async function loadFoundryArtifacts(inputPath, opts = {}) {
|
|
72
84
|
const { outDir } = await resolveFoundryRoot(inputPath);
|
|
73
85
|
if (!await fileExists(outDir)) {
|
|
@@ -101,50 +113,8 @@ async function loadFoundryArtifacts(inputPath, opts = {}) {
|
|
|
101
113
|
return artifacts;
|
|
102
114
|
}
|
|
103
115
|
|
|
104
|
-
// src/detector/
|
|
105
|
-
|
|
106
|
-
"lib/**",
|
|
107
|
-
"test/**",
|
|
108
|
-
"script/**",
|
|
109
|
-
"**/*.t.sol",
|
|
110
|
-
"**/*.s.sol"
|
|
111
|
-
];
|
|
112
|
-
function compilePatterns(globs) {
|
|
113
|
-
return globs.map((g) => {
|
|
114
|
-
const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
|
|
115
|
-
return new RegExp(`^${escaped}$`);
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
function buildIgnore(userGlobs, noDefault) {
|
|
119
|
-
const globs = [
|
|
120
|
-
...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
|
|
121
|
-
...userGlobs ?? []
|
|
122
|
-
];
|
|
123
|
-
if (globs.length === 0) return void 0;
|
|
124
|
-
const patterns = compilePatterns(globs);
|
|
125
|
-
return (sourcePath) => patterns.some((p) => p.test(sourcePath));
|
|
126
|
-
}
|
|
127
|
-
function buildIsFacet(globs) {
|
|
128
|
-
if (!globs || globs.length === 0) return void 0;
|
|
129
|
-
const patterns = compilePatterns(globs);
|
|
130
|
-
return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
|
|
131
|
-
}
|
|
132
|
-
async function detect(options, analyzers) {
|
|
133
|
-
const artifacts = await loadFoundryArtifacts(options.path, {
|
|
134
|
-
ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
|
|
135
|
-
});
|
|
136
|
-
const ctx = {
|
|
137
|
-
artifacts,
|
|
138
|
-
rawSources: /* @__PURE__ */ new Map(),
|
|
139
|
-
isFacet: buildIsFacet(options.facetGlobs)
|
|
140
|
-
};
|
|
141
|
-
const findings = [];
|
|
142
|
-
for (const analyzer of analyzers) {
|
|
143
|
-
const out = await analyzer.run(ctx);
|
|
144
|
-
findings.push(...out);
|
|
145
|
-
}
|
|
146
|
-
return { artifacts, findings };
|
|
147
|
-
}
|
|
116
|
+
// src/detector/analyzers/diamondStorage.ts
|
|
117
|
+
import { keccak_256 as keccak_2562 } from "@noble/hashes/sha3";
|
|
148
118
|
|
|
149
119
|
// src/lib/eip7201.ts
|
|
150
120
|
import { keccak_256 } from "@noble/hashes/sha3";
|
|
@@ -191,6 +161,488 @@ function parseErc7201Annotation(text) {
|
|
|
191
161
|
const match = rest.match(/^[A-Za-z0-9_.\-]+/);
|
|
192
162
|
return match ? match[0] : null;
|
|
193
163
|
}
|
|
164
|
+
|
|
165
|
+
// src/detector/analyzers/diamondStorage.ts
|
|
166
|
+
function keccak256Hex(input) {
|
|
167
|
+
const bytes = keccak_2562(new TextEncoder().encode(input));
|
|
168
|
+
let out = "0x";
|
|
169
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
function isBytes32Constant(node) {
|
|
173
|
+
if (node.nodeType !== "VariableDeclaration") return false;
|
|
174
|
+
if (node.constant !== true) return false;
|
|
175
|
+
const typeName = node.typeName;
|
|
176
|
+
return typeName?.nodeType === "ElementaryTypeName" && typeName.name === "bytes32";
|
|
177
|
+
}
|
|
178
|
+
function extractKeccakStringArg(value) {
|
|
179
|
+
if (!value || typeof value !== "object") return null;
|
|
180
|
+
const v = value;
|
|
181
|
+
if (v.nodeType !== "FunctionCall") return null;
|
|
182
|
+
const expr = v.expression;
|
|
183
|
+
if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
|
|
184
|
+
const args = v.arguments;
|
|
185
|
+
if (!Array.isArray(args) || args.length !== 1) return null;
|
|
186
|
+
const arg = args[0];
|
|
187
|
+
if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
|
|
188
|
+
return typeof arg.value === "string" ? arg.value : null;
|
|
189
|
+
}
|
|
190
|
+
function extractBytes32HexLiteral(value) {
|
|
191
|
+
if (!value || typeof value !== "object") return null;
|
|
192
|
+
const v = value;
|
|
193
|
+
if (v.nodeType !== "Literal" || v.kind !== "number") return null;
|
|
194
|
+
if (typeof v.value !== "string") return null;
|
|
195
|
+
try {
|
|
196
|
+
const big = BigInt(v.value);
|
|
197
|
+
if (big < 0n) return null;
|
|
198
|
+
return "0x" + big.toString(16).padStart(64, "0");
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function findKeccakStringArg(node) {
|
|
204
|
+
if (!node || typeof node !== "object") return null;
|
|
205
|
+
const v = node;
|
|
206
|
+
if (v.nodeType !== "FunctionCall") return null;
|
|
207
|
+
const expr = v.expression;
|
|
208
|
+
if (expr?.nodeType !== "Identifier" || expr.name !== "keccak256") return null;
|
|
209
|
+
const args = v.arguments;
|
|
210
|
+
if (!Array.isArray(args) || args.length !== 1) return null;
|
|
211
|
+
const arg = args[0];
|
|
212
|
+
if (arg.nodeType !== "Literal" || arg.kind !== "string") return null;
|
|
213
|
+
return typeof arg.value === "string" ? arg.value : null;
|
|
214
|
+
}
|
|
215
|
+
function someNode(node, pred) {
|
|
216
|
+
if (!node || typeof node !== "object") return false;
|
|
217
|
+
const n = node;
|
|
218
|
+
if (typeof n.nodeType === "string" && pred(n)) return true;
|
|
219
|
+
for (const key of Object.keys(n)) {
|
|
220
|
+
const value = n[key];
|
|
221
|
+
if (Array.isArray(value)) {
|
|
222
|
+
if (value.some((c) => someNode(c, pred))) return true;
|
|
223
|
+
} else if (value && typeof value === "object") {
|
|
224
|
+
if (someNode(value, pred)) return true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
function extractErc7201FormulaNamespace(value) {
|
|
230
|
+
const strings = [];
|
|
231
|
+
someNode(value, (n) => {
|
|
232
|
+
const s = findKeccakStringArg(n);
|
|
233
|
+
if (s !== null) strings.push(s);
|
|
234
|
+
return false;
|
|
235
|
+
});
|
|
236
|
+
if (strings.length !== 1) return null;
|
|
237
|
+
const hasSubOne = someNode(
|
|
238
|
+
value,
|
|
239
|
+
(n) => n.nodeType === "BinaryOperation" && n.operator === "-" && n.rightExpression?.nodeType === "Literal" && n.rightExpression.value === "1"
|
|
240
|
+
);
|
|
241
|
+
const hasMask = someNode(value, (n) => n.nodeType === "BinaryOperation" && n.operator === "&");
|
|
242
|
+
if (!hasSubOne || !hasMask) return null;
|
|
243
|
+
return strings[0];
|
|
244
|
+
}
|
|
245
|
+
function walkAst(ast, visit, parents = []) {
|
|
246
|
+
if (!ast || typeof ast !== "object") return;
|
|
247
|
+
const node = ast;
|
|
248
|
+
if (typeof node.nodeType === "string") visit(node, parents);
|
|
249
|
+
const nextParents = typeof node.nodeType === "string" ? [...parents, node] : parents;
|
|
250
|
+
for (const key of Object.keys(node)) {
|
|
251
|
+
const value = node[key];
|
|
252
|
+
if (Array.isArray(value)) {
|
|
253
|
+
for (const child of value) walkAst(child, visit, nextParents);
|
|
254
|
+
} else if (value && typeof value === "object") {
|
|
255
|
+
walkAst(value, visit, nextParents);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function declaringContract(parents) {
|
|
260
|
+
for (let i = parents.length - 1; i >= 0; i--) {
|
|
261
|
+
const p = parents[i];
|
|
262
|
+
if (p.nodeType === "ContractDefinition") return p.name ?? null;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
function collectSlotConstants(ctx) {
|
|
267
|
+
const seen = /* @__PURE__ */ new Set();
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const artifact of ctx.artifacts) {
|
|
270
|
+
if (!artifact.ast) continue;
|
|
271
|
+
walkAst(artifact.ast, (node, parents) => {
|
|
272
|
+
if (!isBytes32Constant(node)) return;
|
|
273
|
+
const namespace = extractKeccakStringArg(node.value);
|
|
274
|
+
if (namespace === null) return;
|
|
275
|
+
const declarationId = typeof node.id === "number" ? node.id : -1;
|
|
276
|
+
const variableName = node.name ?? "<anon>";
|
|
277
|
+
const contract = declaringContract(parents) ?? artifact.contractName;
|
|
278
|
+
const src = node.src;
|
|
279
|
+
const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
|
|
280
|
+
if (seen.has(dedupeKey)) return;
|
|
281
|
+
seen.add(dedupeKey);
|
|
282
|
+
out.push({
|
|
283
|
+
declarationId,
|
|
284
|
+
variableName,
|
|
285
|
+
namespace,
|
|
286
|
+
slot: keccak256Hex(namespace),
|
|
287
|
+
contract,
|
|
288
|
+
sourcePath: artifact.sourcePath,
|
|
289
|
+
src
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
function collectLiteralSlotConstants(ctx) {
|
|
296
|
+
const seen = /* @__PURE__ */ new Set();
|
|
297
|
+
const out = [];
|
|
298
|
+
for (const artifact of ctx.artifacts) {
|
|
299
|
+
if (!artifact.ast) continue;
|
|
300
|
+
walkAst(artifact.ast, (node, parents) => {
|
|
301
|
+
if (!isBytes32Constant(node)) return;
|
|
302
|
+
if (extractKeccakStringArg(node.value) !== null) return;
|
|
303
|
+
const slot = extractBytes32HexLiteral(node.value);
|
|
304
|
+
if (slot === null) return;
|
|
305
|
+
const declarationId = typeof node.id === "number" ? node.id : -1;
|
|
306
|
+
const variableName = node.name ?? "<anon>";
|
|
307
|
+
const contract = declaringContract(parents) ?? artifact.contractName;
|
|
308
|
+
const src = node.src;
|
|
309
|
+
const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
|
|
310
|
+
if (seen.has(dedupeKey)) return;
|
|
311
|
+
seen.add(dedupeKey);
|
|
312
|
+
out.push({
|
|
313
|
+
declarationId,
|
|
314
|
+
variableName,
|
|
315
|
+
namespace: null,
|
|
316
|
+
slot,
|
|
317
|
+
contract,
|
|
318
|
+
sourcePath: artifact.sourcePath,
|
|
319
|
+
src
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
function collectFormulaSlotConstants(ctx) {
|
|
326
|
+
const seen = /* @__PURE__ */ new Set();
|
|
327
|
+
const out = [];
|
|
328
|
+
for (const artifact of ctx.artifacts) {
|
|
329
|
+
if (!artifact.ast) continue;
|
|
330
|
+
walkAst(artifact.ast, (node, parents) => {
|
|
331
|
+
if (!isBytes32Constant(node)) return;
|
|
332
|
+
if (extractKeccakStringArg(node.value) !== null) return;
|
|
333
|
+
const namespace = extractErc7201FormulaNamespace(node.value);
|
|
334
|
+
if (namespace === null) return;
|
|
335
|
+
const declarationId = typeof node.id === "number" ? node.id : -1;
|
|
336
|
+
const variableName = node.name ?? "<anon>";
|
|
337
|
+
const contract = declaringContract(parents) ?? artifact.contractName;
|
|
338
|
+
const src = node.src;
|
|
339
|
+
const dedupeKey = `${artifact.sourcePath}::${contract}::${variableName}::${src ?? ""}`;
|
|
340
|
+
if (seen.has(dedupeKey)) return;
|
|
341
|
+
seen.add(dedupeKey);
|
|
342
|
+
out.push({
|
|
343
|
+
declarationId,
|
|
344
|
+
variableName,
|
|
345
|
+
namespace,
|
|
346
|
+
slot: erc7201Slot(namespace),
|
|
347
|
+
contract,
|
|
348
|
+
sourcePath: artifact.sourcePath,
|
|
349
|
+
src
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
function collectAssemblyLiteralSlots(artifacts) {
|
|
356
|
+
const seen = /* @__PURE__ */ new Set();
|
|
357
|
+
const out = [];
|
|
358
|
+
for (const artifact of artifacts) {
|
|
359
|
+
if (!artifact.ast) continue;
|
|
360
|
+
walkAst(artifact.ast, (node, parents) => {
|
|
361
|
+
if (node.nodeType !== "YulAssignment") return;
|
|
362
|
+
const targets = node.variableNames;
|
|
363
|
+
const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
|
|
364
|
+
if (!targetsSlot) return;
|
|
365
|
+
const value = node.value;
|
|
366
|
+
if (value?.nodeType !== "YulLiteral" || value.kind !== "number") return;
|
|
367
|
+
if (typeof value.value !== "string") return;
|
|
368
|
+
let slot;
|
|
369
|
+
try {
|
|
370
|
+
const big = BigInt(value.value);
|
|
371
|
+
if (big < 0n) return;
|
|
372
|
+
slot = "0x" + big.toString(16).padStart(64, "0");
|
|
373
|
+
} catch {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const contract = declaringContract(parents) ?? artifact.contractName;
|
|
377
|
+
const src = node.src;
|
|
378
|
+
const dedupeKey = `${artifact.sourcePath}::${contract}::${slot}::${src ?? ""}`;
|
|
379
|
+
if (seen.has(dedupeKey)) return;
|
|
380
|
+
seen.add(dedupeKey);
|
|
381
|
+
out.push({
|
|
382
|
+
declarationId: -1,
|
|
383
|
+
variableName: "<assembly literal>",
|
|
384
|
+
namespace: null,
|
|
385
|
+
slot,
|
|
386
|
+
contract,
|
|
387
|
+
sourcePath: artifact.sourcePath,
|
|
388
|
+
src
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
function collectSlotAssignmentValueSrcs(yulNode, srcs) {
|
|
395
|
+
if (!yulNode || typeof yulNode !== "object") return;
|
|
396
|
+
const node = yulNode;
|
|
397
|
+
if (node.nodeType === "YulAssignment") {
|
|
398
|
+
const targets = node.variableNames;
|
|
399
|
+
const targetsSlot = Array.isArray(targets) && targets.some((t) => typeof t.name === "string" && t.name.endsWith(".slot"));
|
|
400
|
+
if (targetsSlot) {
|
|
401
|
+
const value = node.value;
|
|
402
|
+
if (value?.nodeType === "YulIdentifier" && typeof value.src === "string") {
|
|
403
|
+
srcs.add(value.src);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const key of Object.keys(node)) {
|
|
408
|
+
const v = node[key];
|
|
409
|
+
if (Array.isArray(v)) {
|
|
410
|
+
for (const child of v) collectSlotAssignmentValueSrcs(child, srcs);
|
|
411
|
+
} else if (v && typeof v === "object") {
|
|
412
|
+
collectSlotAssignmentValueSrcs(v, srcs);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function collectSlotUsedDeclarationIds(artifacts) {
|
|
417
|
+
const ids = /* @__PURE__ */ new Set();
|
|
418
|
+
for (const artifact of artifacts) {
|
|
419
|
+
if (!artifact.ast) continue;
|
|
420
|
+
walkAst(artifact.ast, (node) => {
|
|
421
|
+
if (node.nodeType !== "InlineAssembly") return;
|
|
422
|
+
const yulAst = node.AST;
|
|
423
|
+
if (!yulAst) return;
|
|
424
|
+
const slotValueSrcs = /* @__PURE__ */ new Set();
|
|
425
|
+
collectSlotAssignmentValueSrcs(yulAst, slotValueSrcs);
|
|
426
|
+
if (slotValueSrcs.size === 0) return;
|
|
427
|
+
const refs = node.externalReferences;
|
|
428
|
+
if (!Array.isArray(refs)) return;
|
|
429
|
+
for (const ref of refs) {
|
|
430
|
+
if (typeof ref.src === "string" && slotValueSrcs.has(ref.src) && typeof ref.declaration === "number") {
|
|
431
|
+
ids.add(ref.declaration);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return ids;
|
|
437
|
+
}
|
|
438
|
+
function collectAliases(artifacts) {
|
|
439
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
440
|
+
for (const artifact of artifacts) {
|
|
441
|
+
if (!artifact.ast) continue;
|
|
442
|
+
walkAst(artifact.ast, (node) => {
|
|
443
|
+
if (node.nodeType !== "VariableDeclarationStatement") return;
|
|
444
|
+
const initialValue = node.initialValue;
|
|
445
|
+
if (initialValue?.nodeType !== "Identifier") return;
|
|
446
|
+
const referenced = initialValue.referencedDeclaration;
|
|
447
|
+
if (typeof referenced !== "number") return;
|
|
448
|
+
const decls = node.declarations;
|
|
449
|
+
if (!Array.isArray(decls)) return;
|
|
450
|
+
for (const d of decls) {
|
|
451
|
+
if (typeof d?.id === "number") aliases.set(d.id, referenced);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return aliases;
|
|
456
|
+
}
|
|
457
|
+
function isUsedAsSlot(constantId, slotUsedIds, aliases) {
|
|
458
|
+
if (constantId < 0) return false;
|
|
459
|
+
if (slotUsedIds.has(constantId)) return true;
|
|
460
|
+
for (const [aliasId, refId] of aliases) {
|
|
461
|
+
if (refId === constantId && slotUsedIds.has(aliasId)) return true;
|
|
462
|
+
}
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
function collectGatedSlotConstants(ctx) {
|
|
466
|
+
const constants = [
|
|
467
|
+
...collectSlotConstants(ctx),
|
|
468
|
+
...collectLiteralSlotConstants(ctx),
|
|
469
|
+
...collectFormulaSlotConstants(ctx)
|
|
470
|
+
];
|
|
471
|
+
const slotUsedIds = collectSlotUsedDeclarationIds(ctx.artifacts);
|
|
472
|
+
const aliases = collectAliases(ctx.artifacts);
|
|
473
|
+
return [
|
|
474
|
+
...constants.filter((c) => isUsedAsSlot(c.declarationId, slotUsedIds, aliases)),
|
|
475
|
+
...collectAssemblyLiteralSlots(ctx.artifacts)
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/detector/analyzers/erc7201.ts
|
|
480
|
+
var NAMED_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
481
|
+
"ContractDefinition",
|
|
482
|
+
"StructDefinition",
|
|
483
|
+
"FunctionDefinition",
|
|
484
|
+
"VariableDeclaration",
|
|
485
|
+
"ErrorDefinition",
|
|
486
|
+
"EventDefinition",
|
|
487
|
+
"ModifierDefinition",
|
|
488
|
+
"EnumDefinition"
|
|
489
|
+
]);
|
|
490
|
+
function getDocText(node) {
|
|
491
|
+
const doc = node.documentation;
|
|
492
|
+
if (!doc) return null;
|
|
493
|
+
if (typeof doc === "string") return doc;
|
|
494
|
+
if (typeof doc === "object" && doc !== null) {
|
|
495
|
+
const text = doc.text;
|
|
496
|
+
if (typeof text === "string") return text;
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
function nearestContract(parents) {
|
|
501
|
+
for (let i = parents.length - 1; i >= 0; i--) {
|
|
502
|
+
const p = parents[i];
|
|
503
|
+
if (p.nodeType === "ContractDefinition") return p.name ?? null;
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
function walkAst2(ast, visit, parents = []) {
|
|
508
|
+
if (!ast || typeof ast !== "object") return;
|
|
509
|
+
const node = ast;
|
|
510
|
+
if (typeof node.nodeType === "string") visit(node, parents);
|
|
511
|
+
const nextParents = typeof node.nodeType === "string" ? [...parents, node] : parents;
|
|
512
|
+
for (const key of Object.keys(node)) {
|
|
513
|
+
const value = node[key];
|
|
514
|
+
if (Array.isArray(value)) {
|
|
515
|
+
for (const child of value) walkAst2(child, visit, nextParents);
|
|
516
|
+
} else if (value && typeof value === "object") {
|
|
517
|
+
walkAst2(value, visit, nextParents);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function collectErc7201Annotations(artifacts) {
|
|
522
|
+
const seen = /* @__PURE__ */ new Set();
|
|
523
|
+
const out = [];
|
|
524
|
+
for (const artifact of artifacts) {
|
|
525
|
+
if (!artifact.ast) continue;
|
|
526
|
+
walkAst2(artifact.ast, (node, parents) => {
|
|
527
|
+
if (!node.nodeType || !NAMED_NODE_TYPES.has(node.nodeType)) return;
|
|
528
|
+
const text = getDocText(node);
|
|
529
|
+
if (!text || !text.includes("erc7201:")) return;
|
|
530
|
+
const namespaceId = parseErc7201Annotation(text);
|
|
531
|
+
if (!namespaceId) return;
|
|
532
|
+
const attachedTo = `${node.nodeType}:${node.name ?? "<anon>"}`;
|
|
533
|
+
const contract = node.nodeType === "ContractDefinition" ? node.name ?? artifact.contractName : nearestContract(parents) ?? artifact.contractName;
|
|
534
|
+
const src = node.src;
|
|
535
|
+
const dedupeKey = `${artifact.sourcePath}::${attachedTo}::${src ?? ""}`;
|
|
536
|
+
if (seen.has(dedupeKey)) return;
|
|
537
|
+
seen.add(dedupeKey);
|
|
538
|
+
out.push({
|
|
539
|
+
namespaceId,
|
|
540
|
+
slot: erc7201Slot(namespaceId),
|
|
541
|
+
attachedTo,
|
|
542
|
+
contract,
|
|
543
|
+
sourcePath: artifact.sourcePath,
|
|
544
|
+
src
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/detector/inventory.ts
|
|
552
|
+
function lineOf(src, text) {
|
|
553
|
+
if (!src || !text) return void 0;
|
|
554
|
+
const [startStr] = src.split(":");
|
|
555
|
+
const start = Number(startStr);
|
|
556
|
+
if (!Number.isFinite(start)) return void 0;
|
|
557
|
+
let line = 1;
|
|
558
|
+
for (let i = 0; i < start && i < text.length; i++) {
|
|
559
|
+
if (text.charCodeAt(i) === 10) line++;
|
|
560
|
+
}
|
|
561
|
+
return line;
|
|
562
|
+
}
|
|
563
|
+
var PRIORITY = { erc7201: 0, namespace: 1, hardcoded: 2 };
|
|
564
|
+
function buildInventory(ctx) {
|
|
565
|
+
const bySlot = /* @__PURE__ */ new Map();
|
|
566
|
+
const add = (region) => {
|
|
567
|
+
const existing = bySlot.get(region.slot);
|
|
568
|
+
if (!existing || PRIORITY[region.kind] < PRIORITY[existing.kind]) {
|
|
569
|
+
bySlot.set(region.slot, region);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
for (const c of collectGatedSlotConstants(ctx)) {
|
|
573
|
+
add({
|
|
574
|
+
slot: c.slot,
|
|
575
|
+
label: c.namespace ?? c.variableName,
|
|
576
|
+
kind: c.namespace ? "namespace" : "hardcoded",
|
|
577
|
+
contract: c.contract,
|
|
578
|
+
file: c.sourcePath,
|
|
579
|
+
line: lineOf(c.src, ctx.rawSources.get(c.sourcePath))
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
for (const a of collectErc7201Annotations(ctx.artifacts)) {
|
|
583
|
+
add({
|
|
584
|
+
slot: a.slot,
|
|
585
|
+
label: a.namespaceId,
|
|
586
|
+
kind: "erc7201",
|
|
587
|
+
contract: a.contract,
|
|
588
|
+
file: a.sourcePath,
|
|
589
|
+
line: lineOf(a.src, ctx.rawSources.get(a.sourcePath))
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return [...bySlot.values()].sort(
|
|
593
|
+
(x, y) => x.file.localeCompare(y.file) || x.slot.localeCompare(y.slot)
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/detector/index.ts
|
|
598
|
+
var DEFAULT_IGNORE_GLOBS = [
|
|
599
|
+
"lib/**",
|
|
600
|
+
"test/**",
|
|
601
|
+
"script/**",
|
|
602
|
+
"**/*.t.sol",
|
|
603
|
+
"**/*.s.sol"
|
|
604
|
+
];
|
|
605
|
+
function compilePatterns(globs) {
|
|
606
|
+
return globs.map((g) => {
|
|
607
|
+
const escaped = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::DOUBLESTAR::").replace(/\*/g, "[^/]*").replace(/::DOUBLESTAR::/g, ".*").replace(/\?/g, ".");
|
|
608
|
+
return new RegExp(`^${escaped}$`);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
function buildIgnore(userGlobs, noDefault) {
|
|
612
|
+
const globs = [
|
|
613
|
+
...noDefault ? [] : DEFAULT_IGNORE_GLOBS,
|
|
614
|
+
...userGlobs ?? []
|
|
615
|
+
];
|
|
616
|
+
if (globs.length === 0) return void 0;
|
|
617
|
+
const patterns = compilePatterns(globs);
|
|
618
|
+
return (sourcePath) => patterns.some((p) => p.test(sourcePath));
|
|
619
|
+
}
|
|
620
|
+
function buildIsFacet(globs) {
|
|
621
|
+
if (!globs || globs.length === 0) return void 0;
|
|
622
|
+
const patterns = compilePatterns(globs);
|
|
623
|
+
return (artifact) => patterns.some((p) => p.test(artifact.sourcePath));
|
|
624
|
+
}
|
|
625
|
+
async function detect(options, analyzers) {
|
|
626
|
+
const artifacts = await loadFoundryArtifacts(options.path, {
|
|
627
|
+
ignoreSourcePath: buildIgnore(options.ignoreGlobs, options.noDefaultIgnore)
|
|
628
|
+
});
|
|
629
|
+
const rawSources = await loadRawSources(
|
|
630
|
+
options.path,
|
|
631
|
+
artifacts.map((a) => a.sourcePath)
|
|
632
|
+
);
|
|
633
|
+
const ctx = {
|
|
634
|
+
artifacts,
|
|
635
|
+
rawSources,
|
|
636
|
+
isFacet: buildIsFacet(options.facetGlobs)
|
|
637
|
+
};
|
|
638
|
+
const findings = [];
|
|
639
|
+
for (const analyzer of analyzers) {
|
|
640
|
+
const out = await analyzer.run(ctx);
|
|
641
|
+
findings.push(...out);
|
|
642
|
+
}
|
|
643
|
+
const inventory = buildInventory(ctx);
|
|
644
|
+
return { artifacts, findings, rawSources, inventory };
|
|
645
|
+
}
|
|
194
646
|
export {
|
|
195
647
|
detect,
|
|
196
648
|
erc7201Slot,
|
package/package.json
CHANGED