chainlink-audit 0.3.0 → 0.3.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.
@@ -10,10 +10,23 @@ function hasCcipReceiver(content) {
10
10
  return /(function\s+_ccipReceive\b|function\s+ccipReceive\b|is\s+CCIPReceiver)/.test(content);
11
11
  }
12
12
  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));
13
+ if (/interface\s+\w*I?Any2EVMMessageReceiver\b/.test(content))
14
+ return true;
15
+ if (/interface\s+\w+\s*{[\s\S]*function\s+ccipReceive\b[^{]*;/.test(content))
16
+ return true;
17
+ if (/abstract\s+contract\s+\w*CCIPReceiver\b/.test(content))
18
+ return true;
19
+ if (/function\s+_ccipReceive\b[^{;]*internal[^{;]*virtual\s*;/.test(content))
20
+ return true;
21
+ // Guard stub: ccipReceive body is only a revert — intentional "not a receiver" pattern (e.g. EVM2EVMOffRamp)
22
+ const ccipReceiveBody = extractFunctionBody(content, "ccipReceive");
23
+ if (ccipReceiveBody) {
24
+ const bodyTrimmed = ccipReceiveBody.trim();
25
+ if (/^\s*revert\s*\(\s*\)\s*;\s*$/.test(bodyTrimmed) ||
26
+ /^\s*revert\s+\w[\w.]*\s*\([^)]*\)\s*;\s*$/.test(bodyTrimmed))
27
+ return true;
28
+ }
29
+ return false;
17
30
  }
18
31
  function inheritsCcipReceiver(content) {
19
32
  return /\bis\s+[\s\S{]*\bCCIPReceiver(Upgradeable)?\b/.test(content);
@@ -21,6 +34,20 @@ function inheritsCcipReceiver(content) {
21
34
  function escapeRegExp(value) {
22
35
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
36
  }
37
+ function extractParentNames(content) {
38
+ const match = /\bcontract\s+\w+\s+is\s+([\w\s,]+?)(?:\{|$)/m.exec(content);
39
+ if (!match)
40
+ return [];
41
+ return match[1].split(",").map((s) => s.trim()).filter(Boolean);
42
+ }
43
+ function inheritsViaCcipReceiver(content, repoSignals) {
44
+ const parents = extractParentNames(content);
45
+ return parents.some((parent) => {
46
+ const parentPattern = new RegExp(`\\b(?:abstract\\s+)?contract\\s+${escapeRegExp(parent)}\\b`);
47
+ const parentFile = repoSignals.files.find((f) => parentPattern.test(f.content));
48
+ return parentFile ? inheritsCcipReceiver(parentFile.content) : false;
49
+ });
50
+ }
24
51
  function delegatedCcipContent(body, repoSignals) {
25
52
  const match = /\b([A-Z][A-Za-z0-9_]*)\s*\.\s*([A-Za-z0-9_]*ccipReceive|processCcipReceive)\s*\(/i.exec(body);
26
53
  if (!match)
@@ -31,16 +58,28 @@ function delegatedCcipContent(body, repoSignals) {
31
58
  const delegate = repoSignals.files.find((file) => typePattern.test(file.content) && functionPattern.test(file.content));
32
59
  return delegate ? `${body}\n${delegate.content}` : body;
33
60
  }
61
+ function stripEmitLines(body) {
62
+ return body
63
+ .split("\n")
64
+ .filter((line) => !/\bemit\b/.test(line))
65
+ .join("\n");
66
+ }
34
67
  function validatesSourceChain(content, body) {
35
68
  const header = ccipHeader(content);
36
- const directValidation = /(sourceChainSelector|ccipSelectorToChainId)/.test(body) &&
69
+ // Strip event emissions before checking: sourceChainSelector that only appears in emit
70
+ // calls (e.g. for logging) must not suppress the finding.
71
+ const bodyWithoutEmits = stripEmitLines(body);
72
+ const directValidation = /(sourceChainSelector|ccipSelectorToChainId)/.test(bodyWithoutEmits) &&
37
73
  /(require|revert|allowed|trusted|allowlist|supportedNetworks|UnsupportedNetwork)/i.test(body);
38
74
  const modifierValidation = /(validChain|allowlisted|allowlist|trusted|onlyAllowlisted)[^{;]*(sourceChainSelector|\.sourceChainSelector)/i.test(header);
39
75
  return directValidation || modifierValidation;
40
76
  }
41
77
  function validatesSourceSender(content, body) {
42
78
  const header = ccipHeader(content);
43
- const directValidation = /(message\.sender|data\.sender|sender)/.test(body) &&
79
+ // Strip event emissions before checking: sender that only appears in emit
80
+ // calls must not suppress the finding.
81
+ const bodyWithoutEmits = stripEmitLines(body);
82
+ const directValidation = /(message\.sender|data\.sender|sender)/.test(bodyWithoutEmits) &&
44
83
  /(require|revert|allowed|trusted|allowlist|Unauthorized)/i.test(body);
45
84
  const modifierValidation = /(validSender|trustedSender|allowlistedSender|onlyAllowlisted|allowlisted|allowlist|trusted)[^{;]*(message\.sender|\.sender|sender)/i.test(header);
46
85
  return directValidation || modifierValidation;
@@ -64,6 +103,24 @@ function hasMessageIdTracking(content, body) {
64
103
  return false;
65
104
  return /(processed|received|executed|consumed|handled|seen|replay|duplicate|messageDetail|failedMessages|messageIdTo)/i.test(content);
66
105
  }
106
+ function hasTokenPoolSender(content) {
107
+ return (/function\s+lockOrBurn\b/.test(content) &&
108
+ /(Pool\.LockOrBurnInV1|LockOrBurnInV1|is\s+[\w\s,]*TokenPool)/.test(content));
109
+ }
110
+ function hasTokenPoolReceiver(content) {
111
+ return (/function\s+releaseOrMint\b/.test(content) &&
112
+ /(Pool\.ReleaseOrMintInV1|ReleaseOrMintInV1|is\s+[\w\s,]*TokenPool)/.test(content));
113
+ }
114
+ function isTokenPoolBase(content) {
115
+ if (/abstract\s+contract\s+\w*TokenPool\b/.test(content))
116
+ return true;
117
+ if (/interface\s+\w*(TokenPool|IPool)\b/.test(content))
118
+ return true;
119
+ // Abstract contracts that inherit from a TokenPool base (e.g. BurnMintTokenPoolAbstract is BurnMintTokenPool)
120
+ if (/abstract\s+contract\s+\w+\s+is\s+[\w\s,]*TokenPool\b/.test(content))
121
+ return true;
122
+ return false;
123
+ }
67
124
  export const ccipRules = [
68
125
  {
69
126
  metadata: {
@@ -145,7 +202,7 @@ export const ccipRules = [
145
202
  const body = ccipBody(context.content) || context.content;
146
203
  const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}`;
147
204
  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))
205
+ if (validatesRouter || inheritsCcipReceiver(context.content) || inheritsViaCcipReceiver(context.content, context.repoSignals))
149
206
  return [];
150
207
  return [
151
208
  makeFinding({
@@ -303,4 +360,106 @@ export const ccipRules = [
303
360
  ];
304
361
  },
305
362
  },
363
+ {
364
+ metadata: {
365
+ ruleId: "CL-CCIP-008",
366
+ product: "ccip",
367
+ severity: "high",
368
+ title: "Potential CCIP Token Pool lockOrBurn without _validateLockOrBurn",
369
+ description: "lockOrBurn override does not appear to call _validateLockOrBurn.",
370
+ },
371
+ scan(context) {
372
+ if (!hasTokenPoolSender(context.content))
373
+ return [];
374
+ if (isTokenPoolBase(context.content))
375
+ return [];
376
+ const body = extractFunctionBody(context.content, "lockOrBurn");
377
+ if (!body)
378
+ return [];
379
+ if (/_validateLockOrBurn\s*\(/.test(body))
380
+ return [];
381
+ return [
382
+ makeFinding({
383
+ context,
384
+ ruleId: this.metadata.ruleId,
385
+ severity: this.metadata.severity,
386
+ confidence: "medium",
387
+ line: firstLineMatching(context.lines, /function\s+lockOrBurn\b/),
388
+ title: this.metadata.title,
389
+ description: "Potential issue: lockOrBurn may skip rate limit and chain allowlist enforcement.",
390
+ risk: "Omitting _validateLockOrBurn bypasses CCIP-enforced rate limits and supported-chain checks, enabling unrestricted token locking or burning.",
391
+ recommendation: "Call _validateLockOrBurn(lockOrBurnIn) at the start of every lockOrBurn override before executing custom logic.",
392
+ }),
393
+ ];
394
+ },
395
+ },
396
+ {
397
+ metadata: {
398
+ ruleId: "CL-CCIP-009",
399
+ product: "ccip",
400
+ severity: "high",
401
+ title: "Potential CCIP Token Pool releaseOrMint without _validateReleaseOrMint",
402
+ description: "releaseOrMint override does not appear to call _validateReleaseOrMint.",
403
+ },
404
+ scan(context) {
405
+ if (!hasTokenPoolReceiver(context.content))
406
+ return [];
407
+ if (isTokenPoolBase(context.content))
408
+ return [];
409
+ const body = extractFunctionBody(context.content, "releaseOrMint");
410
+ if (!body)
411
+ return [];
412
+ if (/_validateReleaseOrMint\s*\(/.test(body))
413
+ return [];
414
+ return [
415
+ makeFinding({
416
+ context,
417
+ ruleId: this.metadata.ruleId,
418
+ severity: this.metadata.severity,
419
+ confidence: "medium",
420
+ line: firstLineMatching(context.lines, /function\s+releaseOrMint\b/),
421
+ title: this.metadata.title,
422
+ description: "Potential issue: releaseOrMint may skip offRamp caller verification and rate limit enforcement.",
423
+ risk: "Omitting _validateReleaseOrMint allows arbitrary callers to trigger token minting or release, bypassing CCIP trust boundaries.",
424
+ recommendation: "Call _validateReleaseOrMint(releaseOrMintIn) at the start of every releaseOrMint override before minting or releasing tokens.",
425
+ }),
426
+ ];
427
+ },
428
+ },
429
+ {
430
+ metadata: {
431
+ ruleId: "CL-CCIP-010",
432
+ product: "ccip",
433
+ severity: "medium",
434
+ title: "Potential unsafe CCIP Token Pool sourcePoolData decoding",
435
+ description: "releaseOrMint decodes sourcePoolData without obvious defensive checks.",
436
+ },
437
+ scan(context) {
438
+ if (!hasTokenPoolReceiver(context.content))
439
+ return [];
440
+ if (isTokenPoolBase(context.content))
441
+ return [];
442
+ const body = extractFunctionBody(context.content, "releaseOrMint");
443
+ if (!body)
444
+ return [];
445
+ if (!/abi\.decode\s*\(\s*(?:releaseOrMintIn\.)?sourcePoolData/.test(body))
446
+ return [];
447
+ const defensiveChecks = /(sourcePoolData\.length|try\s+this|catch|InvalidSourcePoolData|InvalidPayload|schema|version)/i.test(body);
448
+ if (defensiveChecks)
449
+ return [];
450
+ return [
451
+ makeFinding({
452
+ context,
453
+ ruleId: this.metadata.ruleId,
454
+ severity: this.metadata.severity,
455
+ confidence: "low",
456
+ line: firstLineMatching(context.lines, /abi\.decode\s*\(\s*(?:releaseOrMintIn\.)?sourcePoolData/),
457
+ title: this.metadata.title,
458
+ description: "Potential issue: malformed or unexpected sourcePoolData may cause releaseOrMint to revert and block token delivery.",
459
+ risk: "If sourcePoolData schema changes across an upgrade or pool version, decoding failures can permanently brick in-flight transfers.",
460
+ recommendation: "Validate sourcePoolData length or schema before decoding, or isolate decode failures so they can be handled without reverting the entire transfer.",
461
+ }),
462
+ ];
463
+ },
464
+ },
306
465
  ];
package/dist/scanner.js CHANGED
@@ -78,7 +78,7 @@ function detectProducts(files) {
78
78
  const allContent = files.map((file) => file.content).join("\n");
79
79
  if (/(AggregatorV3Interface|latestRoundData|IChainlinkAggregator)/.test(allContent))
80
80
  products.add("data-feeds");
81
- if (/(CCIPReceiver|Any2EVMMessage|IRouterClient|_ccipReceive|ccipReceive|sourceChainSelector)/.test(allContent))
81
+ if (/(CCIPReceiver|Any2EVMMessage|IRouterClient|_ccipReceive|ccipReceive|sourceChainSelector|TokenPool|LockOrBurnInV1|ReleaseOrMintInV1|_validateLockOrBurn|_validateReleaseOrMint)/.test(allContent))
82
82
  products.add("ccip");
83
83
  if (/(VRFConsumerBase|VRFCoordinator|fulfillRandomWords|requestRandomWords)/.test(allContent))
84
84
  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.1",
4
4
  "description": "Security review CLI for flagging unverified Chainlink integration risk leads in Solidity repositories.",
5
5
  "license": "MIT",
6
6
  "type": "module",