chainlink-audit 0.3.0 → 0.3.3
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 +11 -0
- package/dist/benchmark.js +58 -0
- package/dist/rules/automation.js +18 -4
- package/dist/rules/ccip.js +216 -17
- package/dist/rules/data-feeds.js +16 -3
- package/dist/rules/functions-cre.js +1 -1
- package/dist/rules/helpers.js +14 -2
- package/dist/rules/vrf.js +3 -1
- package/dist/scanner.js +11 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -17,6 +17,17 @@ Results are heuristic risk leads for manual review, not confirmed vulnerabilitie
|
|
|
17
17
|
|
|
18
18
|
Use `chainlink-audit triage <report.json>` to turn JSON scan output into a manual review checklist.
|
|
19
19
|
|
|
20
|
+
## Ecosystem Benchmark
|
|
21
|
+
|
|
22
|
+
The repository includes a pinned Chainlink Ecosystem benchmark manifest at `benchmarks/ecosystem-repos.json`.
|
|
23
|
+
It tracks public GitHub repositories for Ecosystem projects outside the original manual audit sample.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run benchmark:ecosystem -- --out ../../cache/ecosystem-benchmark-report.json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The runner clones pinned commits into `../../cache/ecosystem-benchmark`, scans each checkout, and writes an aggregate JSON report with per-project and per-rule lead counts.
|
|
30
|
+
|
|
20
31
|
Published package: https://www.npmjs.com/package/chainlink-audit
|
|
21
32
|
|
|
22
33
|
See the repository README for full documentation:
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
function increment(map, key, value = 1) {
|
|
2
|
+
map[key] = (map[key] ?? 0) + value;
|
|
3
|
+
}
|
|
4
|
+
function sortCounts(counts) {
|
|
5
|
+
return Object.fromEntries(Object.entries(counts).sort(([leftKey, leftValue], [rightKey, rightValue]) => {
|
|
6
|
+
return rightValue - leftValue || leftKey.localeCompare(rightKey);
|
|
7
|
+
}));
|
|
8
|
+
}
|
|
9
|
+
function productFromRuleId(ruleId) {
|
|
10
|
+
const productCode = ruleId.split("-")[1]?.toUpperCase();
|
|
11
|
+
return ({
|
|
12
|
+
AUTO: "automation",
|
|
13
|
+
CCIP: "ccip",
|
|
14
|
+
DF: "data-feeds",
|
|
15
|
+
FN: "functions-cre",
|
|
16
|
+
VRF: "vrf",
|
|
17
|
+
}[productCode ?? ""] ?? "unknown");
|
|
18
|
+
}
|
|
19
|
+
export function summarizeScanResults(manifestRepositories, scanResults) {
|
|
20
|
+
return scanResults.map((result, index) => {
|
|
21
|
+
const repository = manifestRepositories[index];
|
|
22
|
+
const findingsByRule = {};
|
|
23
|
+
for (const finding of result.findings) {
|
|
24
|
+
increment(findingsByRule, finding.ruleId);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
project: repository.project,
|
|
28
|
+
githubUrl: repository.githubUrl,
|
|
29
|
+
commit: repository.commit,
|
|
30
|
+
scannedFiles: result.scannedFiles,
|
|
31
|
+
scannerProducts: result.products,
|
|
32
|
+
totalFindings: result.findings.length,
|
|
33
|
+
findingsByRule: sortCounts(findingsByRule),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function summarizeBenchmarkResults(projectResults) {
|
|
38
|
+
const products = new Set();
|
|
39
|
+
const findingsByRule = {};
|
|
40
|
+
const findingsByProduct = {};
|
|
41
|
+
for (const result of projectResults) {
|
|
42
|
+
for (const product of result.scannerProducts) {
|
|
43
|
+
products.add(product);
|
|
44
|
+
}
|
|
45
|
+
for (const [ruleId, count] of Object.entries(result.findingsByRule)) {
|
|
46
|
+
increment(findingsByRule, ruleId, count);
|
|
47
|
+
increment(findingsByProduct, productFromRuleId(ruleId), count);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
repositoryCount: projectResults.length,
|
|
52
|
+
scannedFiles: projectResults.reduce((total, result) => total + result.scannedFiles, 0),
|
|
53
|
+
totalFindings: projectResults.reduce((total, result) => total + result.totalFindings, 0),
|
|
54
|
+
scannerProducts: [...products].sort(),
|
|
55
|
+
findingsByRule: sortCounts(findingsByRule),
|
|
56
|
+
findingsByProduct: sortCounts(findingsByProduct),
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/rules/automation.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { extractFunctionBody, firstLineMatching, makeFinding } from "./helpers.js";
|
|
1
|
+
import { escapeRegExp, extractFunctionBody, firstLineMatching, isInterfaceOnlyFile, makeFinding } from "./helpers.js";
|
|
2
2
|
function hasAutomation(content) {
|
|
3
3
|
return /(AutomationCompatibleInterface|KeeperCompatibleInterface|function\s+checkUpkeep\b|function\s+performUpkeep\b)/.test(content);
|
|
4
4
|
}
|
|
5
|
+
function internalCallBodies(content, body) {
|
|
6
|
+
const calledNames = [...body.matchAll(/\b([a-zA-Z_]\w*)\s*\(/g)].map((match) => match[1]);
|
|
7
|
+
return [...new Set(calledNames)]
|
|
8
|
+
.map((name) => {
|
|
9
|
+
const declaration = new RegExp(`function\\s+${escapeRegExp(name)}\\b[^{;]*\\b(internal|private)\\b`);
|
|
10
|
+
return declaration.test(content) ? extractFunctionBody(content, name) : "";
|
|
11
|
+
})
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.join("\n");
|
|
14
|
+
}
|
|
5
15
|
export const automationRules = [
|
|
6
16
|
{
|
|
7
17
|
metadata: {
|
|
@@ -17,8 +27,9 @@ export const automationRules = [
|
|
|
17
27
|
const body = extractFunctionBody(context.content, "performUpkeep");
|
|
18
28
|
if (!body)
|
|
19
29
|
return [];
|
|
20
|
-
const
|
|
21
|
-
|
|
30
|
+
const extendedBody = `${body}\n${internalCallBodies(context.content, body)}`;
|
|
31
|
+
const revalidates = /(require|revert|UpkeepNotNeeded|checkUpkeep)/.test(extendedBody) &&
|
|
32
|
+
/(block\.timestamp|lastExecutedAt|interval|upkeepNeeded|balance|paused)/.test(extendedBody);
|
|
22
33
|
if (revalidates)
|
|
23
34
|
return [];
|
|
24
35
|
return [
|
|
@@ -81,7 +92,10 @@ export const automationRules = [
|
|
|
81
92
|
return [];
|
|
82
93
|
if (!/function\s+performUpkeep\b/.test(context.content))
|
|
83
94
|
return [];
|
|
84
|
-
|
|
95
|
+
if (isInterfaceOnlyFile(context.content))
|
|
96
|
+
return [];
|
|
97
|
+
const pausePattern = /(Pausable|whenNotPaused|paused|unpause|pause\w*\s*\(|emergency|guardian|abort\w*\s*\(|dismantle|kill\w*\s*\(|circuitBreak)/i;
|
|
98
|
+
const hasPause = pausePattern.test(context.content) || context.repoSignals.files.some((file) => pausePattern.test(file.content));
|
|
85
99
|
if (hasPause)
|
|
86
100
|
return [];
|
|
87
101
|
return [
|
package/dist/rules/ccip.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { countMatches, extractFunctionBody, firstLineMatching, makeFinding } from "./helpers.js";
|
|
1
|
+
import { countMatches, escapeRegExp, extractBlockBody, extractFunctionBody, firstLineMatching, makeFinding } from "./helpers.js";
|
|
2
2
|
function ccipBody(content) {
|
|
3
3
|
return [extractFunctionBody(content, "ccipReceive"), extractFunctionBody(content, "_ccipReceive")].filter(Boolean).join("\n");
|
|
4
4
|
}
|
|
5
|
+
const CCIP_RECEIVE_DECL = /function\s+(_ccipReceive|ccipReceive)\b/;
|
|
5
6
|
function ccipHeader(content) {
|
|
6
7
|
const match = /function\s+(_ccipReceive|ccipReceive)\b[^{;]*/.exec(content);
|
|
7
8
|
return match?.[0] ?? "";
|
|
@@ -10,16 +11,40 @@ function hasCcipReceiver(content) {
|
|
|
10
11
|
return /(function\s+_ccipReceive\b|function\s+ccipReceive\b|is\s+CCIPReceiver)/.test(content);
|
|
11
12
|
}
|
|
12
13
|
function isCcipBaseReceiver(content) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
if (/interface\s+\w*I?Any2EVMMessageReceiver\b/.test(content))
|
|
15
|
+
return true;
|
|
16
|
+
if (/interface\s+\w+\s*{[\s\S]*function\s+ccipReceive\b[^{]*;/.test(content))
|
|
17
|
+
return true;
|
|
18
|
+
if (/abstract\s+contract\s+\w*CCIPReceiver\b/.test(content))
|
|
19
|
+
return true;
|
|
20
|
+
if (/function\s+_ccipReceive\b[^{;]*internal[^{;]*virtual\s*;/.test(content))
|
|
21
|
+
return true;
|
|
22
|
+
// Guard stub: ccipReceive body is only a revert — intentional "not a receiver" pattern (e.g. EVM2EVMOffRamp)
|
|
23
|
+
const ccipReceiveBody = extractFunctionBody(content, "ccipReceive");
|
|
24
|
+
if (ccipReceiveBody) {
|
|
25
|
+
const bodyTrimmed = ccipReceiveBody.trim();
|
|
26
|
+
if (/^\s*revert\s*\(\s*\)\s*;\s*$/.test(bodyTrimmed) ||
|
|
27
|
+
/^\s*revert\s+\w[\w.]*\s*\([^)]*\)\s*;\s*$/.test(bodyTrimmed))
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
17
31
|
}
|
|
18
32
|
function inheritsCcipReceiver(content) {
|
|
19
33
|
return /\bis\s+[\s\S{]*\bCCIPReceiver(Upgradeable)?\b/.test(content);
|
|
20
34
|
}
|
|
21
|
-
function
|
|
22
|
-
|
|
35
|
+
function extractParentNames(content) {
|
|
36
|
+
const match = /\bcontract\s+\w+\s+is\s+([\w\s,]+?)(?:\{|$)/m.exec(content);
|
|
37
|
+
if (!match)
|
|
38
|
+
return [];
|
|
39
|
+
return match[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
function inheritsViaCcipReceiver(content, repoSignals) {
|
|
42
|
+
const parents = extractParentNames(content);
|
|
43
|
+
return parents.some((parent) => {
|
|
44
|
+
const parentPattern = new RegExp(`\\b(?:abstract\\s+)?contract\\s+${escapeRegExp(parent)}\\b`);
|
|
45
|
+
const parentFile = repoSignals.files.find((f) => parentPattern.test(f.content));
|
|
46
|
+
return parentFile ? inheritsCcipReceiver(parentFile.content) : false;
|
|
47
|
+
});
|
|
23
48
|
}
|
|
24
49
|
function delegatedCcipContent(body, repoSignals) {
|
|
25
50
|
const match = /\b([A-Z][A-Za-z0-9_]*)\s*\.\s*([A-Za-z0-9_]*ccipReceive|processCcipReceive)\s*\(/i.exec(body);
|
|
@@ -31,16 +56,66 @@ function delegatedCcipContent(body, repoSignals) {
|
|
|
31
56
|
const delegate = repoSignals.files.find((file) => typePattern.test(file.content) && functionPattern.test(file.content));
|
|
32
57
|
return delegate ? `${body}\n${delegate.content}` : body;
|
|
33
58
|
}
|
|
59
|
+
function extractModifierNames(header) {
|
|
60
|
+
const afterParams = header.replace(/^function\s+\w+\s*\([^)]*\)/s, "");
|
|
61
|
+
const names = afterParams.match(/\b[A-Za-z_]\w*\b/g) ?? [];
|
|
62
|
+
const keywords = new Set([
|
|
63
|
+
"external",
|
|
64
|
+
"public",
|
|
65
|
+
"internal",
|
|
66
|
+
"private",
|
|
67
|
+
"view",
|
|
68
|
+
"pure",
|
|
69
|
+
"payable",
|
|
70
|
+
"override",
|
|
71
|
+
"virtual",
|
|
72
|
+
"returns",
|
|
73
|
+
]);
|
|
74
|
+
return [...new Set(names)].filter((name) => !keywords.has(name));
|
|
75
|
+
}
|
|
76
|
+
function resolveModifierBodies(content, repoSignals) {
|
|
77
|
+
const header = ccipHeader(content);
|
|
78
|
+
const modifierNames = extractModifierNames(header);
|
|
79
|
+
if (modifierNames.length === 0)
|
|
80
|
+
return "";
|
|
81
|
+
const parents = extractParentNames(content);
|
|
82
|
+
const parentFiles = repoSignals.files.filter((file) => parents.some((parent) => new RegExp(`\\b(?:abstract\\s+)?contract\\s+${escapeRegExp(parent)}\\b`).test(file.content)));
|
|
83
|
+
const candidateFiles = [{ content }, ...parentFiles];
|
|
84
|
+
return modifierNames
|
|
85
|
+
.map((name) => {
|
|
86
|
+
const declaration = new RegExp(`modifier\\s+${escapeRegExp(name)}\\b`);
|
|
87
|
+
for (const file of candidateFiles) {
|
|
88
|
+
const body = extractBlockBody(file.content, declaration);
|
|
89
|
+
if (body)
|
|
90
|
+
return body;
|
|
91
|
+
}
|
|
92
|
+
return "";
|
|
93
|
+
})
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join("\n");
|
|
96
|
+
}
|
|
97
|
+
function stripEmitLines(body) {
|
|
98
|
+
return body
|
|
99
|
+
.split("\n")
|
|
100
|
+
.filter((line) => !/\bemit\b/.test(line))
|
|
101
|
+
.join("\n");
|
|
102
|
+
}
|
|
34
103
|
function validatesSourceChain(content, body) {
|
|
35
104
|
const header = ccipHeader(content);
|
|
36
|
-
|
|
105
|
+
// Strip event emissions before checking: sourceChainSelector that only appears in emit
|
|
106
|
+
// calls (e.g. for logging) must not suppress the finding.
|
|
107
|
+
const bodyWithoutEmits = stripEmitLines(body);
|
|
108
|
+
const directValidation = /(sourceChainSelector|ccipSelectorToChainId)/.test(bodyWithoutEmits) &&
|
|
37
109
|
/(require|revert|allowed|trusted|allowlist|supportedNetworks|UnsupportedNetwork)/i.test(body);
|
|
38
110
|
const modifierValidation = /(validChain|allowlisted|allowlist|trusted|onlyAllowlisted)[^{;]*(sourceChainSelector|\.sourceChainSelector)/i.test(header);
|
|
39
111
|
return directValidation || modifierValidation;
|
|
40
112
|
}
|
|
41
113
|
function validatesSourceSender(content, body) {
|
|
42
114
|
const header = ccipHeader(content);
|
|
43
|
-
|
|
115
|
+
// Strip event emissions before checking: sender that only appears in emit
|
|
116
|
+
// calls must not suppress the finding.
|
|
117
|
+
const bodyWithoutEmits = stripEmitLines(body);
|
|
118
|
+
const directValidation = /(message\.sender|data\.sender|sender)/.test(bodyWithoutEmits) &&
|
|
44
119
|
/(require|revert|allowed|trusted|allowlist|Unauthorized)/i.test(body);
|
|
45
120
|
const modifierValidation = /(validSender|trustedSender|allowlistedSender|onlyAllowlisted|allowlisted|allowlist|trusted)[^{;]*(message\.sender|\.sender|sender)/i.test(header);
|
|
46
121
|
return directValidation || modifierValidation;
|
|
@@ -64,6 +139,24 @@ function hasMessageIdTracking(content, body) {
|
|
|
64
139
|
return false;
|
|
65
140
|
return /(processed|received|executed|consumed|handled|seen|replay|duplicate|messageDetail|failedMessages|messageIdTo)/i.test(content);
|
|
66
141
|
}
|
|
142
|
+
function hasTokenPoolSender(content) {
|
|
143
|
+
return (/function\s+lockOrBurn\b/.test(content) &&
|
|
144
|
+
/(Pool\.LockOrBurnInV1|LockOrBurnInV1|is\s+[\w\s,]*TokenPool)/.test(content));
|
|
145
|
+
}
|
|
146
|
+
function hasTokenPoolReceiver(content) {
|
|
147
|
+
return (/function\s+releaseOrMint\b/.test(content) &&
|
|
148
|
+
/(Pool\.ReleaseOrMintInV1|ReleaseOrMintInV1|is\s+[\w\s,]*TokenPool)/.test(content));
|
|
149
|
+
}
|
|
150
|
+
function isTokenPoolBase(content) {
|
|
151
|
+
if (/abstract\s+contract\s+\w*TokenPool\b/.test(content))
|
|
152
|
+
return true;
|
|
153
|
+
if (/interface\s+\w*(TokenPool|IPool)\b/.test(content))
|
|
154
|
+
return true;
|
|
155
|
+
// Abstract contracts that inherit from a TokenPool base (e.g. BurnMintTokenPoolAbstract is BurnMintTokenPool)
|
|
156
|
+
if (/abstract\s+contract\s+\w+\s+is\s+[\w\s,]*TokenPool\b/.test(content))
|
|
157
|
+
return true;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
67
160
|
export const ccipRules = [
|
|
68
161
|
{
|
|
69
162
|
metadata: {
|
|
@@ -79,7 +172,7 @@ export const ccipRules = [
|
|
|
79
172
|
if (isCcipBaseReceiver(context.content))
|
|
80
173
|
return [];
|
|
81
174
|
const body = ccipBody(context.content) || context.content;
|
|
82
|
-
const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}`;
|
|
175
|
+
const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}\n${resolveModifierBodies(context.content, context.repoSignals)}`;
|
|
83
176
|
if (validatesSourceChain(context.content, validationContent))
|
|
84
177
|
return [];
|
|
85
178
|
return [
|
|
@@ -88,7 +181,7 @@ export const ccipRules = [
|
|
|
88
181
|
ruleId: this.metadata.ruleId,
|
|
89
182
|
severity: this.metadata.severity,
|
|
90
183
|
confidence: "medium",
|
|
91
|
-
line: firstLineMatching(context.lines,
|
|
184
|
+
line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
|
|
92
185
|
title: this.metadata.title,
|
|
93
186
|
description: "Potential issue: messages from unexpected source chains may be accepted.",
|
|
94
187
|
risk: "Cross-chain spoofing or misrouted messages can trigger unauthorized state changes.",
|
|
@@ -111,7 +204,7 @@ export const ccipRules = [
|
|
|
111
204
|
if (isCcipBaseReceiver(context.content))
|
|
112
205
|
return [];
|
|
113
206
|
const body = ccipBody(context.content) || context.content;
|
|
114
|
-
const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}`;
|
|
207
|
+
const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}\n${resolveModifierBodies(context.content, context.repoSignals)}`;
|
|
115
208
|
if (validatesSourceSender(context.content, validationContent))
|
|
116
209
|
return [];
|
|
117
210
|
return [
|
|
@@ -120,7 +213,7 @@ export const ccipRules = [
|
|
|
120
213
|
ruleId: this.metadata.ruleId,
|
|
121
214
|
severity: this.metadata.severity,
|
|
122
215
|
confidence: "medium",
|
|
123
|
-
line: firstLineMatching(context.lines,
|
|
216
|
+
line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
|
|
124
217
|
title: this.metadata.title,
|
|
125
218
|
description: "Potential issue: messages from untrusted source contracts may be accepted.",
|
|
126
219
|
risk: "An attacker may spoof business instructions if sender validation is missing or incomplete.",
|
|
@@ -145,7 +238,7 @@ export const ccipRules = [
|
|
|
145
238
|
const body = ccipBody(context.content) || context.content;
|
|
146
239
|
const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}`;
|
|
147
240
|
const validatesRouter = /msg\.sender\s*(!=|==)\s*(router|s_router|i_router|address\(router\))|InvalidRouter|onlyRouter|NotCcipRouter|_msgSender\(\)\s*!=\s*address\([^)]*ccipRouter/i.test(validationContent);
|
|
148
|
-
if (validatesRouter || inheritsCcipReceiver(context.content))
|
|
241
|
+
if (validatesRouter || inheritsCcipReceiver(context.content) || inheritsViaCcipReceiver(context.content, context.repoSignals))
|
|
149
242
|
return [];
|
|
150
243
|
return [
|
|
151
244
|
makeFinding({
|
|
@@ -153,7 +246,7 @@ export const ccipRules = [
|
|
|
153
246
|
ruleId: this.metadata.ruleId,
|
|
154
247
|
severity: this.metadata.severity,
|
|
155
248
|
confidence: "medium",
|
|
156
|
-
line: firstLineMatching(context.lines,
|
|
249
|
+
line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
|
|
157
250
|
title: this.metadata.title,
|
|
158
251
|
description: "Potential issue: direct calls to the receiver may bypass CCIP router trust assumptions.",
|
|
159
252
|
risk: "If the entrypoint is public/external and router validation is absent, fabricated messages may be processed.",
|
|
@@ -181,6 +274,10 @@ export const ccipRules = [
|
|
|
181
274
|
const defensiveChecks = /(message\.data\.length|try\s+this|catch|InvalidPayload|Payload|version|schema)/i.test(body);
|
|
182
275
|
if (defensiveChecks)
|
|
183
276
|
return [];
|
|
277
|
+
// A trusted, validated sender already constrains the payload to the expected schema.
|
|
278
|
+
const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}\n${resolveModifierBodies(context.content, context.repoSignals)}`;
|
|
279
|
+
if (validatesSourceSender(context.content, validationContent))
|
|
280
|
+
return [];
|
|
184
281
|
return [
|
|
185
282
|
makeFinding({
|
|
186
283
|
context,
|
|
@@ -222,7 +319,7 @@ export const ccipRules = [
|
|
|
222
319
|
ruleId: this.metadata.ruleId,
|
|
223
320
|
severity: this.metadata.severity,
|
|
224
321
|
confidence: "low",
|
|
225
|
-
line: firstLineMatching(context.lines,
|
|
322
|
+
line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
|
|
226
323
|
title: this.metadata.title,
|
|
227
324
|
description: "Potential issue: receiver business logic appears coupled to CCIP execution without a visible retry/manual path.",
|
|
228
325
|
risk: "A revert in message handling may require manual execution or leave cross-chain state inconsistent.",
|
|
@@ -294,7 +391,7 @@ export const ccipRules = [
|
|
|
294
391
|
ruleId: this.metadata.ruleId,
|
|
295
392
|
severity: this.metadata.severity,
|
|
296
393
|
confidence: "low",
|
|
297
|
-
line: firstLineMatching(context.lines,
|
|
394
|
+
line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
|
|
298
395
|
title: this.metadata.title,
|
|
299
396
|
description: "Potential issue: repeated or retried CCIP messages may execute mutating business logic more than once.",
|
|
300
397
|
risk: "Without idempotency tracking, replay-like operational scenarios or manual execution flows can duplicate state changes.",
|
|
@@ -303,4 +400,106 @@ export const ccipRules = [
|
|
|
303
400
|
];
|
|
304
401
|
},
|
|
305
402
|
},
|
|
403
|
+
{
|
|
404
|
+
metadata: {
|
|
405
|
+
ruleId: "CL-CCIP-008",
|
|
406
|
+
product: "ccip",
|
|
407
|
+
severity: "high",
|
|
408
|
+
title: "Potential CCIP Token Pool lockOrBurn without _validateLockOrBurn",
|
|
409
|
+
description: "lockOrBurn override does not appear to call _validateLockOrBurn.",
|
|
410
|
+
},
|
|
411
|
+
scan(context) {
|
|
412
|
+
if (!hasTokenPoolSender(context.content))
|
|
413
|
+
return [];
|
|
414
|
+
if (isTokenPoolBase(context.content))
|
|
415
|
+
return [];
|
|
416
|
+
const body = extractFunctionBody(context.content, "lockOrBurn");
|
|
417
|
+
if (!body)
|
|
418
|
+
return [];
|
|
419
|
+
if (/_validateLockOrBurn\s*\(/.test(body))
|
|
420
|
+
return [];
|
|
421
|
+
return [
|
|
422
|
+
makeFinding({
|
|
423
|
+
context,
|
|
424
|
+
ruleId: this.metadata.ruleId,
|
|
425
|
+
severity: this.metadata.severity,
|
|
426
|
+
confidence: "medium",
|
|
427
|
+
line: firstLineMatching(context.lines, /function\s+lockOrBurn\b/),
|
|
428
|
+
title: this.metadata.title,
|
|
429
|
+
description: "Potential issue: lockOrBurn may skip rate limit and chain allowlist enforcement.",
|
|
430
|
+
risk: "Omitting _validateLockOrBurn bypasses CCIP-enforced rate limits and supported-chain checks, enabling unrestricted token locking or burning.",
|
|
431
|
+
recommendation: "Call _validateLockOrBurn(lockOrBurnIn) at the start of every lockOrBurn override before executing custom logic.",
|
|
432
|
+
}),
|
|
433
|
+
];
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
metadata: {
|
|
438
|
+
ruleId: "CL-CCIP-009",
|
|
439
|
+
product: "ccip",
|
|
440
|
+
severity: "high",
|
|
441
|
+
title: "Potential CCIP Token Pool releaseOrMint without _validateReleaseOrMint",
|
|
442
|
+
description: "releaseOrMint override does not appear to call _validateReleaseOrMint.",
|
|
443
|
+
},
|
|
444
|
+
scan(context) {
|
|
445
|
+
if (!hasTokenPoolReceiver(context.content))
|
|
446
|
+
return [];
|
|
447
|
+
if (isTokenPoolBase(context.content))
|
|
448
|
+
return [];
|
|
449
|
+
const body = extractFunctionBody(context.content, "releaseOrMint");
|
|
450
|
+
if (!body)
|
|
451
|
+
return [];
|
|
452
|
+
if (/_validateReleaseOrMint\s*\(/.test(body))
|
|
453
|
+
return [];
|
|
454
|
+
return [
|
|
455
|
+
makeFinding({
|
|
456
|
+
context,
|
|
457
|
+
ruleId: this.metadata.ruleId,
|
|
458
|
+
severity: this.metadata.severity,
|
|
459
|
+
confidence: "medium",
|
|
460
|
+
line: firstLineMatching(context.lines, /function\s+releaseOrMint\b/),
|
|
461
|
+
title: this.metadata.title,
|
|
462
|
+
description: "Potential issue: releaseOrMint may skip offRamp caller verification and rate limit enforcement.",
|
|
463
|
+
risk: "Omitting _validateReleaseOrMint allows arbitrary callers to trigger token minting or release, bypassing CCIP trust boundaries.",
|
|
464
|
+
recommendation: "Call _validateReleaseOrMint(releaseOrMintIn) at the start of every releaseOrMint override before minting or releasing tokens.",
|
|
465
|
+
}),
|
|
466
|
+
];
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
metadata: {
|
|
471
|
+
ruleId: "CL-CCIP-010",
|
|
472
|
+
product: "ccip",
|
|
473
|
+
severity: "medium",
|
|
474
|
+
title: "Potential unsafe CCIP Token Pool sourcePoolData decoding",
|
|
475
|
+
description: "releaseOrMint decodes sourcePoolData without obvious defensive checks.",
|
|
476
|
+
},
|
|
477
|
+
scan(context) {
|
|
478
|
+
if (!hasTokenPoolReceiver(context.content))
|
|
479
|
+
return [];
|
|
480
|
+
if (isTokenPoolBase(context.content))
|
|
481
|
+
return [];
|
|
482
|
+
const body = extractFunctionBody(context.content, "releaseOrMint");
|
|
483
|
+
if (!body)
|
|
484
|
+
return [];
|
|
485
|
+
if (!/abi\.decode\s*\(\s*(?:releaseOrMintIn\.)?sourcePoolData/.test(body))
|
|
486
|
+
return [];
|
|
487
|
+
const defensiveChecks = /(sourcePoolData\.length|try\s+this|catch|InvalidSourcePoolData|InvalidPayload|schema|version)/i.test(body);
|
|
488
|
+
if (defensiveChecks)
|
|
489
|
+
return [];
|
|
490
|
+
return [
|
|
491
|
+
makeFinding({
|
|
492
|
+
context,
|
|
493
|
+
ruleId: this.metadata.ruleId,
|
|
494
|
+
severity: this.metadata.severity,
|
|
495
|
+
confidence: "low",
|
|
496
|
+
line: firstLineMatching(context.lines, /abi\.decode\s*\(\s*(?:releaseOrMintIn\.)?sourcePoolData/),
|
|
497
|
+
title: this.metadata.title,
|
|
498
|
+
description: "Potential issue: malformed or unexpected sourcePoolData may cause releaseOrMint to revert and block token delivery.",
|
|
499
|
+
risk: "If sourcePoolData schema changes across an upgrade or pool version, decoding failures can permanently brick in-flight transfers.",
|
|
500
|
+
recommendation: "Validate sourcePoolData length or schema before decoding, or isolate decode failures so they can be handled without reverting the entire transfer.",
|
|
501
|
+
}),
|
|
502
|
+
];
|
|
503
|
+
},
|
|
504
|
+
},
|
|
306
505
|
];
|
package/dist/rules/data-feeds.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { firstLineMatching, hasAny, makeFinding } from "./helpers.js";
|
|
2
|
+
const AGGREGATOR_WRAPPER_PATTERN = /function\s+latestRoundData\s*\([^)]*\)\s*(?:external|public|view|override|virtual|\s)*returns\s*\(\s*uint80\s*,\s*int256\s*,\s*uint256\s*,\s*uint256\s*,\s*uint80\s*\)\s*\{/s;
|
|
3
|
+
function isAggregatorWrapper(content) {
|
|
4
|
+
return AGGREGATOR_WRAPPER_PATTERN.test(content);
|
|
5
|
+
}
|
|
2
6
|
export const dataFeedRules = [
|
|
3
7
|
{
|
|
4
8
|
metadata: {
|
|
@@ -11,6 +15,8 @@ export const dataFeedRules = [
|
|
|
11
15
|
scan(context) {
|
|
12
16
|
if (!/\.latestRoundData\s*\(/.test(context.content))
|
|
13
17
|
return [];
|
|
18
|
+
if (isAggregatorWrapper(context.content))
|
|
19
|
+
return [];
|
|
14
20
|
const hasFreshnessCheck = /updatedAt/.test(context.content) &&
|
|
15
21
|
/(block\.timestamp|maxStaleness|stale|heartbeat|updatedAt\s*!=\s*0)/i.test(context.content);
|
|
16
22
|
if (hasFreshnessCheck)
|
|
@@ -41,11 +47,18 @@ export const dataFeedRules = [
|
|
|
41
47
|
scan(context) {
|
|
42
48
|
if (!/\.latestRoundData\s*\(/.test(context.content))
|
|
43
49
|
return [];
|
|
50
|
+
if (isAggregatorWrapper(context.content))
|
|
51
|
+
return [];
|
|
44
52
|
const hasPositiveAnswerCheck = hasAny(context.content, [
|
|
45
|
-
/answer\s
|
|
46
|
-
/price\s
|
|
47
|
-
/oracleAnswer\s
|
|
53
|
+
/answer\s*>=?\s*0/,
|
|
54
|
+
/price\s*>=?\s*0/,
|
|
55
|
+
/oracleAnswer\s*>=?\s*0/,
|
|
48
56
|
/InvalidOracleAnswer/,
|
|
57
|
+
/answer\s*<=?\s*0/,
|
|
58
|
+
/price\s*<=?\s*0/,
|
|
59
|
+
/oracleAnswer\s*<=?\s*0/,
|
|
60
|
+
/Invalid(?:Oracle)?(?:Price|Answer|Round)/i,
|
|
61
|
+
/\.\s*minAnswer\s*\(\s*\)/,
|
|
49
62
|
]);
|
|
50
63
|
if (hasPositiveAnswerCheck)
|
|
51
64
|
return [];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { firstLineMatching, hasAny, makeFinding } from "./helpers.js";
|
|
2
2
|
function hasFunctions(content) {
|
|
3
|
-
return /(FunctionsClient|FunctionsRequest|
|
|
3
|
+
return /(FunctionsClient|FunctionsRequest|\bsendRequest\s*\(|\bfulfillRequest\s*\(|DONHostedSecrets|sourceCode|secrets)/i.test(content);
|
|
4
4
|
}
|
|
5
5
|
export const functionsCreRules = [
|
|
6
6
|
{
|
package/dist/rules/helpers.js
CHANGED
|
@@ -5,8 +5,11 @@ export function firstLineMatching(lines, pattern) {
|
|
|
5
5
|
export function hasAny(content, patterns) {
|
|
6
6
|
return patterns.some((pattern) => pattern.test(content));
|
|
7
7
|
}
|
|
8
|
-
export function
|
|
9
|
-
|
|
8
|
+
export function escapeRegExp(value) {
|
|
9
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
10
|
+
}
|
|
11
|
+
export function extractBlockBody(content, declarationPattern) {
|
|
12
|
+
const match = declarationPattern.exec(content);
|
|
10
13
|
if (!match)
|
|
11
14
|
return "";
|
|
12
15
|
const open = content.indexOf("{", match.index);
|
|
@@ -24,9 +27,18 @@ export function extractFunctionBody(content, functionName) {
|
|
|
24
27
|
}
|
|
25
28
|
return content.slice(open + 1);
|
|
26
29
|
}
|
|
30
|
+
export function extractFunctionBody(content, functionName) {
|
|
31
|
+
return extractBlockBody(content, new RegExp(`function\\s+${functionName}\\b`));
|
|
32
|
+
}
|
|
27
33
|
export function countMatches(content, patterns) {
|
|
28
34
|
return patterns.reduce((count, pattern) => count + (pattern.test(content) ? 1 : 0), 0);
|
|
29
35
|
}
|
|
36
|
+
export function isInterfaceOnlyFile(content) {
|
|
37
|
+
const declarations = [...content.matchAll(/\b(?:abstract\s+)?(contract|library|interface)\s+\w+(?:\s+is\s+[^{]+)?\s*\{/g)];
|
|
38
|
+
if (declarations.length === 0)
|
|
39
|
+
return false;
|
|
40
|
+
return declarations.every((match) => match[1] === "interface");
|
|
41
|
+
}
|
|
30
42
|
export function makeFinding(input) {
|
|
31
43
|
return {
|
|
32
44
|
ruleId: input.ruleId,
|
package/dist/rules/vrf.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { countMatches, extractFunctionBody, firstLineMatching, makeFinding } from "./helpers.js";
|
|
1
|
+
import { countMatches, extractFunctionBody, firstLineMatching, isInterfaceOnlyFile, makeFinding } from "./helpers.js";
|
|
2
2
|
function hasVrf(content) {
|
|
3
3
|
return /(VRFConsumerBase|VRFCoordinator|function\s+fulfillRandomWords\b|requestRandomWords)/.test(content);
|
|
4
4
|
}
|
|
@@ -78,6 +78,8 @@ export const vrfRules = [
|
|
|
78
78
|
scan(context) {
|
|
79
79
|
if (!/requestRandomWords\s*\(/.test(context.content))
|
|
80
80
|
return [];
|
|
81
|
+
if (isInterfaceOnlyFile(context.content))
|
|
82
|
+
return [];
|
|
81
83
|
const tracksBeforeFulfillment = /(pendingRequests|requestStatus|requests)\s*\[.*\]\s*=/.test(context.content);
|
|
82
84
|
if (tracksBeforeFulfillment)
|
|
83
85
|
return [];
|
package/dist/scanner.js
CHANGED
|
@@ -60,7 +60,16 @@ function isExcluded(fileOrDirectory, scanRoot, config) {
|
|
|
60
60
|
async function collectSolidityFiles(targetPath, scanRoot, config) {
|
|
61
61
|
if (isExcluded(targetPath, scanRoot, config))
|
|
62
62
|
return [];
|
|
63
|
-
|
|
63
|
+
let stat;
|
|
64
|
+
try {
|
|
65
|
+
stat = await fs.stat(targetPath);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
69
|
+
if (code === "ENOENT" || code === "ELOOP")
|
|
70
|
+
return [];
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
64
73
|
if (stat.isFile()) {
|
|
65
74
|
if (!targetPath.endsWith(".sol"))
|
|
66
75
|
return [];
|
|
@@ -78,7 +87,7 @@ function detectProducts(files) {
|
|
|
78
87
|
const allContent = files.map((file) => file.content).join("\n");
|
|
79
88
|
if (/(AggregatorV3Interface|latestRoundData|IChainlinkAggregator)/.test(allContent))
|
|
80
89
|
products.add("data-feeds");
|
|
81
|
-
if (/(CCIPReceiver|Any2EVMMessage|IRouterClient|_ccipReceive|ccipReceive|sourceChainSelector)/.test(allContent))
|
|
90
|
+
if (/(CCIPReceiver|Any2EVMMessage|IRouterClient|_ccipReceive|ccipReceive|sourceChainSelector|TokenPool|LockOrBurnInV1|ReleaseOrMintInV1|_validateLockOrBurn|_validateReleaseOrMint)/.test(allContent))
|
|
82
91
|
products.add("ccip");
|
|
83
92
|
if (/(VRFConsumerBase|VRFCoordinator|fulfillRandomWords|requestRandomWords)/.test(allContent))
|
|
84
93
|
products.add("vrf");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chainlink-audit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Security review CLI for flagging unverified Chainlink integration risk leads in Solidity repositories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"build": "tsc -p tsconfig.json",
|
|
42
42
|
"test": "vitest run",
|
|
43
43
|
"dev": "tsx src/index.ts",
|
|
44
|
+
"benchmark:ecosystem": "tsx scripts/benchmark-ecosystem.ts",
|
|
44
45
|
"prepack": "npm run build"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|