solguard-cli 0.1.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/README.md +69 -0
- package/bin/solguard-cli.js +19 -0
- package/lib/solguard/agents/consultant.js +57 -0
- package/lib/solguard/agents/localRun.js +537 -0
- package/lib/solguard/aiSummary.js +127 -0
- package/lib/solguard/exploits.js +13 -0
- package/lib/solguard/llm/client.js +83 -0
- package/lib/solguard/llm/prompts/consultant.js +45 -0
- package/lib/solguard/llm/providers.js +43 -0
- package/lib/solguard/openclawAudit.js +187 -0
- package/lib/solguard/reportBuilder.js +105 -0
- package/lib/solguard/scanEngine.js +283 -0
- package/lib/solguard/verdictValidation.js +44 -0
- package/package.json +37 -0
- package/src/api.js +88 -0
- package/src/config.js +93 -0
- package/src/inputs.js +34 -0
- package/src/output.js +82 -0
- package/src/premium.js +85 -0
- package/src/wizard.js +483 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { Connection, PublicKey } from "@solana/web3.js";
|
|
2
|
+
import { normalizeRiskScore } from "./reportBuilder.js";
|
|
3
|
+
|
|
4
|
+
const HELIUS_KEY = process.env.HELIUS_API_KEY;
|
|
5
|
+
const RPC_URL = `https://mainnet.helius-rpc.com/?api-key=${HELIUS_KEY}`;
|
|
6
|
+
|
|
7
|
+
let _conn = null;
|
|
8
|
+
function getConnection() {
|
|
9
|
+
if (!_conn) _conn = new Connection(RPC_URL, "confirmed");
|
|
10
|
+
return _conn;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isValidSolanaAddress(address) {
|
|
14
|
+
return typeof address === "string" && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function fetchAsset(mintAddress) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(RPC_URL, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
jsonrpc: "2.0",
|
|
24
|
+
id: "solguard",
|
|
25
|
+
method: "getAsset",
|
|
26
|
+
params: { id: mintAddress },
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
const j = await res.json();
|
|
30
|
+
return j?.result || null;
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function checkAuthorities(connection, mintPubkey) {
|
|
37
|
+
const info = await connection.getParsedAccountInfo(mintPubkey);
|
|
38
|
+
const parsed = info?.value?.data?.parsed;
|
|
39
|
+
if (!parsed || parsed.type !== "mint") {
|
|
40
|
+
throw new Error("Address is not a valid SPL token mint");
|
|
41
|
+
}
|
|
42
|
+
const inf = parsed.info;
|
|
43
|
+
const mintAuth = inf.mintAuthority || null;
|
|
44
|
+
const freezeAuth = inf.freezeAuthority || null;
|
|
45
|
+
const decimals = inf.decimals;
|
|
46
|
+
const supplyRaw = inf.supply;
|
|
47
|
+
|
|
48
|
+
const riskFlags = [];
|
|
49
|
+
if (mintAuth) riskFlags.push("Mint authority not revoked — developer can mint unlimited new tokens.");
|
|
50
|
+
if (freezeAuth) riskFlags.push("Freeze authority active — developer can freeze any holder wallet.");
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
mintAuthority: mintAuth ? "ACTIVE" : "REVOKED",
|
|
54
|
+
freezeAuthority: freezeAuth ? "ACTIVE" : "REVOKED",
|
|
55
|
+
mintAuthorityAddress: mintAuth,
|
|
56
|
+
freezeAuthorityAddress: freezeAuth,
|
|
57
|
+
decimals,
|
|
58
|
+
supplyRaw,
|
|
59
|
+
riskFlags,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fetchHolderConcentration(connection, mintPubkey, supplyBig) {
|
|
64
|
+
const MAX_ATTEMPTS = 5;
|
|
65
|
+
let lastError = null;
|
|
66
|
+
|
|
67
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
68
|
+
try {
|
|
69
|
+
const largest = await connection.getTokenLargestAccounts(mintPubkey);
|
|
70
|
+
const accounts = largest?.value || [];
|
|
71
|
+
if (accounts.length > 0 && supplyBig > 0n) {
|
|
72
|
+
const topRaw = BigInt(accounts[0]?.amount || "0");
|
|
73
|
+
let sum10 = 0n;
|
|
74
|
+
for (const a of accounts.slice(0, 10)) sum10 += BigInt(a.amount || "0");
|
|
75
|
+
const topHolderPercent = Number((topRaw * 10000n) / supplyBig) / 100;
|
|
76
|
+
const top10Percent = Number((sum10 * 10000n) / supplyBig) / 100;
|
|
77
|
+
if (attempt > 1) {
|
|
78
|
+
console.log(`[scanEngine] getTokenLargestAccounts succeeded on attempt ${attempt} for ${mintPubkey.toBase58()}`);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
topHolderPercent,
|
|
82
|
+
top10Percent,
|
|
83
|
+
holderDataAvailable: true,
|
|
84
|
+
holderDataError: null,
|
|
85
|
+
topAccountsSampled: accounts.length,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
lastError = "getTokenLargestAccounts returned no accounts";
|
|
89
|
+
} catch (e) {
|
|
90
|
+
lastError = e?.message || "getTokenLargestAccounts failed";
|
|
91
|
+
console.warn(`[scanEngine] getTokenLargestAccounts attempt ${attempt}/${MAX_ATTEMPTS} failed for ${mintPubkey.toBase58()}: ${lastError}`);
|
|
92
|
+
}
|
|
93
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
94
|
+
await new Promise((r) => setTimeout(r, 800 * attempt));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.error(`[scanEngine] Holder concentration unavailable for ${mintPubkey.toBase58()}: ${lastError}`);
|
|
99
|
+
return {
|
|
100
|
+
topHolderPercent: null,
|
|
101
|
+
top10Percent: null,
|
|
102
|
+
holderDataAvailable: false,
|
|
103
|
+
holderDataError: lastError,
|
|
104
|
+
topAccountsSampled: 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function detectBundle(connection, mintPubkey) {
|
|
109
|
+
const riskFlags = [];
|
|
110
|
+
let suspiciousWalletCount = 0;
|
|
111
|
+
let earlySlotClustering = false;
|
|
112
|
+
|
|
113
|
+
let supplyBig = 0n;
|
|
114
|
+
let totalSupply = 0;
|
|
115
|
+
try {
|
|
116
|
+
const supplyRes = await connection.getTokenSupply(mintPubkey);
|
|
117
|
+
supplyBig = BigInt(supplyRes?.value?.amount || "0");
|
|
118
|
+
totalSupply = Number(supplyRes?.value?.uiAmountString || supplyRes?.value?.uiAmount || 0);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.warn(`[scanEngine] getTokenSupply failed for ${mintPubkey.toBase58()}:`, e?.message);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const holder = supplyBig > 0n
|
|
124
|
+
? await fetchHolderConcentration(connection, mintPubkey, supplyBig)
|
|
125
|
+
: {
|
|
126
|
+
topHolderPercent: null,
|
|
127
|
+
top10Percent: null,
|
|
128
|
+
holderDataAvailable: false,
|
|
129
|
+
holderDataError: supplyBig > 0n ? null : "Token supply unavailable",
|
|
130
|
+
topAccountsSampled: 0,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const topHolderPercent = holder.topHolderPercent;
|
|
134
|
+
const top10Percent = holder.top10Percent;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const sigs = await connection.getSignaturesForAddress(mintPubkey, { limit: 200 });
|
|
138
|
+
const bySlot = {};
|
|
139
|
+
for (const s of sigs) {
|
|
140
|
+
bySlot[s.slot] = (bySlot[s.slot] || 0) + 1;
|
|
141
|
+
}
|
|
142
|
+
const clusteredSlots = Object.entries(bySlot).filter(([, n]) => n >= 5);
|
|
143
|
+
if (clusteredSlots.length >= 1) {
|
|
144
|
+
earlySlotClustering = true;
|
|
145
|
+
suspiciousWalletCount = clusteredSlots.reduce((s, [, n]) => s + n, 0);
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.warn(`[scanEngine] signature clustering failed for ${mintPubkey.toBase58()}:`, e?.message);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const supplyPercent = holder.holderDataAvailable ? top10Percent : null;
|
|
152
|
+
|
|
153
|
+
if (holder.holderDataAvailable && topHolderPercent > 20) {
|
|
154
|
+
riskFlags.push(`Top wallet holds ${topHolderPercent.toFixed(1)}% of total supply.`);
|
|
155
|
+
}
|
|
156
|
+
if (holder.holderDataAvailable && top10Percent > 50) {
|
|
157
|
+
riskFlags.push(`Top 10 wallets control ${top10Percent.toFixed(1)}% of supply — high concentration risk.`);
|
|
158
|
+
}
|
|
159
|
+
if (!holder.holderDataAvailable) {
|
|
160
|
+
riskFlags.push(`Holder concentration unverified — ${holder.holderDataError || "RPC could not return largest accounts"}.`);
|
|
161
|
+
}
|
|
162
|
+
if (earlySlotClustering) riskFlags.push("Suspicious transaction clustering detected — possible coordinated bundle/snipe.");
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
detected: earlySlotClustering || (holder.holderDataAvailable && top10Percent > 50),
|
|
166
|
+
walletCount: suspiciousWalletCount,
|
|
167
|
+
supplyPercent,
|
|
168
|
+
topHolderPercent,
|
|
169
|
+
top10Percent,
|
|
170
|
+
holderDataAvailable: holder.holderDataAvailable,
|
|
171
|
+
holderDataError: holder.holderDataError,
|
|
172
|
+
topAccountsSampled: holder.topAccountsSampled,
|
|
173
|
+
earlySlotClustering,
|
|
174
|
+
riskFlags,
|
|
175
|
+
totalSupply,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function verifyLiquidityLock(connection, mintAddress, asset) {
|
|
180
|
+
const riskFlags = [];
|
|
181
|
+
// MVP heuristic: rely on Helius asset data if available
|
|
182
|
+
// Real LP burn checking requires Raydium pool lookup which is complex; gracefully fallback
|
|
183
|
+
let poolFound = false;
|
|
184
|
+
let lpBurned = false;
|
|
185
|
+
let burnPercent = 0;
|
|
186
|
+
let liquidityUsd = null;
|
|
187
|
+
|
|
188
|
+
// Try DexScreener as fallback for liquidity info
|
|
189
|
+
try {
|
|
190
|
+
const res = await fetch(`https://api.dexscreener.com/latest/dex/tokens/${mintAddress}`, { cache: "no-store" });
|
|
191
|
+
if (res.ok) {
|
|
192
|
+
const j = await res.json();
|
|
193
|
+
const pairs = j?.pairs || [];
|
|
194
|
+
if (pairs.length > 0) {
|
|
195
|
+
poolFound = true;
|
|
196
|
+
// pick most liquid pair
|
|
197
|
+
pairs.sort((a, b) => (b.liquidity?.usd || 0) - (a.liquidity?.usd || 0));
|
|
198
|
+
liquidityUsd = pairs[0]?.liquidity?.usd || 0;
|
|
199
|
+
if (liquidityUsd < 10000) {
|
|
200
|
+
riskFlags.push(`Low liquidity pool (~$${Math.round(liquidityUsd).toLocaleString()}) — high slippage and exit risk.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {}
|
|
205
|
+
|
|
206
|
+
if (!poolFound) {
|
|
207
|
+
riskFlags.push("No active DEX liquidity pool detected for this token.");
|
|
208
|
+
} else {
|
|
209
|
+
// We cannot easily check LP burn on-chain in MVP; flag as unverified rather than false alarm
|
|
210
|
+
riskFlags.push("LP burn status unverified — manually check Raydium/Orca pool for burned LP tokens.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { poolFound, lpBurned, burnPercent, liquidityUsd, riskFlags };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function calculateRiskScore(authority, bundle, liquidity) {
|
|
217
|
+
let score = 0;
|
|
218
|
+
if (authority.freezeAuthority === "ACTIVE") score += 30;
|
|
219
|
+
if (authority.mintAuthority === "ACTIVE") score += 20;
|
|
220
|
+
if (bundle.detected) score += 25;
|
|
221
|
+
if (bundle.holderDataAvailable) {
|
|
222
|
+
if (bundle.topHolderPercent > 20) score += 10;
|
|
223
|
+
if (bundle.topHolderPercent > 50) score += 15;
|
|
224
|
+
if (bundle.top10Percent > 70) score += 10;
|
|
225
|
+
}
|
|
226
|
+
if (liquidity.poolFound && liquidity.liquidityUsd !== null && liquidity.liquidityUsd < 10000) score += 15;
|
|
227
|
+
if (!liquidity.poolFound) score += 5;
|
|
228
|
+
|
|
229
|
+
const allFlags = [
|
|
230
|
+
...(authority.riskFlags || []),
|
|
231
|
+
...(bundle.riskFlags || []),
|
|
232
|
+
...(liquidity.riskFlags || []),
|
|
233
|
+
];
|
|
234
|
+
score = Math.min(100, score);
|
|
235
|
+
|
|
236
|
+
let level = "LOW";
|
|
237
|
+
if (score >= 76) level = "CRITICAL";
|
|
238
|
+
else if (score >= 51) level = "HIGH";
|
|
239
|
+
else if (score >= 26) level = "MEDIUM";
|
|
240
|
+
|
|
241
|
+
return { score, level, factors: allFlags };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function runTokenScan(mintAddress) {
|
|
245
|
+
if (!isValidSolanaAddress(mintAddress)) {
|
|
246
|
+
throw new Error("Invalid Solana token address format.");
|
|
247
|
+
}
|
|
248
|
+
const connection = getConnection();
|
|
249
|
+
const mintPubkey = new PublicKey(mintAddress);
|
|
250
|
+
|
|
251
|
+
const [authority, bundle, asset] = await Promise.all([
|
|
252
|
+
checkAuthorities(connection, mintPubkey),
|
|
253
|
+
detectBundle(connection, mintPubkey),
|
|
254
|
+
fetchAsset(mintAddress),
|
|
255
|
+
]);
|
|
256
|
+
const liquidity = await verifyLiquidityLock(connection, mintAddress, asset);
|
|
257
|
+
|
|
258
|
+
const meta = asset?.content?.metadata || {};
|
|
259
|
+
const tokenInfo = asset?.token_info || {};
|
|
260
|
+
const metadata = {
|
|
261
|
+
name: meta.name || tokenInfo.symbol || null,
|
|
262
|
+
symbol: meta.symbol || tokenInfo.symbol || null,
|
|
263
|
+
decimals: tokenInfo.decimals ?? authority.decimals,
|
|
264
|
+
image: asset?.content?.links?.image || asset?.content?.files?.[0]?.uri || null,
|
|
265
|
+
description: meta.description || null,
|
|
266
|
+
totalSupply: authority.supplyRaw,
|
|
267
|
+
mintAddress,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const { score, level, factors } = calculateRiskScore(authority, bundle, liquidity);
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
tokenAddress: mintAddress,
|
|
274
|
+
metadata,
|
|
275
|
+
authorityCheck: authority,
|
|
276
|
+
bundleDetection: bundle,
|
|
277
|
+
liquidityLock: liquidity,
|
|
278
|
+
riskScore: normalizeRiskScore(score),
|
|
279
|
+
riskLevel: level,
|
|
280
|
+
riskFactors: factors,
|
|
281
|
+
scannedAt: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Detect model outputs that leak prompts or dump raw scan fields instead of a verdict. */
|
|
2
|
+
export function isInvalidAiVerdict(text) {
|
|
3
|
+
if (!text || typeof text !== "string") return true;
|
|
4
|
+
const t = text.trim();
|
|
5
|
+
if (t.length < 12 || t.length > 420) return true;
|
|
6
|
+
|
|
7
|
+
const lower = t.toLowerCase();
|
|
8
|
+
const leakPhrases = [
|
|
9
|
+
"we need to produce",
|
|
10
|
+
"must reference specific",
|
|
11
|
+
"must be plain english",
|
|
12
|
+
"must not truncate",
|
|
13
|
+
"must be a single",
|
|
14
|
+
"ending with a period",
|
|
15
|
+
"do not start with",
|
|
16
|
+
"do not truncate",
|
|
17
|
+
"do not use asterisks",
|
|
18
|
+
"no bullet points",
|
|
19
|
+
"write exactly one",
|
|
20
|
+
"output rules",
|
|
21
|
+
"we have data:",
|
|
22
|
+
"risk factors:",
|
|
23
|
+
];
|
|
24
|
+
if (leakPhrases.some((p) => lower.includes(p))) return true;
|
|
25
|
+
|
|
26
|
+
const fieldHits = (lower.match(/risk score:|freeze authority:|mint authority:|bundle detected:|top holder:|top 10 holders:|liquidity (pool|usd):/g) || []).length;
|
|
27
|
+
if (fieldHits >= 2) return true;
|
|
28
|
+
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Trim incomplete trailing fragment; prefer last full sentence. */
|
|
33
|
+
export function ensureCompleteSentence(text) {
|
|
34
|
+
if (!text) return text;
|
|
35
|
+
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
36
|
+
if (/[.!?]["']?$/.test(trimmed)) return trimmed;
|
|
37
|
+
const punct = Math.max(
|
|
38
|
+
trimmed.lastIndexOf(". "),
|
|
39
|
+
trimmed.lastIndexOf("! "),
|
|
40
|
+
trimmed.lastIndexOf("? ")
|
|
41
|
+
);
|
|
42
|
+
if (punct > 20) return trimmed.slice(0, punct + 1).trim();
|
|
43
|
+
return trimmed.endsWith(".") ? trimmed : `${trimmed}.`;
|
|
44
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "solguard-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run SolGuard security agents from your terminal — free credits via SolGuard API or premium local BYOK",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"solguard-cli": "./bin/solguard-cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"lib"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"prepublishOnly": "node scripts/bundle-lib.mjs"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@clack/prompts": "^0.10.0",
|
|
22
|
+
"@solana/web3.js": "^1.98.4",
|
|
23
|
+
"openai": "^6.45.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"solguard",
|
|
27
|
+
"solana",
|
|
28
|
+
"security",
|
|
29
|
+
"cli",
|
|
30
|
+
"audit"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/solguard/solguard"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const TIMEOUT_MS = 120_000;
|
|
2
|
+
|
|
3
|
+
async function request(baseUrl, path, { method = "GET", token, body } = {}) {
|
|
4
|
+
const url = `${baseUrl.replace(/\/$/, "")}${path}`;
|
|
5
|
+
const ctl = new AbortController();
|
|
6
|
+
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
|
7
|
+
try {
|
|
8
|
+
const headers = { "Content-Type": "application/json" };
|
|
9
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
10
|
+
let res;
|
|
11
|
+
try {
|
|
12
|
+
res = await fetch(url, {
|
|
13
|
+
method,
|
|
14
|
+
headers,
|
|
15
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
16
|
+
signal: ctl.signal,
|
|
17
|
+
});
|
|
18
|
+
} catch (e) {
|
|
19
|
+
const hint =
|
|
20
|
+
baseUrl.includes("localhost")
|
|
21
|
+
? "Is the dev server running? Try: npm run dev"
|
|
22
|
+
: "For local dev: npm run dev, then SOLGUARD_API=http://localhost:3000/api node bin/solguard-cli.js";
|
|
23
|
+
const err = new Error(`Could not reach SolGuard API at ${baseUrl} (${e?.cause?.code || e?.message || "network error"}). ${hint}`);
|
|
24
|
+
err.cause = e;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
const data = await res.json().catch(() => null);
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const err = new Error(data?.error || `HTTP ${res.status} from ${url}`);
|
|
30
|
+
err.status = res.status;
|
|
31
|
+
err.body = data;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
return data;
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function registerCli(baseUrl, cliInstallId) {
|
|
41
|
+
return request(baseUrl, "/auth/cli", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
body: { cliInstallId },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function fetchConfig(baseUrl) {
|
|
48
|
+
return request(baseUrl, "/config");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function fetchServices(baseUrl) {
|
|
52
|
+
const data = await request(baseUrl, "/services");
|
|
53
|
+
return data.services || [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function fetchServiceDetail(baseUrl, serviceId) {
|
|
57
|
+
return request(baseUrl, `/services/${encodeURIComponent(serviceId)}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function fetchMe(baseUrl, token) {
|
|
61
|
+
return request(baseUrl, "/me", { token });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runAgentFree(baseUrl, token, agentId, inputs, paymentMethod) {
|
|
65
|
+
return request(baseUrl, `/agents/${encodeURIComponent(agentId)}/run`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
token,
|
|
68
|
+
body: { inputs, paymentMethod },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function ensureAuth(config) {
|
|
73
|
+
if (config.token) {
|
|
74
|
+
try {
|
|
75
|
+
const me = await fetchMe(config.baseUrl, config.token);
|
|
76
|
+
return { ...config, credits: me.credits, userId: me.id };
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (e.status !== 401) throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const reg = await registerCli(config.baseUrl, config.cliInstallId);
|
|
82
|
+
return {
|
|
83
|
+
...config,
|
|
84
|
+
token: reg.token,
|
|
85
|
+
credits: reg.user?.credits ?? 0,
|
|
86
|
+
userId: reg.user?.id,
|
|
87
|
+
};
|
|
88
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_BASE_URL = "https://www.solguard.space/api";
|
|
7
|
+
export const LOCAL_DEV_URL = "http://localhost:3000/api";
|
|
8
|
+
|
|
9
|
+
/** Resolve API base: --api flag > SOLGUARD_API env > config > local dev probe > production. */
|
|
10
|
+
export async function resolveBaseUrl(config, cliArgs = process.argv.slice(2)) {
|
|
11
|
+
const flagIdx = cliArgs.findIndex((a) => a === "--api" || a === "-a");
|
|
12
|
+
if (flagIdx >= 0 && cliArgs[flagIdx + 1]) {
|
|
13
|
+
return cliArgs[flagIdx + 1].replace(/\/$/, "");
|
|
14
|
+
}
|
|
15
|
+
if (process.env.SOLGUARD_API) {
|
|
16
|
+
return process.env.SOLGUARD_API.replace(/\/$/, "");
|
|
17
|
+
}
|
|
18
|
+
if (config?.baseUrl && config.baseUrl !== DEFAULT_BASE_URL) {
|
|
19
|
+
return config.baseUrl.replace(/\/$/, "");
|
|
20
|
+
}
|
|
21
|
+
if (await probeApi(LOCAL_DEV_URL)) {
|
|
22
|
+
return LOCAL_DEV_URL;
|
|
23
|
+
}
|
|
24
|
+
return (config?.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function probeApi(baseUrl) {
|
|
28
|
+
try {
|
|
29
|
+
const ctl = new AbortController();
|
|
30
|
+
const t = setTimeout(() => ctl.abort(), 2000);
|
|
31
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/health`, { signal: ctl.signal });
|
|
32
|
+
clearTimeout(t);
|
|
33
|
+
return res.ok;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function configDir() {
|
|
40
|
+
return path.join(os.homedir(), ".solguard");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function configPath() {
|
|
44
|
+
return path.join(configDir(), "config.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadConfig() {
|
|
48
|
+
try {
|
|
49
|
+
const raw = fs.readFileSync(configPath(), "utf8");
|
|
50
|
+
return JSON.parse(raw);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function saveConfig(config) {
|
|
57
|
+
const dir = configDir();
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
59
|
+
const file = configPath();
|
|
60
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
61
|
+
try {
|
|
62
|
+
fs.chmodSync(dir, 0o700);
|
|
63
|
+
fs.chmodSync(file, 0o600);
|
|
64
|
+
} catch {
|
|
65
|
+
// Windows may not support chmod — best effort only
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function ensureConfig() {
|
|
70
|
+
let config = loadConfig();
|
|
71
|
+
if (!config) {
|
|
72
|
+
config = {
|
|
73
|
+
cliInstallId: crypto.randomUUID(),
|
|
74
|
+
token: null,
|
|
75
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
76
|
+
savedKeys: {},
|
|
77
|
+
};
|
|
78
|
+
saveConfig(config);
|
|
79
|
+
}
|
|
80
|
+
if (!config.cliInstallId) {
|
|
81
|
+
config.cliInstallId = crypto.randomUUID();
|
|
82
|
+
saveConfig(config);
|
|
83
|
+
}
|
|
84
|
+
if (!config.baseUrl) config.baseUrl = DEFAULT_BASE_URL;
|
|
85
|
+
if (!config.savedKeys) config.savedKeys = {};
|
|
86
|
+
return config;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function updateConfig(patch) {
|
|
90
|
+
const config = { ...ensureConfig(), ...patch };
|
|
91
|
+
saveConfig(config);
|
|
92
|
+
return config;
|
|
93
|
+
}
|
package/src/inputs.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Input schemas keyed by primary agent id — mirrors lib/solguard/formSchemas.js (no Next.js imports). */
|
|
2
|
+
export const AGENT_INPUTS = {
|
|
3
|
+
"solana-token-verification": [
|
|
4
|
+
{ key: "tokenAddress", label: "Token Mint Address", placeholder: "e.g. DezXAZ8z7Pnr...", example: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" },
|
|
5
|
+
],
|
|
6
|
+
"contract-security": [
|
|
7
|
+
{ key: "tokenAddress", label: "Token Mint Address", placeholder: "e.g. DezXAZ8z7Pnr...", example: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" },
|
|
8
|
+
],
|
|
9
|
+
"wallet-verification": [
|
|
10
|
+
{ key: "walletAddress", label: "Wallet Address", placeholder: "Solana wallet pubkey", example: "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" },
|
|
11
|
+
],
|
|
12
|
+
"website-security": [
|
|
13
|
+
{ key: "url", label: "Website URL", placeholder: "https://example.com", example: "https://solana.com" },
|
|
14
|
+
],
|
|
15
|
+
"ai-consultant": [
|
|
16
|
+
{ key: "query", label: "Your Question", placeholder: "e.g. How do honeypot tokens lock liquidity?", example: "What is the most common Solana token rug-pull pattern?", multiline: true },
|
|
17
|
+
],
|
|
18
|
+
"openclaw-ai-agent-verification": [
|
|
19
|
+
{ key: "config", label: "Agent Config (JSON)", placeholder: '{"name":"support-agent","gateway":{"auth":"none"}}', example: '{"name":"support-agent","gateway":{"auth":"none"}}', multiline: true },
|
|
20
|
+
],
|
|
21
|
+
"private-data-verification": [
|
|
22
|
+
{ key: "cpdv_data", label: "Data to commit", placeholder: "Paste sensitive text or JSON…", example: "customer-record-8842:status=verified", multiline: true },
|
|
23
|
+
],
|
|
24
|
+
"quantum-cryptography-verification": [
|
|
25
|
+
{ key: "cqcv_data", label: "Data to encrypt", placeholder: "Paste text to encrypt…", example: "confidential-api-key-rotation-schedule", multiline: true },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function getInputsForAgent(agentId) {
|
|
30
|
+
return AGENT_INPUTS[agentId] || [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const CONSULTANT_AGENT_ID = "ai-consultant";
|
|
34
|
+
export const CONSULTANT_SERVICE_ID = "cyber-consultant";
|
package/src/output.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const LEVEL_COLOR = {
|
|
2
|
+
LOW: "\x1b[32m",
|
|
3
|
+
MEDIUM: "\x1b[33m",
|
|
4
|
+
HIGH: "\x1b[91m",
|
|
5
|
+
CRITICAL: "\x1b[31m",
|
|
6
|
+
};
|
|
7
|
+
const RESET = "\x1b[0m";
|
|
8
|
+
const DIM = "\x1b[2m";
|
|
9
|
+
const BOLD = "\x1b[1m";
|
|
10
|
+
|
|
11
|
+
function countElevated(findings = []) {
|
|
12
|
+
let elevated = 0;
|
|
13
|
+
for (const f of findings) {
|
|
14
|
+
const imp = (f.impact || "").toLowerCase();
|
|
15
|
+
if (imp.includes("+") && imp.includes("risk")) elevated++;
|
|
16
|
+
}
|
|
17
|
+
return elevated;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Build report text for terminal (also used after clack spinner so output isn't cleared). */
|
|
21
|
+
export function formatReport(result, { creditsRemaining, mode } = {}) {
|
|
22
|
+
const r = result.result || result;
|
|
23
|
+
const level = r.riskLevel || "LOW";
|
|
24
|
+
const score = r.riskScore ?? "—";
|
|
25
|
+
const lines = [];
|
|
26
|
+
|
|
27
|
+
lines.push("━━━ SolGuard Report ━━━");
|
|
28
|
+
if (mode) lines.push(`Mode: ${mode}`);
|
|
29
|
+
lines.push(`RISK: ${level} (${score}/100)`);
|
|
30
|
+
lines.push("");
|
|
31
|
+
|
|
32
|
+
const fullResponse = r.rawEvidence?.fullResponse || r.evidence?.fullResponse;
|
|
33
|
+
const verdict = r.verdict || r.summary || "";
|
|
34
|
+
|
|
35
|
+
if (fullResponse) {
|
|
36
|
+
lines.push("RESPONSE");
|
|
37
|
+
lines.push(fullResponse.trim());
|
|
38
|
+
lines.push("");
|
|
39
|
+
} else if (verdict) {
|
|
40
|
+
lines.push("VERDICT");
|
|
41
|
+
lines.push(verdict.trim());
|
|
42
|
+
lines.push("");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const findings = r.keyFindings || [];
|
|
46
|
+
if (findings.length) {
|
|
47
|
+
const elevated = countElevated(findings);
|
|
48
|
+
lines.push(`KEY FINDINGS (${findings.length} checked${elevated ? `, ${elevated} elevated` : ""})`);
|
|
49
|
+
for (const f of findings.slice(0, 8)) {
|
|
50
|
+
const flag = (f.impact || "").includes("+") ? "!" : "·";
|
|
51
|
+
lines.push(` ${flag} ${f.label}: ${f.value}`);
|
|
52
|
+
}
|
|
53
|
+
if (findings.length > 8) lines.push(` … and ${findings.length - 8} more`);
|
|
54
|
+
lines.push("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (r.recommendations?.length) {
|
|
58
|
+
lines.push("RECOMMENDATIONS");
|
|
59
|
+
for (const rec of r.recommendations.slice(0, 5)) {
|
|
60
|
+
lines.push(` → ${rec}`);
|
|
61
|
+
}
|
|
62
|
+
lines.push("");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (creditsRemaining != null) {
|
|
66
|
+
lines.push(`Credits remaining: ${creditsRemaining}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return lines.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function printReport(result, opts = {}) {
|
|
73
|
+
const text = formatReport(result, opts);
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log(text);
|
|
76
|
+
console.log("");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function reportFilename() {
|
|
80
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
81
|
+
return `solguard-report-${ts}.json`;
|
|
82
|
+
}
|