@weave_protocol/mund 0.2.3 → 0.3.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.
Files changed (98) hide show
  1. package/README.md +94 -0
  2. package/dist/analyzers/code-analyzer.d.ts.map +1 -1
  3. package/dist/analyzers/code-analyzer.js +2 -1
  4. package/dist/analyzers/code-analyzer.js.map +1 -1
  5. package/dist/analyzers/exfiltration-detector.d.ts.map +1 -1
  6. package/dist/analyzers/exfiltration-detector.js +2 -1
  7. package/dist/analyzers/exfiltration-detector.js.map +1 -1
  8. package/dist/analyzers/index.js +1 -1
  9. package/dist/analyzers/injection-detector.d.ts.map +1 -1
  10. package/dist/analyzers/injection-detector.js +2 -1
  11. package/dist/analyzers/injection-detector.js.map +1 -1
  12. package/dist/analyzers/mcp-server-analyzer.d.ts +2 -0
  13. package/dist/analyzers/mcp-server-analyzer.d.ts.map +1 -1
  14. package/dist/analyzers/mcp-server-analyzer.js.map +1 -1
  15. package/dist/analyzers/pii-detector.d.ts.map +1 -1
  16. package/dist/analyzers/pii-detector.js +2 -1
  17. package/dist/analyzers/pii-detector.js.map +1 -1
  18. package/dist/analyzers/regex.d.ts +10 -0
  19. package/dist/analyzers/regex.d.ts.map +1 -0
  20. package/dist/analyzers/regex.js +17 -0
  21. package/dist/analyzers/regex.js.map +1 -0
  22. package/dist/analyzers/secret-scanner.d.ts.map +1 -1
  23. package/dist/analyzers/secret-scanner.js +2 -1
  24. package/dist/analyzers/secret-scanner.js.map +1 -1
  25. package/dist/cli/scan.d.ts +49 -0
  26. package/dist/cli/scan.d.ts.map +1 -0
  27. package/dist/cli/scan.js +226 -0
  28. package/dist/cli/scan.js.map +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +10 -4
  31. package/dist/index.js.map +1 -1
  32. package/dist/notifications/email.d.ts +21 -8
  33. package/dist/notifications/email.d.ts.map +1 -1
  34. package/dist/notifications/email.js +32 -178
  35. package/dist/notifications/email.js.map +1 -1
  36. package/dist/osv.d.ts +94 -0
  37. package/dist/osv.d.ts.map +1 -0
  38. package/dist/osv.js +239 -0
  39. package/dist/osv.js.map +1 -0
  40. package/dist/sarif.d.ts +20 -0
  41. package/dist/sarif.d.ts.map +1 -0
  42. package/dist/sarif.js +122 -0
  43. package/dist/sarif.js.map +1 -0
  44. package/dist/scoring.d.ts +26 -0
  45. package/dist/scoring.d.ts.map +1 -0
  46. package/dist/scoring.js +49 -0
  47. package/dist/scoring.js.map +1 -0
  48. package/dist/threat-intel-manager.d.ts.map +1 -1
  49. package/dist/threat-intel-manager.js +2 -1
  50. package/dist/threat-intel-manager.js.map +1 -1
  51. package/dist/tools/configuration.d.ts.map +1 -1
  52. package/dist/tools/configuration.js +2 -1
  53. package/dist/tools/configuration.js.map +1 -1
  54. package/dist/tools/mcp-scanning.d.ts.map +1 -1
  55. package/dist/tools/mcp-scanning.js +4 -0
  56. package/dist/tools/mcp-scanning.js.map +1 -1
  57. package/dist/tools/monitoring.d.ts.map +1 -1
  58. package/dist/tools/monitoring.js +22 -1
  59. package/dist/tools/monitoring.js.map +1 -1
  60. package/dist/version.d.ts +6 -0
  61. package/dist/version.d.ts.map +1 -0
  62. package/dist/version.js +19 -0
  63. package/dist/version.js.map +1 -0
  64. package/package.json +2 -4
  65. package/src/analyzers/code-analyzer.ts +2 -1
  66. package/src/analyzers/exfiltration-detector.ts +2 -1
  67. package/src/analyzers/index.ts +1 -1
  68. package/src/analyzers/injection-detector.ts +2 -1
  69. package/src/analyzers/mcp-server-analyzer.ts +2 -0
  70. package/src/analyzers/pii-detector.ts +2 -1
  71. package/src/analyzers/regex.ts +17 -0
  72. package/src/analyzers/secret-scanner.ts +2 -1
  73. package/src/cli/scan.ts +251 -0
  74. package/src/index.ts +11 -4
  75. package/src/notifications/email.ts +38 -194
  76. package/src/osv.ts +310 -0
  77. package/src/sarif.ts +146 -0
  78. package/src/scoring.ts +64 -0
  79. package/src/threat-intel-manager.ts +2 -1
  80. package/src/tools/configuration.ts +2 -1
  81. package/src/tools/mcp-scanning.ts +5 -0
  82. package/src/tools/monitoring.ts +23 -1
  83. package/src/version.ts +20 -0
  84. package/tests/analyzers/engine.test.ts +60 -0
  85. package/tests/analyzers/injection-and-code.test.ts +80 -0
  86. package/tests/analyzers/mcp-server-analyzer.test.ts +82 -0
  87. package/tests/analyzers/regex.test.ts +35 -0
  88. package/tests/analyzers/rules.test.ts +54 -0
  89. package/tests/analyzers/secret-scanner.test.ts +80 -0
  90. package/tests/cli/scan.test.ts +92 -0
  91. package/tests/helpers.ts +51 -0
  92. package/tests/notifications/email.test.ts +68 -0
  93. package/tests/notifications/hub.test.ts +160 -0
  94. package/tests/osv.test.ts +117 -0
  95. package/tests/sarif.test.ts +59 -0
  96. package/tests/scoring.test.ts +49 -0
  97. package/tests/tools/monitoring.test.ts +95 -0
  98. 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/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
