@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/ui.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { CLI_VERSION } from "./version.js";
|
|
1
3
|
// Colors
|
|
2
4
|
const isColorsEnabled = () => {
|
|
3
5
|
if (process.env.NO_COLOR)
|
|
@@ -7,62 +9,98 @@ const isColorsEnabled = () => {
|
|
|
7
9
|
return true;
|
|
8
10
|
};
|
|
9
11
|
export const colors = {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
12
|
+
bold: (text) => isColorsEnabled() ? `\x1b[1m${text}\x1b[0m` : text,
|
|
13
|
+
dim: (text) => isColorsEnabled() ? `\x1b[2m${text}\x1b[0m` : text,
|
|
14
|
+
white: (text) => isColorsEnabled() ? `\x1b[1;97m${text}\x1b[0m` : text,
|
|
15
|
+
cyan: (text) => isColorsEnabled() ? `\x1b[36m${text}\x1b[0m` : text,
|
|
16
|
+
gray: (text) => isColorsEnabled() ? `\x1b[90m${text}\x1b[0m` : text,
|
|
17
|
+
success: (text) => isColorsEnabled() ? `\x1b[32m${text}\x1b[0m` : text,
|
|
18
|
+
warning: (text) => isColorsEnabled() ? `\x1b[33m${text}\x1b[0m` : text,
|
|
19
|
+
critical: (text) => isColorsEnabled() ? `\x1b[31m${text}\x1b[0m` : text,
|
|
20
|
+
info: (text) => isColorsEnabled() ? `\x1b[34m${text}\x1b[0m` : text,
|
|
21
|
+
violet: (text) => isColorsEnabled() ? `\x1b[35m${text}\x1b[0m` : text,
|
|
22
|
+
green: (text) => isColorsEnabled() ? `\x1b[32m${text}\x1b[0m` : text,
|
|
23
|
+
// Champagne Gold — EPIC's single brand accent (256-color, soft warm gold).
|
|
24
|
+
gold: (text) => isColorsEnabled() ? `\x1b[38;5;222m${text}\x1b[0m` : text,
|
|
25
|
+
};
|
|
26
|
+
// Inverted "chip" badge — premium severity pills.
|
|
27
|
+
const chip = (text, fg, bg) => isColorsEnabled() ? `\x1b[${fg};${bg}m ${text} \x1b[0m` : `[ ${text} ]`;
|
|
28
|
+
export const DIVIDER = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━";
|
|
29
|
+
const THIN = "──────────────────────────────────────────────────";
|
|
21
30
|
let bannerPrinted = false;
|
|
31
|
+
// The EPIC wordmark — a bold, geometric block font. Rendered as the single
|
|
32
|
+
// brand accent (Champagne Gold) so it reads as a premium product wordmark.
|
|
33
|
+
const EPIC_WORDMARK = [
|
|
34
|
+
"███████╗ ██████╗ ██╗ ██████╗",
|
|
35
|
+
"██╔════╝ ██╔══██╗ ██║ ██╔════╝",
|
|
36
|
+
"█████╗ ██████╔╝ ██║ ██║ ",
|
|
37
|
+
"██╔══╝ ██╔═══╝ ██║ ██║ ",
|
|
38
|
+
"███████╗ ██║ ██║ ╚██████╗",
|
|
39
|
+
"╚══════╝ ╚═╝ ╚═╝ ╚═════╝",
|
|
40
|
+
];
|
|
41
|
+
const EPIC_SUBTITLE = "Upgrade Intelligence for Solana";
|
|
42
|
+
// Single source of truth for when the startup experience is suppressed.
|
|
43
|
+
// Identical conditions to the legacy banner: explicit flag, env var, non-TTY.
|
|
44
|
+
const bannerSuppressed = (noBannerFlag) => {
|
|
45
|
+
if (noBannerFlag || process.env.EPIC_NO_BANNER === "1")
|
|
46
|
+
return true;
|
|
47
|
+
if (!process.stdout.isTTY)
|
|
48
|
+
return true;
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
51
|
+
export const getBannerString = (noBannerFlag = false) => {
|
|
52
|
+
if (bannerSuppressed(noBannerFlag))
|
|
53
|
+
return "";
|
|
54
|
+
const lines = [""];
|
|
55
|
+
for (const row of EPIC_WORDMARK) {
|
|
56
|
+
lines.push(" " + colors.gold(row));
|
|
57
|
+
}
|
|
58
|
+
lines.push("");
|
|
59
|
+
lines.push(" " + colors.white(EPIC_SUBTITLE));
|
|
60
|
+
lines.push(" " + colors.gray(`v${CLI_VERSION}`));
|
|
61
|
+
lines.push("");
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
};
|
|
22
64
|
export const printBanner = (noBannerFlag = false) => {
|
|
23
65
|
if (bannerPrinted)
|
|
24
66
|
return;
|
|
25
|
-
if (noBannerFlag
|
|
67
|
+
if (bannerSuppressed(noBannerFlag)) {
|
|
26
68
|
bannerPrinted = true;
|
|
27
69
|
return;
|
|
28
70
|
}
|
|
29
|
-
|
|
30
|
-
bannerPrinted = true;
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
const logo = `
|
|
34
|
-
███████╗██████╗ ██╗ ██████╗
|
|
35
|
-
██╔════╝██╔══██╗██║██╔════╝
|
|
36
|
-
█████╗ ██████╔╝██║██║
|
|
37
|
-
██╔══╝ ██╔═══╝ ██║██║
|
|
38
|
-
███████╗██║ ██║╚██████╗
|
|
39
|
-
╚══════╝╚═╝ ╚═╝ ╚═════╝`;
|
|
40
|
-
console.log(colors.gold(logo.substring(1)));
|
|
41
|
-
console.log(colors.ivory("EPIC v0.1.0-beta.2"));
|
|
42
|
-
console.log(colors.gray("Know your upgrade before mainnet."));
|
|
43
|
-
console.log(colors.graphite(DIVIDER));
|
|
71
|
+
console.log(getBannerString());
|
|
44
72
|
bannerPrinted = true;
|
|
45
73
|
};
|
|
74
|
+
// The single reusable startup component: brand banner + contextual mode label.
|
|
75
|
+
// Every interactive command consumes this. Returns true when the experience was
|
|
76
|
+
// actually rendered (TTY, not suppressed) so callers can drop a now-redundant
|
|
77
|
+
// in-body title while leaving non-TTY output byte-for-byte unchanged.
|
|
78
|
+
export const printStartup = (mode, noBannerFlag = false) => {
|
|
79
|
+
printBanner(noBannerFlag);
|
|
80
|
+
if (bannerSuppressed(noBannerFlag))
|
|
81
|
+
return false;
|
|
82
|
+
console.log(" " + colors.gold("▌") + " " + colors.white(colors.bold(mode)));
|
|
83
|
+
console.log("");
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
46
86
|
export const printFinalSignature = () => {
|
|
47
|
-
|
|
48
|
-
return;
|
|
49
|
-
console.log(colors.graphite(DIVIDER));
|
|
50
|
-
console.log(colors.gold("EPIC v0.1.0-beta.2"));
|
|
51
|
-
console.log(colors.gray("Know your upgrade before mainnet."));
|
|
87
|
+
// Replaced by end summary
|
|
52
88
|
};
|
|
53
89
|
export const printInitSequence = (steps) => {
|
|
54
90
|
if (!process.stdout.isTTY)
|
|
55
91
|
return;
|
|
56
92
|
for (const step of steps) {
|
|
57
|
-
console.log(`${colors.success("✓")} ${step}`);
|
|
93
|
+
console.log(`${colors.success("✓")} ${colors.dim(step)}`);
|
|
58
94
|
}
|
|
59
|
-
console.log("");
|
|
60
95
|
};
|
|
61
96
|
export const printSection = (title, data) => {
|
|
62
|
-
console.log(colors.
|
|
63
|
-
console.log(colors.
|
|
97
|
+
console.log(colors.gray(DIVIDER));
|
|
98
|
+
console.log(colors.white(colors.bold(title)));
|
|
99
|
+
console.log(colors.gray(DIVIDER));
|
|
100
|
+
console.log("");
|
|
64
101
|
for (const [key, value] of Object.entries(data)) {
|
|
65
|
-
|
|
102
|
+
const dots = colors.gray(".".repeat(Math.max(3, 20 - key.length)));
|
|
103
|
+
console.log(`${colors.white(key)} ${dots} ${colors.cyan(String(value))}`);
|
|
66
104
|
}
|
|
67
105
|
console.log("");
|
|
68
106
|
};
|
|
@@ -82,6 +120,73 @@ export const formatSeverity = (sev) => {
|
|
|
82
120
|
return colors.success(s);
|
|
83
121
|
return s;
|
|
84
122
|
};
|
|
123
|
+
export const bandForScore = (score) => {
|
|
124
|
+
if (score >= 80)
|
|
125
|
+
return "CRITICAL";
|
|
126
|
+
if (score >= 60)
|
|
127
|
+
return "HIGH";
|
|
128
|
+
if (score >= 40)
|
|
129
|
+
return "MAJOR";
|
|
130
|
+
if (score >= 20)
|
|
131
|
+
return "WARNING";
|
|
132
|
+
return "SAFE";
|
|
133
|
+
};
|
|
134
|
+
// Colored severity pill, e.g. CRITICAL .
|
|
135
|
+
export const severityBadge = (band) => {
|
|
136
|
+
const b = band.toUpperCase();
|
|
137
|
+
if (b === "CRITICAL")
|
|
138
|
+
return chip("CRITICAL", "97", "41"); // white on red
|
|
139
|
+
if (b === "HIGH")
|
|
140
|
+
return chip("HIGH", "30", "43"); // black on yellow
|
|
141
|
+
if (b === "MAJOR" || b === "MEDIUM")
|
|
142
|
+
return chip("MAJOR", "30", "43");
|
|
143
|
+
if (b === "WARNING")
|
|
144
|
+
return chip("WARNING", "30", "43");
|
|
145
|
+
if (b === "SAFE" || b === "MINOR")
|
|
146
|
+
return chip("SAFE", "30", "42"); // black on green
|
|
147
|
+
return chip(b, "30", "47");
|
|
148
|
+
};
|
|
149
|
+
// A 20-cell score meter colored by band. Higher score = more risk.
|
|
150
|
+
export const scoreBar = (score, width = 20) => {
|
|
151
|
+
const clamped = Math.max(0, Math.min(100, Math.round(score)));
|
|
152
|
+
const filled = Math.round((clamped / 100) * width);
|
|
153
|
+
const empty = width - filled;
|
|
154
|
+
const band = bandForScore(clamped);
|
|
155
|
+
const paint = band === "CRITICAL" ? colors.critical :
|
|
156
|
+
band === "HIGH" || band === "MAJOR" || band === "WARNING" ? colors.warning :
|
|
157
|
+
colors.success;
|
|
158
|
+
return `${paint("█".repeat(filled))}${colors.gray("░".repeat(empty))}`;
|
|
159
|
+
};
|
|
160
|
+
// Wrap prose to a soft width so paragraphs read like a report, not a log line.
|
|
161
|
+
const wrap = (text, width = 74) => {
|
|
162
|
+
const words = text.split(/\s+/);
|
|
163
|
+
const lines = [];
|
|
164
|
+
let line = "";
|
|
165
|
+
for (const word of words) {
|
|
166
|
+
if ((line + " " + word).trim().length > width) {
|
|
167
|
+
if (line)
|
|
168
|
+
lines.push(line.trim());
|
|
169
|
+
line = word;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
line = `${line} ${word}`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (line.trim())
|
|
176
|
+
lines.push(line.trim());
|
|
177
|
+
return lines.length ? lines : [""];
|
|
178
|
+
};
|
|
179
|
+
// A labelled prose block: heading in white caps, wrapped body indented.
|
|
180
|
+
const block = (heading, body, paint = colors.dim) => {
|
|
181
|
+
console.log(colors.bold(colors.white(heading)));
|
|
182
|
+
for (const line of wrap(body))
|
|
183
|
+
console.log(paint(line));
|
|
184
|
+
console.log("");
|
|
185
|
+
};
|
|
186
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
187
|
+
// Security rule knowledge (audit)
|
|
188
|
+
// Each rule answers: what happened, why it's dangerous, what breaks, how to fix.
|
|
189
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
85
190
|
const ruleNames = {
|
|
86
191
|
"EPIC-SEC-001": "Owner Validation",
|
|
87
192
|
"EPIC-SEC-002": "Missing Signer Validation",
|
|
@@ -89,17 +194,428 @@ const ruleNames = {
|
|
|
89
194
|
"EPIC-SEC-004": "PDA Cryptographic Seed Collision Risk",
|
|
90
195
|
"EPIC-SEC-005": "Arbitrary CPI Target Program Spoofing"
|
|
91
196
|
};
|
|
197
|
+
export const ruleKnowledge = {
|
|
198
|
+
"EPIC-SEC-001": {
|
|
199
|
+
desc: "Missing Owner Validation",
|
|
200
|
+
fix: "Use `#[account(owner = program_id)]`, or type the account as `Account<'info, T>` which enforces the program-owner check automatically.",
|
|
201
|
+
why: "Without an owner check, the runtime will happily accept an account owned by a different program. An attacker crafts a look-alike account, the program trusts its bytes, and privileged logic executes on attacker-controlled state.",
|
|
202
|
+
impact: "Forged accounts pass validation. Funds can be drained or protocol state corrupted, because every downstream check trusts data the attacker fully controls.",
|
|
203
|
+
historical: "Multiple yield aggregators have been drained when forged state accounts passed checks that omitted owner validation.",
|
|
204
|
+
score: 90
|
|
205
|
+
},
|
|
206
|
+
"EPIC-SEC-002": {
|
|
207
|
+
desc: "Missing Signer Validation",
|
|
208
|
+
fix: "Type the authority account as `Signer<'info>`, or add `#[account(signer)]` so the runtime requires a valid signature.",
|
|
209
|
+
why: "A privileged instruction mutates state on behalf of an 'authority', but never verifies that the authority actually signed the transaction. Anyone can pass another user's public key in that slot.",
|
|
210
|
+
impact: "Attackers can act as any user — withdrawing funds, changing parameters, or transferring ownership — without that user's authorization.",
|
|
211
|
+
historical: "Missing signer checks are a top cause of unauthorized withdrawals and admin-takeover bugs on Solana.",
|
|
212
|
+
score: 92
|
|
213
|
+
},
|
|
214
|
+
"EPIC-SEC-003": {
|
|
215
|
+
desc: "Missing Post-CPI Account Reload",
|
|
216
|
+
fix: "Call `account.reload()?` after the CPI and before reading the account's fields again.",
|
|
217
|
+
why: "After a cross-program invocation mutates an account, Anchor's in-memory copy is stale. Reading it returns pre-CPI values while the on-chain state has already changed.",
|
|
218
|
+
impact: "Logic runs on stale balances or flags — enabling double-spends, bypassed checks, and incorrect accounting that only appears under real CPI flows.",
|
|
219
|
+
historical: "Protocols have shipped stale-account bugs from missing reloads after CPIs, leading to double-spends and logic bypasses.",
|
|
220
|
+
score: 78
|
|
221
|
+
},
|
|
222
|
+
"EPIC-SEC-004": {
|
|
223
|
+
desc: "PDA Cryptographic Seed Collision Risk",
|
|
224
|
+
fix: "Insert a fixed-length seed or a literal delimiter between adjacent variable-length seeds so concatenations are unambiguous.",
|
|
225
|
+
why: "Two adjacent variable-length seeds can be re-sliced into a different but equally valid pair. ['ab','c'] and ['a','bc'] hash to the same PDA, so an attacker can derive a colliding address.",
|
|
226
|
+
impact: "An attacker can craft a PDA that collides with a legitimate user's account, spoofing identity or front-running account creation.",
|
|
227
|
+
historical: "Improper PDA derivation has let attackers front-run legitimate users by crafting colliding seeds.",
|
|
228
|
+
score: 70
|
|
229
|
+
},
|
|
230
|
+
"EPIC-SEC-005": {
|
|
231
|
+
desc: "Arbitrary CPI Target Program Spoofing",
|
|
232
|
+
fix: "Replace `AccountInfo<'info>` with `Program<'info, Token>`, or assert `require_keys_eq!(token_program.key(), spl_token::ID)` before the invoke.",
|
|
233
|
+
why: "The program to call is read from an unchecked account, so the caller decides which code runs. An attacker substitutes a malicious program that mimics the expected interface.",
|
|
234
|
+
impact: "The CPI executes attacker-controlled code with your program's authority — token transfers, mints, or burns can be redirected or faked.",
|
|
235
|
+
historical: "Major DEXs have suffered exploits when attacker-controlled programs were passed into CPIs in place of the legitimate token program.",
|
|
236
|
+
score: 88
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
// Derive a numeric risk score from a finding's declared severity, falling back
|
|
240
|
+
// to the rule's knowledge-base score when available.
|
|
241
|
+
export const scoreForFinding = (finding) => {
|
|
242
|
+
const kb = ruleKnowledge[finding.rule_id];
|
|
243
|
+
if (kb)
|
|
244
|
+
return kb.score;
|
|
245
|
+
const s = String(finding.severity || "").toUpperCase();
|
|
246
|
+
if (s === "CRITICAL")
|
|
247
|
+
return 88;
|
|
248
|
+
if (s === "HIGH")
|
|
249
|
+
return 72;
|
|
250
|
+
if (s === "MAJOR" || s === "MEDIUM")
|
|
251
|
+
return 50;
|
|
252
|
+
if (s === "WARNING")
|
|
253
|
+
return 30;
|
|
254
|
+
return 10;
|
|
255
|
+
};
|
|
256
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
257
|
+
// Audit finding card — the "intelligent" rendering of a single finding.
|
|
258
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
92
259
|
export const printRuleFinding = (finding) => {
|
|
93
|
-
const sev = formatSeverity(finding.severity);
|
|
94
|
-
const ruleId = colors.violet(finding.rule_id);
|
|
95
260
|
const ruleName = finding.rule_name || ruleNames[finding.rule_id] || finding.rule_id;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
console.log(colors.gray(
|
|
100
|
-
console.log(
|
|
101
|
-
console.log(colors.gray("
|
|
102
|
-
console.log(
|
|
103
|
-
console.log(colors.
|
|
261
|
+
const knowledge = ruleKnowledge[finding.rule_id];
|
|
262
|
+
const score = scoreForFinding(finding);
|
|
263
|
+
const band = bandForScore(score);
|
|
264
|
+
console.log(colors.gray(THIN));
|
|
265
|
+
console.log("");
|
|
266
|
+
console.log(`${severityBadge(band)} ${colors.white(finding.rule_id)} ${colors.gray("·")} ${colors.white(ruleName)}`);
|
|
267
|
+
console.log("");
|
|
268
|
+
console.log(`${colors.dim("Risk Score")} ${scoreBar(score)} ${colors.white(`${score} / 100`)}`);
|
|
269
|
+
console.log(`${colors.dim("Location")} ${colors.cyan(`${finding.location.file}:${finding.location.line}`)}`);
|
|
270
|
+
console.log("");
|
|
271
|
+
// What happened — prefer the engine's concrete message.
|
|
272
|
+
block("WHAT HAPPENED", finding.message || (knowledge ? knowledge.desc : "Security rule triggered."));
|
|
273
|
+
if (knowledge) {
|
|
274
|
+
block("WHY IT'S DANGEROUS", knowledge.why);
|
|
275
|
+
block("WHAT BREAKS", knowledge.impact, colors.warning);
|
|
276
|
+
block("HOW TO FIX", knowledge.fix, colors.green);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
block("RECOMMENDATION", finding.recommendation || "Review and validate.", colors.green);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
export const upgradeRiskKnowledge = {
|
|
283
|
+
"Serialization Break": {
|
|
284
|
+
score: 90,
|
|
285
|
+
why: "The on-disk byte layout of this account changed. Every account already stored on-chain was serialized with the previous layout, so the offset of every field after the change no longer lines up with what the new program expects.",
|
|
286
|
+
impact: "Existing accounts will fail to deserialize after deployment, or silently decode into the wrong fields. Users may be unable to access funds or state created before the upgrade."
|
|
287
|
+
},
|
|
288
|
+
"Account Shrink": {
|
|
289
|
+
score: 88,
|
|
290
|
+
why: "The account's serialized size decreased. Anchor allocates a fixed buffer per account; shrinking the layout means trailing bytes from the old layout are now reinterpreted or truncated.",
|
|
291
|
+
impact: "Accounts created before the upgrade are larger than the new layout. Deserialization can truncate data or fail outright, and realloc cannot recover bytes that were already written."
|
|
292
|
+
},
|
|
293
|
+
"Account Expansion": {
|
|
294
|
+
score: 42,
|
|
295
|
+
why: "A new field was appended, increasing the account's serialized size. Appending is the safe direction for layout evolution, but only if existing accounts are grown to match.",
|
|
296
|
+
impact: "Accounts created before this upgrade are smaller than the new layout. Without an explicit realloc and rent top-up, reads of the new field run past the allocated buffer."
|
|
297
|
+
},
|
|
298
|
+
"Field Reorder": {
|
|
299
|
+
score: 86,
|
|
300
|
+
why: "Persisted fields were reordered. In Borsh serialization, field order defines byte offsets — moving a field changes where every later field lives on disk.",
|
|
301
|
+
impact: "Every existing account decodes into the wrong fields. Values are silently swapped rather than erroring, which is harder to detect and can corrupt accounting."
|
|
302
|
+
},
|
|
303
|
+
"Dynamic Type Introduction": {
|
|
304
|
+
score: 72,
|
|
305
|
+
why: "A dynamically-sized type (Vec, String, HashMap, …) was introduced into a persisted account. The account no longer has a fixed, predictable byte size.",
|
|
306
|
+
impact: "Fixed-size accounts created before the upgrade cannot represent the new layout without migration, and unbounded growth can exceed the rent-exempt allocation."
|
|
307
|
+
},
|
|
308
|
+
"Enum Expansion": {
|
|
309
|
+
score: 48,
|
|
310
|
+
why: "An enum used in a persisted account gained or changed a variant. Old clients and indexers were compiled against the previous variant set.",
|
|
311
|
+
impact: "Clients and indexers that don't know the new variant can panic or mis-decode. IDLs and SDKs must be regenerated before the new variant is written."
|
|
312
|
+
},
|
|
313
|
+
"Discriminator Mismatch": {
|
|
314
|
+
score: 95,
|
|
315
|
+
why: "An account struct or instruction was renamed, which changes its 8-byte Anchor discriminator. The discriminator is how the runtime identifies which type or instruction it is looking at.",
|
|
316
|
+
impact: "Accounts stored under the old discriminator are no longer recognized, and clients calling the old instruction name fail. This breaks both existing state and existing callers."
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
const KIND_TITLES = {
|
|
320
|
+
FIELD_ADDED: "Field Added",
|
|
321
|
+
FIELD_REMOVED: "Field Removed",
|
|
322
|
+
FIELD_REORDERED: "Field Reordered",
|
|
323
|
+
TYPE_CHANGED: "Type Changed",
|
|
324
|
+
SIZE_REDUCED: "Account Size Reduced",
|
|
325
|
+
DISCRIMINATOR_CHANGED: "Discriminator Changed"
|
|
326
|
+
};
|
|
327
|
+
// Concrete, human "what happened" sentence built from the structured finding.
|
|
328
|
+
const upgradeWhatHappened = (finding) => {
|
|
329
|
+
const f = finding.field || {};
|
|
330
|
+
switch (finding.kind) {
|
|
331
|
+
case "FIELD_ADDED":
|
|
332
|
+
return `A new field \`${f.name}: ${f.newType ?? "?"}\` was added to account \`${finding.account}\`. Serialized size grows from ${finding.oldSize} to ${finding.newSize} bytes.`;
|
|
333
|
+
case "FIELD_REMOVED":
|
|
334
|
+
return `Field \`${f.name}: ${f.oldType ?? "?"}\` was removed from account \`${finding.account}\`. Serialized size changes from ${finding.oldSize} to ${finding.newSize} bytes.`;
|
|
335
|
+
case "FIELD_REORDERED":
|
|
336
|
+
return `The field order of account \`${finding.account}\` changed. Persisted byte offsets shift even though the set of fields is unchanged.`;
|
|
337
|
+
case "TYPE_CHANGED":
|
|
338
|
+
return `Field \`${f.name}\` on account \`${finding.account}\` changed type from \`${f.oldType ?? "?"}\` to \`${f.newType ?? "?"}\`.`;
|
|
339
|
+
case "SIZE_REDUCED":
|
|
340
|
+
return `Account \`${finding.account}\` shrank from ${finding.oldSize} to ${finding.newSize} bytes.`;
|
|
341
|
+
case "DISCRIMINATOR_CHANGED":
|
|
342
|
+
return `\`${f?.name ?? finding.account}\` was renamed, changing its 8-byte discriminator${f?.oldType ? ` (was ${f.oldType})` : ""}.`;
|
|
343
|
+
default:
|
|
344
|
+
return `Layout change detected on account \`${finding.account}\`.`;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
// item: UpgradeIntelligenceItem from diff-engine (riskCategory, affectedSurface, recommendation)
|
|
348
|
+
export const printUpgradeFinding = (finding, item) => {
|
|
349
|
+
const knowledge = upgradeRiskKnowledge[item.riskCategory];
|
|
350
|
+
const score = knowledge ? knowledge.score : scoreForFinding({ severity: finding.severity });
|
|
351
|
+
const band = bandForScore(score);
|
|
352
|
+
const title = KIND_TITLES[finding.kind] || "Layout Change";
|
|
353
|
+
console.log(colors.gray(THIN));
|
|
354
|
+
console.log("");
|
|
355
|
+
console.log(`${severityBadge(band)} ${colors.white(finding.account)} ${colors.gray("·")} ${colors.white(title)} ${colors.gray("·")} ${colors.violet(item.riskCategory)}`);
|
|
356
|
+
console.log("");
|
|
357
|
+
console.log(`${colors.dim("Risk Score")} ${scoreBar(score)} ${colors.white(`${score} / 100`)}`);
|
|
358
|
+
console.log("");
|
|
359
|
+
block("WHAT HAPPENED", upgradeWhatHappened(finding));
|
|
360
|
+
if (knowledge) {
|
|
361
|
+
block("WHY IT'S DANGEROUS", knowledge.why);
|
|
362
|
+
block("WHAT BREAKS", knowledge.impact, colors.warning);
|
|
363
|
+
}
|
|
364
|
+
// Affected surface as a concrete bullet list.
|
|
365
|
+
if (item.affectedSurface && item.affectedSurface.length) {
|
|
366
|
+
console.log(colors.bold(colors.white("AFFECTED SURFACE")));
|
|
367
|
+
for (const surface of item.affectedSurface) {
|
|
368
|
+
console.log(colors.warning(` • ${surface}`));
|
|
369
|
+
}
|
|
370
|
+
console.log("");
|
|
371
|
+
}
|
|
372
|
+
block("HOW TO FIX", item.recommendation, colors.green);
|
|
373
|
+
};
|
|
374
|
+
// Full upgrade report header + verdict for `epic check`.
|
|
375
|
+
export const printUpgradeReport = (report, intelligence, meta, opts = {}) => {
|
|
376
|
+
// The startup mode label ("Upgrade Intelligence") already announced the mode
|
|
377
|
+
// in TTY mode; skip the redundant title there. In non-TTY the label is
|
|
378
|
+
// suppressed, so the title still prints — keeping piped output unchanged.
|
|
379
|
+
if (!opts.skipTitle) {
|
|
380
|
+
console.log(colors.gray(DIVIDER));
|
|
381
|
+
console.log(colors.white(colors.bold("EPIC UPGRADE INTELLIGENCE")));
|
|
382
|
+
console.log(colors.gray(DIVIDER));
|
|
383
|
+
console.log("");
|
|
384
|
+
}
|
|
385
|
+
if (!report.findings.length) {
|
|
386
|
+
console.log(`${severityBadge("SAFE")} ${colors.white(meta.program)}`);
|
|
387
|
+
console.log("");
|
|
388
|
+
block("WHAT HAPPENED", `No structural account layout changes were detected between the two versions of \`${meta.program}\`. Field layouts, sizes, and discriminators are unchanged.`);
|
|
389
|
+
block("WHY IT'S SAFE", "Existing on-chain accounts will continue to deserialize correctly against the new program. This upgrade does not alter persisted state layout.", colors.green);
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
// Overall risk = the worst single finding; one break dooms the migration.
|
|
393
|
+
const scores = report.findings.map((f, i) => {
|
|
394
|
+
const item = intelligence.items[i];
|
|
395
|
+
const kb = item ? upgradeRiskKnowledge[item.riskCategory] : undefined;
|
|
396
|
+
return kb ? kb.score : scoreForFinding({ severity: f.severity });
|
|
397
|
+
});
|
|
398
|
+
const overall = Math.max(...scores);
|
|
399
|
+
const band = bandForScore(overall);
|
|
400
|
+
console.log(`${colors.dim("Program")} ${colors.white(meta.program)}`);
|
|
401
|
+
console.log(`${colors.dim("Findings")} ${colors.white(String(report.findings.length))}`);
|
|
402
|
+
console.log(`${colors.dim("Upgrade Risk")} ${scoreBar(overall)} ${colors.white(`${overall} / 100`)} ${severityBadge(band)}`);
|
|
403
|
+
console.log("");
|
|
404
|
+
report.findings.forEach((finding, i) => {
|
|
405
|
+
printUpgradeFinding(finding, intelligence.items[i]);
|
|
406
|
+
});
|
|
407
|
+
return overall;
|
|
408
|
+
};
|
|
409
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
410
|
+
// Account Compatibility (check) — EPIC's signature output.
|
|
411
|
+
// Answers: what changed, why it matters, what breaks, can I deploy, what next.
|
|
412
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
413
|
+
// Verdict pill keyed by compatibility status.
|
|
414
|
+
const compatBadge = (status) => {
|
|
415
|
+
switch (status) {
|
|
416
|
+
case "Blocked":
|
|
417
|
+
return chip("BLOCKED", "97", "41"); // white on red
|
|
418
|
+
case "Migration-Required":
|
|
419
|
+
return chip("MIGRATION", "30", "43"); // black on yellow
|
|
420
|
+
case "Compatible":
|
|
421
|
+
default:
|
|
422
|
+
return chip("SAFE", "30", "42"); // black on green
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const compatPaint = (status) => status === "Blocked" ? colors.critical : status === "Migration-Required" ? colors.warning : colors.success;
|
|
426
|
+
const STATUS_LABEL = {
|
|
427
|
+
Blocked: "Existing accounts would be corrupted",
|
|
428
|
+
"Migration-Required": "Existing accounts must be migrated first",
|
|
429
|
+
Compatible: "Existing accounts remain valid"
|
|
430
|
+
};
|
|
431
|
+
// A single fixed-width layout cell: "name start–end".
|
|
432
|
+
const layoutCell = (f, width = 22) => {
|
|
433
|
+
if (!f)
|
|
434
|
+
return "".padEnd(width);
|
|
435
|
+
const span = f.offset + f.byteSize - 1;
|
|
436
|
+
const range = f.byteSize <= 1 ? `${f.offset}` : `${f.offset}–${span}`;
|
|
437
|
+
const approx = f.offsetApproximate || f.dynamic ? "~" : "";
|
|
438
|
+
const text = `${f.name} ${approx}${range}`;
|
|
439
|
+
return text.length >= width ? text.slice(0, width) : text.padEnd(width);
|
|
440
|
+
};
|
|
441
|
+
const rowsDiverge = (a, b) => {
|
|
442
|
+
if (!a || !b)
|
|
443
|
+
return true;
|
|
444
|
+
return a.name !== b.name || a.type !== b.type || a.offset !== b.offset;
|
|
445
|
+
};
|
|
446
|
+
// account: AccountCompatibility from diff-engine.
|
|
447
|
+
export const printAccountCompatibility = (account) => {
|
|
448
|
+
const paint = compatPaint(account.status);
|
|
449
|
+
console.log(colors.gray(THIN));
|
|
450
|
+
console.log("");
|
|
451
|
+
console.log(`${compatBadge(account.status)} ${colors.white(account.account)} ${colors.gray("·")} ${paint(STATUS_LABEL[account.status] || account.status)}`);
|
|
452
|
+
console.log("");
|
|
453
|
+
// Size line — concrete, not editorial.
|
|
454
|
+
const sizeText = account.oldSize != null && account.newSize != null
|
|
455
|
+
? `${account.oldSize} → ${account.newSize} bytes${account.sizeDelta ? ` (${account.sizeDelta > 0 ? "+" : ""}${account.sizeDelta})` : ""}`
|
|
456
|
+
: account.newSize != null
|
|
457
|
+
? `${account.newSize} bytes (new)`
|
|
458
|
+
: `${account.oldSize} bytes (removed)`;
|
|
459
|
+
console.log(`${colors.dim("Size")} ${colors.white(sizeText)}`);
|
|
460
|
+
console.log(`${colors.dim("Certainty")} ${colors.white(account.certainty)}`);
|
|
461
|
+
if (typeof account.rentDeltaLamports === "number") {
|
|
462
|
+
const sol = (account.rentDeltaLamports / 1e9).toFixed(6);
|
|
463
|
+
console.log(`${colors.dim("Rent")} ${colors.white(`+${account.rentDeltaLamports.toLocaleString()} lamports`)} ${colors.gray(`(~${sol} SOL per account)`)}`);
|
|
464
|
+
}
|
|
465
|
+
console.log("");
|
|
466
|
+
// WHY — the reasons this verdict was reached.
|
|
467
|
+
if (account.reasons?.length) {
|
|
468
|
+
console.log(colors.bold(colors.white("WHY")));
|
|
469
|
+
for (const reason of account.reasons) {
|
|
470
|
+
for (const line of wrap(`• ${reason}`))
|
|
471
|
+
console.log(colors.dim(line));
|
|
472
|
+
}
|
|
473
|
+
console.log("");
|
|
474
|
+
}
|
|
475
|
+
// Byte-level reasoning — the "I've never seen a tool do this" moment.
|
|
476
|
+
const br = account.byteReasoning;
|
|
477
|
+
if (br && (br.oldLayout?.length || br.newLayout?.length)) {
|
|
478
|
+
const rows = Math.max(br.oldLayout.length, br.newLayout.length);
|
|
479
|
+
console.log(`${colors.bold(colors.white("Old Layout"))} ${colors.bold(colors.white("New Layout"))}`);
|
|
480
|
+
for (let i = 0; i < rows; i++) {
|
|
481
|
+
const o = br.oldLayout[i];
|
|
482
|
+
const n = br.newLayout[i];
|
|
483
|
+
const diverged = rowsDiverge(o, n);
|
|
484
|
+
const left = diverged && o ? colors.gray(layoutCell(o)) : colors.dim(layoutCell(o));
|
|
485
|
+
const sep = diverged ? colors.gold(" → ") : colors.gray(" │ ");
|
|
486
|
+
const right = diverged && n ? paint(layoutCell(n)) : colors.dim(layoutCell(n));
|
|
487
|
+
console.log(` ${left}${sep}${right}`);
|
|
488
|
+
}
|
|
489
|
+
console.log("");
|
|
490
|
+
if (br.explanations?.length) {
|
|
491
|
+
console.log(colors.bold(colors.white("WHAT BREAKS")));
|
|
492
|
+
for (const ex of br.explanations) {
|
|
493
|
+
for (const line of wrap(ex))
|
|
494
|
+
console.log(colors.warning(line));
|
|
495
|
+
}
|
|
496
|
+
console.log("");
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// WHAT TO DO NEXT — the diff-conditioned plan.
|
|
500
|
+
if (account.upgradePlan?.length) {
|
|
501
|
+
console.log(colors.bold(colors.white("RECOMMENDED UPGRADE PLAN")));
|
|
502
|
+
account.upgradePlan.forEach((step, i) => {
|
|
503
|
+
const num = colors.gold(`${i + 1}.`);
|
|
504
|
+
const lines = wrap(step, 70);
|
|
505
|
+
console.log(` ${num} ${paint(lines[0])}`);
|
|
506
|
+
for (const cont of lines.slice(1))
|
|
507
|
+
console.log(` ${paint(cont)}`);
|
|
508
|
+
});
|
|
509
|
+
console.log("");
|
|
510
|
+
}
|
|
511
|
+
// Honesty: never bury the caveats.
|
|
512
|
+
if (account.caveats?.length) {
|
|
513
|
+
console.log(colors.bold(colors.gray("CAVEATS")));
|
|
514
|
+
for (const c of account.caveats) {
|
|
515
|
+
for (const line of wrap(`• ${c}`))
|
|
516
|
+
console.log(colors.dim(line));
|
|
517
|
+
}
|
|
518
|
+
console.log("");
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
// Full compatibility report header + verdict for `epic check`.
|
|
522
|
+
// report: CompatibilityReport from diff-engine.
|
|
523
|
+
export const printCompatibilityReport = (report, meta, opts = {}) => {
|
|
524
|
+
if (!opts.skipTitle) {
|
|
525
|
+
console.log(colors.gray(DIVIDER));
|
|
526
|
+
console.log(colors.white(colors.bold("EPIC ACCOUNT COMPATIBILITY")));
|
|
527
|
+
console.log(colors.gray(DIVIDER));
|
|
528
|
+
console.log("");
|
|
529
|
+
}
|
|
530
|
+
const counts = { Blocked: 0, "Migration-Required": 0, Compatible: 0 };
|
|
531
|
+
for (const a of report.accounts)
|
|
532
|
+
counts[a.status] = (counts[a.status] || 0) + 1;
|
|
533
|
+
console.log(`${colors.dim("Program")} ${colors.white(meta.program)}`);
|
|
534
|
+
console.log(`${colors.dim("Accounts")} ${colors.white(String(report.accounts.length))}`);
|
|
535
|
+
console.log(`${colors.dim("Verdict")} ${compatBadge(report.overall)} ${compatPaint(report.overall)(STATUS_LABEL[report.overall] || report.overall)}`);
|
|
536
|
+
if (report.accounts.length) {
|
|
537
|
+
console.log(`${colors.dim("Breakdown")} ${colors.critical(`${counts.Blocked} blocked`)} ${colors.gray("·")} ${colors.warning(`${counts["Migration-Required"]} migration`)} ${colors.gray("·")} ${colors.success(`${counts.Compatible} safe`)}`);
|
|
538
|
+
}
|
|
539
|
+
console.log("");
|
|
540
|
+
if (!report.accounts.length) {
|
|
541
|
+
block("WHAT HAPPENED", `No state accounts were found to compare for \`${meta.program}\`. There is nothing to migrate.`, colors.dim);
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
for (const account of report.accounts)
|
|
545
|
+
printAccountCompatibility(account);
|
|
546
|
+
}
|
|
547
|
+
// Standing assumptions — rendered once, never per account.
|
|
548
|
+
if (report.assumptions?.length) {
|
|
549
|
+
console.log(colors.gray(THIN));
|
|
550
|
+
console.log("");
|
|
551
|
+
console.log(colors.bold(colors.gray("ANALYSIS ASSUMPTIONS")));
|
|
552
|
+
for (const a of report.assumptions) {
|
|
553
|
+
for (const line of wrap(`• ${a}`))
|
|
554
|
+
console.log(colors.dim(line));
|
|
555
|
+
}
|
|
556
|
+
console.log("");
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
560
|
+
// End summary (audit)
|
|
561
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
562
|
+
export const printEndSummary = (projectName, rulesExec, critical, high, timeMs, nextSteps = []) => {
|
|
563
|
+
console.log(colors.gray(DIVIDER));
|
|
564
|
+
console.log("");
|
|
565
|
+
console.log(colors.bold(colors.white("EPIC Security Report")));
|
|
566
|
+
console.log("");
|
|
567
|
+
const deduction = (critical * 20) + (high * 10);
|
|
568
|
+
let score = 100 - deduction;
|
|
569
|
+
if (score < 0)
|
|
570
|
+
score = 0;
|
|
571
|
+
let status = "Unsafe For Deployment";
|
|
572
|
+
let statusColor = colors.critical;
|
|
573
|
+
if (score >= 95) {
|
|
574
|
+
status = "Production Ready";
|
|
575
|
+
statusColor = colors.success;
|
|
576
|
+
}
|
|
577
|
+
else if (score >= 80) {
|
|
578
|
+
status = "Minor Issues";
|
|
579
|
+
statusColor = colors.info;
|
|
580
|
+
}
|
|
581
|
+
else if (score >= 60) {
|
|
582
|
+
status = "Needs Review";
|
|
583
|
+
statusColor = colors.warning;
|
|
584
|
+
}
|
|
585
|
+
else if (score >= 40) {
|
|
586
|
+
status = "High Risk";
|
|
587
|
+
statusColor = colors.warning;
|
|
588
|
+
}
|
|
589
|
+
const printLine = (key, val) => {
|
|
590
|
+
const spaces = " ".repeat(Math.max(1, 15 - key.length));
|
|
591
|
+
console.log(`${colors.dim(key)}${spaces}${colors.white(String(val))}`);
|
|
592
|
+
};
|
|
593
|
+
printLine("Repository", projectName);
|
|
594
|
+
console.log("");
|
|
595
|
+
printLine("Score", `${score} / 100`);
|
|
596
|
+
console.log(`${colors.dim("Health")} ${scoreBar(100 - score)} ${statusColor(status)}`);
|
|
597
|
+
console.log("");
|
|
598
|
+
printLine("Critical", critical);
|
|
599
|
+
printLine("High", high);
|
|
600
|
+
console.log("");
|
|
601
|
+
printLine("Scan Time", (timeMs / 1000).toFixed(2) + " seconds");
|
|
602
|
+
printLine("Generated by", `EPIC v${CLI_VERSION}`);
|
|
603
|
+
console.log("");
|
|
604
|
+
console.log(colors.dim("Know your upgrade before mainnet."));
|
|
605
|
+
console.log("");
|
|
606
|
+
console.log(colors.gray(DIVIDER));
|
|
607
|
+
console.log("");
|
|
608
|
+
// Tips
|
|
609
|
+
const tips = [
|
|
610
|
+
"Run: epic explain EPIC-SEC-003 to understand this vulnerability.",
|
|
611
|
+
"Use: epic audit . --format markdown to generate a GitHub-ready report.",
|
|
612
|
+
"Use: epic audit . --format sarif to upload findings to GitHub code scanning.",
|
|
613
|
+
"Run: epic doctor to check your environment.",
|
|
614
|
+
"Run: epic audit . --include-tests to analyze test directories.",
|
|
615
|
+
];
|
|
616
|
+
const randomTip = tips[Math.floor(Math.random() * tips.length)];
|
|
617
|
+
console.log(colors.bold(colors.white("Tip")));
|
|
618
|
+
console.log(colors.cyan(randomTip));
|
|
619
|
+
console.log("");
|
|
104
620
|
};
|
|
105
621
|
//# sourceMappingURL=ui.js.map
|