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,127 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { isInvalidAiVerdict, ensureCompleteSentence } from "./verdictValidation.js";
|
|
3
|
+
|
|
4
|
+
export { isInvalidAiVerdict, ensureCompleteSentence } from "./verdictValidation.js";
|
|
5
|
+
|
|
6
|
+
export const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || "openrouter/free";
|
|
7
|
+
|
|
8
|
+
let _client = null;
|
|
9
|
+
function getOpenRouterClient() {
|
|
10
|
+
if (!_client) {
|
|
11
|
+
_client = new OpenAI({
|
|
12
|
+
apiKey: process.env.OPENROUTER_API_KEY,
|
|
13
|
+
baseURL: "https://openrouter.ai/api/v1",
|
|
14
|
+
defaultHeaders: {
|
|
15
|
+
"HTTP-Referer": "https://www.solguard.space",
|
|
16
|
+
"X-Title": "SolGuard AI",
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return _client;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SYSTEM_PROMPT = `You are a Solana security auditor. Respond with ONLY one plain-English sentence (max 35 words) that states the bottom-line risk for this token.
|
|
24
|
+
|
|
25
|
+
Output rules:
|
|
26
|
+
- Exactly one sentence ending with a period
|
|
27
|
+
- Reference at least one specific number from the scan data
|
|
28
|
+
- No preamble, no bullet points, no markdown, no labels like "Risk Score:" or "Verdict:"
|
|
29
|
+
- Do not repeat the instructions or restate the raw data fields
|
|
30
|
+
- Do not start with "Based on", "Here is", or "This token"`;
|
|
31
|
+
|
|
32
|
+
function logOpenRouterError(context, e, extra = {}) {
|
|
33
|
+
const status = e?.status ?? e?.code ?? extra.status ?? "unknown";
|
|
34
|
+
console.error(`[OpenRouter] ${context} failed:`, {
|
|
35
|
+
status,
|
|
36
|
+
message: e?.message || extra.message || "unknown error",
|
|
37
|
+
model: OPENROUTER_MODEL,
|
|
38
|
+
...extra,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function requestCompletion(messages, maxTokens, temperature) {
|
|
43
|
+
return getOpenRouterClient().chat.completions.create({
|
|
44
|
+
model: OPENROUTER_MODEL,
|
|
45
|
+
max_tokens: maxTokens,
|
|
46
|
+
temperature,
|
|
47
|
+
messages,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildRiskSummaryMessages(scan) {
|
|
52
|
+
const user = `Scan data:
|
|
53
|
+
Risk ${scan.riskScore}/100 (${scan.riskLevel})
|
|
54
|
+
Mint authority ${scan.authorityCheck.mintAuthority}, freeze authority ${scan.authorityCheck.freezeAuthority}
|
|
55
|
+
Bundle clustering ${scan.bundleDetection.detected ? `yes (${scan.bundleDetection.walletCount} wallets)` : "not detected"}
|
|
56
|
+
Top holder ${scan.bundleDetection.holderDataAvailable ? scan.bundleDetection.topHolderPercent.toFixed(1) + "%" : "unverified"}
|
|
57
|
+
Top 10 holders ${scan.bundleDetection.holderDataAvailable ? scan.bundleDetection.top10Percent.toFixed(1) + "%" : "unverified"}
|
|
58
|
+
DEX liquidity ${scan.liquidityLock.poolFound ? `$${Math.round(scan.liquidityLock.liquidityUsd || 0).toLocaleString("en-US")}` : "none found"}
|
|
59
|
+
Flags: ${scan.riskFactors.slice(0, 3).join("; ") || "none"}`;
|
|
60
|
+
|
|
61
|
+
return [
|
|
62
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
63
|
+
{ role: "user", content: user },
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function completeRiskSummary(messages, { llmClient, maxTokens, temperature } = {}) {
|
|
68
|
+
if (llmClient?.complete) {
|
|
69
|
+
const raw = await llmClient.complete(messages, { maxTokens, temperature });
|
|
70
|
+
return { raw: raw?.trim() || "", finishReason: null };
|
|
71
|
+
}
|
|
72
|
+
const completion = await requestCompletion(messages, maxTokens, temperature);
|
|
73
|
+
return {
|
|
74
|
+
raw: completion.choices?.[0]?.message?.content?.trim() || "",
|
|
75
|
+
finishReason: completion.choices?.[0]?.finish_reason,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {object} scan
|
|
81
|
+
* @param {{ llmClient?: { complete: Function, model?: string } }} [opts] — inject BYOK client for CLI local runs
|
|
82
|
+
* @returns {{ text: string|null, verdict: string|null, aiAvailable: boolean, reason: string|null }}
|
|
83
|
+
*/
|
|
84
|
+
export async function generateRiskSummary(scan, { llmClient } = {}) {
|
|
85
|
+
const messages = buildRiskSummaryMessages(scan);
|
|
86
|
+
const logTag = llmClient?.model ? `[LLM:${llmClient.model}]` : "[OpenRouter]";
|
|
87
|
+
|
|
88
|
+
let lastError = null;
|
|
89
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
90
|
+
try {
|
|
91
|
+
const maxTokens = attempt === 1 ? 120 : 200;
|
|
92
|
+
const { raw, finishReason } = await completeRiskSummary(messages, { llmClient, maxTokens, temperature: 0.3 });
|
|
93
|
+
if (raw) {
|
|
94
|
+
const text = ensureCompleteSentence(raw);
|
|
95
|
+
if (isInvalidAiVerdict(text)) {
|
|
96
|
+
console.warn(`${logTag} generateRiskSummary rejected invalid verdict shape, attempt`, attempt);
|
|
97
|
+
lastError = { message: "invalid_verdict_shape", status: 200 };
|
|
98
|
+
if (attempt < 2) continue;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (finishReason === "length" && attempt < 2) {
|
|
102
|
+
console.warn(`${logTag} generateRiskSummary hit token limit, retrying with higher max_tokens`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (attempt > 1) console.log(`${logTag} generateRiskSummary succeeded on retry`);
|
|
106
|
+
return { text, verdict: text, aiAvailable: true, reason: null };
|
|
107
|
+
}
|
|
108
|
+
lastError = { message: "Empty response", status: 200 };
|
|
109
|
+
} catch (e) {
|
|
110
|
+
lastError = e;
|
|
111
|
+
if (llmClient) {
|
|
112
|
+
console.error(`${logTag} generateRiskSummary failed:`, { message: e?.message, attempt });
|
|
113
|
+
} else {
|
|
114
|
+
logOpenRouterError("generateRiskSummary", e, { attempt });
|
|
115
|
+
}
|
|
116
|
+
if (attempt < 2) await new Promise((r) => setTimeout(r, 800));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (llmClient) {
|
|
121
|
+
console.error(`${logTag} generateRiskSummary failed:`, { message: lastError?.message, final: true });
|
|
122
|
+
} else {
|
|
123
|
+
logOpenRouterError("generateRiskSummary", lastError, { final: true });
|
|
124
|
+
}
|
|
125
|
+
const reason = lastError?.message?.slice(0, 120) || "request_failed";
|
|
126
|
+
return { text: null, verdict: null, aiAvailable: false, reason };
|
|
127
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Hardcoded recent Solana/web3 exploit feed for the Exploit Watch page.
|
|
2
|
+
export const EXPLOITS = [
|
|
3
|
+
{ id: "e1", project: "Loopscale", date: "2025-04-26", lossUsd: 5800000, chain: "Solana", vector: "Oracle Manipulation", relevantAgent: "contract-security", summary: "Attacker exploited a price oracle dependency to drain $5.8M from the lending protocol." },
|
|
4
|
+
{ id: "e2", project: "Mango Markets v2", date: "2025-03-12", lossUsd: 2100000, chain: "Solana", vector: "Liquidation Logic", relevantAgent: "contract-security", summary: "Edge case in cross-margin liquidation allowed extraction of liquidity from insurance fund." },
|
|
5
|
+
{ id: "e3", project: "Pump.fun rug — $BOOM", date: "2025-06-18", lossUsd: 740000, chain: "Solana", vector: "Freeze Authority Abuse", relevantAgent: "contract-security", summary: "Deployer froze all holder wallets and dumped reserves. Mint had active freeze authority at launch." },
|
|
6
|
+
{ id: "e4", project: "Slope wallet leak", date: "2025-01-09", lossUsd: 8000000, chain: "Solana", vector: "Seed Phrase Exposure", relevantAgent: "wallet-verification", summary: "Mobile wallet logged seed phrases server-side; multiple users drained.", relatedWallets: ["7VSwHQuy1CZrxQZPqJySmWmetm9jWUSMWSR3LRFrrgCv"] },
|
|
7
|
+
{ id: "e9", project: "Phishing campaign", date: "2025-06-20", lossUsd: 95000, chain: "Solana", vector: "Drainer dApp", relevantAgent: "website-security", summary: "Fake airdrop site impersonated a major launchpad and harvested approvals.", relatedWallets: ["DrainerX1111111111111111111111111111111111"] },
|
|
8
|
+
{ id: "e5", project: "Cetus AMM", date: "2025-05-22", lossUsd: 230000000, chain: "Sui/Solana", vector: "Integer Overflow", relevantAgent: "contract-security", summary: "AMM math overflow allowed minting of liquidity tokens against negligible deposit." },
|
|
9
|
+
{ id: "e6", project: "Anonymous SPL Bundle", date: "2025-06-04", lossUsd: 1200000, chain: "Solana", vector: "Coordinated Bundle Snipe", relevantAgent: "solana-token-verification", summary: "12-wallet bundle accumulated 41% of supply in first slot then dumped over 90 seconds." },
|
|
10
|
+
{ id: "e7", project: "Wormhole bridge ", date: "2025-02-14", lossUsd: 320000000, chain: "Solana/ETH", vector: "Signature Verification Bypass", relevantAgent: "contract-security", summary: "Signature check accepted forged guardian signatures." },
|
|
11
|
+
{ id: "e8", project: "Raydium rug — $MOONK", date: "2025-06-12", lossUsd: 380000, chain: "Solana", vector: "LP Pull", relevantAgent: "solana-token-verification", summary: "Deployer removed unlocked LP tokens 47 minutes after launch." },
|
|
12
|
+
{ id: "e10", project: "Volume wash farm", date: "2025-06-08", lossUsd: 0, chain: "Solana", vector: "Wash Trading", relevantAgent: "volume-authenticity", summary: "Several tokens exhibited 100x+ daily turnover from a single MEV bot." },
|
|
13
|
+
];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { getProvider } from "./providers.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create an injectable LLM client for server (OpenRouter) or CLI BYOK.
|
|
6
|
+
* @param {{ provider: string, apiKey: string, model?: string }} opts
|
|
7
|
+
* @returns {{ provider: string, model: string, complete: Function }}
|
|
8
|
+
*/
|
|
9
|
+
export function createLlmClient({ provider: providerId, apiKey, model }) {
|
|
10
|
+
if (!apiKey) throw new Error("API key is required");
|
|
11
|
+
const provider = getProvider(providerId);
|
|
12
|
+
if (!provider) throw new Error(`Unknown provider: ${providerId}`);
|
|
13
|
+
|
|
14
|
+
const resolvedModel = model || provider.defaultModel;
|
|
15
|
+
|
|
16
|
+
if (provider.type === "anthropic") {
|
|
17
|
+
return {
|
|
18
|
+
provider: providerId,
|
|
19
|
+
model: resolvedModel,
|
|
20
|
+
complete: (messages, { maxTokens = 600, temperature = 0.6 } = {}) =>
|
|
21
|
+
anthropicComplete({ apiKey, model: resolvedModel, messages, maxTokens, temperature }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const client = new OpenAI({
|
|
26
|
+
apiKey,
|
|
27
|
+
baseURL: provider.baseURL,
|
|
28
|
+
defaultHeaders: provider.defaultHeaders,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
provider: providerId,
|
|
33
|
+
model: resolvedModel,
|
|
34
|
+
complete: async (messages, { maxTokens = 600, temperature = 0.6 } = {}) => {
|
|
35
|
+
const completion = await client.chat.completions.create({
|
|
36
|
+
model: resolvedModel,
|
|
37
|
+
max_tokens: maxTokens,
|
|
38
|
+
temperature,
|
|
39
|
+
messages,
|
|
40
|
+
});
|
|
41
|
+
return completion.choices?.[0]?.message?.content?.trim() || "";
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Server-side OpenRouter client using env key. */
|
|
47
|
+
export function createServerOpenRouterClient() {
|
|
48
|
+
return createLlmClient({
|
|
49
|
+
provider: "openrouter",
|
|
50
|
+
apiKey: process.env.OPENROUTER_API_KEY,
|
|
51
|
+
model: process.env.OPENROUTER_MODEL || undefined,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function anthropicComplete({ apiKey, model, messages, maxTokens, temperature }) {
|
|
56
|
+
const system = messages.find((m) => m.role === "system")?.content || "";
|
|
57
|
+
const userMessages = messages.filter((m) => m.role !== "system");
|
|
58
|
+
|
|
59
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"x-api-key": apiKey,
|
|
64
|
+
"anthropic-version": "2023-06-01",
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
model,
|
|
68
|
+
max_tokens: maxTokens,
|
|
69
|
+
temperature,
|
|
70
|
+
system: system || undefined,
|
|
71
|
+
messages: userMessages.map((m) => ({ role: m.role, content: m.content })),
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const data = await res.json().catch(() => ({}));
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const err = new Error(data?.error?.message || `Anthropic API error (${res.status})`);
|
|
78
|
+
err.status = res.status;
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
const block = data.content?.find((c) => c.type === "text");
|
|
82
|
+
return block?.text?.trim() || "";
|
|
83
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const CONSULTANT_SYSTEM_PROMPT =
|
|
2
|
+
"You are SolGuard's expert crypto security consultant. Answer concisely (under 250 words) about Solana token security, wallet safety, rug pulls, smart contract risks, and DeFi best practices. Be direct, technical, and reference specific attack vectors when relevant. End with a clear, actionable recommendation.";
|
|
3
|
+
|
|
4
|
+
export function buildConsultantMessages(query) {
|
|
5
|
+
return [
|
|
6
|
+
{ role: "system", content: CONSULTANT_SYSTEM_PROMPT },
|
|
7
|
+
{ role: "user", content: query },
|
|
8
|
+
];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Parse model output into verdict + recommendations (same logic as legacy execAiConsultant). */
|
|
12
|
+
export function parseConsultantResponse(text, query) {
|
|
13
|
+
const sentences = text.split(/(?<=[.!?])\s+/);
|
|
14
|
+
const verdict = sentences[0] || text.slice(0, 200);
|
|
15
|
+
const recMatch = text.match(/(?:recommend|should|avoid|ensure)[^.!?]*[.!?]/i);
|
|
16
|
+
const recommendations = recMatch
|
|
17
|
+
? [recMatch[0].trim(), "Cross-check advice against on-chain data using SolGuard's token and wallet agents."]
|
|
18
|
+
: [
|
|
19
|
+
"Apply this guidance alongside an on-chain scan before making financial decisions.",
|
|
20
|
+
"Verify any token or wallet mentioned using SolGuard's automated agents.",
|
|
21
|
+
];
|
|
22
|
+
return {
|
|
23
|
+
verdict,
|
|
24
|
+
recommendations: recommendations.slice(0, 4),
|
|
25
|
+
keyFindings: [
|
|
26
|
+
{
|
|
27
|
+
label: "Consultation Topic",
|
|
28
|
+
value: query.slice(0, 120) + (query.length > 120 ? "…" : ""),
|
|
29
|
+
impact: "neutral",
|
|
30
|
+
explanation: "Question submitted for AI security analysis.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
label: "Full Response Length",
|
|
34
|
+
value: `${text.split(/\s+/).length} words`,
|
|
35
|
+
impact: "neutral",
|
|
36
|
+
explanation: "Detailed guidance is available in the raw response below.",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
rawEvidence: {
|
|
40
|
+
question: query,
|
|
41
|
+
fullResponse: text,
|
|
42
|
+
ai_summary_available: true,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Provider registry — official API endpoints only (BYOK calls user's machine → provider). */
|
|
2
|
+
export const PROVIDERS = {
|
|
3
|
+
openai: {
|
|
4
|
+
id: "openai",
|
|
5
|
+
label: "OpenAI",
|
|
6
|
+
type: "openai",
|
|
7
|
+
baseURL: "https://api.openai.com/v1",
|
|
8
|
+
defaultModel: "gpt-4o-mini",
|
|
9
|
+
},
|
|
10
|
+
anthropic: {
|
|
11
|
+
id: "anthropic",
|
|
12
|
+
label: "Anthropic (Claude)",
|
|
13
|
+
type: "anthropic",
|
|
14
|
+
baseURL: "https://api.anthropic.com/v1",
|
|
15
|
+
defaultModel: "claude-sonnet-4-20250514",
|
|
16
|
+
},
|
|
17
|
+
gemini: {
|
|
18
|
+
id: "gemini",
|
|
19
|
+
label: "Google Gemini",
|
|
20
|
+
type: "openai",
|
|
21
|
+
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
22
|
+
defaultModel: "gemini-2.0-flash",
|
|
23
|
+
},
|
|
24
|
+
openrouter: {
|
|
25
|
+
id: "openrouter",
|
|
26
|
+
label: "OpenRouter",
|
|
27
|
+
type: "openai",
|
|
28
|
+
baseURL: "https://openrouter.ai/api/v1",
|
|
29
|
+
defaultModel: process.env.OPENROUTER_MODEL || "openrouter/free",
|
|
30
|
+
defaultHeaders: {
|
|
31
|
+
"HTTP-Referer": "https://www.solguard.space",
|
|
32
|
+
"X-Title": "SolGuard AI",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function getProvider(id) {
|
|
38
|
+
return PROVIDERS[id] || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function listByokProviders() {
|
|
42
|
+
return [PROVIDERS.openai, PROVIDERS.anthropic, PROVIDERS.gemini];
|
|
43
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/** Rule-based static analysis of OpenClaw-style AI agent JSON configs. */
|
|
2
|
+
|
|
3
|
+
function parseAgentConfig(raw) {
|
|
4
|
+
if (raw == null) throw new Error("Config is required");
|
|
5
|
+
if (typeof raw === "object") return raw;
|
|
6
|
+
const trimmed = String(raw).trim();
|
|
7
|
+
if (!trimmed) throw new Error("Config is required");
|
|
8
|
+
let outer = JSON.parse(trimmed);
|
|
9
|
+
if (typeof outer.config === "string") {
|
|
10
|
+
try {
|
|
11
|
+
outer = JSON.parse(outer.config);
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error("Nested config string is not valid JSON");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return outer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const RISKY_TOOLS = new Set([
|
|
20
|
+
"http", "fetch", "axios", "curl", "shell", "exec", "bash", "cmd",
|
|
21
|
+
"filesystem", "fs", "read", "write", "file", "spawn", "subprocess",
|
|
22
|
+
"network", "socket", "wget",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const WEAK_AUTH = new Set(["none", "null", "disabled", "open", "public", ""]);
|
|
26
|
+
|
|
27
|
+
function walkStrings(obj, out = []) {
|
|
28
|
+
if (obj == null) return out;
|
|
29
|
+
if (typeof obj === "string") {
|
|
30
|
+
out.push(obj);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(obj)) {
|
|
34
|
+
for (const v of obj) walkStrings(v, out);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
if (typeof obj === "object") {
|
|
38
|
+
for (const v of Object.values(obj)) walkStrings(v, out);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function analyzeOpenClawConfig(rawConfig) {
|
|
44
|
+
let config;
|
|
45
|
+
try {
|
|
46
|
+
config = parseAgentConfig(rawConfig);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return { error: e.message || "Invalid JSON config" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const findings = [];
|
|
52
|
+
const recommendations = [];
|
|
53
|
+
let score = 0;
|
|
54
|
+
|
|
55
|
+
const gateway = config.gateway || config.auth || {};
|
|
56
|
+
const authVal = (
|
|
57
|
+
gateway.auth ??
|
|
58
|
+
gateway.authentication ??
|
|
59
|
+
config.auth ??
|
|
60
|
+
config.authentication ??
|
|
61
|
+
null
|
|
62
|
+
);
|
|
63
|
+
const authStr = authVal == null ? "" : String(authVal).toLowerCase();
|
|
64
|
+
|
|
65
|
+
if (authStr === "" || WEAK_AUTH.has(authStr)) {
|
|
66
|
+
score += 45;
|
|
67
|
+
findings.push({
|
|
68
|
+
label: "Authentication",
|
|
69
|
+
value: authStr === "" ? "Missing" : `"${authVal}"`,
|
|
70
|
+
impact: "+45 risk",
|
|
71
|
+
explanation: "Gateway accepts connections without meaningful authentication, allowing unauthorized agent control.",
|
|
72
|
+
});
|
|
73
|
+
recommendations.push("Require API keys, OAuth, or mTLS on the agent gateway before production use.");
|
|
74
|
+
} else if (["basic", "password", "token"].includes(authStr)) {
|
|
75
|
+
score += 15;
|
|
76
|
+
findings.push({
|
|
77
|
+
label: "Authentication",
|
|
78
|
+
value: String(authVal),
|
|
79
|
+
impact: "+15 risk",
|
|
80
|
+
explanation: "Authentication mode may be weak without rate limits, rotation, and TLS enforcement.",
|
|
81
|
+
});
|
|
82
|
+
recommendations.push("Use short-lived tokens with rotation and enforce HTTPS-only gateway access.");
|
|
83
|
+
} else {
|
|
84
|
+
findings.push({
|
|
85
|
+
label: "Authentication",
|
|
86
|
+
value: String(authVal),
|
|
87
|
+
impact: "neutral",
|
|
88
|
+
explanation: "An explicit authentication mode is configured on the gateway.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const toolList = [
|
|
93
|
+
...(Array.isArray(config.tools) ? config.tools : []),
|
|
94
|
+
...(Array.isArray(config.skills) ? config.skills : []),
|
|
95
|
+
].map((t) => (typeof t === "string" ? t : t?.name || t?.id || JSON.stringify(t)).toLowerCase());
|
|
96
|
+
|
|
97
|
+
const risky = toolList.filter((t) => [...RISKY_TOOLS].some((r) => t.includes(r)));
|
|
98
|
+
if (risky.length) {
|
|
99
|
+
score += Math.min(40, risky.length * 12);
|
|
100
|
+
findings.push({
|
|
101
|
+
label: "Tool Permissions",
|
|
102
|
+
value: risky.slice(0, 6).join(", ") + (risky.length > 6 ? "…" : ""),
|
|
103
|
+
impact: "+40 risk",
|
|
104
|
+
explanation: "Tools with network, shell, or filesystem access can exfiltrate data or execute arbitrary commands.",
|
|
105
|
+
});
|
|
106
|
+
recommendations.push("Remove or sandbox high-privilege tools; allowlist only required HTTP endpoints.");
|
|
107
|
+
} else {
|
|
108
|
+
findings.push({
|
|
109
|
+
label: "Tool Permissions",
|
|
110
|
+
value: toolList.length ? `${toolList.length} tool(s) listed` : "None declared",
|
|
111
|
+
impact: "neutral",
|
|
112
|
+
explanation: "No obvious unrestricted shell/filesystem tools were declared in the config.",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const strings = walkStrings(config);
|
|
117
|
+
const injectionPatterns = strings.filter((s) =>
|
|
118
|
+
/\{\{\s*user\s*\}\}|\$\{.*user|\{\{\s*input\s*\}\}|raw\s*:\s*true|unsanitized/i.test(s)
|
|
119
|
+
);
|
|
120
|
+
if (injectionPatterns.length) {
|
|
121
|
+
score += 25;
|
|
122
|
+
findings.push({
|
|
123
|
+
label: "Injection Surface",
|
|
124
|
+
value: `${injectionPatterns.length} pattern(s) detected`,
|
|
125
|
+
impact: "+25 risk",
|
|
126
|
+
explanation: "Raw user placeholders or unsanitized message-passing can enable prompt injection or template abuse.",
|
|
127
|
+
});
|
|
128
|
+
recommendations.push("Sanitize and validate all user input before passing it to tools or system prompts.");
|
|
129
|
+
} else {
|
|
130
|
+
findings.push({
|
|
131
|
+
label: "Injection Surface",
|
|
132
|
+
value: "No raw user placeholders found",
|
|
133
|
+
impact: "neutral",
|
|
134
|
+
explanation: "No obvious unsanitized user-input templates were detected in the config strings.",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const session = config.sessionId ?? config.session ?? config.sessions;
|
|
139
|
+
const sessionIssues = [];
|
|
140
|
+
if (session != null && typeof session === "string" && session.length < 16) {
|
|
141
|
+
sessionIssues.push("predictable session id");
|
|
142
|
+
}
|
|
143
|
+
if (config.persistSession === true || config.sessionPersist === true) {
|
|
144
|
+
sessionIssues.push("persistent sessions without expiry");
|
|
145
|
+
}
|
|
146
|
+
if (sessionIssues.length) {
|
|
147
|
+
score += 20;
|
|
148
|
+
findings.push({
|
|
149
|
+
label: "Session Handling",
|
|
150
|
+
value: sessionIssues.join("; "),
|
|
151
|
+
impact: "+20 risk",
|
|
152
|
+
explanation: "Weak session identifiers or indefinite persistence increase hijacking and replay risk.",
|
|
153
|
+
});
|
|
154
|
+
recommendations.push("Use cryptographically random session IDs with explicit TTL and rotation.");
|
|
155
|
+
} else {
|
|
156
|
+
findings.push({
|
|
157
|
+
label: "Session Handling",
|
|
158
|
+
value: session ? "Configured" : "Not specified",
|
|
159
|
+
impact: "neutral",
|
|
160
|
+
explanation: "No obvious session fixation or persistence issues were detected in the config structure.",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
score = Math.min(100, score);
|
|
165
|
+
const riskLevel = score >= 60 ? "HIGH" : score >= 30 ? "MEDIUM" : "LOW";
|
|
166
|
+
const agentName = config.name || "unnamed agent";
|
|
167
|
+
const verdict =
|
|
168
|
+
score >= 60
|
|
169
|
+
? `The "${agentName}" configuration has critical gaps — ${findings.filter((f) => f.impact.includes("+")).length} high-risk checks failed in this automated audit.`
|
|
170
|
+
: score >= 30
|
|
171
|
+
? `The "${agentName}" configuration passes basic checks but has moderate security gaps that should be fixed before production.`
|
|
172
|
+
: `The "${agentName}" configuration passed this automated audit with no critical rule violations detected.`;
|
|
173
|
+
|
|
174
|
+
if (!recommendations.length) {
|
|
175
|
+
recommendations.push("Re-run this audit after any gateway, tool, or session configuration change.");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
config,
|
|
180
|
+
riskScore: score,
|
|
181
|
+
riskLevel,
|
|
182
|
+
verdict,
|
|
183
|
+
keyFindings: findings,
|
|
184
|
+
recommendations: recommendations.slice(0, 4),
|
|
185
|
+
confidence: "High — deterministic rule-based analysis of submitted JSON structure (not LLM inference)",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Never surface 0 in UI/API — floor at 2 so "zero risk" reads as a small positive score. */
|
|
2
|
+
export const MIN_RISK_SCORE = 2;
|
|
3
|
+
|
|
4
|
+
export function normalizeRiskScore(score) {
|
|
5
|
+
const n = Math.round(Number(score));
|
|
6
|
+
if (!Number.isFinite(n) || n <= 0) return MIN_RISK_SCORE;
|
|
7
|
+
return Math.min(100, n);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Standard agent report envelope — keeps legacy `summary` / `evidence` aliases. */
|
|
11
|
+
export function buildReport({
|
|
12
|
+
agentId,
|
|
13
|
+
input,
|
|
14
|
+
riskScore,
|
|
15
|
+
riskLevel,
|
|
16
|
+
verdict,
|
|
17
|
+
keyFindings = [],
|
|
18
|
+
recommendations = [],
|
|
19
|
+
confidence,
|
|
20
|
+
dataSource = [],
|
|
21
|
+
rawEvidence = {},
|
|
22
|
+
scannedAt = new Date().toISOString(),
|
|
23
|
+
ai_summary_available,
|
|
24
|
+
ai_summary_reason,
|
|
25
|
+
}) {
|
|
26
|
+
const report = {
|
|
27
|
+
agentId,
|
|
28
|
+
input,
|
|
29
|
+
riskScore: normalizeRiskScore(riskScore),
|
|
30
|
+
riskLevel,
|
|
31
|
+
scannedAt,
|
|
32
|
+
dataSource,
|
|
33
|
+
verdict,
|
|
34
|
+
summary: verdict,
|
|
35
|
+
keyFindings,
|
|
36
|
+
recommendations,
|
|
37
|
+
confidence,
|
|
38
|
+
rawEvidence,
|
|
39
|
+
evidence: rawEvidence,
|
|
40
|
+
};
|
|
41
|
+
if (ai_summary_available !== undefined) {
|
|
42
|
+
report.ai_summary_available = ai_summary_available;
|
|
43
|
+
report.ai_summary_reason = ai_summary_reason ?? null;
|
|
44
|
+
}
|
|
45
|
+
return report;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function finding(label, value, impact, explanation) {
|
|
49
|
+
return { label, value, impact, explanation };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function impactFromScore(delta) {
|
|
53
|
+
if (delta === 0) return "neutral";
|
|
54
|
+
return delta > 0 ? `+${delta} risk` : `${delta} risk`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatSol(lamports) {
|
|
58
|
+
const n = Number(lamports) / 1e9;
|
|
59
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M SOL`;
|
|
60
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(2)}K SOL`;
|
|
61
|
+
if (n >= 1) return `${n.toFixed(4)} SOL`;
|
|
62
|
+
return `${n.toFixed(6)} SOL`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build a holder-concentration finding; never reports 0% when data was unavailable. */
|
|
66
|
+
export function holderConcentrationFinding(label, bundle, { highThreshold = 20, top10Threshold = 50, isTop10 = false } = {}) {
|
|
67
|
+
const available = bundle.holderDataAvailable === true;
|
|
68
|
+
const pct = isTop10 ? bundle.top10Percent : bundle.topHolderPercent;
|
|
69
|
+
if (!available) {
|
|
70
|
+
return finding(
|
|
71
|
+
label,
|
|
72
|
+
"Unable to verify",
|
|
73
|
+
"unknown",
|
|
74
|
+
bundle.holderDataError
|
|
75
|
+
? `Holder data could not be fetched (${bundle.holderDataError}) — zero concentration must not be assumed.`
|
|
76
|
+
: "Holder concentration could not be verified on-chain — do not assume zero concentration."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const threshold = isTop10 ? top10Threshold : highThreshold;
|
|
80
|
+
const impact = pct > threshold ? `+${isTop10 ? 25 : 10} risk` : "neutral";
|
|
81
|
+
const explanation = isTop10
|
|
82
|
+
? (pct > threshold
|
|
83
|
+
? "A small group could coordinate a mass sell-off."
|
|
84
|
+
: "Supply is spread across many holders rather than a tight cluster.")
|
|
85
|
+
: (pct > threshold
|
|
86
|
+
? "A single wallet controls a large share, increasing dump risk."
|
|
87
|
+
: "No single wallet dominates supply, which supports healthier distribution.");
|
|
88
|
+
return finding(label, `${pct.toFixed(1)}%`, impact, explanation);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function holderShareLabel(bundle, isTop10 = false) {
|
|
92
|
+
if (bundle.holderDataAvailable !== true) return "Unable to verify";
|
|
93
|
+
const pct = isTop10 ? bundle.top10Percent : bundle.topHolderPercent;
|
|
94
|
+
return `${pct.toFixed(1)}%`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Deterministic one-sentence verdict when AI output is missing or invalid. */
|
|
98
|
+
export function buildOnChainVerdict(scan) {
|
|
99
|
+
const flag = scan.riskFactors?.length
|
|
100
|
+
? scan.riskFactors[0].replace(/\.\s*$/, "")
|
|
101
|
+
: "no major red flags detected";
|
|
102
|
+
const top10 = holderShareLabel(scan.bundleDetection, true);
|
|
103
|
+
const score = normalizeRiskScore(scan.riskScore);
|
|
104
|
+
return `This token scores ${score}/100 (${scan.riskLevel}) with mint authority ${scan.authorityCheck.mintAuthority.toLowerCase()}, freeze authority ${scan.authorityCheck.freezeAuthority.toLowerCase()}, and top 10 holders at ${top10} — ${flag}.`;
|
|
105
|
+
}
|