@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/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +20 -0
- package/dist/api.js.map +1 -0
- package/dist/formatters.d.ts +4 -0
- package/dist/formatters.d.ts.map +1 -0
- package/dist/formatters.js +134 -0
- package/dist/formatters.js.map +1 -0
- package/dist/index.js +385 -508
- package/dist/index.js.map +1 -1
- package/dist/reports.d.ts +6 -0
- package/dist/reports.d.ts.map +1 -0
- package/dist/reports.js +187 -0
- package/dist/reports.js.map +1 -0
- package/dist/ui.d.ts +51 -4
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +563 -47
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/package.json +11 -10
package/dist/index.js
CHANGED
|
@@ -1,19 +1,54 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
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(
|
|
13
|
+
.version(CLI_VERSION)
|
|
14
14
|
.option("--no-banner", "Disable the startup banner");
|
|
15
15
|
import { resolveParserBinary } from "./loader.js";
|
|
16
|
-
import {
|
|
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
|
-
|
|
69
|
+
printStartup("Workspace Analysis", !opts.banner);
|
|
35
70
|
printInitSequence([
|
|
36
|
-
"Rust AST
|
|
37
|
-
"Anchor Workspace
|
|
38
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
192
|
+
else if (severityFails) {
|
|
193
|
+
console.log(colors.critical(`✖ EPIC Guard Blocked: Upgrade severity is ${report.severity} (threshold: ${epicConfig.failOnSeverity}).`));
|
|
125
194
|
}
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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:
|
|
349
|
-
"
|
|
409
|
+
"Project": projName,
|
|
410
|
+
"Rules Loaded": Object.keys(ruleKnowledge).length,
|
|
350
411
|
"Configuration": options.config || "epic.toml"
|
|
351
412
|
});
|
|
352
|
-
|
|
353
|
-
"
|
|
354
|
-
"
|
|
355
|
-
"
|
|
356
|
-
"
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
"
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
console.log(
|
|
384
|
-
console.log(`${
|
|
385
|
-
console.log(`${
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
console.log(
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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(
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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);
|