chainlink-audit 0.1.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.
package/README.md CHANGED
@@ -1,16 +1,23 @@
1
1
  # chainlink-audit
2
2
 
3
- Security CLI for detecting potential Chainlink integration risks in Solidity repositories.
3
+ Security review CLI for flagging unverified Chainlink integration risk leads in Solidity repositories.
4
4
 
5
5
  ```bash
6
6
  npm install -g chainlink-audit
7
+ chainlink-audit version
7
8
  chainlink-audit init
8
9
  chainlink-audit scan .
9
10
  chainlink-audit scan . --format markdown --out chainlink-report.md
10
11
  chainlink-audit scan . --format html --out chainlink-report.html
12
+ chainlink-audit scan . --format sarif --out chainlink-report.sarif
13
+ chainlink-audit triage chainlink-report.json --out triage.md
11
14
  ```
12
15
 
13
- Findings are heuristic leads for manual review, not confirmed vulnerabilities.
16
+ Results are heuristic risk leads for manual review, not confirmed vulnerabilities. Potential impact reflects what could happen if the lead is real; it does not prove exploitability.
17
+
18
+ Use `chainlink-audit triage <report.json>` to turn JSON scan output into a manual review checklist.
19
+
20
+ Published package: https://www.npmjs.com/package/chainlink-audit
14
21
 
15
22
  See the repository README for full documentation:
16
23
  https://github.com/alva-p/chainlink-integration-audit-kit
package/dist/config.js CHANGED
@@ -15,9 +15,10 @@ export function severityRank(severity) {
15
15
  }[severity];
16
16
  }
17
17
  export function parseOutputFormat(value) {
18
- if (value === "text" || value === "json" || value === "markdown" || value === "html")
18
+ if (value === "text" || value === "json" || value === "markdown" || value === "html" || value === "sarif") {
19
19
  return value;
20
- throw new Error(`Unsupported format "${value}". Use text, json, markdown, or html.`);
20
+ }
21
+ throw new Error(`Unsupported format "${value}". Use text, json, markdown, html, or sarif.`);
21
22
  }
