@weave_protocol/mund 0.2.3 → 0.3.1
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 +135 -1
- package/dist/analyzers/code-analyzer.d.ts.map +1 -1
- package/dist/analyzers/code-analyzer.js +2 -1
- package/dist/analyzers/code-analyzer.js.map +1 -1
- package/dist/analyzers/exfiltration-detector.d.ts.map +1 -1
- package/dist/analyzers/exfiltration-detector.js +2 -1
- package/dist/analyzers/exfiltration-detector.js.map +1 -1
- package/dist/analyzers/index.d.ts.map +1 -1
- package/dist/analyzers/index.js +4 -2
- package/dist/analyzers/index.js.map +1 -1
- package/dist/analyzers/injection-detector.d.ts.map +1 -1
- package/dist/analyzers/injection-detector.js +2 -1
- package/dist/analyzers/injection-detector.js.map +1 -1
- package/dist/analyzers/mcp-server-analyzer.d.ts +2 -0
- package/dist/analyzers/mcp-server-analyzer.d.ts.map +1 -1
- package/dist/analyzers/mcp-server-analyzer.js.map +1 -1
- package/dist/analyzers/pii-detector.d.ts.map +1 -1
- package/dist/analyzers/pii-detector.js +2 -1
- package/dist/analyzers/pii-detector.js.map +1 -1
- package/dist/analyzers/regex.d.ts +10 -0
- package/dist/analyzers/regex.d.ts.map +1 -0
- package/dist/analyzers/regex.js +17 -0
- package/dist/analyzers/regex.js.map +1 -0
- package/dist/analyzers/secret-scanner.d.ts.map +1 -1
- package/dist/analyzers/secret-scanner.js +2 -1
- package/dist/analyzers/secret-scanner.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/scan.d.ts +50 -0
- package/dist/cli/scan.d.ts.map +1 -0
- package/dist/cli/scan.js +235 -0
- package/dist/cli/scan.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/notifications/email.d.ts +21 -8
- package/dist/notifications/email.d.ts.map +1 -1
- package/dist/notifications/email.js +32 -178
- package/dist/notifications/email.js.map +1 -1
- package/dist/osv.d.ts +94 -0
- package/dist/osv.d.ts.map +1 -0
- package/dist/osv.js +239 -0
- package/dist/osv.js.map +1 -0
- package/dist/rugpull.d.ts +73 -0
- package/dist/rugpull.d.ts.map +1 -0
- package/dist/rugpull.js +177 -0
- package/dist/rugpull.js.map +1 -0
- package/dist/sarif.d.ts +20 -0
- package/dist/sarif.d.ts.map +1 -0
- package/dist/sarif.js +122 -0
- package/dist/sarif.js.map +1 -0
- package/dist/scoring.d.ts +26 -0
- package/dist/scoring.d.ts.map +1 -0
- package/dist/scoring.js +49 -0
- package/dist/scoring.js.map +1 -0
- package/dist/threat-intel-manager.d.ts.map +1 -1
- package/dist/threat-intel-manager.js +2 -1
- package/dist/threat-intel-manager.js.map +1 -1
- 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/configuration.d.ts.map +1 -1
- package/dist/tools/configuration.js +2 -1
- package/dist/tools/configuration.js.map +1 -1
- 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/mcp-scanning.d.ts.map +1 -1
- package/dist/tools/mcp-scanning.js +4 -0
- package/dist/tools/mcp-scanning.js.map +1 -1
- package/dist/tools/monitoring.d.ts.map +1 -1
- package/dist/tools/monitoring.js +22 -1
- package/dist/tools/monitoring.js.map +1 -1
- package/dist/version.d.ts +6 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +2 -4
- package/src/analyzers/code-analyzer.ts +2 -1
- package/src/analyzers/exfiltration-detector.ts +2 -1
- package/src/analyzers/index.ts +4 -2
- package/src/analyzers/injection-detector.ts +2 -1
- package/src/analyzers/mcp-server-analyzer.ts +2 -0
- package/src/analyzers/pii-detector.ts +2 -1
- package/src/analyzers/regex.ts +17 -0
- package/src/analyzers/secret-scanner.ts +2 -1
- package/src/canary.ts +165 -0
- package/src/cli/scan.ts +260 -0
- package/src/index.ts +13 -4
- package/src/notifications/email.ts +38 -194
- package/src/osv.ts +310 -0
- package/src/rugpull.ts +233 -0
- package/src/sarif.ts +146 -0
- package/src/scoring.ts +64 -0
- package/src/threat-intel-manager.ts +2 -1
- package/src/tools/canary-rugpull.ts +75 -0
- package/src/tools/configuration.ts +2 -1
- package/src/tools/index.ts +5 -0
- package/src/tools/mcp-scanning.ts +5 -0
- package/src/tools/monitoring.ts +23 -1
- package/src/version.ts +20 -0
- package/tests/analyzers/engine.test.ts +60 -0
- package/tests/analyzers/injection-and-code.test.ts +80 -0
- package/tests/analyzers/mcp-server-analyzer.test.ts +82 -0
- package/tests/analyzers/regex.test.ts +35 -0
- package/tests/analyzers/rules.test.ts +54 -0
- package/tests/analyzers/secret-scanner.test.ts +80 -0
- package/tests/canary.test.ts +79 -0
- package/tests/cli/scan.test.ts +92 -0
- 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/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/helpers.ts +51 -0
- package/tests/notifications/email.test.ts +68 -0
- package/tests/notifications/hub.test.ts +160 -0
- package/tests/osv.test.ts +117 -0
- package/tests/rugpull.test.ts +80 -0
- package/tests/sarif.test.ts +59 -0
- package/tests/scoring.test.ts +49 -0
- package/tests/tools/canary-rugpull.test.ts +57 -0
- package/tests/tools/monitoring.test.ts +95 -0
- package/vitest.config.ts +8 -0
package/src/osv.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mund - The Guardian Protocol
|
|
3
|
+
* OSV.dev dependency vulnerability lookups with an offline fallback
|
|
4
|
+
*
|
|
5
|
+
* Live path: POST /v1/querybatch for every name@version in a lockfile,
|
|
6
|
+
* then GET /v1/vulns/{id} once per unique advisory for details.
|
|
7
|
+
*
|
|
8
|
+
* Offline path: every successful live lookup is written to an on-disk
|
|
9
|
+
* cache. When the network is unavailable (or MUND_OFFLINE=1 / --offline),
|
|
10
|
+
* results come from that cache and are reported with source "cache", so
|
|
11
|
+
* callers can tell a clean result from an unchecked one. Packages that
|
|
12
|
+
* were never looked up are reported as unchecked, never as clean.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
import { dirname, join } from 'path';
|
|
18
|
+
import type { SecurityIssue, Severity } from './types.js';
|
|
19
|
+
|
|
20
|
+
const OSV_API = 'https://api.osv.dev/v1';
|
|
21
|
+
const BATCH_SIZE = 1000;
|
|
22
|
+
const DETAIL_CONCURRENCY = 8;
|
|
23
|
+
|
|
24
|
+
export interface LockedPackage {
|
|
25
|
+
name: string;
|
|
26
|
+
version: string;
|
|
27
|
+
/** Lockfile key, e.g. "node_modules/qs", used to locate the finding. */
|
|
28
|
+
path?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OsvAdvisory {
|
|
32
|
+
id: string;
|
|
33
|
+
summary: string;
|
|
34
|
+
severity: Severity;
|
|
35
|
+
aliases: string[];
|
|
36
|
+
fixed?: string;
|
|
37
|
+
url: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type OsvSource = 'live' | 'cache' | 'unavailable';
|
|
41
|
+
|
|
42
|
+
export interface OsvLookupResult {
|
|
43
|
+
source: OsvSource;
|
|
44
|
+
/** Keyed by "name@version". */
|
|
45
|
+
advisories: Map<string, OsvAdvisory[]>;
|
|
46
|
+
/** name@version keys with no live or cached answer. */
|
|
47
|
+
unchecked: string[];
|
|
48
|
+
/** Why the live lookup did not run or failed, if it didn't. */
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface OsvClientOptions {
|
|
53
|
+
offline?: boolean;
|
|
54
|
+
cachePath?: string;
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
fetch?: typeof fetch;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface CacheFile {
|
|
60
|
+
version: 1;
|
|
61
|
+
packages: Record<string, { checkedAt: string; advisories: OsvAdvisory[] }>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface OsvVuln {
|
|
65
|
+
id: string;
|
|
66
|
+
summary?: string;
|
|
67
|
+
details?: string;
|
|
68
|
+
aliases?: string[];
|
|
69
|
+
severity?: Array<{ type: string; score: string }>;
|
|
70
|
+
database_specific?: { severity?: string };
|
|
71
|
+
affected?: Array<{
|
|
72
|
+
package?: { name: string; ecosystem: string };
|
|
73
|
+
ranges?: Array<{ events: Array<{ introduced?: string; fixed?: string }> }>;
|
|
74
|
+
}>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function defaultCachePath(): string {
|
|
78
|
+
if (process.env.MUND_OSV_CACHE) return process.env.MUND_OSV_CACHE;
|
|
79
|
+
const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
|
80
|
+
return join(base, 'mund', 'osv-cache.json');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Lockfile parsing
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
/** Parse an npm package-lock.json (lockfileVersion 1, 2 or 3). */
|
|
88
|
+
export function parseNpmLockfile(content: string): LockedPackage[] {
|
|
89
|
+
const lock = JSON.parse(content) as {
|
|
90
|
+
packages?: Record<string, { version?: string; name?: string; link?: boolean }>;
|
|
91
|
+
dependencies?: Record<string, V1Dep>;
|
|
92
|
+
};
|
|
93
|
+
const seen = new Map<string, LockedPackage>();
|
|
94
|
+
const add = (pkg: LockedPackage) => {
|
|
95
|
+
const key = `${pkg.name}@${pkg.version}`;
|
|
96
|
+
if (!seen.has(key)) seen.set(key, pkg);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (lock.packages) {
|
|
100
|
+
for (const [path, entry] of Object.entries(lock.packages)) {
|
|
101
|
+
if (!path || entry.link || !entry.version) continue;
|
|
102
|
+
const idx = path.lastIndexOf('node_modules/');
|
|
103
|
+
if (idx === -1) continue; // workspace package, not a dependency
|
|
104
|
+
add({ name: entry.name ?? path.slice(idx + 'node_modules/'.length), version: entry.version, path });
|
|
105
|
+
}
|
|
106
|
+
} else if (lock.dependencies) {
|
|
107
|
+
const walk = (deps: Record<string, V1Dep>) => {
|
|
108
|
+
for (const [name, dep] of Object.entries(deps)) {
|
|
109
|
+
if (dep.version && !dep.version.startsWith('file:')) add({ name, version: dep.version });
|
|
110
|
+
if (dep.dependencies) walk(dep.dependencies);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(lock.dependencies);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return [...seen.values()];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface V1Dep {
|
|
120
|
+
version?: string;
|
|
121
|
+
dependencies?: Record<string, V1Dep>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// Client
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
export class OsvClient {
|
|
129
|
+
private readonly offline: boolean;
|
|
130
|
+
private readonly cachePath: string;
|
|
131
|
+
private readonly timeoutMs: number;
|
|
132
|
+
private readonly fetchImpl: typeof fetch;
|
|
133
|
+
|
|
134
|
+
constructor(options: OsvClientOptions = {}) {
|
|
135
|
+
this.offline = options.offline ?? process.env.MUND_OFFLINE === '1';
|
|
136
|
+
this.cachePath = options.cachePath ?? defaultCachePath();
|
|
137
|
+
this.timeoutMs = options.timeoutMs ?? 15000;
|
|
138
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async lookup(packages: LockedPackage[], ecosystem = 'npm'): Promise<OsvLookupResult> {
|
|
142
|
+
const keys = packages.map(p => `${p.name}@${p.version}`);
|
|
143
|
+
if (packages.length === 0) return { source: 'live', advisories: new Map(), unchecked: [] };
|
|
144
|
+
|
|
145
|
+
if (!this.offline) {
|
|
146
|
+
try {
|
|
147
|
+
const advisories = await this.lookupLive(packages, ecosystem);
|
|
148
|
+
await this.writeCache(advisories);
|
|
149
|
+
return { source: 'live', advisories, unchecked: [] };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return this.lookupCached(keys, `OSV.dev unreachable: ${(error as Error).message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return this.lookupCached(keys, 'offline mode');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async lookupLive(packages: LockedPackage[], ecosystem: string): Promise<Map<string, OsvAdvisory[]>> {
|
|
158
|
+
const idsByKey = new Map<string, string[]>();
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < packages.length; i += BATCH_SIZE) {
|
|
161
|
+
const chunk = packages.slice(i, i + BATCH_SIZE);
|
|
162
|
+
const body = { queries: chunk.map(p => ({ package: { name: p.name, ecosystem }, version: p.version })) };
|
|
163
|
+
const data = await this.request<{ results: Array<{ vulns?: Array<{ id: string }> }> }>(
|
|
164
|
+
`${OSV_API}/querybatch`, { method: 'POST', body: JSON.stringify(body) },
|
|
165
|
+
);
|
|
166
|
+
chunk.forEach((p, j) => {
|
|
167
|
+
idsByKey.set(`${p.name}@${p.version}`, (data.results[j]?.vulns ?? []).map(v => v.id));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const uniqueIds = [...new Set([...idsByKey.values()].flat())];
|
|
172
|
+
const details = new Map<string, OsvVuln>();
|
|
173
|
+
for (let i = 0; i < uniqueIds.length; i += DETAIL_CONCURRENCY) {
|
|
174
|
+
const batch = uniqueIds.slice(i, i + DETAIL_CONCURRENCY);
|
|
175
|
+
const fetched = await Promise.all(batch.map(id => this.request<OsvVuln>(`${OSV_API}/vulns/${encodeURIComponent(id)}`)));
|
|
176
|
+
fetched.forEach(v => details.set(v.id, v));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const advisories = new Map<string, OsvAdvisory[]>();
|
|
180
|
+
for (const [key, ids] of idsByKey) {
|
|
181
|
+
const name = key.slice(0, key.lastIndexOf('@'));
|
|
182
|
+
advisories.set(key, ids.map(id => toAdvisory(details.get(id) ?? { id }, name)));
|
|
183
|
+
}
|
|
184
|
+
return advisories;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async lookupCached(keys: string[], reason: string): Promise<OsvLookupResult> {
|
|
188
|
+
const cache = await this.readCache();
|
|
189
|
+
const advisories = new Map<string, OsvAdvisory[]>();
|
|
190
|
+
const unchecked: string[] = [];
|
|
191
|
+
|
|
192
|
+
for (const key of keys) {
|
|
193
|
+
const hit = cache?.packages[key];
|
|
194
|
+
if (hit) advisories.set(key, hit.advisories);
|
|
195
|
+
else unchecked.push(key);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const source: OsvSource = advisories.size > 0 ? 'cache' : 'unavailable';
|
|
199
|
+
return { source, advisories, unchecked, error: reason };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async request<T>(url: string, init: RequestInit = {}): Promise<T> {
|
|
203
|
+
const res = await this.fetchImpl(url, {
|
|
204
|
+
...init,
|
|
205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
206
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText} from ${url}`);
|
|
209
|
+
return (await res.json()) as T;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async readCache(): Promise<CacheFile | null> {
|
|
213
|
+
try {
|
|
214
|
+
const cache = JSON.parse(await readFile(this.cachePath, 'utf8')) as CacheFile;
|
|
215
|
+
return cache.version === 1 && cache.packages ? cache : null;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async writeCache(advisories: Map<string, OsvAdvisory[]>): Promise<void> {
|
|
222
|
+
const cache: CacheFile = (await this.readCache()) ?? { version: 1, packages: {} };
|
|
223
|
+
const checkedAt = new Date().toISOString();
|
|
224
|
+
for (const [key, list] of advisories) cache.packages[key] = { checkedAt, advisories: list };
|
|
225
|
+
try {
|
|
226
|
+
await mkdir(dirname(this.cachePath), { recursive: true });
|
|
227
|
+
await writeFile(this.cachePath, JSON.stringify(cache));
|
|
228
|
+
} catch {
|
|
229
|
+
// A read-only cache dir shouldn't fail the scan; we just lose the fallback.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function toAdvisory(vuln: OsvVuln, packageName: string): OsvAdvisory {
|
|
235
|
+
let fixed: string | undefined;
|
|
236
|
+
for (const affected of vuln.affected ?? []) {
|
|
237
|
+
if (affected.package && affected.package.name !== packageName) continue;
|
|
238
|
+
for (const range of affected.ranges ?? []) {
|
|
239
|
+
for (const event of range.events) if (event.fixed) fixed = event.fixed;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
id: vuln.id,
|
|
245
|
+
summary: vuln.summary || vuln.details?.split('\n')[0]?.slice(0, 200) || 'No summary available',
|
|
246
|
+
severity: parseSeverity(vuln),
|
|
247
|
+
aliases: vuln.aliases ?? [],
|
|
248
|
+
fixed,
|
|
249
|
+
url: `https://osv.dev/vulnerability/${encodeURIComponent(vuln.id)}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* OSV `severity[].score` is usually a CVSS *vector* string, not a number,
|
|
255
|
+
* so prefer the database's own label (GHSA sets it for npm advisories).
|
|
256
|
+
*/
|
|
257
|
+
export function parseSeverity(vuln: Pick<OsvVuln, 'severity' | 'database_specific'>): Severity {
|
|
258
|
+
const label = vuln.database_specific?.severity?.toLowerCase();
|
|
259
|
+
if (label === 'critical') return 'critical';
|
|
260
|
+
if (label === 'high') return 'high';
|
|
261
|
+
if (label === 'moderate' || label === 'medium') return 'medium';
|
|
262
|
+
if (label === 'low') return 'low';
|
|
263
|
+
|
|
264
|
+
for (const s of vuln.severity ?? []) {
|
|
265
|
+
const score = Number(s.score);
|
|
266
|
+
if (!Number.isNaN(score)) {
|
|
267
|
+
if (score >= 9) return 'critical';
|
|
268
|
+
if (score >= 7) return 'high';
|
|
269
|
+
if (score >= 4) return 'medium';
|
|
270
|
+
if (score > 0) return 'low';
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return 'medium';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ============================================================================
|
|
277
|
+
// Findings
|
|
278
|
+
// ============================================================================
|
|
279
|
+
|
|
280
|
+
/** Turn lookup results into SecurityIssues located in the lockfile. */
|
|
281
|
+
export function advisoriesToIssues(
|
|
282
|
+
packages: LockedPackage[],
|
|
283
|
+
result: OsvLookupResult,
|
|
284
|
+
lockfileContent: string,
|
|
285
|
+
): SecurityIssue[] {
|
|
286
|
+
const issues: SecurityIssue[] = [];
|
|
287
|
+
|
|
288
|
+
for (const pkg of packages) {
|
|
289
|
+
const key = `${pkg.name}@${pkg.version}`;
|
|
290
|
+
for (const adv of result.advisories.get(key) ?? []) {
|
|
291
|
+
const needle = pkg.path ? `"${pkg.path}"` : `"${pkg.name}"`;
|
|
292
|
+
const start = Math.max(0, lockfileContent.indexOf(needle));
|
|
293
|
+
const before = lockfileContent.slice(0, start).split('\n');
|
|
294
|
+
|
|
295
|
+
issues.push({
|
|
296
|
+
rule_id: adv.id,
|
|
297
|
+
rule_name: `${key}: ${adv.summary}`,
|
|
298
|
+
type: 'dependency',
|
|
299
|
+
severity: adv.severity,
|
|
300
|
+
action: 'alert',
|
|
301
|
+
match: key,
|
|
302
|
+
location: { start, end: start + needle.length, line: before.length, column: before[before.length - 1].length + 1 },
|
|
303
|
+
suggestion: adv.fixed
|
|
304
|
+
? `Upgrade ${pkg.name} to ${adv.fixed} or later. ${adv.url}`
|
|
305
|
+
: `No fixed version published yet. ${adv.url}`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return issues;
|
|
310
|
+
}
|
package/src/rugpull.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mund - The Guardian Protocol
|
|
3
|
+
* MCP Rug-Pull Detection (OWASP ASI04)
|
|
4
|
+
*
|
|
5
|
+
* A rug pull is an MCP server that passes review at install time and
|
|
6
|
+
* then changes what its tools say or do. Static scanning can't see that,
|
|
7
|
+
* so Mund records a fingerprint of every tool when a server is first
|
|
8
|
+
* approved (the baseline) and diffs later manifests against it.
|
|
9
|
+
*
|
|
10
|
+
* tool description changed -> high (the classic rug pull)
|
|
11
|
+
* tool input schema changed -> medium
|
|
12
|
+
* tool added -> medium
|
|
13
|
+
* tool removed -> low
|
|
14
|
+
* server name changed -> high
|
|
15
|
+
*
|
|
16
|
+
* Any changed or added tool is re-scanned with the MCP analyzer; if it
|
|
17
|
+
* now trips a high/critical finding, or newly gains execution
|
|
18
|
+
* capability, the change is escalated to critical.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'crypto';
|
|
22
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
import { dirname, join } from 'path';
|
|
25
|
+
import type { IAnalyzer, SecurityIssue, Severity } from './types.js';
|
|
26
|
+
import { McpServerAnalyzer, type McpServerManifest, type McpToolDefinition } from './analyzers/mcp-server-analyzer.js';
|
|
27
|
+
|
|
28
|
+
export type ToolChangeKind = 'description_changed' | 'schema_changed' | 'tool_added' | 'tool_removed' | 'server_renamed';
|
|
29
|
+
|
|
30
|
+
export interface ToolFingerprint {
|
|
31
|
+
descriptionHash: string;
|
|
32
|
+
schemaHash: string;
|
|
33
|
+
/** Kept so a diff can show what the description used to say. */
|
|
34
|
+
description: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ManifestBaseline {
|
|
38
|
+
serverId: string;
|
|
39
|
+
serverName: string;
|
|
40
|
+
recordedAt: string;
|
|
41
|
+
tools: Record<string, ToolFingerprint>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ToolChange {
|
|
45
|
+
kind: ToolChangeKind;
|
|
46
|
+
tool: string;
|
|
47
|
+
severity: Severity;
|
|
48
|
+
detail: string;
|
|
49
|
+
/** Set when the changed tool now trips the MCP analyzer or gains execution capability. */
|
|
50
|
+
escalated?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RugPullResult {
|
|
54
|
+
serverId: string;
|
|
55
|
+
status: 'baseline_created' | 'unchanged' | 'changed';
|
|
56
|
+
changes: ToolChange[];
|
|
57
|
+
baselineRecordedAt?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SEVERITY_RANK: Record<Severity, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
61
|
+
|
|
62
|
+
export function defaultBaselinePath(): string {
|
|
63
|
+
if (process.env.MUND_BASELINE_PATH) return process.env.MUND_BASELINE_PATH;
|
|
64
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
65
|
+
return join(base, 'mund', 'mcp-baselines.json');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Stable JSON: object keys sorted so equivalent schemas hash the same. */
|
|
69
|
+
function canonical(value: unknown): string {
|
|
70
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
71
|
+
if (value && typeof value === 'object') {
|
|
72
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b));
|
|
73
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify(value ?? null);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
|
79
|
+
|
|
80
|
+
export function fingerprintManifest(serverId: string, manifest: McpServerManifest): ManifestBaseline {
|
|
81
|
+
const tools: Record<string, ToolFingerprint> = {};
|
|
82
|
+
for (const tool of manifest.tools ?? []) {
|
|
83
|
+
tools[tool.name] = {
|
|
84
|
+
descriptionHash: sha256(tool.description ?? ''),
|
|
85
|
+
schemaHash: sha256(canonical(tool.inputSchema ?? {})),
|
|
86
|
+
description: (tool.description ?? '').slice(0, 2000),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { serverId, serverName: manifest.name ?? serverId, recordedAt: new Date().toISOString(), tools };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Compare a manifest against its baseline. Pure: no I/O. */
|
|
93
|
+
export async function diffManifest(
|
|
94
|
+
baseline: ManifestBaseline,
|
|
95
|
+
manifest: McpServerManifest,
|
|
96
|
+
analyzer: IAnalyzer = new McpServerAnalyzer(),
|
|
97
|
+
): Promise<ToolChange[]> {
|
|
98
|
+
const current = fingerprintManifest(baseline.serverId, manifest);
|
|
99
|
+
const byName = new Map((manifest.tools ?? []).map(t => [t.name, t]));
|
|
100
|
+
const changes: ToolChange[] = [];
|
|
101
|
+
|
|
102
|
+
if (current.serverName !== baseline.serverName) {
|
|
103
|
+
changes.push({
|
|
104
|
+
kind: 'server_renamed', tool: '*', severity: 'high',
|
|
105
|
+
detail: `Server name changed from "${baseline.serverName}" to "${current.serverName}"`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const [name, now] of Object.entries(current.tools)) {
|
|
110
|
+
const before = baseline.tools[name];
|
|
111
|
+
if (!before) {
|
|
112
|
+
changes.push({ kind: 'tool_added', tool: name, severity: 'medium', detail: `New tool "${name}" not present at baseline` });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (now.descriptionHash !== before.descriptionHash) {
|
|
116
|
+
changes.push({
|
|
117
|
+
kind: 'description_changed', tool: name, severity: 'high',
|
|
118
|
+
detail: `Description of "${name}" changed after approval`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (now.schemaHash !== before.schemaHash) {
|
|
122
|
+
changes.push({ kind: 'schema_changed', tool: name, severity: 'medium', detail: `Input schema of "${name}" changed after approval` });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const name of Object.keys(baseline.tools)) {
|
|
127
|
+
if (!current.tools[name]) {
|
|
128
|
+
changes.push({ kind: 'tool_removed', tool: name, severity: 'low', detail: `Tool "${name}" removed since baseline` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Escalate changed/added tools that now look dangerous.
|
|
133
|
+
for (const change of changes) {
|
|
134
|
+
if (change.kind === 'tool_removed' || change.kind === 'server_renamed') continue;
|
|
135
|
+
const tool = byName.get(change.tool);
|
|
136
|
+
if (!tool) continue;
|
|
137
|
+
const reason = await escalationReason(tool, baseline.tools[change.tool], analyzer);
|
|
138
|
+
if (reason) {
|
|
139
|
+
change.severity = 'critical';
|
|
140
|
+
change.escalated = reason;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return changes.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function escalationReason(
|
|
148
|
+
tool: McpToolDefinition,
|
|
149
|
+
before: ToolFingerprint | undefined,
|
|
150
|
+
analyzer: IAnalyzer,
|
|
151
|
+
): Promise<string | undefined> {
|
|
152
|
+
const single = JSON.stringify({ name: 'rugpull-check', tools: [tool] });
|
|
153
|
+
const findings = await analyzer.analyze(single, []);
|
|
154
|
+
const serious = findings.find(f => f.severity === 'critical' || f.severity === 'high');
|
|
155
|
+
if (serious) return `Changed tool now triggers "${serious.rule_name}"`;
|
|
156
|
+
|
|
157
|
+
const nowExec = McpServerAnalyzer.analyzeCapabilities({ name: 'x', version: '0', tools: [tool] }).execution;
|
|
158
|
+
const wasExec = before
|
|
159
|
+
? McpServerAnalyzer.analyzeCapabilities({ name: 'x', version: '0', tools: [{ name: tool.name, description: before.description }] }).execution
|
|
160
|
+
: false;
|
|
161
|
+
if (nowExec && !wasExec) return 'Tool newly requests command-execution capability';
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Persisted baselines, keyed by server id. */
|
|
166
|
+
export class BaselineStore {
|
|
167
|
+
constructor(private path: string = defaultBaselinePath()) {}
|
|
168
|
+
|
|
169
|
+
async load(): Promise<Record<string, ManifestBaseline>> {
|
|
170
|
+
try {
|
|
171
|
+
const data = JSON.parse(await readFile(this.path, 'utf8'));
|
|
172
|
+
return data && typeof data === 'object' && data.version === 1 ? data.baselines : {};
|
|
173
|
+
} catch {
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async get(serverId: string): Promise<ManifestBaseline | undefined> {
|
|
179
|
+
return (await this.load())[serverId];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async put(baseline: ManifestBaseline): Promise<void> {
|
|
183
|
+
const all = await this.load();
|
|
184
|
+
all[baseline.serverId] = baseline;
|
|
185
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
186
|
+
await writeFile(this.path, JSON.stringify({ version: 1, baselines: all }, null, 2));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Check a manifest against its stored baseline. The first check for a
|
|
192
|
+
* server records the baseline. Pass acceptChanges to re-baseline after
|
|
193
|
+
* a human has reviewed the changes.
|
|
194
|
+
*/
|
|
195
|
+
export async function checkRugPull(
|
|
196
|
+
serverId: string,
|
|
197
|
+
manifest: McpServerManifest,
|
|
198
|
+
opts: { store?: BaselineStore; analyzer?: IAnalyzer; acceptChanges?: boolean } = {},
|
|
199
|
+
): Promise<RugPullResult> {
|
|
200
|
+
const store = opts.store ?? new BaselineStore();
|
|
201
|
+
const baseline = await store.get(serverId);
|
|
202
|
+
|
|
203
|
+
if (!baseline) {
|
|
204
|
+
await store.put(fingerprintManifest(serverId, manifest));
|
|
205
|
+
return { serverId, status: 'baseline_created', changes: [] };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const changes = await diffManifest(baseline, manifest, opts.analyzer);
|
|
209
|
+
if (opts.acceptChanges && changes.length > 0) {
|
|
210
|
+
await store.put(fingerprintManifest(serverId, manifest));
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
serverId,
|
|
214
|
+
status: changes.length > 0 ? 'changed' : 'unchanged',
|
|
215
|
+
changes,
|
|
216
|
+
baselineRecordedAt: baseline.recordedAt,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Express rug-pull changes as standard SecurityIssues (for scoring / SARIF). */
|
|
221
|
+
export function changesToIssues(result: RugPullResult): SecurityIssue[] {
|
|
222
|
+
return result.changes.map(c => ({
|
|
223
|
+
rule_id: `mcp_rugpull_${c.kind}`,
|
|
224
|
+
rule_name: `MCP rug pull: ${c.detail}`,
|
|
225
|
+
type: 'mcp_rugpull',
|
|
226
|
+
severity: c.severity,
|
|
227
|
+
action: c.severity === 'critical' || c.severity === 'high' ? 'block' : 'alert',
|
|
228
|
+
match: `${result.serverId}:${c.tool}`,
|
|
229
|
+
suggestion: c.escalated
|
|
230
|
+
? `${c.escalated}. Do not use this server until the change is reviewed.`
|
|
231
|
+
: 'Review the change before continuing to use this server; accept it to update the baseline.',
|
|
232
|
+
}));
|
|
233
|
+
}
|
package/src/sarif.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mund - The Guardian Protocol
|
|
3
|
+
* SARIF 2.1.0 output - for GitHub code scanning and other SARIF consumers
|
|
4
|
+
*
|
|
5
|
+
* https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
6
|
+
* GitHub-specific: rules carry `security-severity` (0.0-10.0) so code
|
|
7
|
+
* scanning ranks alerts as critical/high/medium/low.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
import type { SecurityIssue, Severity } from './types.js';
|
|
12
|
+
import { computeRiskScore } from './scoring.js';
|
|
13
|
+
|
|
14
|
+
export interface SarifTarget {
|
|
15
|
+
/** Path relative to the repository root, using forward slashes. */
|
|
16
|
+
uri: string;
|
|
17
|
+
issues: SecurityIssue[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SarifOptions {
|
|
21
|
+
toolVersion: string;
|
|
22
|
+
informationUri?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type SarifLevel = 'error' | 'warning' | 'note' | 'none';
|
|
26
|
+
|
|
27
|
+
const LEVELS: Record<Severity, SarifLevel> = {
|
|
28
|
+
critical: 'error',
|
|
29
|
+
high: 'error',
|
|
30
|
+
medium: 'warning',
|
|
31
|
+
low: 'note',
|
|
32
|
+
info: 'note',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** GitHub maps >=9.0 critical, >=7.0 high, >=4.0 medium, >0.1 low. */
|
|
36
|
+
const SECURITY_SEVERITY: Record<Severity, string> = {
|
|
37
|
+
critical: '9.5',
|
|
38
|
+
high: '8.0',
|
|
39
|
+
medium: '5.5',
|
|
40
|
+
low: '3.0',
|
|
41
|
+
info: '0.0',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function toSarif(targets: SarifTarget[], options: SarifOptions): object {
|
|
45
|
+
const rules = new Map<string, { issue: SecurityIssue; index: number }>();
|
|
46
|
+
const results: object[] = [];
|
|
47
|
+
const allIssues: SecurityIssue[] = [];
|
|
48
|
+
|
|
49
|
+
for (const target of targets) {
|
|
50
|
+
for (const issue of target.issues) {
|
|
51
|
+
allIssues.push(issue);
|
|
52
|
+
|
|
53
|
+
let rule = rules.get(issue.rule_id);
|
|
54
|
+
if (!rule) {
|
|
55
|
+
rule = { issue, index: rules.size };
|
|
56
|
+
rules.set(issue.rule_id, rule);
|
|
57
|
+
} else if (severityRank(issue.severity) < severityRank(rule.issue.severity)) {
|
|
58
|
+
// A rule's descriptor takes the worst severity seen for it.
|
|
59
|
+
rule.issue = issue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
results.push({
|
|
63
|
+
ruleId: issue.rule_id,
|
|
64
|
+
ruleIndex: rule.index,
|
|
65
|
+
level: LEVELS[issue.severity] ?? 'warning',
|
|
66
|
+
message: { text: issue.suggestion ? `${issue.rule_name}. ${issue.suggestion}` : issue.rule_name },
|
|
67
|
+
locations: [{
|
|
68
|
+
physicalLocation: {
|
|
69
|
+
artifactLocation: { uri: target.uri },
|
|
70
|
+
...(issue.location?.line ? { region: toRegion(issue) } : {}),
|
|
71
|
+
},
|
|
72
|
+
}],
|
|
73
|
+
partialFingerprints: {
|
|
74
|
+
'mundFinding/v1': fingerprint(target.uri, issue),
|
|
75
|
+
},
|
|
76
|
+
properties: {
|
|
77
|
+
severity: issue.severity,
|
|
78
|
+
type: issue.type,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const risk = computeRiskScore(allIssues);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
88
|
+
version: '2.1.0',
|
|
89
|
+
runs: [{
|
|
90
|
+
tool: {
|
|
91
|
+
driver: {
|
|
92
|
+
name: 'Mund',
|
|
93
|
+
fullName: 'Mund - The Guardian Protocol',
|
|
94
|
+
version: options.toolVersion,
|
|
95
|
+
semanticVersion: options.toolVersion,
|
|
96
|
+
informationUri: options.informationUri ?? 'https://github.com/Tyox-all/Weave_Protocol/tree/main/mund',
|
|
97
|
+
rules: [...rules.values()].map(({ issue }) => ({
|
|
98
|
+
id: issue.rule_id,
|
|
99
|
+
name: toPascalCase(issue.rule_name),
|
|
100
|
+
shortDescription: { text: issue.rule_name },
|
|
101
|
+
...(issue.suggestion ? { help: { text: issue.suggestion } } : {}),
|
|
102
|
+
defaultConfiguration: { level: LEVELS[issue.severity] ?? 'warning' },
|
|
103
|
+
properties: {
|
|
104
|
+
'security-severity': SECURITY_SEVERITY[issue.severity] ?? '5.5',
|
|
105
|
+
tags: ['security', issue.type],
|
|
106
|
+
},
|
|
107
|
+
})),
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
results,
|
|
111
|
+
properties: {
|
|
112
|
+
riskScore: risk.score,
|
|
113
|
+
riskLabel: risk.label,
|
|
114
|
+
},
|
|
115
|
+
}],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toRegion(issue: SecurityIssue): object {
|
|
120
|
+
const loc = issue.location!;
|
|
121
|
+
const region: Record<string, number> = { startLine: loc.line! };
|
|
122
|
+
if (loc.column) {
|
|
123
|
+
region.startColumn = loc.column;
|
|
124
|
+
const length = loc.end - loc.start;
|
|
125
|
+
// Only emit endColumn for single-line matches; multi-line spans would
|
|
126
|
+
// need the source text to compute endLine.
|
|
127
|
+
if (length > 0 && !issue.match.includes('\n')) region.endColumn = loc.column + length;
|
|
128
|
+
}
|
|
129
|
+
return region;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function fingerprint(uri: string, issue: SecurityIssue): string {
|
|
133
|
+
return createHash('sha256')
|
|
134
|
+
.update(`${uri}\0${issue.rule_id}\0${issue.location?.line ?? 0}\0${issue.match}`)
|
|
135
|
+
.digest('hex')
|
|
136
|
+
.slice(0, 32);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function severityRank(severity: Severity): number {
|
|
140
|
+
return ['critical', 'high', 'medium', 'low', 'info'].indexOf(severity);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toPascalCase(text: string): string {
|
|
144
|
+
return text.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ? c.toUpperCase() : ''))
|
|
145
|
+
.replace(/^./, c => c.toUpperCase());
|
|
146
|
+
}
|