+ }
package/src/scoring.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Mund - The Guardian Protocol
3
+ * Risk Scoring - collapse a set of findings into a 0-100 score + label
4
+ *
5
+ * Each finding independently "consumes" a fraction of the remaining
6
+ * headroom, so the score rises quickly with severe findings, has
7
+ * diminishing returns for many minor ones, and never exceeds 100:
8
+ *
9
+ * score = 100 * (1 - Π (1 - weight_i))
10
+ *
11
+ * One critical finding alone lands in the critical band; one high in the
12
+ * high band; it takes many low findings to escalate past "low".
13
+ */
14
+
15
+ import type { SecurityIssue, Severity } from './types.js';
16
+
17
+ export type RiskLabel = 'none' | 'low' | 'medium' | 'high' | 'critical';
18
+
19
+ export interface RiskScore {
20
+ score: number;
21
+ label: RiskLabel;
22
+ counts: Record<Severity, number>;
23
+ }
24
+
25
+ export const SEVERITY_WEIGHTS: Record<Severity, number> = {
26
+ critical: 0.8,
27
+ high: 0.4,
28
+ medium: 0.15,
29
+ low: 0.05,
30
+ info: 0,
31
+ };
32
+
33
+ /** Lower bound (inclusive) of each label band. */
34
+ export const RISK_BANDS: Array<[RiskLabel, number]> = [
35
+ ['critical', 80],
36
+ ['high', 40],
37
+ ['medium', 15],
38
+ ['low', 1],
39
+ ];
40
+
41
+ export function labelForScore(score: number): RiskLabel {
42
+ for (const [label, min] of RISK_BANDS) {
43
+ if (score >= min) return label;
44
+ }
45
+ return 'none';
46
+ }
47
+
48
+ export function computeRiskScore(issues: Array<Pick<SecurityIssue, 'severity'>>): RiskScore {
49
+ const counts: Record<Severity, number> = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
50
+ let headroom = 1;
51
+
52
+ for (const issue of issues) {
53
+ const severity = issue.severity in SEVERITY_WEIGHTS ? issue.severity : 'info';
54
+ counts[severity]++;
55
+ headroom *= 1 - SEVERITY_WEIGHTS[severity];
56
+ }
57
+
58
+ // Any non-info finding scores at least 1 so it never reads as "none".
59
+ const raw = Math.round(100 * (1 - headroom));
60
+ const hasFinding = counts.critical + counts.high + counts.medium + counts.low > 0;
61
+ const score = hasFinding ? Math.max(1, raw) : 0;
62
+
63
+ return { score, label: labelForScore(score), counts };
64
+ }
@@ -16,6 +16,7 @@ import type {
16
16
  ThreatScanResult,
17
17
  Severity,
18
18
  } from './threat-intel-types.js';
19
+ import { compileRulePattern } from './analyzers/regex.js';
19
20
 
20
21
  // ============================================================================
21
22
  // Built-in Threat Patterns
