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 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
+ }
@@ -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 revalidates = /(require|revert|UpkeepNotNeeded|checkUpkeep)/.test(body) &&
21
- /(block\.timestamp|lastExecutedAt|interval|upkeepNeeded|balance|paused)/.test(body);
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
- const hasPause = /(Pausable|whenNotPaused|paused|pause\(|emergency|guardian)/i.test(context.content);
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 [
@@ -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
- return (/interface\s+\w*I?Any2EVMMessageReceiver\b/.test(content) ||
14
- /interface\s+\w+\s*{[\s\S]*function\s+ccipReceive\b[^{]*;/.test(content) ||
15
- /abstract\s+contract\s+\w*CCIPReceiver\b/.test(content) ||
16
- /function\s+_ccipReceive\b[^{;]*internal[^{;]*virtual\s*;/.test(content));
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 escapeRegExp(value) {
22
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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
- const directValidation = /(sourceChainSelector|ccipSelectorToChainId)/.test(body) &&
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
- const directValidation = /(message\.sender|data\.sender|sender)/.test(body) &&
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, /(_ccipReceive|ccipReceive)/),
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, /(_ccipReceive|ccipReceive)/),
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, /(ccipReceive|_ccipReceive)/),
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, /(_ccipReceive|ccipReceive)/),
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, /(_ccipReceive|ccipReceive)/),
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
  ];
@@ -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*>\s*0/,
46
- /price\s*>\s*0/,
47
- /oracleAnswer\s*>\s*0/,
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|sendRequest|fulfillRequest|DONHostedSecrets|sourceCode|secrets)/i.test(content);
3
+ return /(FunctionsClient|FunctionsRequest|\bsendRequest\s*\(|\bfulfillRequest\s*\(|DONHostedSecrets|sourceCode|secrets)/i.test(content);
4
4
  }
5
5
  export const functionsCreRules = [
6
6
  {
@@ -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 extractFunctionBody(content, functionName) {
9
- const match = new RegExp(`function\\s+${functionName}\\b`).exec(content);
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
- const stat = await fs.stat(targetPath);
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.0",
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": {