22
23
  export function parseSeverity(value) {
23
24
  if (value === "info" || value === "low" || value === "medium" || value === "high")
package/dist/index.js CHANGED
@@ -5,10 +5,12 @@ import { configFileName, loadConfig, parseOutputFormat, parseSeverity, writeDefa
5
5
  import { renderJson } from "./reporters/json.js";
6
6
  import { renderHtml } from "./reporters/html.js";
7
7
  import { renderMarkdown } from "./reporters/markdown.js";
8
+ import { renderSarif } from "./reporters/sarif.js";
8
9
  import { renderText } from "./reporters/text.js";
10
+ import { renderTriageMarkdown } from "./reporters/triage.js";
9
11
  import { rules } from "./rules/index.js";
10
12
  import { scanPath } from "./scanner.js";
11
- const version = "0.1.0";
13
+ const version = "0.3.0";
12
14
  function render(format, result) {
13
15
  if (format === "json")
14
16
  return renderJson(result);
@@ -16,17 +18,19 @@ function render(format, result) {
16
18
  return renderMarkdown(result);
17
19
  if (format === "html")
18
20
  return renderHtml(result);
21
+ if (format === "sarif")
22
+ return renderSarif(result);
19
23
  return renderText(result);
20
24
  }
21
25
  const program = new Command();
22
26
  program
23
27
  .name("chainlink-audit")
24
- .description("CLI-first scanner for potential Chainlink integration risks in Solidity repositories.")
28
+ .description("CLI-first audit assistant for unverified Chainlink integration risk leads.")
25
29
  .version(version);
26
30
  program
27
31
  .command("scan")
28
32
  .argument("<path>", "Solidity file or repository path to scan")
29
- .option("--format <format>", "Output format: text, json, markdown, html")
33
+ .option("--format <format>", "Output format: text, json, markdown, html, sarif")
30
34
  .option("--min-severity <severity>", "Minimum severity: info, low, medium, high")
31
35
  .option("--out <file>", "Write report to a file instead of stdout")
32
36
  .action(async (targetPath, options) => {
@@ -53,6 +57,33 @@ program
53
57
  process.exitCode = 2;
54
58
  }
55
59
  });
60
+ program
61
+ .command("triage")
62
+ .argument("<report.json>", "JSON report produced by chainlink-audit scan --format json")
63
+ .option("--out <file>", "Write triage markdown to a file instead of stdout")
64
+ .description("Convert a JSON scan report into a manual triage checklist")
65
+ .action(async (reportPath, options) => {
66
+ try {
67
+ const raw = await fs.readFile(reportPath, "utf8");
68
+ const parsed = JSON.parse(raw);
69
+ if (!Array.isArray(parsed.findings) || typeof parsed.targetPath !== "string") {
70
+ throw new Error("Input does not look like a chainlink-audit JSON report");
71
+ }
72
+ const output = renderTriageMarkdown(parsed);
73
+ if (options.out) {
74
+ await fs.writeFile(options.out, `${output}\n`, "utf8");
75
+ }
76
+ else {
77
+ console.log(output);
78
+ }
79
+ process.exitCode = 0;
80
+ }
81
+ catch (error) {
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ console.error(`chainlink-audit: ${message}`);
84
+ process.exitCode = 2;
85
+ }
86
+ });
56
87
  program.command("init").description(`Create ${configFileName} with recommended defaults`).action(async () => {
57
88
  try {
58
89
  await writeDefaultConfig();
@@ -73,7 +104,7 @@ program.command("init").description(`Create ${configFileName} with recommended d
73
104
  program.command("rules").description("List available MVP rules").action(() => {
74
105
  for (const rule of rules) {
75
106
  const metadata = rule.metadata;
76
- console.log(`${metadata.ruleId} [${metadata.severity}] ${metadata.product} - ${metadata.title}`);
107
+ console.log(`${metadata.ruleId} [${metadata.severity} potential impact] ${metadata.product} - ${metadata.title}`);
77
108
  }
78
109
  });
79
110
  program.command("version").description("Print CLI version").action(() => {
@@ -18,13 +18,14 @@ function findingCard(finding) {
18
18
  <h3>${escapeHtml(finding.title)}</h3>
19
19
  </div>
20
20
  <div class="badges">
21
- <span class="badge severity">${escapeHtml(finding.severity)}</span>
22
- <span class="badge confidence">${escapeHtml(finding.confidence)} confidence</span>
21
+ <span class="badge severity">${escapeHtml(finding.severity)} potential impact</span>
22
+ <span class="badge confidence">${escapeHtml(finding.confidence)} detection confidence</span>
23
23
  </div>
24
24
  </header>
25
25
  <dl class="meta">
26
26
  <div><dt>Location</dt><dd>${escapeHtml(finding.file)}:${finding.line}</dd></div>
27
27
  <div><dt>Manual Review</dt><dd>${finding.manualReviewRequired ? "Required" : "Optional"}</dd></div>
28
+ <div><dt>Confirmed Vulnerability</dt><dd>No</dd></div>
28
29
  </dl>
29
30
  <section>
30
31
  <h4>Description</h4>
@@ -46,7 +47,7 @@ export function renderHtml(result) {
46
47
  const generatedAt = new Date().toISOString();
47
48
  const findings = result.findings.length > 0
48
49
  ? result.findings.map(findingCard).join("\n")
49
- : '<section class="empty">No potential Chainlink integration issues found by MVP rules. Manual review still required.</section>';
50
+ : '<section class="empty">No Chainlink integration risk leads found by MVP rules. Manual review still required.</section>';
50
51
  return `<!doctype html>
51
52
  <html lang="en">
52
53
  <head>
@@ -325,22 +326,23 @@ export function renderHtml(result) {
325
326
  <header class="hero">
326
327
  <p class="eyebrow">Chainlink Audit Kit</p>
327
328
  <h1>Chainlink Integration Audit Report</h1>
328
- <p class="subtitle">Potential Chainlink integration risks detected by heuristic MVP rules. Findings require manual review and are not confirmed vulnerabilities.</p>
329
+ <p class="subtitle">Unverified Chainlink integration risk leads detected by heuristic MVP rules. Potential impact is not confirmed exploitability.</p>
329
330
  </header>
330
331
 
331
332
  <section class="summary" aria-label="Scan summary">
332
333
  <div class="summary-card"><span>Target</span><strong>${escapeHtml(result.targetPath)}</strong></div>
333
334
  <div class="summary-card"><span>Solidity Files</span><strong>${result.scannedFiles}</strong></div>
334
335
  <div class="summary-card"><span>Products</span><strong>${escapeHtml(products)}</strong></div>
335
- <div class="summary-card"><span>Findings</span><strong>${result.findings.length}</strong></div>
336
- <div class="summary-card"><span>Minimum Severity</span><strong>${escapeHtml(result.config.minSeverity)}</strong></div>
336
+ <div class="summary-card"><span>Unverified Leads</span><strong>${result.findings.length}</strong></div>
337
+ <div class="summary-card"><span>Confirmed Vulnerabilities</span><strong>0</strong></div>
338
+ <div class="summary-card"><span>Minimum Potential Impact</span><strong>${escapeHtml(result.config.minSeverity)}</strong></div>
337
339
  <div class="summary-card"><span>Excluded Paths</span><strong>${escapeHtml(excludedPaths)}</strong></div>
338
340
  </section>
339
341
 
340
- <p class="note">This report is designed for audit triage. Validate each lead against source code, deployment configuration, and protocol assumptions before disclosure or remediation.</p>
342
+ <p class="note">This report is designed for audit triage. Validate each lead against source code, deployment configuration, and protocol assumptions before disclosure or remediation. High potential impact does not mean a confirmed or exploitable vulnerability.</p>
341
343
 
342
344
  <section>
343
- <h2>Findings</h2>
345
+ <h2>Risk Leads</h2>
344
346
  <div class="findings">
345
347
  ${findings}
346
348
  </div>
@@ -8,20 +8,21 @@ export function renderMarkdown(result) {
8
8
  `- Target: \`${result.targetPath}\``,
9
9
  `- Solidity files scanned: ${result.scannedFiles}`,
10
10
  `- Detected Chainlink products: ${products}`,
11
- `- Minimum severity: ${result.config.minSeverity}`,
11
+ `- Minimum potential impact: ${result.config.minSeverity}`,
12
12
  `- Excluded paths: ${result.config.exclude.length > 0 ? result.config.exclude.join(", ") : "none"}`,
13
- `- Findings: ${result.findings.length}`,
13
+ `- Unverified leads: ${result.findings.length}`,
14
+ `- Confirmed vulnerabilities: 0`,
14
15
  "",
15
- "This report contains potential issues produced by heuristic MVP rules. Findings require manual review before disclosure or remediation.",
16
+ "This report contains unverified risk leads produced by heuristic MVP rules. Potential impact is not confirmed exploitability. Validate each lead manually before disclosure or remediation.",
16
17
  "",
17
18
  ];
18
19
  if (result.findings.length === 0) {
19
- lines.push("## Findings", "", "No potential Chainlink integration issues found by MVP rules.");
20
+ lines.push("## Risk Leads", "", "No Chainlink integration risk leads found by MVP rules.");
20
21
  return lines.join("\n");
21
22
  }
22
- lines.push("## Findings", "");
23
+ lines.push("## Risk Leads", "");
23
24
  for (const finding of result.findings) {
24
- lines.push(`### ${finding.ruleId}: ${finding.title}`, "", `- Severity: ${finding.severity}`, `- Confidence: ${finding.confidence}`, `- Location: \`${finding.file}:${finding.line}\``, `- Manual review required: ${finding.manualReviewRequired ? "yes" : "no"}`, "", "#### Description", "", finding.description, "", "#### Risk", "", finding.risk, "", "#### Recommendation", "", finding.recommendation, "");
25
+ lines.push(`### ${finding.ruleId}: ${finding.title}`, "", `- Potential impact: ${finding.severity}`, `- Detection confidence: ${finding.confidence}`, `- Location: \`${finding.file}:${finding.line}\``, `- Manual review required: ${finding.manualReviewRequired ? "yes" : "no"}`, `- Confirmed vulnerability: no`, "", "#### Description", "", finding.description, "", "#### Risk", "", finding.risk, "", "#### Recommendation", "", finding.recommendation, "");
25
26
  }
26
27
  return lines.join("\n");
27
28
  }
@@ -0,0 +1,105 @@
1
+ import { rules } from "../rules/index.js";
2
+ function normalizeUri(value) {
3
+ return value.split("\\").join("/");
4
+ }
5
+ function sarifLevel(severity) {
6
+ if (severity === "high")
7
+ return "error";
8
+ if (severity === "info")
9
+ return "note";
10
+ return "warning";
11
+ }
12
+ function resultMessage(finding) {
13
+ return [
14
+ `Unverified risk lead. Potential impact: ${finding.severity}. Detection confidence: ${finding.confidence}.`,
15
+ "",
16
+ finding.description,
17
+ "",
18
+ `Risk: ${finding.risk}`,
19
+ "",
20
+ `Recommendation: ${finding.recommendation}`,
21
+ "",
22
+ `Manual review required: ${finding.manualReviewRequired ? "yes" : "no"}. Confirmed vulnerability: no.`,
23
+ ].join("\n");
24
+ }
25
+ export function renderSarif(result) {
26
+ const ruleMetadata = new Map(rules.map((rule) => [rule.metadata.ruleId, rule.metadata]));
27
+ const sarif = {
28
+ version: "2.1.0",
29
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
30
+ runs: [
31
+ {
32
+ tool: {
33
+ driver: {
34
+ name: "chainlink-audit",
35
+ informationUri: "https://github.com/alva-p/chainlink-integration-audit-kit",
36
+ rules: rules.map((rule) => ({
37
+ id: rule.metadata.ruleId,
38
+ name: rule.metadata.title,
39
+ shortDescription: {
40
+ text: rule.metadata.title,
41
+ },
42
+ fullDescription: {
43
+ text: rule.metadata.description,
44
+ },
45
+ help: {
46
+ text: `${rule.metadata.description}\n\nProduct: ${rule.metadata.product}. Potential impact: ${rule.metadata.severity}. Results are unverified risk leads and require manual review.`,
47
+ markdown: `${rule.metadata.description}\n\n**Product:** ${rule.metadata.product}\n\n**Potential impact:** ${rule.metadata.severity}\n\nResults are unverified risk leads and require manual review.`,
48
+ },
49
+ properties: {
50
+ product: rule.metadata.product,
51
+ potentialImpact: rule.metadata.severity,
52
+ },
53
+ })),
54
+ },
55
+ },
56
+ automationDetails: {
57
+ id: "chainlink-audit/",
58
+ },
59
+ invocations: [
60
+ {
61
+ executionSuccessful: true,
62
+ properties: {
63
+ targetPath: result.targetPath,
64
+ scannedFiles: result.scannedFiles,
65
+ detectedProducts: result.products,
66
+ minimumPotentialImpact: result.config.minSeverity,
67
+ excludedPaths: result.config.exclude,
68
+ },
69
+ },
70
+ ],
71
+ results: result.findings.map((finding) => {
72
+ const metadata = ruleMetadata.get(finding.ruleId);
73
+ return {
74
+ ruleId: finding.ruleId,
75
+ ruleIndex: rules.findIndex((rule) => rule.metadata.ruleId === finding.ruleId),
76
+ level: sarifLevel(finding.severity),
77
+ message: {
78
+ text: resultMessage(finding),
79
+ },
80
+ locations: [
81
+ {
82
+ physicalLocation: {
83
+ artifactLocation: {
84
+ uri: normalizeUri(finding.file),
85
+ },
86
+ region: {
87
+ startLine: finding.line,
88
+ },
89
+ },
90
+ },
91
+ ],
92
+ properties: {
93
+ potentialImpact: finding.severity,
94
+ detectionConfidence: finding.confidence,
95
+ product: metadata?.product,
96
+ manualReviewRequired: finding.manualReviewRequired,
97
+ confirmedVulnerability: false,
98
+ },
99
+ };
100
+ }),
101
+ },
102
+ ],
103
+ };
104
+ return JSON.stringify(sarif, null, 2);
105
+ }
@@ -5,22 +5,23 @@ export function renderText(result) {
5
5
  `Target: ${result.targetPath}`,
6
6
  `Solidity files scanned: ${result.scannedFiles}`,
7
7
  `Detected Chainlink products: ${products}`,
8
- `Minimum severity: ${result.config.minSeverity}`,
8
+ `Minimum potential impact: ${result.config.minSeverity}`,
9
9
  `Excluded paths: ${result.config.exclude.length > 0 ? result.config.exclude.join(", ") : "none"}`,
10
- `Findings: ${result.findings.length}`,
10
+ `Unverified leads: ${result.findings.length}`,
11
11
  ].join("\n");
12
12
  if (result.findings.length === 0) {
13
- return `${header}\n\nNo potential Chainlink integration issues found by MVP rules. Manual review still required.`;
13
+ return `${header}\n\nNo Chainlink integration risk leads found by MVP rules. Manual review still required.`;
14
14
  }
15
15
  const findings = result.findings
16
16
  .map((finding) => [
17
- `[${finding.severity.toUpperCase()}] ${finding.ruleId} - ${finding.title}`,
18
- ` Confidence: ${finding.confidence}`,
17
+ `[${finding.severity.toUpperCase()} POTENTIAL IMPACT] ${finding.ruleId} - ${finding.title}`,
18
+ ` Detection confidence: ${finding.confidence}`,
19
19
  ` Location: ${finding.file}:${finding.line}`,
20
20
  ` Description: ${finding.description}`,
21
21
  ` Risk: ${finding.risk}`,
22
22
  ` Recommendation: ${finding.recommendation}`,
23
23
  ` Manual review required: ${finding.manualReviewRequired ? "yes" : "no"}`,
24
+ " Confirmed vulnerability: no",
24
25
  ].join("\n"))
25
26
  .join("\n\n");
26
27
  return `${header}\n\n${findings}`;
@@ -0,0 +1,55 @@
1
+ function severityRank(finding) {
2
+ return {
3
+ info: 0,
4
+ low: 1,
5
+ medium: 2,
6
+ high: 3,
7
+ }[finding.severity];
8
+ }
9
+ function checkbox(label) {
10
+ return `- [ ] ${label}`;
11
+ }
12
+ export function renderTriageMarkdown(result) {
13
+ const products = result.products.length > 0 ? result.products.join(", ") : "none detected";
14
+ const findings = [...result.findings].sort((a, b) => {
15
+ const severityCompare = severityRank(b) - severityRank(a);
16
+ if (severityCompare !== 0)
17
+ return severityCompare;
18
+ const confidenceCompare = b.confidence.localeCompare(a.confidence);
19
+ if (confidenceCompare !== 0)
20
+ return confidenceCompare;
21
+ return a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId);
22
+ });
23
+ const lines = [
24
+ "# Chainlink Audit Triage",
25
+ "",
26
+ "## Summary",
27
+ "",
28
+ `- Target: \`${result.targetPath}\``,
29
+ `- Solidity files scanned: ${result.scannedFiles}`,
30
+ `- Detected Chainlink products: ${products}`,
31
+ `- Unverified leads: ${findings.length}`,
32
+ "- Confirmed vulnerabilities: 0",
33
+ "",
34
+ "This triage file is for manual audit review. A high potential-impact lead is not a confirmed vulnerability.",
35
+ "",
36
+ "## Review Status Legend",
37
+ "",
38
+ checkbox("True positive"),
39
+ checkbox("False positive"),
40
+ checkbox("Accepted risk"),
41
+ checkbox("Needs more context"),
42
+ checkbox("Not reportable"),
43
+ "",
44
+ "## Leads",
45
+ "",
46
+ ];
47
+ if (findings.length === 0) {
48
+ lines.push("No unverified risk leads were found by the current rule set.", "");
49
+ return lines.join("\n");
50
+ }
51
+ for (const finding of findings) {
52
+ lines.push(`### ${finding.ruleId}: ${finding.title}`, "", `- Potential impact: ${finding.severity}`, `- Detection confidence: ${finding.confidence}`, `- Location: \`${finding.file}:${finding.line}\``, "- Confirmed vulnerability: no", "", "#### Manual Status", "", checkbox("True positive"), checkbox("False positive"), checkbox("Accepted risk"), checkbox("Needs more context"), checkbox("Not reportable"), "", "#### Reviewer Notes", "", "- Evidence:", "- Exploitability:", "- Impact:", "- Recommendation:", "- Disclosure path:", "", "#### Scanner Context", "", `- Description: ${finding.description}`, `- Risk: ${finding.risk}`, `- Recommendation: ${finding.recommendation}`, "");
53
+ }
54
+ return lines.join("\n");
55
+ }
@@ -1,10 +1,126 @@
1
1
  import { countMatches, extractFunctionBody, firstLineMatching, makeFinding } from "./helpers.js";
2
2
  function ccipBody(content) {
3
- return extractFunctionBody(content, "_ccipReceive") || extractFunctionBody(content, "ccipReceive");
3
+ return [extractFunctionBody(content, "ccipReceive"), extractFunctionBody(content, "_ccipReceive")].filter(Boolean).join("\n");
4
+ }
5
+ function ccipHeader(content) {
6
+ const match = /function\s+(_ccipReceive|ccipReceive)\b[^{;]*/.exec(content);
7
+ return match?.[0] ?? "";
4
8
  }
5
9
  function hasCcipReceiver(content) {
6
10
  return /(function\s+_ccipReceive\b|function\s+ccipReceive\b|is\s+CCIPReceiver)/.test(content);
7
11
  }
12
+ function isCcipBaseReceiver(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;
30
+ }
31
+ function inheritsCcipReceiver(content) {
32
+ return /\bis\s+[\s\S{]*\bCCIPReceiver(Upgradeable)?\b/.test(content);
33
+ }
34
+ function escapeRegExp(value) {
35
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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
+ }
51
+ function delegatedCcipContent(body, repoSignals) {
52
+ const match = /\b([A-Z][A-Za-z0-9_]*)\s*\.\s*([A-Za-z0-9_]*ccipReceive|processCcipReceive)\s*\(/i.exec(body);
53
+ if (!match)
54
+ return body;
55
+ const [, libraryOrContractName, functionName] = match;
56
+ const typePattern = new RegExp(`\\b(library|contract)\\s+${escapeRegExp(libraryOrContractName)}\\b`);
57
+ const functionPattern = new RegExp(`\\bfunction\\s+${escapeRegExp(functionName)}\\b`);
58
+ const delegate = repoSignals.files.find((file) => typePattern.test(file.content) && functionPattern.test(file.content));
59
+ return delegate ? `${body}\n${delegate.content}` : body;
60
+ }
61
+ function stripEmitLines(body) {
62
+ return body
63
+ .split("\n")
64
+ .filter((line) => !/\bemit\b/.test(line))
65
+ .join("\n");
66
+ }
67
+ function validatesSourceChain(content, body) {
68
+ const header = ccipHeader(content);
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) &&
73
+ /(require|revert|allowed|trusted|allowlist|supportedNetworks|UnsupportedNetwork)/i.test(body);
74
+ const modifierValidation = /(validChain|allowlisted|allowlist|trusted|onlyAllowlisted)[^{;]*(sourceChainSelector|\.sourceChainSelector)/i.test(header);
75
+ return directValidation || modifierValidation;
76
+ }
77
+ function validatesSourceSender(content, body) {
78
+ const header = ccipHeader(content);
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) &&
83
+ /(require|revert|allowed|trusted|allowlist|Unauthorized)/i.test(body);
84
+ const modifierValidation = /(validSender|trustedSender|allowlistedSender|onlyAllowlisted|allowlisted|allowlist|trusted)[^{;]*(message\.sender|\.sender|sender)/i.test(header);
85
+ return directValidation || modifierValidation;
86
+ }
87
+ function hasBusinessMutation(body) {
88
+ return (countMatches(body, [
89
+ /transfer/i,
90
+ /mint/i,
91
+ /burn/i,
92
+ /swap/i,
93
+ /deposit/i,
94
+ /withdraw/i,
95
+ /credit/i,
96
+ /call\s*\{/,
97
+ /\[[^\]]+\]\s*(\+\+|--|\+=|-=|=)/,
98
+ /\b(push|pop)\s*\(/,
99
+ ]) > 0);
100
+ }
101
+ function hasMessageIdTracking(content, body) {
102
+ if (!/(messageId|\.messageId)/.test(body))
103
+ return false;
104
+ return /(processed|received|executed|consumed|handled|seen|replay|duplicate|messageDetail|failedMessages|messageIdTo)/i.test(content);
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
+ }
8
124
  export const ccipRules = [
9
125
  {
10
126
  metadata: {
@@ -17,9 +133,11 @@ export const ccipRules = [
17
133
  scan(context) {
18
134
  if (!hasCcipReceiver(context.content))
19
135
  return [];
136
+ if (isCcipBaseReceiver(context.content))
137
+ return [];
20
138
  const body = ccipBody(context.content) || context.content;
21
- const validates = /sourceChainSelector/.test(body) && /(require|revert|allowed|trusted|allowlist)/i.test(body);
22
- if (validates)
139
+ const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}`;
140
+ if (validatesSourceChain(context.content, validationContent))
23
141
  return [];
24
142
  return [
25
143
  makeFinding({
@@ -47,9 +165,11 @@ export const ccipRules = [
47
165
  scan(context) {
48
166
  if (!hasCcipReceiver(context.content))
49
167
  return [];
168
+ if (isCcipBaseReceiver(context.content))
169
+ return [];
50
170
  const body = ccipBody(context.content) || context.content;
51
- const validates = /(message\.sender|sender)/.test(body) && /(require|revert|allowed|trusted|allowlist)/i.test(body);
52
- if (validates)
171
+ const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}`;
172
+ if (validatesSourceSender(context.content, validationContent))
53
173
  return [];
54
174
  return [
55
175
  makeFinding({
@@ -77,8 +197,12 @@ export const ccipRules = [
77
197
  scan(context) {
78
198
  if (!hasCcipReceiver(context.content))
79
199
  return [];
80
- const validatesRouter = /msg\.sender\s*(!=|==)\s*(router|s_router|i_router|address\(router\))|InvalidRouter|onlyRouter/i.test(context.content);
81
- if (validatesRouter || /is\s+CCIPReceiver/.test(context.content))
200
+ if (isCcipBaseReceiver(context.content))
201
+ return [];
202
+ const body = ccipBody(context.content) || context.content;
203
+ const validationContent = `${ccipHeader(context.content)}\n${delegatedCcipContent(body, context.repoSignals)}`;
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);
205
+ if (validatesRouter || inheritsCcipReceiver(context.content) || inheritsViaCcipReceiver(context.content, context.repoSignals))
82
206
  return [];
83
207
  return [
84
208
  makeFinding({
@@ -106,6 +230,8 @@ export const ccipRules = [
106
230
  scan(context) {
107
231
  if (!hasCcipReceiver(context.content))
108
232
  return [];
233
+ if (isCcipBaseReceiver(context.content))
234
+ return [];
109
235
  const body = ccipBody(context.content) || context.content;
110
236
  if (!/abi\.decode\s*\(\s*message\.data/.test(body))
111
237
  return [];
@@ -138,6 +264,8 @@ export const ccipRules = [
138
264
  scan(context) {
139
265
  if (!hasCcipReceiver(context.content))
140
266
  return [];
267
+ if (isCcipBaseReceiver(context.content))
268
+ return [];
141
269
  const body = ccipBody(context.content);
142
270
  if (!body)
143
271
  return [];
@@ -160,4 +288,178 @@ export const ccipRules = [
160
288
  ];
161
289
  },
162
290
  },
291
+ {
292
+ metadata: {
293
+ ruleId: "CL-CCIP-006",
294
+ product: "ccip",
295
+ severity: "medium",
296
+ title: "Potential CCIP token amount indexing without length check",
297
+ description: "Receiver appears to index destTokenAmounts without an obvious length check.",
298
+ },
299
+ scan(context) {
300
+ if (!hasCcipReceiver(context.content))
301
+ return [];
302
+ if (isCcipBaseReceiver(context.content))
303
+ return [];
304
+ const body = ccipBody(context.content) || context.content;
305
+ const indexesTokenAmounts = /(?:message\.|any2EvmMessage\.)?destTokenAmounts\s*\[\s*\d+\s*\]/.test(body) ||
306
+ /(?:tokenAmounts|destTokenAmounts)\s*\[\s*\d+\s*\]/.test(body);
307
+ if (!indexesTokenAmounts)
308
+ return [];
309
+ const checksLength = /(?:destTokenAmounts|tokenAmounts)\.length/.test(body) ||
310
+ /(InvalidTokenAmounts|InvalidTokenAmount|NoToken|ExpectedToken|UnexpectedToken)/i.test(body);
311
+ if (checksLength)
312
+ return [];
313
+ return [
314
+ makeFinding({
315
+ context,
316
+ ruleId: this.metadata.ruleId,
317
+ severity: this.metadata.severity,
318
+ confidence: "medium",
319
+ line: firstLineMatching(context.lines, /(?:destTokenAmounts|tokenAmounts)\s*\[/),
320
+ title: this.metadata.title,
321
+ description: "Potential issue: malformed or unexpected CCIP token payloads may revert before business logic can handle them.",
322
+ risk: "Assuming at least one token amount can cause message processing failure or inconsistent recovery behavior.",
323
+ recommendation: "Validate destTokenAmounts.length and expected token addresses/amounts before indexing token amounts.",
324
+ }),
325
+ ];
326
+ },
327
+ },
328
+ {
329
+ metadata: {
330
+ ruleId: "CL-CCIP-007",
331
+ product: "ccip",
332
+ severity: "medium",
333
+ title: "Potential CCIP receiver without messageId idempotency tracking",
334
+ description: "Mutating receiver logic does not show obvious messageId replay or idempotency tracking.",
335
+ },
336
+ scan(context) {
337
+ if (!hasCcipReceiver(context.content))
338
+ return [];
339
+ if (isCcipBaseReceiver(context.content))
340
+ return [];
341
+ const body = ccipBody(context.content);
342
+ if (!body)
343
+ return [];
344
+ if (!hasBusinessMutation(body))
345
+ return [];
346
+ if (hasMessageIdTracking(context.content, body))
347
+ return [];
348
+ return [
349
+ makeFinding({
350
+ context,
351
+ ruleId: this.metadata.ruleId,
352
+ severity: this.metadata.severity,
353
+ confidence: "low",
354
+ line: firstLineMatching(context.lines, /(_ccipReceive|ccipReceive)/),
355
+ title: this.metadata.title,
356
+ description: "Potential issue: repeated or retried CCIP messages may execute mutating business logic more than once.",
357
+ risk: "Without idempotency tracking, replay-like operational scenarios or manual execution flows can duplicate state changes.",
358
+ recommendation: "Track processed messageId values or design receiver logic to be explicitly idempotent before mutating state.",
359
+ }),
360
+ ];
361
+ },
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
+ },
163
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,7 +1,7 @@
1
1
  {
2
2
  "name": "chainlink-audit",
3
- "version": "0.1.0",
4
- "description": "Security CLI for detecting potential Chainlink integration risks in Solidity repositories.",
3
+ "version": "0.3.1",
4
+ "description": "Security review CLI for flagging unverified Chainlink integration risk leads in Solidity repositories.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "keywords": [