@weave_protocol/mund 0.3.0 → 0.4.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/DETECTION_CLAIMS.md +48 -0
- package/README.md +91 -30
- package/dist/agent-audit.d.ts +139 -0
- package/dist/agent-audit.d.ts.map +1 -0
- package/dist/agent-audit.js +424 -0
- package/dist/agent-audit.js.map +1 -0
- package/dist/analyzers/index.d.ts.map +1 -1
- package/dist/analyzers/index.js +3 -1
- package/dist/analyzers/index.js.map +1 -1
- package/dist/analyzers/mcp-server-analyzer.d.ts.map +1 -1
- package/dist/analyzers/mcp-server-analyzer.js +4 -0
- package/dist/analyzers/mcp-server-analyzer.js.map +1 -1
- package/dist/canary.d.ts +48 -0
- package/dist/canary.d.ts.map +1 -0
- package/dist/canary.js +149 -0
- package/dist/canary.js.map +1 -0
- package/dist/cli/agent.d.ts +9 -0
- package/dist/cli/agent.d.ts.map +1 -0
- package/dist/cli/agent.js +91 -0
- package/dist/cli/agent.js.map +1 -0
- package/dist/cli/scan.d.ts +2 -1
- package/dist/cli/scan.d.ts.map +1 -1
- package/dist/cli/scan.js +10 -1
- package/dist/cli/scan.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/rugpull.d.ts +79 -0
- package/dist/rugpull.d.ts.map +1 -0
- package/dist/rugpull.js +190 -0
- package/dist/rugpull.js.map +1 -0
- package/dist/tools/canary-rugpull.d.ts +7 -0
- package/dist/tools/canary-rugpull.d.ts.map +1 -0
- package/dist/tools/canary-rugpull.js +50 -0
- package/dist/tools/canary-rugpull.js.map +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/monitoring.js +2 -2
- package/dist/tools/monitoring.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-audit.ts +499 -0
- package/src/analyzers/index.ts +3 -1
- package/src/analyzers/mcp-server-analyzer.ts +3 -0
- package/src/canary.ts +165 -0
- package/src/cli/agent.ts +80 -0
- package/src/cli/scan.ts +10 -1
- package/src/index.ts +11 -0
- package/src/rugpull.ts +247 -0
- package/src/tools/canary-rugpull.ts +75 -0
- package/src/tools/index.ts +5 -0
- package/src/tools/monitoring.ts +2 -2
- package/tests/analyzers/engine.test.ts +1 -1
- package/tests/analyzers/mcp-server-analyzer.test.ts +1 -3
- package/tests/canary.test.ts +79 -0
- package/tests/cli/scan.test.ts +2 -2
- package/tests/fixtures/canary/base64.json +9 -0
- package/tests/fixtures/canary/clean-output.json +8 -0
- package/tests/fixtures/canary/exact-leak.json +9 -0
- package/tests/fixtures/canary/lowercased.json +9 -0
- package/tests/fixtures/canary/other-token.json +8 -0
- package/tests/fixtures/canary/prefix-only.json +8 -0
- package/tests/fixtures/canary/reversed.json +9 -0
- package/tests/fixtures/canary/spaced-out.json +9 -0
- package/tests/fixtures/canary/zero-width.json +9 -0
- package/tests/fixtures/preinstall/fake-mcp-server.mjs +8 -0
- package/tests/fixtures/preinstall/mcp-config/clean-pinned.json +13 -0
- package/tests/fixtures/preinstall/mcp-config/env-reference.json +15 -0
- package/tests/fixtures/preinstall/mcp-config/exfil-service-url.json +10 -0
- package/tests/fixtures/preinstall/mcp-config/inline-token.json +16 -0
- package/tests/fixtures/preinstall/mcp-config/name-typosquat.json +13 -0
- package/tests/fixtures/preinstall/mcp-config/plain-http-remote.json +10 -0
- package/tests/fixtures/preinstall/mcp-config/scope-typosquat.json +13 -0
- package/tests/fixtures/preinstall/mcp-config/shell-pipeline-launcher.json +13 -0
- package/tests/fixtures/preinstall/mcp-config/unpinned-package.json +14 -0
- package/tests/fixtures/preinstall/skills/bidi-override/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/bidi-override/expected.json +4 -0
- package/tests/fixtures/preinstall/skills/clean/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/clean/expected.json +3 -0
- package/tests/fixtures/preinstall/skills/curl-bash-installer/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/curl-bash-installer/expected.json +4 -0
- package/tests/fixtures/preinstall/skills/embedded-secret/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/embedded-secret/expected.json +4 -0
- package/tests/fixtures/preinstall/skills/exfil-url/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/exfil-url/expected.json +4 -0
- package/tests/fixtures/preinstall/skills/hidden-tag-characters/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/hidden-tag-characters/expected.json +4 -0
- package/tests/fixtures/rugpull/description-changed/baseline.json +36 -0
- package/tests/fixtures/rugpull/description-changed/current.json +36 -0
- package/tests/fixtures/rugpull/description-changed/expected.json +11 -0
- package/tests/fixtures/rugpull/gains-execution/baseline.json +36 -0
- package/tests/fixtures/rugpull/gains-execution/current.json +36 -0
- package/tests/fixtures/rugpull/gains-execution/expected.json +18 -0
- package/tests/fixtures/rugpull/schema-changed/baseline.json +36 -0
- package/tests/fixtures/rugpull/schema-changed/current.json +39 -0
- package/tests/fixtures/rugpull/schema-changed/expected.json +11 -0
- package/tests/fixtures/rugpull/schema-key-reorder/baseline.json +36 -0
- package/tests/fixtures/rugpull/schema-key-reorder/current.json +36 -0
- package/tests/fixtures/rugpull/schema-key-reorder/expected.json +5 -0
- package/tests/fixtures/rugpull/server-renamed/baseline.json +36 -0
- package/tests/fixtures/rugpull/server-renamed/current.json +36 -0
- package/tests/fixtures/rugpull/server-renamed/expected.json +11 -0
- package/tests/fixtures/rugpull/tool-added/baseline.json +36 -0
- package/tests/fixtures/rugpull/tool-added/current.json +44 -0
- package/tests/fixtures/rugpull/tool-added/expected.json +11 -0
- package/tests/fixtures/rugpull/tool-removed/baseline.json +36 -0
- package/tests/fixtures/rugpull/tool-removed/current.json +21 -0
- package/tests/fixtures/rugpull/tool-removed/expected.json +11 -0
- package/tests/fixtures/rugpull/unchanged/baseline.json +36 -0
- package/tests/fixtures/rugpull/unchanged/current.json +36 -0
- package/tests/fixtures/rugpull/unchanged/expected.json +5 -0
- package/tests/fixtures.test.ts +40 -0
- package/tests/preinstall.test.ts +153 -0
- package/tests/rugpull.test.ts +80 -0
- package/tests/tools/canary-rugpull.test.ts +57 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mund - The Guardian Protocol
|
|
3
|
+
* Agent audit: pre-install checks for skills and MCP servers
|
|
4
|
+
*
|
|
5
|
+
* Backs the "pre-install" hooks in the Weave Protocol harness adapters:
|
|
6
|
+
*
|
|
7
|
+
* - Skills: scan SKILL.md and every text file in a skill directory for
|
|
8
|
+
* prompt injection, exfiltration, dangerous code, secrets and hidden
|
|
9
|
+
* Unicode (invisible "tag" characters can smuggle instructions that a
|
|
10
|
+
* human reviewer never sees).
|
|
11
|
+
* - MCP servers: static checks on how each configured server is launched
|
|
12
|
+
* (package typosquats, unpinned packages, insecure or exfil URLs, inline
|
|
13
|
+
* secrets), plus an optional live check that connects, lists the tools,
|
|
14
|
+
* scans their descriptions, and compares them with the rug-pull baseline.
|
|
15
|
+
*
|
|
16
|
+
* SAFETY: the live check starts server processes. It only does that for
|
|
17
|
+
* servers the user configured themselves (user/local scope) or explicitly
|
|
18
|
+
* approved for this project. Project `.mcp.json` servers that Claude Code
|
|
19
|
+
* would still ask about are checked statically only, so opening a
|
|
20
|
+
* malicious repository never runs its servers through Mund.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'crypto';
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
25
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
26
|
+
import { homedir } from 'os';
|
|
27
|
+
import { dirname, join, resolve } from 'path';
|
|
28
|
+
import type { SecurityIssue, Severity } from './types.js';
|
|
29
|
+
import { DEFAULT_RULES, DANGEROUS_URL_PATTERNS } from './constants.js';
|
|
30
|
+
import { AnalyzerEngine } from './analyzers/index.js';
|
|
31
|
+
import { SecretScanner } from './analyzers/secret-scanner.js';
|
|
32
|
+
import { McpServerAnalyzer, type McpServerManifest } from './analyzers/mcp-server-analyzer.js';
|
|
33
|
+
import { computeRiskScore, type RiskScore } from './scoring.js';
|
|
34
|
+
import { BaselineStore, checkRugPull, changesToIssues, type RugPullResult } from './rugpull.js';
|
|
35
|
+
import { getVersion } from './version.js';
|
|
36
|
+
|
|
37
|
+
export type Verdict = 'allow' | 'warn' | 'block';
|
|
38
|
+
|
|
39
|
+
/** Which agent harness's config layout to audit. */
|
|
40
|
+
export type Harness = 'claude' | 'antigravity';
|
|
41
|
+
|
|
42
|
+
export interface SkillAudit {
|
|
43
|
+
name: string;
|
|
44
|
+
path: string;
|
|
45
|
+
scope: 'project' | 'user' | 'workspace' | 'global' | 'plugin';
|
|
46
|
+
verdict: Verdict;
|
|
47
|
+
risk: RiskScore;
|
|
48
|
+
issues: SecurityIssue[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface McpServerConfig {
|
|
52
|
+
type?: string;
|
|
53
|
+
command?: string;
|
|
54
|
+
args?: string[];
|
|
55
|
+
env?: Record<string, string>;
|
|
56
|
+
url?: string;
|
|
57
|
+
/** Antigravity spelling of url */
|
|
58
|
+
serverUrl?: string;
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface McpServerAudit {
|
|
64
|
+
name: string;
|
|
65
|
+
scope: 'project' | 'local' | 'user' | 'desktop' | 'workspace' | 'global';
|
|
66
|
+
transport: 'stdio' | 'http' | 'sse' | 'unknown';
|
|
67
|
+
target: string;
|
|
68
|
+
verdict: Verdict;
|
|
69
|
+
risk: RiskScore;
|
|
70
|
+
issues: SecurityIssue[];
|
|
71
|
+
live: { status: 'ok' | 'error' | 'skipped'; reason?: string; tools?: number; rugpull?: RugPullResult['status'] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AgentAuditReport {
|
|
75
|
+
version: 1;
|
|
76
|
+
tool: { name: 'mund'; version: string };
|
|
77
|
+
generatedAt: string;
|
|
78
|
+
cwd: string;
|
|
79
|
+
servers: McpServerAudit[];
|
|
80
|
+
skills: SkillAudit[];
|
|
81
|
+
summary: Record<Verdict, number>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// mcp_rugpull is deliberately absent: a plain change (reworded description,
|
|
85
|
+
// new optional parameter) warns until accepted; only escalated changes
|
|
86
|
+
// (now malicious-looking or newly executing commands) are critical → block.
|
|
87
|
+
const BLOCK_TYPES = new Set(['injection', 'exfiltration', 'hidden_unicode']);
|
|
88
|
+
const MAX_FILE = 512 * 1024;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* critical → block; high in a blocking category (injection, exfiltration,
|
|
92
|
+
* hidden Unicode) → block; other high/medium → warn; else allow.
|
|
93
|
+
*/
|
|
94
|
+
export function verdictFor(issues: Array<Pick<SecurityIssue, 'severity' | 'type'>>): Verdict {
|
|
95
|
+
let verdict: Verdict = 'allow';
|
|
96
|
+
for (const i of issues) {
|
|
97
|
+
if (i.severity === 'critical' || (i.severity === 'high' && BLOCK_TYPES.has(i.type))) return 'block';
|
|
98
|
+
if (i.severity === 'high' || i.severity === 'medium') verdict = 'warn';
|
|
99
|
+
}
|
|
100
|
+
return verdict;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Skills
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
const engine = new AnalyzerEngine();
|
|
108
|
+
|
|
109
|
+
/** Invisible characters used to hide instructions from human reviewers. */
|
|
110
|
+
const HIDDEN_CHARS: Array<{ re: RegExp; name: string; severity: Severity }> = [
|
|
111
|
+
{ re: /[\u{E0000}-\u{E007F}]/u, name: 'Unicode tag characters (invisible ASCII smuggling)', severity: 'critical' },
|
|
112
|
+
{ re: /[--]/, name: 'bidirectional override characters', severity: 'high' },
|
|
113
|
+
{ re: /[-]{3,}/, name: 'runs of zero-width characters', severity: 'high' },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
function hiddenUnicode(content: string): SecurityIssue[] {
|
|
117
|
+
const issues: SecurityIssue[] = [];
|
|
118
|
+
for (const h of HIDDEN_CHARS) {
|
|
119
|
+
const m = h.re.exec(content);
|
|
120
|
+
if (!m) continue;
|
|
121
|
+
const before = content.slice(0, m.index).split('\n');
|
|
122
|
+
issues.push({
|
|
123
|
+
rule_id: 'hidden_unicode',
|
|
124
|
+
rule_name: `Hidden text: ${h.name}`,
|
|
125
|
+
type: 'hidden_unicode',
|
|
126
|
+
severity: h.severity,
|
|
127
|
+
action: 'block',
|
|
128
|
+
match: `U+${m[0].codePointAt(0)!.toString(16).toUpperCase()}`,
|
|
129
|
+
location: { start: m.index, end: m.index + m[0].length, line: before.length, column: before[before.length - 1].length + 1 },
|
|
130
|
+
suggestion: 'Text that is invisible to reviewers but readable by the model. Remove it or reject this skill.',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return issues;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Scan one piece of skill content (a SKILL.md or a file shipped with the skill). */
|
|
137
|
+
export async function scanSkillContent(content: string): Promise<SecurityIssue[]> {
|
|
138
|
+
return [...hiddenUnicode(content), ...(await engine.analyzeAll(content, DEFAULT_RULES))];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function textFiles(dir: string, out: string[] = [], depth = 0): string[] {
|
|
142
|
+
if (depth > 4) return out;
|
|
143
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
144
|
+
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
145
|
+
const p = join(dir, e.name);
|
|
146
|
+
if (e.isDirectory()) textFiles(p, out, depth + 1);
|
|
147
|
+
else if (e.isFile() && statSync(p).size <= MAX_FILE) out.push(p);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function scanSkillDir(dir: string, scope: SkillAudit['scope'] = 'project'): Promise<SkillAudit> {
|
|
153
|
+
const issues: SecurityIssue[] = [];
|
|
154
|
+
for (const file of textFiles(dir)) {
|
|
155
|
+
const buf = readFileSync(file);
|
|
156
|
+
if (buf.subarray(0, 4096).includes(0)) continue; // binary
|
|
157
|
+
for (const issue of await scanSkillContent(buf.toString('utf8'))) {
|
|
158
|
+
issues.push({ ...issue, rule_name: `${issue.rule_name} (${file.slice(dir.length + 1)})` });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { name: dir.split(/[\\/]/).pop()!, path: dir, scope, verdict: verdictFor(issues), risk: computeRiskScore(issues), issues };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function skillRoots(cwd: string, home = homedir(), harness: Harness = 'claude'): Array<[string, SkillAudit['scope']]> {
|
|
165
|
+
if (harness === 'antigravity') {
|
|
166
|
+
const roots: Array<[string, SkillAudit['scope']]> = [
|
|
167
|
+
[join(cwd, '.agents', 'skills'), 'workspace'],
|
|
168
|
+
[join(cwd, '.agent', 'skills'), 'workspace'],
|
|
169
|
+
[join(home, '.gemini', 'config', 'skills'), 'global'],
|
|
170
|
+
[join(home, '.gemini', 'antigravity-cli', 'skills'), 'global'],
|
|
171
|
+
[join(home, '.gemini', 'antigravity', 'skills'), 'global'],
|
|
172
|
+
];
|
|
173
|
+
const plugins = join(home, '.gemini', 'antigravity-cli', 'plugins');
|
|
174
|
+
if (existsSync(plugins)) {
|
|
175
|
+
for (const p of readdirSync(plugins, { withFileTypes: true })) if (p.isDirectory()) roots.push([join(plugins, p.name, 'skills'), 'plugin']);
|
|
176
|
+
}
|
|
177
|
+
return roots;
|
|
178
|
+
}
|
|
179
|
+
return [[join(cwd, '.claude', 'skills'), 'project'], [join(home, '.claude', 'skills'), 'user']];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function discoverSkillDirs(cwd: string, home = homedir(), harness: Harness = 'claude'): Array<{ dir: string; scope: SkillAudit['scope'] }> {
|
|
183
|
+
const roots = skillRoots(cwd, home, harness);
|
|
184
|
+
const out: Array<{ dir: string; scope: SkillAudit['scope'] }> = [];
|
|
185
|
+
for (const [root, scope] of roots) {
|
|
186
|
+
if (!existsSync(root)) continue;
|
|
187
|
+
for (const e of readdirSync(root, { withFileTypes: true })) {
|
|
188
|
+
if (e.isDirectory() && existsSync(join(root, e.name, 'SKILL.md'))) out.push({ dir: join(root, e.name), scope });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// MCP servers
|
|
196
|
+
// ============================================================================
|
|
197
|
+
|
|
198
|
+
interface DiscoveredServer {
|
|
199
|
+
name: string;
|
|
200
|
+
scope: McpServerAudit['scope'];
|
|
201
|
+
config: McpServerConfig;
|
|
202
|
+
/** Started by the user's own configuration or explicitly approved; safe to start for a live check. */
|
|
203
|
+
approved: boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readJson(path: string): Record<string, any> | null {
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Claude Desktop's config, which also supplies MCP servers to its Code tab. */
|
|
215
|
+
export function claudeDesktopConfigPath(home = homedir()): string {
|
|
216
|
+
if (process.platform === 'win32') return join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
|
|
217
|
+
if (process.platform === 'darwin') return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
218
|
+
return join(home, '.config', 'Claude', 'claude_desktop_config.json');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Claude Code MCP config: <cwd>/.mcp.json (project), ~/.claude.json (user +
|
|
223
|
+
* per-project local), and Claude Desktop's claude_desktop_config.json.
|
|
224
|
+
*/
|
|
225
|
+
export function discoverMcpServers(cwd: string, home = homedir()): DiscoveredServer[] {
|
|
226
|
+
const out: DiscoveredServer[] = [];
|
|
227
|
+
const claudeJson = readJson(join(home, '.claude.json')) ?? {};
|
|
228
|
+
const projectEntry = claudeJson.projects?.[cwd] ?? {};
|
|
229
|
+
const settings = [join(cwd, '.claude', 'settings.json'), join(cwd, '.claude', 'settings.local.json'), join(home, '.claude', 'settings.json')]
|
|
230
|
+
.map(readJson)
|
|
231
|
+
.filter(Boolean) as Array<Record<string, any>>;
|
|
232
|
+
const enableAll = settings.some((s) => s.enableAllProjectMcpServers === true) || projectEntry.enableAllProjectMcpServers === true;
|
|
233
|
+
const enabled = new Set<string>([
|
|
234
|
+
...(projectEntry.enabledMcpjsonServers ?? []),
|
|
235
|
+
...settings.flatMap((s) => s.enabledMcpjsonServers ?? []),
|
|
236
|
+
]);
|
|
237
|
+
const disabled = new Set<string>([...(projectEntry.disabledMcpjsonServers ?? []), ...settings.flatMap((s) => s.disabledMcpjsonServers ?? [])]);
|
|
238
|
+
|
|
239
|
+
for (const [name, config] of Object.entries<McpServerConfig>(readJson(join(cwd, '.mcp.json'))?.mcpServers ?? {})) {
|
|
240
|
+
out.push({ name, scope: 'project', config, approved: !disabled.has(name) && (enableAll || enabled.has(name)) });
|
|
241
|
+
}
|
|
242
|
+
for (const [name, config] of Object.entries<McpServerConfig>(projectEntry.mcpServers ?? {})) {
|
|
243
|
+
out.push({ name, scope: 'local', config, approved: true });
|
|
244
|
+
}
|
|
245
|
+
for (const [name, config] of Object.entries<McpServerConfig>(claudeJson.mcpServers ?? {})) {
|
|
246
|
+
out.push({ name, scope: 'user', config, approved: true });
|
|
247
|
+
}
|
|
248
|
+
const seen = new Set(out.map((s) => s.name));
|
|
249
|
+
for (const [name, config] of Object.entries<McpServerConfig>(readJson(claudeDesktopConfigPath(home))?.mcpServers ?? {})) {
|
|
250
|
+
if (!seen.has(name)) out.push({ name, scope: 'desktop', config, approved: true });
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Antigravity MCP config: <workspace>/.agents/mcp_config.json and
|
|
257
|
+
* ~/.gemini/config/mcp_config.json. Workspace servers are checked
|
|
258
|
+
* statically only (same consent rule as Claude Code project servers);
|
|
259
|
+
* servers marked "disabled" are never started.
|
|
260
|
+
*/
|
|
261
|
+
export function discoverAntigravityMcpServers(cwd: string, home = homedir()): DiscoveredServer[] {
|
|
262
|
+
const out: DiscoveredServer[] = [];
|
|
263
|
+
const add = (path: string, scope: McpServerAudit['scope'], approved: boolean) => {
|
|
264
|
+
for (const [name, raw] of Object.entries<McpServerConfig>(readJson(path)?.mcpServers ?? {})) {
|
|
265
|
+
const config = { ...raw, url: raw.url ?? raw.serverUrl };
|
|
266
|
+
out.push({ name, scope, config, approved: approved && !raw.disabled });
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
add(join(cwd, '.agents', 'mcp_config.json'), 'workspace', false);
|
|
270
|
+
add(join(home, '.gemini', 'config', 'mcp_config.json'), 'global', true);
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Expand ${VAR} and ${VAR:-default} the way Claude Code does in .mcp.json. */
|
|
275
|
+
function expand(value: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
276
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, k, d) => env[k] ?? d ?? '');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const PACKAGE_RUNNERS = new Set(['npx', 'bunx', 'uvx', 'pipx', 'pnpx']);
|
|
280
|
+
const OFFICIAL_SCOPE = 'modelcontextprotocol';
|
|
281
|
+
|
|
282
|
+
function lev(a: string, b: string): number {
|
|
283
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
284
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
285
|
+
for (let i = 1; i <= a.length; i++)
|
|
286
|
+
for (let j = 1; j <= b.length; j++)
|
|
287
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
288
|
+
return d[a.length][b.length];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The package a launcher like `npx -y pkg@1.2.3` runs, if any. */
|
|
292
|
+
export function launchedPackage(config: McpServerConfig): { name: string; version?: string } | null {
|
|
293
|
+
const cmd = (config.command ?? '').split(/[\\/]/).pop() ?? '';
|
|
294
|
+
let args = config.args ?? [];
|
|
295
|
+
if (cmd === 'pnpm' || cmd === 'yarn') args = args[0] === 'dlx' ? args.slice(1) : [];
|
|
296
|
+
else if (cmd === 'pipx') args = args[0] === 'run' ? args.slice(1) : [];
|
|
297
|
+
else if (!PACKAGE_RUNNERS.has(cmd)) return null;
|
|
298
|
+
const spec = args.find((a) => !a.startsWith('-'));
|
|
299
|
+
if (!spec) return null;
|
|
300
|
+
const at = spec.lastIndexOf('@');
|
|
301
|
+
return at > 0 ? { name: spec.slice(0, at), version: spec.slice(at + 1) } : { name: spec };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const finding = (rule_id: string, rule_name: string, severity: Severity, type: string, match: string, suggestion: string): SecurityIssue => ({
|
|
305
|
+
rule_id, rule_name, severity, type, action: severity === 'critical' ? 'block' : 'alert', match, suggestion,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
/** Static checks on how a server is launched. No processes are started. */
|
|
309
|
+
export async function checkMcpServerConfig(name: string, config: McpServerConfig): Promise<SecurityIssue[]> {
|
|
310
|
+
const issues: SecurityIssue[] = [];
|
|
311
|
+
|
|
312
|
+
// Inline secrets in args, env, headers or URL
|
|
313
|
+
const secrets = await new SecretScanner().analyze(
|
|
314
|
+
[...(config.args ?? []), ...Object.values(config.env ?? {}), ...Object.values(config.headers ?? {}), config.url ?? ''].filter((v) => !/^\$\{[^}]+\}$/.test(v)).join('\n'),
|
|
315
|
+
DEFAULT_RULES,
|
|
316
|
+
);
|
|
317
|
+
for (const s of secrets) {
|
|
318
|
+
issues.push(finding('mcp_config_inline_secret', `Secret written into MCP config: ${s.rule_name}`, 'high', 'secret', s.match,
|
|
319
|
+
'Reference it as ${ENV_VAR} instead; config files get committed and shared.'));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Package typosquats and unpinned packages
|
|
323
|
+
const pkg = launchedPackage(config);
|
|
324
|
+
if (pkg) {
|
|
325
|
+
const scope = pkg.name.startsWith('@') ? pkg.name.slice(1).split('/')[0] : null;
|
|
326
|
+
if (scope && scope !== OFFICIAL_SCOPE && lev(scope, OFFICIAL_SCOPE) <= 3) {
|
|
327
|
+
issues.push(finding('mcp_package_scope_typosquat', `Package scope "@${scope}" imitates the official "@${OFFICIAL_SCOPE}"`, 'critical', 'supply_chain', pkg.name,
|
|
328
|
+
'Do not run this server. Check the package name character by character.'));
|
|
329
|
+
}
|
|
330
|
+
const short = pkg.name.split('/').pop()!.replace(/^(mcp-server-|server-|mcp-)|(-mcp-server|-mcp|-server)$/g, '');
|
|
331
|
+
for (const t of new McpServerAnalyzer().checkTyposquatting({ name: short, version: '0' } as McpServerManifest)) {
|
|
332
|
+
issues.push({ ...t, type: 'supply_chain', rule_name: `${t.rule_name} (package ${pkg.name})` });
|
|
333
|
+
}
|
|
334
|
+
if (!pkg.version || pkg.version === 'latest') {
|
|
335
|
+
issues.push(finding('mcp_unpinned_package', `Package ${pkg.name} is not pinned to a version`, 'low', 'supply_chain', pkg.name,
|
|
336
|
+
`Pin it (e.g. ${pkg.name}@1.2.3) so an upstream update can't change the server under you.`));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Remote URL checks
|
|
341
|
+
if (config.url) {
|
|
342
|
+
const url = expand(config.url);
|
|
343
|
+
for (const p of DANGEROUS_URL_PATTERNS) {
|
|
344
|
+
if (p.test(url)) issues.push(finding('mcp_dangerous_url', 'MCP server URL points at a data-collection service', 'critical', 'exfiltration', url, 'Remove this server.'));
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const u = new URL(url);
|
|
348
|
+
const local = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname);
|
|
349
|
+
if (u.protocol === 'http:' && !local) {
|
|
350
|
+
issues.push(finding('mcp_insecure_transport', 'Remote MCP server over plain HTTP', 'medium', 'transport', url, 'Use https:// so tool traffic and tokens are encrypted.'));
|
|
351
|
+
}
|
|
352
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(u.hostname) && !local) {
|
|
353
|
+
issues.push(finding('mcp_ip_url', 'Remote MCP server addressed by raw IP', 'medium', 'transport', url, 'Prefer a named, TLS-verified host.'));
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
issues.push(finding('mcp_bad_url', 'MCP server URL does not parse', 'medium', 'transport', url, 'Fix the URL.'));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Dangerous launch commands (e.g. sh -c "curl … | bash")
|
|
361
|
+
const launch = [config.command ?? '', ...(config.args ?? [])].join(' ');
|
|
362
|
+
for (const i of await engine.analyzeAll(launch, DEFAULT_RULES.filter((r) => r.type === 'code_pattern'))) {
|
|
363
|
+
issues.push({ ...i, rule_name: `Launch command: ${i.rule_name}` });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
void name;
|
|
367
|
+
return issues;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Connect to a server, list its tools, and return them as a manifest. */
|
|
371
|
+
export async function fetchLiveManifest(name: string, config: McpServerConfig, timeoutMs = 8000): Promise<McpServerManifest> {
|
|
372
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
373
|
+
const client = new Client({ name: 'mund-agent-audit', version: getVersion() });
|
|
374
|
+
let transport: any;
|
|
375
|
+
if (config.url) {
|
|
376
|
+
if (config.type === 'sse') throw new Error('SSE transport not checked live (deprecated); static checks only');
|
|
377
|
+
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
|
378
|
+
const headers = Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v)]));
|
|
379
|
+
transport = new StreamableHTTPClientTransport(new URL(expand(config.url)), { requestInit: { headers } });
|
|
380
|
+
} else if (config.command) {
|
|
381
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
|
|
382
|
+
transport = new StdioClientTransport({
|
|
383
|
+
command: expand(config.command),
|
|
384
|
+
args: (config.args ?? []).map((a) => expand(a)),
|
|
385
|
+
env: { ...(process.env as Record<string, string>), ...Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v)])) },
|
|
386
|
+
stderr: 'ignore',
|
|
387
|
+
});
|
|
388
|
+
} else {
|
|
389
|
+
throw new Error('no command or url');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const timeout = new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs).unref());
|
|
393
|
+
try {
|
|
394
|
+
await Promise.race([client.connect(transport), timeout]);
|
|
395
|
+
const tools: McpServerManifest['tools'] = [];
|
|
396
|
+
let cursor: string | undefined;
|
|
397
|
+
do {
|
|
398
|
+
const page: any = await Promise.race([client.listTools(cursor ? { cursor } : undefined), timeout]);
|
|
399
|
+
for (const t of page.tools) tools.push({ name: t.name, description: t.description, inputSchema: t.inputSchema });
|
|
400
|
+
cursor = page.nextCursor;
|
|
401
|
+
} while (cursor && tools.length < 2000);
|
|
402
|
+
const info = client.getServerVersion();
|
|
403
|
+
return { name: info?.name ?? name, version: info?.version ?? '0', tools };
|
|
404
|
+
} finally {
|
|
405
|
+
await client.close().catch(() => {});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Live manifest findings, calibrated for a pre-install verdict:
|
|
411
|
+
* - typosquat rules are dropped: a server's self-reported name proves
|
|
412
|
+
* nothing, and the launch package is already checked statically;
|
|
413
|
+
* - capability findings ("can run commands", "touches files", many tools)
|
|
414
|
+
* describe what a server does, not that it's malicious, so they're kept
|
|
415
|
+
* as info. A server that newly GAINS execution is still escalated by
|
|
416
|
+
* rug-pull detection.
|
|
417
|
+
*/
|
|
418
|
+
const DROP_LIVE = new Set(['mcp_typosquatting', 'mcp_typosquatting_pattern', 'mcp_missing_version']);
|
|
419
|
+
const DESCRIPTIVE_LIVE = /^mcp_capability_|^mcp_execution_warning$|^mcp_excessive_tools$/;
|
|
420
|
+
export function calibrateLiveFindings(issues: SecurityIssue[]): SecurityIssue[] {
|
|
421
|
+
return issues
|
|
422
|
+
.filter((i) => !DROP_LIVE.has(i.rule_id))
|
|
423
|
+
.map((i) => (DESCRIPTIVE_LIVE.test(i.rule_id) ? { ...i, severity: 'info' as Severity, action: 'log' as const } : i));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function auditMcpServer(
|
|
427
|
+
server: DiscoveredServer,
|
|
428
|
+
opts: { live?: boolean; store?: BaselineStore; timeoutMs?: number; accept?: string[]; harness?: Harness } = {},
|
|
429
|
+
): Promise<McpServerAudit> {
|
|
430
|
+
const { name, scope, config } = server;
|
|
431
|
+
const issues = await checkMcpServerConfig(name, config);
|
|
432
|
+
const transport: McpServerAudit['transport'] = config.url ? (config.type === 'sse' ? 'sse' : 'http') : config.command ? 'stdio' : 'unknown';
|
|
433
|
+
let live: McpServerAudit['live'] = { status: 'skipped', reason: 'live checks disabled' };
|
|
434
|
+
|
|
435
|
+
// Never launch a server the static checks already distrust: a typosquat
|
|
436
|
+
// package or an exfil URL must not be started "to have a look".
|
|
437
|
+
const distrusted = verdictFor(issues) === 'block' ||
|
|
438
|
+
issues.some((i) => (i.severity === 'critical' || i.severity === 'high') && (i.type === 'supply_chain' || i.type === 'exfiltration'));
|
|
439
|
+
|
|
440
|
+
if (opts.live) {
|
|
441
|
+
if (!server.approved) {
|
|
442
|
+
live = { status: 'skipped', reason: server.config.disabled ? 'server is disabled in config' : 'workspace/project server not approved for automatic start; static checks only' };
|
|
443
|
+
} else if (distrusted) {
|
|
444
|
+
live = { status: 'skipped', reason: 'not started: static checks already flag its package or URL' };
|
|
445
|
+
} else {
|
|
446
|
+
try {
|
|
447
|
+
const manifest = await fetchLiveManifest(name, config, opts.timeoutMs);
|
|
448
|
+
issues.push(...calibrateLiveFindings(await new McpServerAnalyzer().analyze(JSON.stringify(manifest), [])));
|
|
449
|
+
const accept = !!opts.accept?.some((a) => a === 'all' || a === name);
|
|
450
|
+
const rug = await checkRugPull(`${opts.harness ?? 'claude'}:${scope}:${name}`, manifest, { store: opts.store, acceptChanges: accept });
|
|
451
|
+
issues.push(...changesToIssues(rug));
|
|
452
|
+
live = { status: 'ok', tools: manifest.tools?.length ?? 0, rugpull: rug.status };
|
|
453
|
+
} catch (err) {
|
|
454
|
+
live = { status: 'error', reason: (err as Error).message };
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
name,
|
|
461
|
+
scope,
|
|
462
|
+
transport,
|
|
463
|
+
target: config.url ?? [config.command, ...(config.args ?? [])].filter(Boolean).join(' '),
|
|
464
|
+
verdict: verdictFor(issues),
|
|
465
|
+
risk: computeRiskScore(issues),
|
|
466
|
+
issues,
|
|
467
|
+
live,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ============================================================================
|
|
472
|
+
// Full audit + cache
|
|
473
|
+
// ============================================================================
|
|
474
|
+
|
|
475
|
+
/** Cache key: sha256(resolved cwd), plus the harness for non-Claude layouts. */
|
|
476
|
+
export function auditCachePath(cwd: string, harness: Harness = 'claude'): string {
|
|
477
|
+
const base = process.env.MUND_AUDIT_CACHE_DIR || join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'mund', 'agent-audit');
|
|
478
|
+
const key = createHash('sha256').update(resolve(cwd)).digest('hex').slice(0, 16);
|
|
479
|
+
return join(base, harness === 'claude' ? `${key}.json` : `${key}.${harness}.json`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function runAgentAudit(opts: { cwd?: string; live?: boolean; home?: string; store?: BaselineStore; timeoutMs?: number; writeCache?: boolean; accept?: string[]; harness?: Harness } = {}): Promise<AgentAuditReport> {
|
|
483
|
+
const cwd = resolve(opts.cwd ?? process.cwd());
|
|
484
|
+
const home = opts.home ?? homedir();
|
|
485
|
+
const [servers, skills] = await Promise.all([
|
|
486
|
+
Promise.all((opts.harness === 'antigravity' ? discoverAntigravityMcpServers(cwd, home) : discoverMcpServers(cwd, home)).map((s) => auditMcpServer(s, opts))),
|
|
487
|
+
Promise.all(discoverSkillDirs(cwd, home, opts.harness).map(({ dir, scope }) => scanSkillDir(dir, scope))),
|
|
488
|
+
]);
|
|
489
|
+
const summary: Record<Verdict, number> = { allow: 0, warn: 0, block: 0 };
|
|
490
|
+
for (const x of [...servers, ...skills]) summary[x.verdict]++;
|
|
491
|
+
const report: AgentAuditReport = { version: 1, tool: { name: 'mund', version: getVersion() }, generatedAt: new Date().toISOString(), cwd, servers, skills, summary };
|
|
492
|
+
|
|
493
|
+
if (opts.writeCache !== false) {
|
|
494
|
+
const path = auditCachePath(cwd, opts.harness);
|
|
495
|
+
await mkdir(dirname(path), { recursive: true });
|
|
496
|
+
await writeFile(path, JSON.stringify(report, null, 2));
|
|
497
|
+
}
|
|
498
|
+
return report;
|
|
499
|
+
}
|
package/src/analyzers/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { CodeAnalyzer } from './code-analyzer.js';
|
|
|
16
16
|
import { InjectionDetector } from './injection-detector.js';
|
|
17
17
|
import { ExfiltrationDetector } from './exfiltration-detector.js';
|
|
18
18
|
import { McpServerAnalyzer } from './mcp-server-analyzer.js';
|
|
19
|
+
import { CanaryDetector } from '../canary.js';
|
|
19
20
|
import type { IAnalyzer } from '../types.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -28,7 +29,8 @@ export function getAnalyzers(): IAnalyzer[] {
|
|
|
28
29
|
new CodeAnalyzer(),
|
|
29
30
|
new InjectionDetector(),
|
|
30
31
|
new ExfiltrationDetector(),
|
|
31
|
-
new McpServerAnalyzer()
|
|
32
|
+
new McpServerAnalyzer(),
|
|
33
|
+
new CanaryDetector()
|
|
32
34
|
];
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -497,6 +497,9 @@ export class McpServerAnalyzer implements IAnalyzer {
|
|
|
497
497
|
const name = (manifest.name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
498
498
|
|
|
499
499
|
if (!name) return issues;
|
|
500
|
+
// A known legitimate name can't be a typosquat of a different one
|
|
501
|
+
// ("github" is two edits from "gitlab").
|
|
502
|
+
if (LEGITIMATE_SERVERS.some((l) => l.replace(/[^a-z0-9]/g, '') === name)) return issues;
|
|
500
503
|
|
|
501
504
|
for (const legitimate of LEGITIMATE_SERVERS) {
|
|
502
505
|
const legitNormalized = legitimate.replace(/[^a-z0-9]/g, '');
|