chainlink-audit 0.3.1 → 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] ?? "";
@@ -31,9 +32,6 @@ function isCcipBaseReceiver(content) {
31
32
  function inheritsCcipReceiver(content) {
32
33
  return /\bis\s+[\s\S{]*\bCCIPReceiver(Upgradeable)?\b/.test(content);
33
34
  }
34
- function escapeRegExp(value) {
35
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
- }
37
35
  function extractParentNames(content) {
38
36
  const match = /\bcontract\s+\w+\s+is\s+([\w\s,]+?)(?:\{|$)/m.exec(content);
39
37
  if (!match)
@@ -58,6 +56,44 @@ function delegatedCcipContent(body, repoSignals) {
58
56
  const delegate = repoSignals.files.find((file) => typePattern.test(file.content) && functionPattern.test(file.content));
59
57
  return delegate ? `${body}\n${delegate.content}` : body;
60
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
+ }
61
97
  function stripEmitLines(body) {
62
98
  return body
63
99
  .split("\n")
@@ -136,7 +172,7 @@ export const ccipRules = [
136
172
  if (isCcipBaseReceiver(context.content))
137
173
  return [];
138
174
  const body = ccipBody(context.content) || context.content;
139
- 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)}`;
140
176
  if (validatesSourceChain(context.content, validationContent))
141
177
  return [];
142
178
  return [
@@ -145,7 +181,7 @@ export const ccipRules = [
145
181
  ruleId: this.metadata.ruleId,
146
182
  severity: this.metadata.severity,
147
183
  confidence: "medium",
148
- line: firstLineMatching(context.lines, /(_ccipReceive|ccipReceive)/),
184
+ line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
149
185
  title: this.metadata.title,
150
186
  description: "Potential issue: messages from unexpected source chains may be accepted.",
151
187
  risk: "Cross-chain spoofing or misrouted messages can trigger unauthorized state changes.",
@@ -168,7 +204,7 @@ export const ccipRules = [
168
204
  if (isCcipBaseReceiver(context.content))
169
205
  return [];
170
206
  const body = ccipBody(context.content) || context.content;
171
- 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)}`;
172
208
  if (validatesSourceSender(context.content, validationContent))
173
209
  return [];
174
210
  return [
@@ -177,7 +213,7 @@ export const ccipRules = [
177
213
  ruleId: this.metadata.ruleId,
178
214
  severity: this.metadata.severity,
179
215
  confidence: "medium",
180
- line: firstLineMatching(context.lines, /(_ccipReceive|ccipReceive)/),
216
+ line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
181
217
  title: this.metadata.title,
182
218
  description: "Potential issue: messages from untrusted source contracts may be accepted.",
183
219
  risk: "An attacker may spoof business instructions if sender validation is missing or incomplete.",
@@ -210,7 +246,7 @@ export const ccipRules = [
210
246
  ruleId: this.metadata.ruleId,
211
247
  severity: this.metadata.severity,
212
248
  confidence: "medium",
213
- line: firstLineMatching(context.lines, /(ccipReceive|_ccipReceive)/),
249
+ line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
214
250
  title: this.metadata.title,
215
251
  description: "Potential issue: direct calls to the receiver may bypass CCIP router trust assumptions.",
216
252
  risk: "If the entrypoint is public/external and router validation is absent, fabricated messages may be processed.",
@@ -238,6 +274,10 @@ export const ccipRules = [
238
274
  const defensiveChecks = /(message\.data\.length|try\s+this|catch|InvalidPayload|Payload|version|schema)/i.test(body);
239
275
  if (defensiveChecks)
240
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 [];
241
281
  return [
242
282
  makeFinding({
243
283
  context,
@@ -279,7 +319,7 @@ export const ccipRules = [
279
319
  ruleId: this.metadata.ruleId,
280
320
  severity: this.metadata.severity,
281
321
  confidence: "low",
282
- line: firstLineMatching(context.lines, /(_ccipReceive|ccipReceive)/),
322
+ line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
283
323
  title: this.metadata.title,
284
324
  description: "Potential issue: receiver business logic appears coupled to CCIP execution without a visible retry/manual path.",
285
325
  risk: "A revert in message handling may require manual execution or leave cross-chain state inconsistent.",
@@ -351,7 +391,7 @@ export const ccipRules = [
351
391
  ruleId: this.metadata.ruleId,
352
392
  severity: this.metadata.severity,
353
393
  confidence: "low",
354
- line: firstLineMatching(context.lines, /(_ccipReceive|ccipReceive)/),
394
+ line: firstLineMatching(context.lines, CCIP_RECEIVE_DECL),
355
395
  title: this.metadata.title,
356
396
  description: "Potential issue: repeated or retried CCIP messages may execute mutating business logic more than once.",
357
397
  risk: "Without idempotency tracking, replay-like operational scenarios or manual execution flows can duplicate state changes.",
@@ -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 [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainlink-audit",
3
- "version": "0.3.1",
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": {