weavatrix 0.2.19 → 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 +103 -93
- package/SECURITY.md +13 -31
- package/bin/weavatrix-mcp.mjs +2 -1
- package/docs/adr/0001-v0.3-offline-online-split.md +9 -7
- package/docs/releases/v0.3.0.md +43 -0
- package/docs/releases/v0.3.1.md +30 -0
- package/package.json +9 -2
- package/skill/SKILL.md +34 -59
- package/src/analysis/architecture/contract-storage.js +2 -2
- package/src/analysis/audit-extensions.js +60 -0
- package/src/analysis/change-classification.js +72 -10
- package/src/analysis/duplicate-groups.js +13 -2
- package/src/analysis/duplicates.compute.js +7 -2
- package/src/analysis/health-capabilities.js +2 -1
- package/src/analysis/internal-audit/supply-chain.js +17 -2
- package/src/analysis/jvm-artifact-index.js +87 -0
- package/src/analysis/jvm-dependency-evidence.js +31 -8
- package/src/analysis/transport-contracts.js +78 -11
- package/src/analysis/transport-runtime-evidence.js +155 -0
- package/src/child-env.js +2 -2
- package/src/extension/local-services.mjs +75 -0
- package/src/graph/internal-builder.langs.js +2 -0
- package/src/graph/repo-registry.js +2 -2
- package/src/mcp/catalog.mjs +47 -21
- package/src/mcp/evidence/duplicate-member-order.mjs +1 -1
- package/src/mcp/evidence-snapshot.health.mjs +2 -2
- package/src/mcp/extension-api.mjs +77 -0
- package/src/mcp/health/audit-format.mjs +11 -1
- package/src/mcp/health/audit.mjs +10 -7
- package/src/mcp/health/duplicates.mjs +5 -3
- package/src/mcp/sync-payload.mjs +1 -1
- package/src/mcp/tools-actions.mjs +1 -8
- package/src/mcp/tools-architecture.mjs +2 -2
- package/src/mcp/tools-company.mjs +4 -2
- package/src/mcp-runtime.mjs +2 -0
- package/src/mcp-server.mjs +28 -17
- package/src/security/advisory-store.js +133 -181
- package/src/security/rust-advisory-report.js +60 -0
- package/src/mcp/actions/advisories.mjs +0 -17
- package/src/mcp/actions/graph-sync.mjs +0 -195
- package/src/mcp/actions/hosted-architecture.mjs +0 -57
|
@@ -1,221 +1,173 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// load it. Same API surface; swap to SQLite if/when a full baked snapshot ships (P6).
|
|
10
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
-
import { join, dirname } from "node:path";
|
|
12
|
-
import { homedir } from "node:os";
|
|
13
|
-
import { createHash } from "node:crypto";
|
|
14
|
-
import { uniqueBy } from "../util.js";
|
|
1
|
+
// Offline advisory cache. The MIT core plans source-free OSV coordinates, validates returned
|
|
2
|
+
// records and reads/writes the local cache, but never performs network I/O. A connector extension
|
|
3
|
+
// owns remote transport and passes bounded response data to commitAdvisoryRefresh().
|
|
4
|
+
import {existsSync, readFileSync, writeFileSync, mkdirSync} from 'node:fs'
|
|
5
|
+
import {join, dirname} from 'node:path'
|
|
6
|
+
import {homedir} from 'node:os'
|
|
7
|
+
import {createHash} from 'node:crypto'
|
|
8
|
+
import {uniqueBy} from '../util.js'
|
|
15
9
|
|
|
16
|
-
export const DEFAULT_STORE = join(homedir(),
|
|
17
|
-
const
|
|
18
|
-
const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
|
|
19
|
-
const DEFAULT_FETCH_TIMEOUT_MS = Number(process.env.WEAVATRIX_OSV_TIMEOUT_MS || 20000);
|
|
20
|
-
export const OSV_SUPPORTED_ECOSYSTEMS = new Set(["npm", "PyPI", "Go", "Maven", "crates.io"]);
|
|
10
|
+
export const DEFAULT_STORE = join(homedir(), '.weavatrix', 'advisories.json')
|
|
11
|
+
export const OSV_SUPPORTED_ECOSYSTEMS = new Set(['npm', 'PyPI', 'Go', 'Maven', 'crates.io'])
|
|
21
12
|
|
|
22
|
-
const keyOf = (ecosystem, name) => `${ecosystem}|${ecosystem ===
|
|
13
|
+
const keyOf = (ecosystem, name) => `${ecosystem}|${ecosystem === 'PyPI' ? String(name).toLowerCase().replace(/[-_.]+/g, '-') : name}`
|
|
14
|
+
const uniquePackages = (packages) => uniqueBy(packages, (item) => `${item.ecosystem}|${item.name}|${item.version}`)
|
|
23
15
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
.map((
|
|
29
|
-
|
|
30
|
-
return createHash("sha256").update(rows.join("\n"), "utf8").digest("hex");
|
|
16
|
+
export function createAdvisoryQueryPlan(installed = []) {
|
|
17
|
+
const pinned = installed.filter((item) => item?.ecosystem && item?.name && item?.version)
|
|
18
|
+
const unsupported = pinned.filter((item) => !OSV_SUPPORTED_ECOSYSTEMS.has(item.ecosystem)).length
|
|
19
|
+
const packages = uniquePackages(pinned.filter((item) => OSV_SUPPORTED_ECOSYSTEMS.has(item.ecosystem)))
|
|
20
|
+
.map(({ecosystem, name, version}) => ({ecosystem, name, version}))
|
|
21
|
+
return Object.freeze({packages: Object.freeze(packages), unsupported})
|
|
31
22
|
}
|
|
32
23
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const request = fetcher(url, ctrl ? { ...options, signal: ctrl.signal } : options);
|
|
39
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
40
|
-
timer = setTimeout(() => {
|
|
41
|
-
try { ctrl?.abort(); } catch { /* ignore */ }
|
|
42
|
-
reject(new Error(`OSV request timed out after ${Math.round(timeout / 1000)}s`));
|
|
43
|
-
}, timeout);
|
|
44
|
-
});
|
|
45
|
-
const res = await Promise.race([request, timeoutPromise]);
|
|
46
|
-
if (!res || typeof res.json !== "function") throw new Error("invalid response from OSV");
|
|
47
|
-
if (res.ok === false) throw new Error(`HTTP ${res.status || "error"} from OSV`);
|
|
48
|
-
return await res.json();
|
|
49
|
-
} catch (error) {
|
|
50
|
-
if (error?.name === "AbortError") throw new Error(`OSV request timed out after ${Math.round(timeout / 1000)}s`);
|
|
51
|
-
throw error;
|
|
52
|
-
} finally {
|
|
53
|
-
if (timer) clearTimeout(timer);
|
|
54
|
-
}
|
|
24
|
+
export function advisoryQueryFingerprint(installed = []) {
|
|
25
|
+
const rows = createAdvisoryQueryPlan(installed).packages
|
|
26
|
+
.map((item) => `${item.ecosystem}|${item.name}|${item.version}`)
|
|
27
|
+
.sort()
|
|
28
|
+
return createHash('sha256').update(rows.join('\n'), 'utf8').digest('hex')
|
|
55
29
|
}
|
|
56
30
|
|
|
57
31
|
export function loadStore(storePath = DEFAULT_STORE) {
|
|
58
32
|
try {
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
} catch { /* missing/corrupt
|
|
62
|
-
return {
|
|
33
|
+
const store = JSON.parse(readFileSync(storePath, 'utf8'))
|
|
34
|
+
if (store && typeof store === 'object' && store.records) return store
|
|
35
|
+
} catch { /* missing/corrupt -> empty */ }
|
|
36
|
+
return {meta: {fetched_at: null}, records: {}}
|
|
63
37
|
}
|
|
64
38
|
|
|
65
|
-
export
|
|
66
|
-
return (store && store.records && store.records[keyOf(ecosystem, name)]) || [];
|
|
67
|
-
}
|
|
39
|
+
export const queryStore = (store, ecosystem, name) => (store?.records?.[keyOf(ecosystem, name)]) || []
|
|
68
40
|
|
|
69
41
|
export function storeMeta(storePath = DEFAULT_STORE) {
|
|
70
|
-
const
|
|
71
|
-
return {
|
|
42
|
+
const store = loadStore(storePath)
|
|
43
|
+
return {
|
|
44
|
+
fetchedAt: store.meta?.fetched_at || null,
|
|
45
|
+
advisoryCount: Object.values(store.records || {}).reduce((total, records) => total + records.length, 0),
|
|
46
|
+
}
|
|
72
47
|
}
|
|
73
48
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (label ===
|
|
79
|
-
if (label ===
|
|
80
|
-
if (label ===
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
49
|
+
function severityOf(record) {
|
|
50
|
+
if (String(record.id || '').startsWith('MAL-')) return 'critical'
|
|
51
|
+
const label = String(record.database_specific?.severity || '').toLowerCase()
|
|
52
|
+
if (label === 'critical') return 'critical'
|
|
53
|
+
if (label === 'high') return 'high'
|
|
54
|
+
if (label === 'moderate' || label === 'medium') return 'medium'
|
|
55
|
+
if (label === 'low') return 'low'
|
|
56
|
+
let best = 0
|
|
57
|
+
for (const severity of record.severity || []) {
|
|
58
|
+
const value = String(severity.score || '')
|
|
59
|
+
const match = /CVSS:[\d.]+\/.*?\bA[VC]?:/i.test(value) ? null : value.match(/^(\d+(?:\.\d+)?)$/)
|
|
60
|
+
if (match) best = Math.max(best, Number(match[1]))
|
|
86
61
|
}
|
|
87
|
-
if (best >= 9) return
|
|
88
|
-
if (best >= 7) return
|
|
89
|
-
|
|
90
|
-
return "medium"; // unknown → medium, never silently info
|
|
62
|
+
if (best >= 9) return 'critical'
|
|
63
|
+
if (best >= 7) return 'high'
|
|
64
|
+
return 'medium'
|
|
91
65
|
}
|
|
92
66
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
67
|
+
export function normalizeOsvAdvisory(record, ecosystem, name) {
|
|
68
|
+
const affected = (record?.affected || []).find((item) => item?.package
|
|
69
|
+
&& item.package.ecosystem === ecosystem
|
|
70
|
+
&& keyOf(ecosystem, item.package.name) === keyOf(ecosystem, name))
|
|
71
|
+
if (!affected) return null
|
|
72
|
+
const fixed = []
|
|
73
|
+
for (const range of affected.ranges || []) for (const event of range.events || []) if (event.fixed) fixed.push(event.fixed)
|
|
99
74
|
return {
|
|
100
|
-
id:
|
|
101
|
-
kind: String(
|
|
102
|
-
severity: severityOf(
|
|
103
|
-
summary: String(
|
|
104
|
-
url: `https://osv.dev/vulnerability/${
|
|
105
|
-
modified:
|
|
106
|
-
aliases: (
|
|
75
|
+
id: record.id,
|
|
76
|
+
kind: String(record.id || '').startsWith('MAL-') ? 'malicious' : 'vuln',
|
|
77
|
+
severity: severityOf(record),
|
|
78
|
+
summary: String(record.summary || record.details || '').slice(0, 300),
|
|
79
|
+
url: `https://osv.dev/vulnerability/${record.id}`,
|
|
80
|
+
modified: record.modified || '',
|
|
81
|
+
aliases: (record.aliases || []).slice(0, 6),
|
|
107
82
|
fixedIn: [...new Set(fixed)].slice(0, 4),
|
|
108
|
-
affected: {
|
|
109
|
-
}
|
|
83
|
+
affected: {versions: affected.versions || [], ranges: affected.ranges || []},
|
|
84
|
+
}
|
|
110
85
|
}
|
|
111
86
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
export
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
87
|
+
// idsByPackage is an array parallel to plan.packages; advisoryRecords is an id-keyed plain object.
|
|
88
|
+
// Remote failures arrive explicitly in errors. A zero-response failure never stamps the cache fresh.
|
|
89
|
+
export function commitAdvisoryRefresh({
|
|
90
|
+
plan,
|
|
91
|
+
idsByPackage = [],
|
|
92
|
+
advisoryRecords = {},
|
|
93
|
+
queriedOk = 0,
|
|
94
|
+
errors: initialErrors = [],
|
|
95
|
+
storePath = DEFAULT_STORE,
|
|
96
|
+
repoKey = '',
|
|
97
|
+
repoKeys = [],
|
|
98
|
+
} = {}) {
|
|
99
|
+
const packages = plan?.packages || []
|
|
100
|
+
const unsupported = Number(plan?.unsupported) || 0
|
|
101
|
+
if (!packages.length) return {ok: false, queried: 0, unsupported, error: 'No OSV-supported pinned package versions found to check.'}
|
|
102
|
+
const errors = [...initialErrors.map(String)]
|
|
103
|
+
if (errors.length && queriedOk === 0) {
|
|
104
|
+
return {ok: false, queried: packages.length, unsupported, error: `advisory refresh failed: ${errors[0]}`, errors}
|
|
126
105
|
}
|
|
127
|
-
const store = loadStore(storePath);
|
|
128
|
-
const idsByPkg = new Map(); // pkgIndex -> [vuln ids]
|
|
129
|
-
const errors = [];
|
|
130
|
-
let queriedOk = 0;
|
|
131
106
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
body: JSON.stringify({ queries: batch.map((p) => ({ package: { ecosystem: p.ecosystem, name: p.name }, version: p.version })) }),
|
|
139
|
-
}, timeoutMs);
|
|
140
|
-
if (!Array.isArray(json?.results) || json.results.length !== batch.length) {
|
|
141
|
-
throw new Error(`OSV querybatch returned ${Array.isArray(json?.results) ? json.results.length : "no"} result(s) for ${batch.length} query item(s)`);
|
|
142
|
-
}
|
|
143
|
-
for (const [resultIndex, result] of json.results.entries()) {
|
|
144
|
-
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
145
|
-
throw new Error(`OSV querybatch result ${resultIndex + 1} is not an object`);
|
|
146
|
-
}
|
|
147
|
-
if (result.vulns !== undefined && !Array.isArray(result.vulns)) {
|
|
148
|
-
throw new Error(`OSV querybatch result ${resultIndex + 1} has a non-array vulns field`);
|
|
149
|
-
}
|
|
150
|
-
if (Array.isArray(result.vulns) && result.vulns.some((v) => !v || typeof v.id !== "string" || !v.id.trim())) {
|
|
151
|
-
throw new Error(`OSV querybatch result ${resultIndex + 1} contains an advisory without a valid id`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
queriedOk += batch.length;
|
|
155
|
-
json.results.forEach((r, j) => { if (r && Array.isArray(r.vulns) && r.vulns.length) idsByPkg.set(i + j, r.vulns.map((v) => v.id)); });
|
|
156
|
-
} catch (error) {
|
|
157
|
-
errors.push(`querybatch ${Math.floor(i / batchSize) + 1}/${Math.ceil(pkgs.length / batchSize)}: ${error.message}`);
|
|
107
|
+
const store = loadStore(storePath)
|
|
108
|
+
const wanted = new Map()
|
|
109
|
+
idsByPackage.forEach((ids, index) => {
|
|
110
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
111
|
+
if (!wanted.has(id)) wanted.set(id, [])
|
|
112
|
+
wanted.get(id).push(packages[index])
|
|
158
113
|
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const wanted = new Map(); // id -> [pkg,...] (an id can hit several packages)
|
|
162
|
-
for (const [pi, ids] of idsByPkg) for (const id of ids) (wanted.get(id) || wanted.set(id, []).get(id)).push(pkgs[pi]);
|
|
114
|
+
})
|
|
163
115
|
|
|
164
|
-
let fetched = 0
|
|
165
|
-
for (const [id,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
normalized++;
|
|
178
|
-
const key = keyOf(p.ecosystem, p.name);
|
|
179
|
-
const list = store.records[key] || (store.records[key] = []);
|
|
180
|
-
const at = list.findIndex((x) => x.id === row.id);
|
|
181
|
-
if (at >= 0) list[at] = row; else list.push(row);
|
|
116
|
+
let fetched = 0
|
|
117
|
+
for (const [id, packageList] of wanted) {
|
|
118
|
+
const record = advisoryRecords[id]
|
|
119
|
+
if (!record || record.id !== id) {
|
|
120
|
+
errors.push(`${id}: advisory detail response is missing or has a mismatched id`)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
let normalized = 0
|
|
124
|
+
for (const item of packageList) {
|
|
125
|
+
const row = normalizeOsvAdvisory(record, item.ecosystem, item.name)
|
|
126
|
+
if (!row) {
|
|
127
|
+
errors.push(`${id}: advisory detail does not describe ${item.ecosystem}:${item.name}`)
|
|
128
|
+
continue
|
|
182
129
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
130
|
+
normalized++
|
|
131
|
+
const key = keyOf(item.ecosystem, item.name)
|
|
132
|
+
const records = store.records[key] || (store.records[key] = [])
|
|
133
|
+
const index = records.findIndex((existing) => existing.id === row.id)
|
|
134
|
+
if (index >= 0) records[index] = row
|
|
135
|
+
else records.push(row)
|
|
188
136
|
}
|
|
137
|
+
if (normalized) fetched++
|
|
189
138
|
}
|
|
190
139
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
store.meta.fetched_at = new Date().toISOString();
|
|
197
|
-
const status = errors.length ? "PARTIAL" : "OK";
|
|
198
|
-
// per-repo stamp: the cache only covers packages that were QUERIED — a repo that never refreshed must
|
|
199
|
-
// show "fetch advisories", not "0 vulnerabilities as of <someone else's date>" (false assurance).
|
|
200
|
-
// repoKeys[] lets one online pass (a "refresh all repos") stamp every repo whose packages it covered.
|
|
201
|
-
const stampRepos = [...new Set([repoKey, ...repoKeys].filter(Boolean))];
|
|
140
|
+
const fetchedAt = new Date().toISOString()
|
|
141
|
+
const status = errors.length ? 'PARTIAL' : 'OK'
|
|
142
|
+
store.meta.fetched_at = fetchedAt
|
|
143
|
+
const stampRepos = [...new Set([repoKey, ...repoKeys].filter(Boolean))]
|
|
202
144
|
if (stampRepos.length) {
|
|
203
|
-
store.meta.repos
|
|
204
|
-
for (const
|
|
205
|
-
fetched_at:
|
|
145
|
+
store.meta.repos ||= {}
|
|
146
|
+
for (const key of stampRepos) store.meta.repos[key] = {
|
|
147
|
+
fetched_at: fetchedAt,
|
|
206
148
|
status,
|
|
207
|
-
queried:
|
|
149
|
+
queried: packages.length,
|
|
208
150
|
queried_ok: queriedOk,
|
|
209
151
|
unsupported,
|
|
210
152
|
error_count: errors.length,
|
|
211
|
-
query_fingerprint: advisoryQueryFingerprint(
|
|
212
|
-
}
|
|
153
|
+
query_fingerprint: advisoryQueryFingerprint(packages),
|
|
154
|
+
}
|
|
213
155
|
}
|
|
214
156
|
try {
|
|
215
|
-
mkdirSync(dirname(storePath), {
|
|
216
|
-
writeFileSync(storePath, JSON.stringify(store),
|
|
157
|
+
mkdirSync(dirname(storePath), {recursive: true})
|
|
158
|
+
writeFileSync(storePath, JSON.stringify(store), 'utf8')
|
|
217
159
|
} catch (error) {
|
|
218
|
-
return {
|
|
160
|
+
return {ok: false, error: `store write failed: ${error.message}`, errors}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
status,
|
|
165
|
+
queried: packages.length,
|
|
166
|
+
queriedOk,
|
|
167
|
+
unsupported,
|
|
168
|
+
vulnerable: wanted.size,
|
|
169
|
+
fetched,
|
|
170
|
+
saved: existsSync(storePath),
|
|
171
|
+
errors,
|
|
219
172
|
}
|
|
220
|
-
return { ok: true, status, queried: pkgs.length, queriedOk, unsupported, vulnerable: wanted.size, fetched, saved: existsSync(storePath), errors };
|
|
221
173
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
3
|
+
|
|
4
|
+
const REPORT_PATHS = [
|
|
5
|
+
".weavatrix/reports/cargo-audit.json",
|
|
6
|
+
".weavatrix/cargo-audit.json",
|
|
7
|
+
"cargo-audit.json",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const warningEntries = (warnings) => Object.entries(warnings || {}).flatMap(([kind, value]) =>
|
|
11
|
+
(Array.isArray(value) ? value : value?.list || []).map((item) => ({ kind, ...item })));
|
|
12
|
+
|
|
13
|
+
// Imports an explicitly generated `cargo audit --json` report. Core never runs Cargo, downloads the
|
|
14
|
+
// RustSec database, or executes repository code; an absent/stale report remains visibly incomplete.
|
|
15
|
+
export function loadRustAdvisoryReport(repoPath, { now = Date.now(), maxAgeDays = 30 } = {}) {
|
|
16
|
+
const boundary = createRepoBoundary(repoPath);
|
|
17
|
+
if (!boundary.root) return { status: "ERROR", detail: "Repository boundary is unavailable.", findings: [] };
|
|
18
|
+
const found = REPORT_PATHS.map((file) => ({ file, resolved: boundary.resolve(file) }))
|
|
19
|
+
.find((item) => item.resolved.ok && existsSync(item.resolved.path));
|
|
20
|
+
if (!found) return {
|
|
21
|
+
status: "NOT_CHECKED",
|
|
22
|
+
detail: "No saved cargo audit --json report was found; Cargo.lock OSV matching is separate evidence.",
|
|
23
|
+
findings: [],
|
|
24
|
+
};
|
|
25
|
+
try {
|
|
26
|
+
const report = JSON.parse(readFileSync(found.resolved.path, "utf8"));
|
|
27
|
+
const vulnerabilities = report?.vulnerabilities?.list;
|
|
28
|
+
if (!Array.isArray(vulnerabilities)) throw new Error("vulnerabilities.list is missing");
|
|
29
|
+
const checkedAt = report?.database?.["last-updated"] || report?.database?.lastUpdated || report?.generatedAt || null;
|
|
30
|
+
const ageMs = checkedAt ? now - new Date(checkedAt).getTime() : Number.POSITIVE_INFINITY;
|
|
31
|
+
const stale = !Number.isFinite(ageMs) || ageMs > maxAgeDays * 86_400_000;
|
|
32
|
+
const findings = vulnerabilities.map((item) => ({
|
|
33
|
+
kind: "vulnerability",
|
|
34
|
+
id: item.advisory?.id || "RUSTSEC-UNKNOWN",
|
|
35
|
+
title: item.advisory?.title || item.advisory?.description || "RustSec advisory",
|
|
36
|
+
url: item.advisory?.url || "",
|
|
37
|
+
package: item.package?.name || item.advisory?.package || "unknown-crate",
|
|
38
|
+
version: item.package?.version || "",
|
|
39
|
+
patched: item.versions?.patched || [],
|
|
40
|
+
}));
|
|
41
|
+
findings.push(...warningEntries(report?.warnings).map((item) => ({
|
|
42
|
+
kind: item.kind || "warning",
|
|
43
|
+
id: item.advisory?.id || `cargo-audit-${item.kind || "warning"}`,
|
|
44
|
+
title: item.advisory?.title || item.message || `Cargo audit ${item.kind || "warning"}`,
|
|
45
|
+
url: item.advisory?.url || "",
|
|
46
|
+
package: item.package?.name || item.advisory?.package || "unknown-crate",
|
|
47
|
+
version: item.package?.version || "",
|
|
48
|
+
patched: item.versions?.patched || [],
|
|
49
|
+
})));
|
|
50
|
+
return {
|
|
51
|
+
status: stale ? "PARTIAL" : "OK",
|
|
52
|
+
detail: `${stale ? "Imported stale/undated" : "Imported"} cargo-audit report ${found.file}: ${vulnerabilities.length} vulnerability/vulnerabilities and ${findings.length - vulnerabilities.length} warning(s).`,
|
|
53
|
+
checkedAt,
|
|
54
|
+
file: found.file,
|
|
55
|
+
findings,
|
|
56
|
+
};
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return { status: "ERROR", detail: `Could not read ${found.file}: ${error instanceof Error ? error.message : String(error)}`, file: found.file, findings: [] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import {refreshAdvisories, storeMeta, DEFAULT_STORE} from '../../security/advisory-store.js'
|
|
2
|
-
import {collectInstalled} from '../../security/installed.js'
|
|
3
|
-
|
|
4
|
-
export async function tRefreshAdvisories(g, args, ctx) {
|
|
5
|
-
if (!ctx.repoRoot) return 'No repo root — cannot collect installed packages.'
|
|
6
|
-
const {installed} = collectInstalled(ctx.repoRoot)
|
|
7
|
-
if (!installed.length) return 'No pinned packages found in supported manifests/lockfiles (npm/yarn/pip/poetry/uv/go/Maven/Gradle/Cargo) — nothing to query.'
|
|
8
|
-
const res = await refreshAdvisories({installed, repoKey: ctx.repoRoot, timeoutMs: Number(args.timeout_ms) || undefined})
|
|
9
|
-
if (res.ok === false) return `Advisory refresh failed: ${res.error}`
|
|
10
|
-
const meta = storeMeta()
|
|
11
|
-
return [
|
|
12
|
-
`Advisory store ${res.status === 'PARTIAL' ? 'partially refreshed' : 'refreshed'} from OSV.dev: ${res.queriedOk ?? res.queried}/${res.queried} package versions queried successfully, ${res.vulnerable} with known advisories (${res.fetched} advisory records fetched).`,
|
|
13
|
-
res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — supported: npm/PyPI/Go/Maven/crates.io).` : null,
|
|
14
|
-
res.errors?.length ? `Partial: ${res.errors.length} request error(s), first: ${res.errors[0]}` : null,
|
|
15
|
-
`Store: ${DEFAULT_STORE} (${meta.advisoryCount} advisories, fetched ${meta.fetchedAt}). run_audit now reflects it — offline.`,
|
|
16
|
-
].filter(Boolean).join('\n')
|
|
17
|
-
}
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import {createHash} from 'node:crypto'
|
|
2
|
-
import {readFileSync, statSync} from 'node:fs'
|
|
3
|
-
import {graphStaleness} from '../graph-context.mjs'
|
|
4
|
-
import {graphHomeDir, graphOutDirForRepo} from '../../graph/layout.js'
|
|
5
|
-
import {registerRepository, repositoryRecord} from '../../graph/repo-registry.js'
|
|
6
|
-
import {createSyncPayload, createSyncPayloadV3, MAX_SYNC_BODY_BYTES} from '../sync-payload.mjs'
|
|
7
|
-
import {createEvidenceSnapshot} from '../evidence-snapshot.mjs'
|
|
8
|
-
import {toolResult} from '../tool-result.mjs'
|
|
9
|
-
|
|
10
|
-
const MAX_SYNC_GRAPH_FILE_BYTES = 64 * 1024 * 1024
|
|
11
|
-
const SYNC_PREVIEW_TTL_MS = 5 * 60 * 1000
|
|
12
|
-
const MAX_SYNC_PREVIEWS = 4
|
|
13
|
-
const syncPreviews = new Map()
|
|
14
|
-
|
|
15
|
-
function syncRepoLabel(repoRoot) {
|
|
16
|
-
const basename = String(repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
|
|
17
|
-
const safe = basename.normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '')
|
|
18
|
-
return (safe || 'repo').slice(0, 128)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function syncDestination(raw) {
|
|
22
|
-
let url
|
|
23
|
-
try { url = new URL(raw) } catch { throw new Error('WEAVATRIX_SYNC_URL is invalid') }
|
|
24
|
-
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('WEAVATRIX_SYNC_URL must use HTTPS (or HTTP for loopback development)')
|
|
25
|
-
if (url.username || url.password) throw new Error('WEAVATRIX_SYNC_URL must not contain embedded credentials; use WEAVATRIX_SYNC_TOKEN')
|
|
26
|
-
if (url.hash) throw new Error('WEAVATRIX_SYNC_URL must not contain a fragment')
|
|
27
|
-
const loopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(url.hostname.toLowerCase())
|
|
28
|
-
if (url.protocol !== 'https:' && !loopback) throw new Error('WEAVATRIX_SYNC_URL must use HTTPS unless the destination is loopback')
|
|
29
|
-
const display = `${url.origin}${url.pathname}${url.search ? ' (query redacted)' : ''}`
|
|
30
|
-
return {url: url.toString(), display}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function pruneSyncPreviews(now = Date.now()) {
|
|
34
|
-
for (const [token, preview] of syncPreviews) if (preview.expiresAt <= now) syncPreviews.delete(token)
|
|
35
|
-
while (syncPreviews.size >= MAX_SYNC_PREVIEWS) syncPreviews.delete(syncPreviews.keys().next().value)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function confirmationToken({url, repositoryId, payloadVersion, bodyHash}) {
|
|
39
|
-
return createHash('sha256')
|
|
40
|
-
.update(`weavatrix-sync-preview-v1\0${url}\0${repositoryId}\0${payloadVersion}\0${bodyHash}`)
|
|
41
|
-
.digest('hex').slice(0, 24)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function syncSectionSummary(payload) {
|
|
45
|
-
if (payload.syncPayloadV !== 3) return 'graph topology only (explicit V2 compatibility mode)'
|
|
46
|
-
const sections = payload.evidence?.sections || {}
|
|
47
|
-
const names = Object.entries(sections).map(([name, section]) => `${name}:${section?.state || section?.verdict || 'included'}`)
|
|
48
|
-
return names.join(', ') || 'bounded architecture/health/stack/package/duplicate evidence'
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function syncPreviewText(preview, {expired = false} = {}) {
|
|
52
|
-
return [
|
|
53
|
-
`SYNC PREVIEW${expired ? ' (the supplied confirmation was missing, expired, or did not match)' : ''} — no network request was made.`,
|
|
54
|
-
`Destination: ${preview.destinationDisplay}.`,
|
|
55
|
-
`Repository: ${preview.repoName}; opaque repository UUID: ${preview.repositoryId}.`,
|
|
56
|
-
`Payload V${preview.payload.syncPayloadV}: ${preview.payload.nodes.length} nodes / ${preview.payload.links.length} edges, ${Math.round(preview.bodyBytes / 1024)} KB; body SHA-256 ${preview.bodyHash.slice(0, 12)}.`,
|
|
57
|
-
`Payload fields: ${Object.keys(preview.payload).sort().join(', ')}.`,
|
|
58
|
-
`Included sections: ${syncSectionSummary(preview.payload)}.`,
|
|
59
|
-
'Excluded by the wire allowlist: source bodies, snippets, absolute host paths, environment values, credentials, Git remotes, and unknown fields.',
|
|
60
|
-
`After the user approves this exact destination and summary, call sync_graph again within 5 minutes with dry_run:false and confirm_token: "${preview.token}".`,
|
|
61
|
-
].join('\n')
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function sendSyncPreview(preview, timeoutMs) {
|
|
65
|
-
try {
|
|
66
|
-
const res = await fetch(preview.url, {
|
|
67
|
-
method: 'POST',
|
|
68
|
-
headers: {
|
|
69
|
-
'content-type': 'application/json',
|
|
70
|
-
'x-weavatrix-payload-version': String(preview.payload.syncPayloadV),
|
|
71
|
-
'x-weavatrix-repo': preview.repoName,
|
|
72
|
-
'x-weavatrix-repository-id': preview.repositoryId,
|
|
73
|
-
...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
|
|
74
|
-
},
|
|
75
|
-
body: preview.body,
|
|
76
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
77
|
-
})
|
|
78
|
-
if (!res.ok) {
|
|
79
|
-
const accepted = res.headers?.get?.('x-weavatrix-accept-payload-versions')
|
|
80
|
-
const compatibility = (res.status === 415 || res.status === 422) && accepted
|
|
81
|
-
? ` Endpoint accepts payload version(s) ${accepted}; create and approve a new V2 preview only if graph-only sync is intentional.`
|
|
82
|
-
: ''
|
|
83
|
-
return `Sync endpoint ${preview.destinationDisplay} answered HTTP ${res.status} — graph NOT accepted.${compatibility}`
|
|
84
|
-
}
|
|
85
|
-
syncPreviews.delete(preview.token)
|
|
86
|
-
const evidenceNote = preview.payload.syncPayloadV === 3
|
|
87
|
-
? ` + evidence ${preview.payload.evidence?.snapshotHash?.slice(0, 12) || 'unknown'}`
|
|
88
|
-
: ''
|
|
89
|
-
return `Graph for ${preview.repoName} (${preview.payload.nodes.length} nodes / ${preview.payload.links.length} edges${evidenceNote}, ${Math.round(preview.bodyBytes / 1024)} KB) pushed to approved destination ${preview.destinationDisplay}.`
|
|
90
|
-
} catch (error) {
|
|
91
|
-
return `Sync failed: ${error.message} — the graph stays local; the approved preview remains retryable until it expires.`
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function buildSyncPreview(g, args, ctx) {
|
|
96
|
-
const configuredUrl = process.env.WEAVATRIX_SYNC_URL
|
|
97
|
-
if (!configuredUrl) {
|
|
98
|
-
return 'Graph sync is not configured (optional feature). Set WEAVATRIX_SYNC_URL to the upload endpoint'
|
|
99
|
-
+ ' (and WEAVATRIX_SYNC_TOKEN for bearer auth) in the MCP registration env, then call again.'
|
|
100
|
-
}
|
|
101
|
-
let destination
|
|
102
|
-
try { destination = syncDestination(configuredUrl) }
|
|
103
|
-
catch (error) { return `Graph sync is not configured safely: ${error.message}.` }
|
|
104
|
-
if (!g) return 'No graph loaded — build one first (open_repo / rebuild_graph).'
|
|
105
|
-
let raw
|
|
106
|
-
try {
|
|
107
|
-
const size = statSync(ctx.graphPath).size
|
|
108
|
-
if (size > MAX_SYNC_GRAPH_FILE_BYTES) {
|
|
109
|
-
return `Cannot sync: graph.json is ${Math.ceil(size / 1024 / 1024)} MB; the local safety limit is ${MAX_SYNC_GRAPH_FILE_BYTES / 1024 / 1024} MB.`
|
|
110
|
-
}
|
|
111
|
-
raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8'))
|
|
112
|
-
} catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
|
|
113
|
-
const requestedVersion = Number(args.payload_version) === 2 ? 2 : 3
|
|
114
|
-
let payload
|
|
115
|
-
try {
|
|
116
|
-
if (requestedVersion === 2) {
|
|
117
|
-
payload = createSyncPayload(raw)
|
|
118
|
-
} else {
|
|
119
|
-
if (!ctx.repoRoot) return 'Cannot build evidence: no repository root is active.'
|
|
120
|
-
if (graphStaleness(ctx).stale) {
|
|
121
|
-
return 'Cannot sync evidence from a stale graph. Run rebuild_graph, then call sync_graph again.'
|
|
122
|
-
}
|
|
123
|
-
const evidence = await createEvidenceSnapshot({repoRoot: ctx.repoRoot, graph: raw})
|
|
124
|
-
payload = createSyncPayloadV3(raw, evidence)
|
|
125
|
-
}
|
|
126
|
-
} catch (e) {
|
|
127
|
-
return `Cannot sync: ${e.message}. Run rebuild_graph once before sync_graph.`
|
|
128
|
-
}
|
|
129
|
-
const body = JSON.stringify(payload)
|
|
130
|
-
const bodyBytes = Buffer.byteLength(body)
|
|
131
|
-
if (bodyBytes > MAX_SYNC_BODY_BYTES) {
|
|
132
|
-
return `Cannot sync: payload is ${Math.ceil(bodyBytes / 1024)} KB; the hosted safety limit is ${MAX_SYNC_BODY_BYTES / 1024} KB. Narrow the graph scope and rebuild before retrying.`
|
|
133
|
-
}
|
|
134
|
-
const repoName = syncRepoLabel(ctx.repoRoot)
|
|
135
|
-
const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
|
|
136
|
-
|| registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
|
|
137
|
-
const bodyHash = createHash('sha256').update(body).digest('hex')
|
|
138
|
-
const token = confirmationToken({url: destination.url, repositoryId: registry.repositoryId, payloadVersion: payload.syncPayloadV, bodyHash})
|
|
139
|
-
const preview = {
|
|
140
|
-
token, url: destination.url, destinationDisplay: destination.display,
|
|
141
|
-
graphPath: ctx.graphPath, repoName, repositoryId: registry.repositoryId,
|
|
142
|
-
payload, body, bodyBytes, bodyHash, expiresAt: Date.now() + SYNC_PREVIEW_TTL_MS,
|
|
143
|
-
}
|
|
144
|
-
syncPreviews.set(token, preview)
|
|
145
|
-
return preview
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function previewResult(preview, {expired = false} = {}) {
|
|
149
|
-
if (typeof preview === 'string') return preview
|
|
150
|
-
return toolResult(syncPreviewText(preview, {expired}), {
|
|
151
|
-
status: 'PREVIEW_READY', networkRequestMade: false,
|
|
152
|
-
destination: preview.destinationDisplay, repository: preview.repoName,
|
|
153
|
-
repositoryId: preview.repositoryId, payloadVersion: preview.payload.syncPayloadV,
|
|
154
|
-
nodes: preview.payload.nodes.length, links: preview.payload.links.length,
|
|
155
|
-
bodyBytes: preview.bodyBytes, bodyHash: preview.bodyHash,
|
|
156
|
-
payloadFields: Object.keys(preview.payload).sort(), sections: syncSectionSummary(preview.payload),
|
|
157
|
-
expiresAt: new Date(preview.expiresAt).toISOString(), confirmToken: preview.token,
|
|
158
|
-
}, {completeness: {status: 'COMPLETE', reason: 'exact allowlisted payload serialized locally; no network request made'}})
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Build the exact upload body and approval token without any network request. Kept separate from the
|
|
162
|
-
// mutating tool so safety layers and humans can approve a local preview without authorizing egress.
|
|
163
|
-
export async function tPreviewSyncGraph(g, args, ctx) {
|
|
164
|
-
pruneSyncPreviews()
|
|
165
|
-
return previewResult(await buildSyncPreview(g, args, ctx))
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Push the exact payload previously approved through preview_sync. The old dry_run form remains a
|
|
169
|
-
// compatibility alias for one release, but can never send unless dry_run:false and the token matches.
|
|
170
|
-
export async function tSyncGraph(g, args, ctx) {
|
|
171
|
-
if (args.dry_run !== false) {
|
|
172
|
-
pruneSyncPreviews()
|
|
173
|
-
const preview = await buildSyncPreview(g, args, ctx)
|
|
174
|
-
if (typeof preview === 'string') return preview
|
|
175
|
-
const suppliedToken = String(args.confirm_token || '').trim()
|
|
176
|
-
const exact = suppliedToken && suppliedToken === preview.token
|
|
177
|
-
return `${syncPreviewText(preview, {expired: !!suppliedToken && !exact})}${exact ? '\nConfirmation token recognized, but dry_run is still true; no network request was made.' : ''}`
|
|
178
|
-
}
|
|
179
|
-
const configuredUrl = process.env.WEAVATRIX_SYNC_URL
|
|
180
|
-
if (!configuredUrl) return 'Graph sync is not configured (optional feature). Set WEAVATRIX_SYNC_URL first.'
|
|
181
|
-
let destination
|
|
182
|
-
try { destination = syncDestination(configuredUrl) }
|
|
183
|
-
catch (error) { return `Graph sync is not configured safely: ${error.message}.` }
|
|
184
|
-
pruneSyncPreviews()
|
|
185
|
-
const suppliedToken = String(args.confirm_token || '').trim()
|
|
186
|
-
const approved = suppliedToken ? syncPreviews.get(suppliedToken) : null
|
|
187
|
-
if (approved && approved.expiresAt > Date.now()
|
|
188
|
-
&& approved.url === destination.url && approved.graphPath === ctx.graphPath) {
|
|
189
|
-
const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
|
|
190
|
-
return sendSyncPreview(approved, timeoutMs)
|
|
191
|
-
}
|
|
192
|
-
const preview = await buildSyncPreview(g, args, ctx)
|
|
193
|
-
return typeof preview === 'string' ? preview : syncPreviewText(preview, {expired: true})
|
|
194
|
-
}
|
|
195
|
-
|