@@ -759,7 +760,7 @@ export class ThreatIntelManager {
759
760
 
760
761
  // Check pattern
761
762
  try {
762
- const regex = new RegExp(pattern.pattern, 'gi');
763
+ const regex = compileRulePattern(pattern.pattern, 'gi');
763
764
  let match;
764
765
  while ((match = regex.exec(content)) !== null) {
765
766
  matches.push({
@@ -12,6 +12,7 @@ import type {
12
12
  Severity,
13
13
  ActionType
14
14
  } from '../types.js';
15
+ import { compileRulePattern } from '../analyzers/regex.js';
15
16
 
16
17
  // ============================================================================
17
18
  // Input Schemas
@@ -131,7 +132,7 @@ Returns:
131
132
  async (params) => {
132
133
  // Validate the regex pattern
133
134
  try {
134
- new RegExp(params.pattern);
135
+ compileRulePattern(params.pattern);
135
136
  } catch {
136
137
  return {
137
138
  content: [{ type: 'text', text: JSON.stringify({
@@ -17,6 +17,7 @@ import {
17
17
  type McpScanResult
18
18
  } from '../analyzers/mcp-server-analyzer.js';
19
19
  import type { SecurityIssue } from '../types.js';
20
+ import { computeRiskScore } from '../scoring.js';
20
21
 
21
22
  // ============================================================================
22
23
  // Tool Schemas
@@ -76,6 +77,8 @@ export function registerMcpScanningTools(server: McpServer): void {
76
77
  const capabilities = McpServerAnalyzer.analyzeCapabilities(parsed);
77
78
  const riskLevel = McpServerAnalyzer.getRiskLevel(capabilities);
78
79
 
80
+ const risk = computeRiskScore(issues);
81
+
79
82
  // Count by severity
80
83
  const bySeverity = {
81
84
  critical: issues.filter(i => i.severity === 'critical').length,
@@ -103,6 +106,8 @@ export function registerMcpScanningTools(server: McpServer): void {
103
106
  scanned_at: new Date().toISOString(),
104
107
  source,
105
108
  issue_count: issues.length,
109
+ risk_score: risk.score,
110
+ risk_label: risk.label,
106
111
  by_severity: bySeverity,
107
112
  recommendation,
108
113
  capabilities,
@@ -18,6 +18,9 @@ import type {
18
18
  } from '../types.js';
19
19
  import { AnalyzerEngine } from '../analyzers/index.js';
20
20
  import { NotificationHub } from '../notifications/index.js';
21
+ import { computeRiskScore } from '../scoring.js';
22
+ import { toSarif } from '../sarif.js';
23
+ import { getVersion } from '../version.js';
21
24
  import { MAX_SNIPPET_LENGTH, DEFAULT_EVENTS_LIMIT, MAX_EVENTS_PER_QUERY } from '../constants.js';
22
25
 
23
26
  // ============================================================================
@@ -34,7 +37,11 @@ const ScanContentInputSchema = z.object({
34
37
  .describe('Type of content being scanned'),
35
38
  tool_name: z.string().optional().describe('Name of the tool that generated this content'),
36
39
  agent_id: z.string().optional().describe('ID of the agent making this request'),
37
- session_id: z.string().optional().describe('Current session ID')
40
+ session_id: z.string().optional().describe('Current session ID'),
41
+ format: z.enum(['json', 'sarif']).default('json')
42
+ .describe('Response format: json (default) or sarif (SARIF 2.1.0 for code-scanning tools)'),
43
+ file_path: z.string().optional()
44
+ .describe('Path to report in SARIF locations (defaults to "content")')
38
45
  }).strict();
39
46
 
40
47
  const CheckUrlInputSchema = z.object({
@@ -103,11 +110,15 @@ Args:
103
110
  - tool_name (string): Name of tool that generated this content (optional)
104
111
  - agent_id (string): ID of the requesting agent (optional)
105
112
  - session_id (string): Current session ID (optional)
113
+ - format ('json' | 'sarif'): Response format (default: 'json')
114
+ - file_path (string): Path to use in SARIF locations (optional)
106
115
 
107
116
  Returns:
108
117
  JSON object with:
109
118
  - scan_id: Unique identifier for this scan
110
119
  - issues_found: Number of security issues detected
120
+ - risk_score: 0-100 aggregate risk
121
+ - risk_label: none | low | medium | high | critical
111
122
  - blocked: Whether the content was blocked (if block_mode is enabled)
112
123
  - issues: Array of detected issues with severity, type, and suggestions
113
124
  - scan_duration_ms: Time taken to complete the scan
@@ -173,10 +184,21 @@ Examples:
173
184
  await notificationHub.notify(event);
174
185
  }
175
186
 
187
+ if (params.format === 'sarif') {
188
+ const sarif = toSarif([{ uri: params.file_path ?? 'content', issues }], { toolVersion: getVersion() });
189
+ return {
190
+ content: [{ type: 'text', text: JSON.stringify(sarif, null, 2) }],
191
+ structuredContent: sarif as Record<string, unknown>
192
+ };
193
+ }
194
+
176
195
  // Format response
196
+ const risk = computeRiskScore(issues);
177
197
  const response = {
178
198
  scan_id: scanResult.id,
179
199
  issues_found: issues.length,
200
+ risk_score: risk.score,
201
+ risk_label: risk.label,
180
202
  blocked: shouldBlock,
181
203
  issues: issues.map(i => ({
182
204
  rule_id: i.rule_id,
package/src/version.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Mund - The Guardian Protocol
3
+ * Package version, read from package.json at runtime (dist/version.js -> ../package.json)
4
+ */
5
+
6
+ import { readFileSync } from 'fs';
7
+
8
+ let cached: string | undefined;
9
+
10
+ export function getVersion(): string {
11
+ if (!cached) {
12
+ try {
13
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version?: string };
14
+ cached = pkg.version ?? '0.0.0';
15
+ } catch {
16
+ cached = '0.0.0';
17
+ }
18
+ }
19
+ return cached;
20
+ }