@solana-epic/cli 0.1.0-beta.2 → 0.2.0-beta.0

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/dist/index.js CHANGED
@@ -1,19 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { compareAnchorPrograms, formatHumanReport } from "@solana-epic/diff-engine";
3
+ import { analyzePrograms, compareAccountLayouts, createUpgradeIntelligence, simulateCompatibility } from "@solana-epic/diff-engine";
4
4
  import { config } from "@solana-epic/parser";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawnSync, execSync } from "node:child_process";
6
6
  import path from "node:path";
7
- import { fileURLToPath } from "node:url";
8
7
  import fs from "node:fs";
9
8
  const program = new Command();
9
+ import { CLI_VERSION } from "./version.js";
10
10
  program
11
11
  .name("epic")
12
12
  .description("EPIC CLI for Solana Upgrade Intelligence (powered by parser-v2 Rust AST engine).")
13
- .version("0.4.0")
13
+ .version(CLI_VERSION)
14
14
  .option("--no-banner", "Disable the startup banner");
15
15
  import { resolveParserBinary } from "./loader.js";
16
- import { printBanner, printInitSequence, printSection, printRuleFinding, colors, printFinalSignature, DIVIDER } from "./ui.js";
16
+ import { printStartup, getBannerString, printInitSequence, printSection, printRuleFinding, colors, printEndSummary, printUpgradeReport, printCompatibilityReport, severityBadge, scoreBar, bandForScore, DIVIDER, ruleKnowledge } from "./ui.js";
17
+ import { generateSarif, generateMarkdown } from "./reports.js";
18
+ // Count Rust source files under a path (real, not fabricated metrics).
19
+ function countRustFiles(target) {
20
+ let count = 0;
21
+ const skip = new Set([".git", "target", "node_modules", "vendor"]);
22
+ const walk = (dir) => {
23
+ let entries;
24
+ try {
25
+ entries = fs.readdirSync(dir, { withFileTypes: true });
26
+ }
27
+ catch {
28
+ return;
29
+ }
30
+ for (const entry of entries) {
31
+ if (entry.isDirectory()) {
32
+ if (skip.has(entry.name))
33
+ continue;
34
+ walk(path.join(dir, entry.name));
35
+ }
36
+ else if (entry.isFile() && entry.name.endsWith(".rs")) {
37
+ count++;
38
+ }
39
+ }
40
+ };
41
+ try {
42
+ const stat = fs.statSync(target);
43
+ if (stat.isFile())
44
+ return target.endsWith(".rs") ? 1 : 0;
45
+ }
46
+ catch {
47
+ return 0;
48
+ }
49
+ walk(target);
50
+ return count;
51
+ }
17
52
  function findRustBinary() {
18
53
  try {
19
54
  return resolveParserBinary();
@@ -31,12 +66,13 @@ program
31
66
  const startTime = Date.now();
32
67
  try {
33
68
  const opts = program.opts();
34
- printBanner(!opts.banner);
69
+ printStartup("Workspace Analysis", !opts.banner);
35
70
  printInitSequence([
36
- "Rust AST Engine Ready",
37
- "Anchor Workspace Detected",
38
- "Analysis Engine Ready"
71
+ "Rust AST Loaded",
72
+ "Parsing Anchor Workspace",
73
+ "Building Call Graph"
39
74
  ]);
75
+ console.log("");
40
76
  const binary = findRustBinary();
41
77
  const resolvedPath = path.resolve(targetPath);
42
78
  const result = spawnSync(binary, [resolvedPath], { encoding: "utf-8" });
@@ -62,22 +98,19 @@ program
62
98
  console.log(colors.info("No state accounts (#[account] structures) found.\n"));
63
99
  }
64
100
  else {
65
- console.log("STATE ACCOUNTS:");
101
+ console.log(colors.bold("STATE ACCOUNTS"));
102
+ console.log("");
66
103
  for (const account of report.accounts) {
67
104
  const layoutType = account.dynamic ? "Dynamic" : "Static";
68
105
  const prefix = account.dynamic ? colors.warning("⚠️") : "├──";
69
- console.log(`${prefix} ${account.account} (${account.size} bytes) [${account.namespace}] [${layoutType}]`);
106
+ console.log(`${prefix} ${colors.white(account.account)} (${account.size} bytes) [${colors.dim(account.namespace)}] [${colors.cyan(layoutType)}]`);
70
107
  if (account.dynamic) {
71
- console.log(` └─ Warning: Dynamic size detected. Static layout realloc checks may be inaccurate.`);
108
+ console.log(` └─ ${colors.warning("Warning:")} Dynamic size detected. Static layout realloc checks may be inaccurate.`);
72
109
  }
73
110
  }
74
111
  console.log("");
75
112
  }
76
- console.log(colors.graphite(DIVIDER));
77
- console.log(colors.success("Completed successfully."));
78
- console.log(`${"State Accounts".padEnd(19)} ${report.accounts ? report.accounts.length : 0}`);
79
- console.log(`Completed in ${Date.now() - startTime} ms\n`);
80
- printFinalSignature();
113
+ printEndSummary(path.basename(resolvedPath) || ".", 0, 0, 0, Date.now() - startTime);
81
114
  }
82
115
  catch (error) {
83
116
  const message = error instanceof Error ? error.message : String(error);
@@ -89,18 +122,23 @@ program
89
122
  .command("check")
90
123
  .description("Compare two Solana program workspace versions and report upgrade readiness.")
91
124
  .option("-c, --config <path>", "Path to epic.toml configuration file")
125
+ .option("-f, --format <format>", "Output format: text, json", "text")
92
126
  .argument("<old_path>", "Path to the old program version source directory")
93
127
  .argument("<new_path>", "Path to the new program version source directory")
94
128
  .action(async (oldPath, newPath, options) => {
95
129
  const startTime = Date.now();
130
+ const isJson = options.format === "json";
96
131
  try {
97
132
  const opts = program.opts();
98
- printBanner(!opts.banner);
99
- printInitSequence([
100
- "Rust AST Engine Ready",
101
- "Anchor Workspace Detected",
102
- "Upgrade Graph Built"
103
- ]);
133
+ const startupShown = isJson ? false : printStartup("Upgrade Intelligence", !opts.banner);
134
+ if (!isJson) {
135
+ printInitSequence([
136
+ "Rust AST Loaded",
137
+ "Parsing Anchor Workspace",
138
+ "Building Call Graph"
139
+ ]);
140
+ console.log("");
141
+ }
104
142
  const resolvedOldPath = path.resolve(oldPath);
105
143
  const resolvedNewPath = path.resolve(newPath);
106
144
  let epicConfig;
@@ -111,26 +149,59 @@ program
111
149
  console.error(`epic.toml validation error: ${err.message}`);
112
150
  process.exit(1);
113
151
  }
114
- const report = await compareAnchorPrograms(resolvedOldPath, resolvedNewPath, epicConfig);
115
- console.log(formatHumanReport(report));
152
+ // Parse both versions once, then run BOTH the compatibility simulator
153
+ // (state survival) and the existing layout-diff findings off the same AST.
154
+ const { oldProgram, newProgram } = await analyzePrograms(resolvedOldPath, resolvedNewPath, epicConfig);
155
+ const compatibility = simulateCompatibility(oldProgram, newProgram, epicConfig);
156
+ const report = compareAccountLayouts(oldProgram, newProgram, epicConfig);
157
+ const intelligence = createUpgradeIntelligence(report);
158
+ const programName = compatibility.accounts[0]?.account || report.findings[0]?.account || path.basename(resolvedNewPath);
159
+ if (isJson) {
160
+ console.log(JSON.stringify({
161
+ program: programName,
162
+ compatibility,
163
+ findings: report.findings,
164
+ severity: report.severity
165
+ }, null, 2));
166
+ process.exit(compatibility.overall === "Blocked" ? 1 : 0);
167
+ }
168
+ // Lead with the compatibility verdict (the product), then keep the
169
+ // detailed layout findings below it as supporting evidence.
170
+ printCompatibilityReport(compatibility, { program: programName }, { skipTitle: startupShown });
171
+ if (report.findings.length) {
172
+ console.log(colors.gray(DIVIDER));
173
+ console.log(colors.white(colors.bold("LAYOUT FINDINGS (DETAIL)")));
174
+ console.log(colors.gray(DIVIDER));
175
+ console.log("");
176
+ printUpgradeReport(report, intelligence, { program: programName }, { skipTitle: true });
177
+ console.log("");
178
+ }
179
+ // Exit code. BLOCKED always fails CI (state corruption is non-negotiable),
180
+ // overriding fail_on_severity. Other outcomes respect the configured threshold.
116
181
  const severityOrder = ["SAFE", "MINOR", "WARNING", "MAJOR", "CRITICAL"];
117
182
  const thresholdIndex = severityOrder.indexOf(epicConfig.failOnSeverity);
118
183
  const reportSeverityIndex = severityOrder.indexOf(report.severity);
119
- console.log(colors.graphite(DIVIDER));
120
- if (thresholdIndex !== -1 && reportSeverityIndex !== -1 && reportSeverityIndex >= thresholdIndex) {
121
- console.log(colors.critical(`❌ EPIC Guard Blocked: Upgrade severity is ${report.severity} (threshold: ${epicConfig.failOnSeverity}).`));
184
+ const severityFails = thresholdIndex !== -1 && reportSeverityIndex !== -1 && reportSeverityIndex >= thresholdIndex;
185
+ const blocked = compatibility.overall === "Blocked";
186
+ const fails = blocked || severityFails;
187
+ console.log(colors.gray(DIVIDER));
188
+ console.log("");
189
+ if (blocked) {
190
+ console.log(colors.critical(`✖ EPIC Guard Blocked: deploying would corrupt existing on-chain accounts.`));
122
191
  }
123
- else {
124
- console.log(colors.success(`✅ EPIC Guard Approved Upgrade.`));
192
+ else if (severityFails) {
193
+ console.log(colors.critical(`✖ EPIC Guard Blocked: Upgrade severity is ${report.severity} (threshold: ${epicConfig.failOnSeverity}).`));
125
194
  }
126
- console.log(`Completed in ${Date.now() - startTime} ms\n`);
127
- printFinalSignature();
128
- if (thresholdIndex !== -1 && reportSeverityIndex !== -1 && reportSeverityIndex >= thresholdIndex) {
129
- process.exit(1);
195
+ else if (compatibility.overall === "Migration-Required") {
196
+ console.log(colors.warning(`▲ EPIC Guard: Upgrade is safe only after the migration above is performed.`));
130
197
  }
131
198
  else {
132
- process.exit(0);
199
+ console.log(colors.success(`✓ EPIC Guard Approved Upgrade.`));
133
200
  }
201
+ console.log("");
202
+ console.log(colors.dim(`Time: ${(Date.now() - startTime) / 1000} s`));
203
+ console.log("");
204
+ process.exit(fails ? 1 : 0);
134
205
  }
135
206
  catch (error) {
136
207
  const message = error instanceof Error ? error.message : String(error);
@@ -150,251 +221,273 @@ function getSeverityLevel(sev) {
150
221
  return 3;
151
222
  return 3;
152
223
  }
153
- function generateSarif(findings) {
154
- const rulesMap = new Map();
155
- rulesMap.set("EPIC-SEC-001", {
156
- id: "EPIC-SEC-001",
157
- shortDescription: {
158
- text: "Owner Validation"
159
- },
160
- fullDescription: {
161
- text: "Unchecked mutable account write without dominating owner validation."
162
- },
163
- helpUri: "https://github.com/akxh5/Solana-EPIC/blob/main/docs/rules/EPIC-SEC-001.md",
164
- properties: {
165
- category: "Security",
166
- precision: "high"
167
- }
168
- });
169
- rulesMap.set("EPIC-SEC-002", {
170
- id: "EPIC-SEC-002",
171
- shortDescription: {
172
- text: "Missing Signer Validation"
173
- },
174
- fullDescription: {
175
- text: "Unchecked mutable write or administrative mutation without dominating signer validation."
176
- },
177
- helpUri: "https://github.com/akxh5/Solana-EPIC/blob/main/docs/rules/EPIC-SEC-002.md",
178
- properties: {
179
- category: "Security",
180
- precision: "high"
181
- }
182
- });
183
- rulesMap.set("EPIC-SEC-003", {
184
- id: "EPIC-SEC-003",
185
- shortDescription: {
186
- text: "Missing Post-CPI Account Reload"
187
- },
188
- fullDescription: {
189
- text: "Account state accessed after a CPI mutation without reload, which may read or write stale cache state."
190
- },
191
- helpUri: "https://github.com/akxh5/Solana-EPIC/blob/main/docs/rules/EPIC-SEC-003.md",
192
- properties: {
193
- category: "Security",
194
- precision: "high"
195
- }
196
- });
197
- rulesMap.set("EPIC-SEC-004", {
198
- id: "EPIC-SEC-004",
199
- shortDescription: {
200
- text: "PDA Cryptographic Seed Collision Risk"
201
- },
202
- fullDescription: {
203
- text: "Adjacent variable-length seeds without separation delimiters create boundary ambiguities that permit PDA hijacking."
204
- },
205
- helpUri: "https://github.com/akxh5/Solana-EPIC/blob/main/docs/rules/EPIC-SEC-004.md",
206
- properties: {
207
- category: "Security",
208
- precision: "high"
209
- }
210
- });
211
- rulesMap.set("EPIC-SEC-005", {
212
- id: "EPIC-SEC-005",
213
- shortDescription: {
214
- text: "Arbitrary CPI Target Program Spoofing"
215
- },
216
- fullDescription: {
217
- text: "Invoking CPI on an external program without verifying that the target program matches a trusted program ID."
218
- },
219
- helpUri: "https://github.com/akxh5/Solana-EPIC/blob/main/docs/rules/EPIC-SEC-005.md",
220
- properties: {
221
- category: "Security",
222
- precision: "high"
223
- }
224
- });
225
- const results = findings.map((f) => {
226
- let level = "warning";
227
- const sev = f.severity.toLowerCase();
228
- if (sev === "critical" || sev === "high") {
229
- level = "error";
230
- }
231
- else if (sev === "medium") {
232
- level = "warning";
224
+ program
225
+ .command("doctor")
226
+ .description("Run diagnostics on the environment")
227
+ .action(() => {
228
+ const opts = program.opts();
229
+ const startupShown = printStartup("Environment Diagnostics", !opts.banner);
230
+ if (!startupShown) {
231
+ console.log(colors.gray(DIVIDER));
232
+ console.log(colors.bold(colors.white("Environment Diagnostics")));
233
+ console.log(colors.gray(DIVIDER));
234
+ console.log("");
235
+ }
236
+ let hasErrors = false;
237
+ const checkVersion = (cmd, name, required) => {
238
+ try {
239
+ const out = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
240
+ const shortVer = out.split("\n")[0].substring(0, 50);
241
+ console.log(`${colors.success("✓")} ${name.padEnd(10)}: ${colors.dim(shortVer)}`);
242
+ }
243
+ catch (e) {
244
+ if (required) {
245
+ console.log(`${colors.critical("✖")} ${name.padEnd(10)}: ${colors.critical("Not found (Required)")}`);
246
+ hasErrors = true;
247
+ }
248
+ else {
249
+ console.log(`${colors.warning("⚠️")} ${name.padEnd(10)}: ${colors.dim("Not found (Optional)")}`);
250
+ }
233
251
  }
234
- else if (sev === "warning" || sev === "low") {
235
- level = "note";
252
+ };
253
+ checkVersion("rustc --version", "Rust", true);
254
+ checkVersion("cargo --version", "Cargo", true);
255
+ checkVersion("node --version", "Node.js", true);
256
+ checkVersion("solana --version", "Solana", false);
257
+ checkVersion("anchor --version", "Anchor", false);
258
+ console.log("");
259
+ try {
260
+ const binaryPath = resolveParserBinary();
261
+ fs.accessSync(binaryPath, fs.constants.X_OK);
262
+ console.log(`${colors.success("✓")} parser-v2 : ${colors.dim(binaryPath)}`);
263
+ }
264
+ catch (e) {
265
+ console.log(`${colors.critical("✖")} parser-v2 : ${colors.critical("Binary not found or not executable")}`);
266
+ hasErrors = true;
267
+ }
268
+ const hasAnchor = fs.existsSync(path.join(process.cwd(), "Anchor.toml"));
269
+ const hasCargo = fs.existsSync(path.join(process.cwd(), "Cargo.toml"));
270
+ if (hasAnchor) {
271
+ console.log(`${colors.success("✓")} Workspace : ${colors.dim("Anchor Workspace detected")}`);
272
+ }
273
+ else if (hasCargo) {
274
+ console.log(`${colors.success("✓")} Workspace : ${colors.dim("Cargo Workspace detected")}`);
275
+ }
276
+ else {
277
+ console.log(`${colors.warning("⚠️")} Workspace : ${colors.warning("No Anchor.toml or Cargo.toml found in current directory")}`);
278
+ }
279
+ const hasEpicToml = fs.existsSync(path.join(process.cwd(), "epic.toml"));
280
+ if (hasEpicToml) {
281
+ try {
282
+ config.loadEpicConfig();
283
+ console.log(`${colors.success("✓")} Config : ${colors.dim("Loaded epic.toml successfully")}`);
236
284
  }
237
- const relFile = path.relative(process.cwd(), f.location.file);
238
- return {
239
- ruleId: f.rule_id,
240
- ruleIndex: 0,
241
- level,
242
- message: {
243
- text: f.message
244
- },
245
- locations: [
246
- {
247
- physicalLocation: {
248
- artifactLocation: {
249
- uri: relFile,
250
- uriBaseId: "%SRCROOT%"
251
- },
252
- region: {
253
- startLine: f.location.line,
254
- startColumn: f.location.column || 1
255
- }
256
- }
257
- }
258
- ]
259
- };
260
- });
261
- const rules = Array.from(rulesMap.values());
262
- for (const f of findings) {
263
- if (!rulesMap.has(f.rule_id)) {
264
- const genericRule = {
265
- id: f.rule_id,
266
- shortDescription: {
267
- text: f.rule_id
268
- },
269
- fullDescription: {
270
- text: f.message
271
- }
272
- };
273
- rulesMap.set(f.rule_id, genericRule);
274
- rules.push(genericRule);
285
+ catch (e) {
286
+ console.log(`${colors.critical("✖")} Config : ${colors.critical("Invalid epic.toml: " + e.message)}`);
287
+ hasErrors = true;
275
288
  }
276
289
  }
277
- return {
278
- $schema: "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
279
- version: "2.1.0",
280
- runs: [
281
- {
282
- tool: {
283
- driver: {
284
- name: "EPIC",
285
- informationUri: "https://github.com/akxh5/Solana-EPIC",
286
- version: "0.4.0",
287
- rules
288
- }
289
- },
290
- results
291
- }
292
- ]
293
- };
294
- }
290
+ else {
291
+ console.log(`${colors.success("✓")} Config : ${colors.dim("Using default configuration")}`);
292
+ }
293
+ const ruleCount = Object.keys(ruleKnowledge).length;
294
+ console.log(`${colors.success("✓")} Rules : ${colors.dim(`${ruleCount} safety rules loaded`)}`);
295
+ console.log("");
296
+ if (hasErrors) {
297
+ console.log(colors.critical("Diagnostics failed. EPIC may not function correctly."));
298
+ process.exit(1);
299
+ }
300
+ else {
301
+ console.log(colors.success("Ready for Audit"));
302
+ process.exit(0);
303
+ }
304
+ });
305
+ program
306
+ .command("explain <rule_id>")
307
+ .description("Explain a security rule in detail")
308
+ .action((ruleId) => {
309
+ const opts = program.opts();
310
+ printStartup("Rule Explanation", !opts.banner);
311
+ const knowledge = ruleKnowledge[ruleId];
312
+ if (!knowledge) {
313
+ console.log(colors.critical(`Rule ${ruleId} not found.`));
314
+ console.log(colors.dim("Run 'epic rules' to list all available rules."));
315
+ process.exit(1);
316
+ }
317
+ const band = bandForScore(knowledge.score);
318
+ console.log(colors.gray(DIVIDER));
319
+ console.log("");
320
+ console.log(`${severityBadge(band)} ${colors.white(ruleId)} ${colors.gray("·")} ${colors.white(knowledge.desc)}`);
321
+ console.log("");
322
+ console.log(`${colors.dim("Risk Score")} ${scoreBar(knowledge.score)} ${colors.white(`${knowledge.score} / 100`)}`);
323
+ console.log("");
324
+ console.log(colors.gray(DIVIDER));
325
+ console.log("");
326
+ console.log(colors.bold(colors.white("WHY IT'S DANGEROUS")));
327
+ console.log(colors.dim(knowledge.why));
328
+ console.log("");
329
+ console.log(colors.bold(colors.white("WHAT BREAKS")));
330
+ console.log(colors.warning(knowledge.impact));
331
+ console.log("");
332
+ console.log(colors.bold(colors.white("HOW TO FIX")));
333
+ console.log(colors.green(knowledge.fix));
334
+ console.log("");
335
+ console.log(colors.bold(colors.white("HISTORICAL EXPLOITS")));
336
+ console.log(colors.dim(knowledge.historical));
337
+ console.log("");
338
+ console.log(colors.gray(DIVIDER));
339
+ console.log("");
340
+ });
295
341
  program
296
- .command("audit")
342
+ .command("audit [path]")
297
343
  .description("Run security rules against the repository.")
298
- .argument("[path]", "Path to search and audit", ".")
299
- .option("-f, --format <format>", "Output format: text, json, sarif", "text")
344
+ .option("-f, --format <format>", "Output format: text, json, sarif, markdown", "text")
300
345
  .option("-s, --strict", "Exit code 1 if findings severity >= threshold", false)
301
346
  .option("-c, --config <path>", "Path to epic.toml configuration file")
347
+ .option("-v, --verbose", "Show all findings without summarizing")
348
+ .option("--include-tests", "Include test and fixture directories")
349
+ .option("--include-fixtures", "Include fixture directories")
350
+ .option("--all", "Do not ignore any directories")
302
351
  .option("--ignore <rules>", "Rule IDs to ignore (comma-separated)", (val) => val.split(",").map(r => r.trim()))
303
- .action(async (targetPath, options) => {
352
+ .action(async (targetPath = ".", options) => {
304
353
  const startTime = Date.now();
305
354
  try {
306
355
  const opts = program.opts();
307
- if (options.format === "text") {
308
- printBanner(!opts.banner);
309
- printInitSequence([
310
- "Rust AST Engine Ready",
311
- "5 Security Rules Loaded",
312
- "Anchor Workspace Detected"
313
- ]);
314
- }
356
+ if (options.format === "text")
357
+ printStartup("Security Audit", !opts.banner);
315
358
  const binary = findRustBinary();
316
359
  const resolvedPath = path.resolve(targetPath);
360
+ const auditStart = Date.now();
317
361
  const result = spawnSync(binary, ["audit", resolvedPath], { encoding: "utf-8" });
318
- if (result.error) {
319
- throw new Error(`Failed to execute parser-v2 binary: ${result.error.message}`);
320
- }
321
- if (result.status !== 0) {
322
- console.error(result.stderr || `Execution failed with status code ${result.status}`);
323
- process.exit(result.status ?? 1);
324
- }
362
+ const ruleEngineMs = Date.now() - auditStart;
363
+ if (result.status !== 0)
364
+ throw new Error("Parser failed");
325
365
  const findings = JSON.parse(result.stdout.trim());
326
- let epicConfig;
327
- try {
328
- epicConfig = config.loadEpicConfig(options.config);
329
- }
330
- catch (err) {
331
- epicConfig = config.getDefaultConfig();
332
- }
333
- const ignoredRules = new Set();
334
- if (epicConfig.ignore) {
335
- for (const r of epicConfig.ignore) {
336
- ignoredRules.add(r.trim());
366
+ let epicConfig = config.loadEpicConfig(options.config);
367
+ const ignoredRules = new Set([...(epicConfig.ignore || []), ...(options.ignore || [])]);
368
+ const builtinIgnore = [".git", "target", "node_modules", "vendor"];
369
+ if (!options.all) {
370
+ if (!options.includeTests)
371
+ builtinIgnore.push("test", "tests", "test-repos");
372
+ if (!options.includeFixtures)
373
+ builtinIgnore.push("fixtures", "demo", "examples");
374
+ }
375
+ const activeFindings = findings.filter((f) => {
376
+ if (ignoredRules.has(f.rule_id))
377
+ return false;
378
+ const relPath = path.relative(process.cwd(), f.location.file);
379
+ return !builtinIgnore.some(p => relPath.includes(`/${p}/`) || relPath.startsWith(`${p}/`) || relPath === p);
380
+ });
381
+ if (options.format === "text") {
382
+ // Real repository structure from the parser's analyze pass + filesystem.
383
+ const fileCount = countRustFiles(resolvedPath);
384
+ let structsFound = 0, enumsFound = 0, accountsFound = 0;
385
+ let analyzeMs = 0;
386
+ try {
387
+ const analyzeStart = Date.now();
388
+ const analyzeResult = spawnSync(binary, [resolvedPath], { encoding: "utf-8" });
389
+ analyzeMs = Date.now() - analyzeStart;
390
+ if (analyzeResult.status === 0 && analyzeResult.stdout) {
391
+ const overview = JSON.parse(analyzeResult.stdout.trim());
392
+ structsFound = overview.structs_found ?? 0;
393
+ enumsFound = overview.enums_found ?? 0;
394
+ accountsFound = Array.isArray(overview.accounts) ? overview.accounts.length : 0;
395
+ }
337
396
  }
338
- }
339
- if (options.ignore) {
340
- const cliIgnores = Array.isArray(options.ignore) ? options.ignore : [options.ignore];
341
- for (const r of cliIgnores) {
342
- ignoredRules.add(r.trim());
397
+ catch {
398
+ // Analyze enrichment is best-effort; the audit result is authoritative.
343
399
  }
344
- }
345
- const activeFindings = findings.filter((f) => !ignoredRules.has(f.rule_id));
346
- if (options.format === "text") {
400
+ const totalTimeMs = Date.now() - startTime;
401
+ printInitSequence([
402
+ `Scanning Files\n${colors.cyan("█████████████████████████")} ${colors.dim(`${fileCount} / ${fileCount}`)}`,
403
+ `Building AST\n${colors.cyan("█████████████████████████")} ${colors.dim("100%")}`,
404
+ `Running Security Rules\n${colors.cyan("█████████████████████████")} ${colors.dim("100%")}`
405
+ ]);
406
+ console.log("");
407
+ const projName = path.basename(resolvedPath) || ".";
347
408
  printSection("Workspace", {
348
- Project: path.basename(resolvedPath) || ".",
349
- "Security Rules": 5,
409
+ "Project": projName,
410
+ "Rules Loaded": Object.keys(ruleKnowledge).length,
350
411
  "Configuration": options.config || "epic.toml"
351
412
  });
352
- printInitSequence([
353
- "Workspace Scanned",
354
- "Account Layout Analysis Complete",
355
- "Upgrade Safety Verified",
356
- "Security Rules Executed"
357
- ]);
413
+ printSection("Repository Overview", {
414
+ "Rust Files": fileCount,
415
+ "Structs": structsFound,
416
+ "Enums": enumsFound,
417
+ "State Accounts": accountsFound
418
+ });
358
419
  const criticalCount = activeFindings.filter((f) => getSeverityLevel(f.severity) === 3).length;
359
420
  const warningCount = activeFindings.filter((f) => getSeverityLevel(f.severity) < 3).length;
360
- printSection("Scan Summary", {
361
- "Rules Executed": 5,
362
- "Findings": activeFindings.length
421
+ const rulesTriggered = new Set(activeFindings.map((f) => f.rule_id)).size;
422
+ printSection("Execution Metrics", {
423
+ "Indexed Files": fileCount,
424
+ "Parse + AST": `${Math.max(1, analyzeMs)} ms`,
425
+ "Rule Engine": `${Math.max(1, ruleEngineMs)} ms`,
426
+ "Total": `${(totalTimeMs / 1000).toFixed(2)} s`
363
427
  });
364
- for (const finding of activeFindings) {
365
- const relPath = path.relative(process.cwd(), finding.location.file);
366
- printRuleFinding({
367
- severity: finding.severity,
368
- rule_id: finding.rule_id,
369
- rule_name: finding.rule_name,
370
- location: {
371
- file: relPath,
372
- line: finding.location.line,
373
- },
374
- message: finding.message
428
+ printSection("Security Summary", {
429
+ "Critical": criticalCount,
430
+ "High": warningCount,
431
+ "Rules Triggered": rulesTriggered
432
+ });
433
+ if (options.verbose) {
434
+ activeFindings.forEach((f) => printRuleFinding(f));
435
+ }
436
+ else {
437
+ const grouped = {};
438
+ activeFindings.forEach((f) => {
439
+ if (!grouped[f.rule_id])
440
+ grouped[f.rule_id] = { occurrences: 0, files: new Set(), name: f.rule_name || f.rule_id };
441
+ grouped[f.rule_id].occurrences++;
442
+ grouped[f.rule_id].files.add(f.location.file);
375
443
  });
444
+ for (const [id, s] of Object.entries(grouped)) {
445
+ console.log(colors.gray(DIVIDER));
446
+ console.log(colors.violet(id));
447
+ console.log(colors.bold(colors.white(s.name)));
448
+ console.log("");
449
+ console.log(colors.dim("Occurrences: ") + colors.white(s.occurrences));
450
+ console.log(colors.dim("Files: ") + colors.white(s.files.size));
451
+ console.log("");
452
+ }
453
+ if (activeFindings.length > 0) {
454
+ console.log(colors.gray(DIVIDER));
455
+ console.log("");
456
+ }
376
457
  }
377
- console.log(colors.graphite(DIVIDER));
378
- if (activeFindings.length === 0) {
379
- console.log(colors.success("No critical vulnerabilities detected."));
458
+ let mostCommonRule = null;
459
+ let highestOccurrences = 0;
460
+ const occurrenceMap = {};
461
+ for (const finding of activeFindings) {
462
+ occurrenceMap[finding.rule_id] = (occurrenceMap[finding.rule_id] || 0) + 1;
463
+ if (occurrenceMap[finding.rule_id] > highestOccurrences) {
464
+ highestOccurrences = occurrenceMap[finding.rule_id];
465
+ mostCommonRule = finding.rule_id;
466
+ }
380
467
  }
381
- else {
382
- console.log(colors.success("Completed successfully."));
383
- console.log(`${"Rules Executed".padEnd(19)} 5`);
384
- console.log(`${"Critical Findings".padEnd(19)} ${criticalCount}`);
385
- console.log(`${"Warnings".padEnd(19)} ${warningCount}`);
468
+ if (mostCommonRule && ruleKnowledge[mostCommonRule]) {
469
+ const knowledge = ruleKnowledge[mostCommonRule];
470
+ console.log(colors.bold(colors.white("Most Common Issue")));
471
+ console.log(colors.dim(`${knowledge.desc}`));
472
+ console.log(colors.cyan(`${highestOccurrences} occurrence${highestOccurrences === 1 ? "" : "s"}`));
473
+ console.log("");
474
+ console.log(colors.dim("Priority"));
475
+ console.log(colors.white(`Resolve this rule first — it accounts for the most findings.`));
476
+ console.log("");
386
477
  }
387
- console.log(`Completed in ${Date.now() - startTime} ms\n`);
388
- printFinalSignature();
478
+ printEndSummary(projName, 5, criticalCount, warningCount, Date.now() - startTime);
389
479
  }
390
480
  else if (options.format === "json") {
391
481
  console.log(JSON.stringify(activeFindings, null, 2));
392
482
  }
393
483
  else if (options.format === "sarif") {
394
- const sarif = generateSarif(activeFindings);
395
- const sarifString = JSON.stringify(sarif, null, 2);
396
- fs.writeFileSync("sarif.json", sarifString, "utf8");
397
- console.log(sarifString);
484
+ console.log(JSON.stringify(generateSarif(activeFindings), null, 2));
485
+ }
486
+ else if (options.format === "markdown") {
487
+ console.log(generateMarkdown(activeFindings, {
488
+ project: path.basename(resolvedPath) || ".",
489
+ scanMs: Date.now() - startTime
490
+ }));
398
491
  }
399
492
  if (options.strict) {
400
493
  const threshold = epicConfig.failOnSeverity || "CRITICAL";
@@ -428,273 +521,57 @@ program
428
521
  .command("rules")
429
522
  .description("List all available security rules.")
430
523
  .action(() => {
431
- console.log("EPIC-SEC-001");
432
- console.log("Owner Validation");
433
- console.log("Critical");
434
- console.log("Implemented\n");
435
- console.log("EPIC-SEC-002");
436
- console.log("Missing Signer Validation");
437
- console.log("Critical");
438
- console.log("Implemented\n");
439
- console.log("EPIC-SEC-003");
440
- console.log("Missing Post-CPI Account Reload");
441
- console.log("Critical");
442
- console.log("Implemented\n");
443
- console.log("EPIC-SEC-004");
444
- console.log("PDA Cryptographic Seed Collision Risk");
445
- console.log("High");
446
- console.log("Implemented\n");
447
- console.log("EPIC-SEC-005");
448
- console.log("Arbitrary CPI Target Program Spoofing");
449
- console.log("Critical");
450
- console.log("Implemented");
451
- });
452
- program
453
- .command("explain")
454
- .description("Explain a security rule in detail.")
455
- .argument("<rule_id>", "Rule ID to explain")
456
- .action(async (ruleId) => {
457
- const normRuleId = ruleId.trim().toUpperCase();
458
- if (normRuleId === "EPIC-SEC-001") {
459
- let content = "";
460
- try {
461
- const docPaths = [
462
- path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../docs/rules/EPIC-SEC-001.md"),
463
- path.resolve(process.cwd(), "docs/rules/EPIC-SEC-001.md")
464
- ];
465
- for (const p of docPaths) {
466
- if (fs.existsSync(p)) {
467
- content = fs.readFileSync(p, "utf8");
468
- break;
469
- }
470
- }
471
- }
472
- catch (err) {
473
- // ignore error
474
- }
475
- if (content) {
476
- console.log(content);
477
- }
478
- else {
479
- console.log(`# EPIC-SEC-001: Owner Validation
480
-
481
- ## Description
482
- Tracks mutable account write operations to ensure they are protected by an ownership check (\`account.owner == program_id\`) that dominates the write path.
483
-
484
- ## Threat Model
485
- In Solana, any account can be passed to an instruction. If a program writes data to a mutable account without verifying that the account is owned by the program itself, an attacker can pass a forged account with malicious data.
486
-
487
- ## Vulnerable Example
488
- \`\`\`rust
489
- pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
490
- let vault = &mut ctx.accounts.vault;
491
- let mut vault_data = vault.try_borrow_mut_data()?;
492
- vault_data[0] = 9;
493
- Ok(())
494
- }
495
- \`\`\`
496
-
497
- ## Safe Example
498
- \`\`\`rust
499
- #[derive(Accounts)]
500
- pub struct Withdraw<'info> {
501
- #[account(mut)]
502
- pub vault: Account<'info, VaultState>,
503
- }
504
- \`\`\`
505
-
506
- ## Historical Exploit References
507
- * Cashio App ($52M, March 2022)`);
508
- }
509
- }
510
- else if (normRuleId === "EPIC-SEC-002") {
511
- let content = "";
512
- try {
513
- const docPaths = [
514
- path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../docs/rules/EPIC-SEC-002.md"),
515
- path.resolve(process.cwd(), "docs/rules/EPIC-SEC-002.md")
516
- ];
517
- for (const p of docPaths) {
518
- if (fs.existsSync(p)) {
519
- content = fs.readFileSync(p, "utf8");
520
- break;
521
- }
522
- }
523
- }
524
- catch (err) {
525
- // ignore error
526
- }
527
- if (content) {
528
- console.log(content);
529
- }
530
- else {
531
- console.log(`# EPIC-SEC-002: Missing Signer Validation
532
-
533
- ## Description
534
- Detects situations where authority-like accounts are capable of mutating state, authorizing actions, or executing privileged flows without proving signer authority.
535
-
536
- ## Threat Model
537
- In Solana, callers supply all account inputs. Because any caller can pass arbitrary public keys, the program must verify that the authority-like account signed the transaction. Failing to perform this signer validation allows an attacker to spoof the authority account.
538
-
539
- ## Vulnerable Example
540
- \`\`\`rust
541
- pub fn update_config(ctx: Context<UpdateConfig>, new_val: u64) -> Result<()> {
542
- ctx.accounts.config.admin_value = new_val;
543
- Ok(())
544
- }
545
- // with authority declared as AccountInfo without Signer constraint
546
- \`\`\`
547
-
548
- ## Safe Example
549
- \`\`\`rust
550
- pub fn update_config(ctx: Context<UpdateConfig>, new_val: u64) -> Result<()> {
551
- require!(ctx.accounts.authority.is_signer, ErrorCode::MissingSignature);
552
- ctx.accounts.config.admin_value = new_val;
553
- Ok(())
554
- }
555
- \`\`\``);
556
- }
524
+ const opts = program.opts();
525
+ const startupShown = printStartup("Security Rules", !opts.banner);
526
+ const rules = [
527
+ ["EPIC-SEC-001", "Owner Validation"],
528
+ ["EPIC-SEC-002", "Missing Signer Validation"],
529
+ ["EPIC-SEC-003", "Missing Post-CPI Account Reload"],
530
+ ["EPIC-SEC-004", "PDA Cryptographic Seed Collision Risk"],
531
+ ["EPIC-SEC-005", "Arbitrary CPI Target Program Spoofing"]
532
+ ];
533
+ if (!startupShown) {
534
+ console.log(colors.gray(DIVIDER));
535
+ console.log(colors.white(colors.bold("EPIC Security Rules")));
536
+ console.log(colors.gray(DIVIDER));
537
+ console.log("");
557
538
  }
558
- else if (normRuleId === "EPIC-SEC-003") {
559
- let content = "";
560
- try {
561
- const docPaths = [
562
- path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../docs/rules/EPIC-SEC-003.md"),
563
- path.resolve(process.cwd(), "docs/rules/EPIC-SEC-003.md")
564
- ];
565
- for (const p of docPaths) {
566
- if (fs.existsSync(p)) {
567
- content = fs.readFileSync(p, "utf8");
568
- break;
569
- }
570
- }
571
- }
572
- catch (err) {
573
- // ignore error
574
- }
575
- if (content) {
576
- console.log(content);
539
+ for (const [id, name] of rules) {
540
+ const kb = ruleKnowledge[id];
541
+ const score = kb ? kb.score : 50;
542
+ const band = bandForScore(score);
543
+ console.log(`${severityBadge(band)} ${colors.white(id)} ${colors.gray("·")} ${colors.white(name)}`);
544
+ if (kb) {
545
+ console.log(` ${scoreBar(score, 16)} ${colors.dim(`${score} / 100`)} ${colors.gray("Implemented")}`);
577
546
  }
578
547
  else {
579
- console.log(`# EPIC-SEC-003: Missing Post-CPI Account Reload
580
-
581
- ## Description
582
- Detects scenarios where an account's state data is read or written after a Cross-Program Invocation (CPI) that potentially mutates on-chain state, without executing an intervening reload.
583
-
584
- ## Threat Model
585
- In Solana, external programs (like token program or pools) mutate accounts during CPI calls. The local execution context maintains a deserialized in-memory cache of accounts. Calling programs must refresh this cache via \`reload()\` before accessing fields again.
586
-
587
- ## Vulnerable Example
588
- \`\`\`rust
589
- token::transfer(cpi_ctx, amount)?;
590
- ctx.accounts.vault.amount -= amount; // Stale layout read and write!
591
- \`\`\`
592
-
593
- ## Safe Example
594
- \`\`\`rust
595
- token::transfer(cpi_ctx, amount)?;
596
- ctx.accounts.vault.reload()?; // Safe: reload matches state
597
- ctx.accounts.vault.amount -= amount;
598
- \`\`\``);
548
+ console.log(` ${colors.gray("Implemented")}`);
599
549
  }
550
+ console.log("");
600
551
  }
601
- else if (normRuleId === "EPIC-SEC-004") {
602
- let content = "";
603
- try {
604
- const docPaths = [
605
- path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../docs/rules/EPIC-SEC-004.md"),
606
- path.resolve(process.cwd(), "docs/rules/EPIC-SEC-004.md")
607
- ];
608
- for (const p of docPaths) {
609
- if (fs.existsSync(p)) {
610
- content = fs.readFileSync(p, "utf8");
611
- break;
612
- }
613
- }
614
- }
615
- catch (err) {
616
- // ignore error
617
- }
618
- if (content) {
619
- console.log(content);
620
- }
621
- else {
622
- console.log(`# EPIC-SEC-004: PDA Cryptographic Seed Collision Risk
623
-
624
- ## Description
625
- Detects scenarios where adjacent variable-length seeds in Program Derived Address (PDA) derivation can merge boundaries, allowing an attacker to generate the same address from two different inputs.
626
-
627
- ## Threat Model
628
- In Solana, PDAs are derived by concatenating the raw seed bytes without adding delimiters. When two variable-length seeds (like strings or dynamic byte arrays) are placed next to each other, boundary bytes can shift between seeds while keeping the concatenated byte stream identical.
629
-
630
- ## Vulnerable Example
631
- \`\`\`rust
632
- Pubkey::find_program_address(
633
- &[
634
- user_name.as_bytes(),
635
- folder_name.as_bytes(),
636
- ],
637
- program_id,
638
- );
639
- \`\`\`
640
-
641
- ## Safe Example
642
- \`\`\`rust
643
- Pubkey::find_program_address(
644
- &[
645
- user_name.as_bytes(),
646
- b"|",
647
- folder_name.as_bytes(),
648
- ],
649
- program_id,
650
- );
651
- \`\`\``);
652
- }
653
- }
654
- else if (normRuleId === "EPIC-SEC-005") {
655
- let content = "";
656
- try {
657
- const docPaths = [
658
- path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../docs/rules/EPIC-SEC-005.md"),
659
- path.resolve(process.cwd(), "docs/rules/EPIC-SEC-005.md")
660
- ];
661
- for (const p of docPaths) {
662
- if (fs.existsSync(p)) {
663
- content = fs.readFileSync(p, "utf8");
664
- break;
665
- }
666
- }
667
- }
668
- catch (err) {
669
- // ignore error
670
- }
671
- if (content) {
672
- console.log(content);
673
- }
674
- else {
675
- console.log(`# EPIC-SEC-005: Arbitrary CPI Target Program Spoofing
676
-
677
- ## Description
678
- Detects scenarios where a program invokes another program via CPI without verifying that the target program matches a trusted program ID.
679
-
680
- ## Threat Model
681
- In Solana, callers supply all input accounts, including the program being invoked. If the program fails to verify that the target program account is trusted, an attacker can pass a custom malicious program and hijack control flow under the PDA authority of the program.
682
-
683
- ## Vulnerable Example
684
- \`\`\`rust
685
- invoke(&ix, &[source, dest, token_program])?; // token_program is not validated!
686
- \`\`\`
552
+ console.log(colors.dim("Run 'epic explain <RULE-ID>' for a full breakdown of any rule."));
553
+ console.log("");
554
+ });
555
+ program.configureHelp({
556
+ formatHelp: (cmd, helper) => {
557
+ const noBannerFlag = !!cmd.opts().noBanner || process.argv.includes("--no-banner");
558
+ const header = getBannerString(noBannerFlag);
559
+ return `${header}
560
+ ${colors.bold("Commands")}
561
+ ${colors.white("audit".padEnd(14))} Run security rules against the repository.
562
+ ${colors.white("doctor".padEnd(14))} Run diagnostics on the environment.
563
+ ${colors.white("explain".padEnd(14))} Explain a security rule in detail.
564
+ ${colors.white("rules".padEnd(14))} List all available security rules.
565
+ ${colors.white("analyze".padEnd(14))} Analyze a Solana program workspace.
566
+ ${colors.white("check".padEnd(14))} Compare two workspace versions.
687
567
 
688
- ## Safe Example
689
- \`\`\`rust
690
- require_keys_eq!(token_program.key(), token::ID);
691
- invoke(&ix, &[source, dest, token_program])?;
692
- \`\`\``);
693
- }
694
- }
695
- else {
696
- console.log(`Rule ${ruleId} not found.`);
697
- process.exit(1);
568
+ ${colors.bold("Flags")}
569
+ ${colors.white("-v, --verbose".padEnd(16))} Show all findings instead of grouping
570
+ ${colors.white("--include-tests".padEnd(16))} Include test directories in scan
571
+ ${colors.white("-f, --format".padEnd(16))} Output format: text, json, sarif, markdown
572
+ ${colors.white("--no-banner".padEnd(16))} Disable the startup banner
573
+ ${colors.white("-h, --help".padEnd(16))} Print help
574
+ `;
698
575
  }
699
576
  });
700
577
  program.parse(process.argv);