weavatrix 0.2.18 → 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 (43) hide show
  1. package/README.md +102 -93
  2. package/SECURITY.md +13 -31
  3. package/bin/weavatrix-mcp.mjs +2 -1
  4. package/docs/adr/0001-v0.3-offline-online-split.md +9 -7
  5. package/docs/releases/v0.2.19.md +33 -0
  6. package/docs/releases/v0.3.0.md +43 -0
  7. package/package.json +9 -3
  8. package/skill/SKILL.md +34 -59
  9. package/src/analysis/architecture/contract-storage.js +2 -2
  10. package/src/analysis/audit-extensions.js +60 -0
  11. package/src/analysis/duplicate-groups.js +13 -2
  12. package/src/analysis/duplicates.compute.js +7 -2
  13. package/src/analysis/health-capabilities.js +2 -1
  14. package/src/analysis/internal-audit/supply-chain.js +17 -2
  15. package/src/analysis/jvm-artifact-index.js +87 -0
  16. package/src/analysis/jvm-dependency-evidence.js +31 -8
  17. package/src/analysis/transport-contracts.js +78 -11
  18. package/src/analysis/transport-runtime-evidence.js +155 -0
  19. package/src/child-env.js +2 -2
  20. package/src/extension/local-services.mjs +75 -0
  21. package/src/graph/internal-builder.langs.js +2 -0
  22. package/src/graph/repo-registry.js +2 -2
  23. package/src/mcp/catalog.mjs +47 -21
  24. package/src/mcp/evidence/duplicate-member-order.mjs +1 -1
  25. package/src/mcp/evidence-snapshot.health.mjs +2 -2
  26. package/src/mcp/extension-api.mjs +77 -0
  27. package/src/mcp/health/audit-format.mjs +11 -1
  28. package/src/mcp/health/audit.mjs +10 -7
  29. package/src/mcp/health/duplicates.mjs +5 -3
  30. package/src/mcp/sync-payload.mjs +1 -1
  31. package/src/mcp/tools-actions.mjs +1 -8
  32. package/src/mcp/tools-architecture.mjs +2 -2
  33. package/src/mcp/tools-company.mjs +4 -2
  34. package/src/mcp-runtime.mjs +2 -0
  35. package/src/mcp-server.mjs +28 -17
  36. package/src/security/advisory-store.js +133 -181
  37. package/src/security/malware-scoring.js +4 -0
  38. package/src/security/registry-sig.rules.js +4 -3
  39. package/src/security/rust-advisory-report.js +60 -0
  40. package/docs/releases/v0.2.18.md +0 -41
  41. package/src/mcp/actions/advisories.mjs +0 -17
  42. package/src/mcp/actions/graph-sync.mjs +0 -195
  43. package/src/mcp/actions/hosted-architecture.mjs +0 -57
@@ -1,221 +1,173 @@
1
- // Local advisory cache for supply-chain scanning. SCANS are 100% offline (read this store only);
2
- // REFRESH is an explicit, user-triggered online call to OSV.dev (it necessarily sends the installed
3
- // package names+versions surfaced in the UI, never automatic). OSV is the single source: CVE/GHSA
4
- // vulnerabilities AND OSSF malicious-package records (MAL-*) come through one schema/one matcher.
5
- //
6
- // Storage is a JSON file, not SQLite — deliberate P4 deviation from the plan: we cache advisories for
7
- // THIS machine's installed packages (hundreds of records), not the full npm snapshot (that's what
8
- // needed SQLite), and better-sqlite3 here is built for Electron's ABI so plain-node tests couldn't
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(), ".weavatrix", "advisories.json");
17
- const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
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 === "PyPI" ? String(name).toLowerCase().replace(/[-_.]+/g, "-") : name}`;
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
- const uniquePackages = (pkgs) => uniqueBy(pkgs, (p) => `${p.ecosystem}|${p.name}|${p.version}`);
25
-
26
- export function advisoryQueryFingerprint(installed = []) {
27
- const rows = uniquePackages(installed.filter((p) => p?.ecosystem && p?.name && p?.version && OSV_SUPPORTED_ECOSYSTEMS.has(p.ecosystem)))
28
- .map((p) => `${p.ecosystem}|${p.name}|${p.version}`)
29
- .sort();
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
- async function fetchJson(fetcher, url, options, timeoutMs) {
34
- const timeout = Math.max(50, Number(timeoutMs) || DEFAULT_FETCH_TIMEOUT_MS);
35
- const ctrl = typeof AbortController === "function" ? new AbortController() : null;
36
- let timer;
37
- try {
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 s = JSON.parse(readFileSync(storePath, "utf8"));
60
- if (s && typeof s === "object" && s.records) return s;
61
- } catch { /* missing/corrupt empty */ }
62
- return { meta: { fetched_at: null }, records: {} };
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 function queryStore(store, ecosystem, name) {
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 s = loadStore(storePath);
71
- return { fetchedAt: s.meta?.fetched_at || null, advisoryCount: Object.values(s.records || {}).reduce((n, l) => n + l.length, 0) };
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
- // GHSA-style labels + CVSS score → our severity scale. MAL-* records are always critical.
75
- function severityOf(rec) {
76
- if (String(rec.id || "").startsWith("MAL-")) return "critical";
77
- const label = String(rec.database_specific?.severity || "").toLowerCase();
78
- if (label === "critical") return "critical";
79
- if (label === "high") return "high";
80
- if (label === "moderate" || label === "medium") return "medium";
81
- if (label === "low") return "low";
82
- let best = 0;
83
- for (const s of rec.severity || []) {
84
- const m = String(s.score || "").match(/CVSS:[\d.]+\/.*?\bA[VC]?:/i) ? null : String(s.score || "").match(/^(\d+(\.\d+)?)$/);
85
- if (m) best = Math.max(best, Number(m[1]));
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 "critical";
88
- if (best >= 7) return "high";
89
- if (best >= 4) return "medium";
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
- // One normalized row per (record × matching affected entry): everything the matcher/UI needs, nothing else.
94
- function normalizeRecord(rec, ecosystem, name) {
95
- const affected = (rec.affected || []).find((a) => a?.package && a.package.ecosystem === ecosystem && keyOf(ecosystem, a.package.name) === keyOf(ecosystem, name));
96
- if (!affected) return null;
97
- const fixed = [];
98
- for (const r of affected.ranges || []) for (const e of r.events || []) if (e.fixed) fixed.push(e.fixed);
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: rec.id,
101
- kind: String(rec.id || "").startsWith("MAL-") ? "malicious" : "vuln",
102
- severity: severityOf(rec),
103
- summary: String(rec.summary || rec.details || "").slice(0, 300),
104
- url: `https://osv.dev/vulnerability/${rec.id}`,
105
- modified: rec.modified || "",
106
- aliases: (rec.aliases || []).slice(0, 6),
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: { versions: affected.versions || [], ranges: affected.ranges || [] },
109
- };
83
+ affected: {versions: affected.versions || [], ranges: affected.ranges || []},
84
+ }
110
85
  }
111
86
 
112
- // Refresh the cache from OSV for the given installed set. fetcher is injectable for tests.
113
- // Returns { queried, vulnerable, fetched, saved, errors }.
114
- export async function refreshAdvisories({ installed = [], storePath = DEFAULT_STORE, fetcher = globalThis.fetch, batchSize = 100, repoKey = "", repoKeys = [], timeoutMs = DEFAULT_FETCH_TIMEOUT_MS } = {}) {
115
- if (typeof fetcher !== "function") return { ok: false, error: "no fetch available" };
116
- const withVersions = installed.filter((p) => p && p.ecosystem && p.name && p.version);
117
- const unsupported = withVersions.filter((p) => !OSV_SUPPORTED_ECOSYSTEMS.has(p.ecosystem)).length;
118
- const pkgs = uniquePackages(withVersions.filter((p) => OSV_SUPPORTED_ECOSYSTEMS.has(p.ecosystem)));
119
- if (!pkgs.length) {
120
- return {
121
- ok: false,
122
- queried: 0,
123
- unsupported,
124
- error: "No OSV-supported pinned package versions found to check. Weavatrix queries OSV for npm, PyPI, Go, Maven/Gradle, and crates.io packages with concrete versions.",
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
- for (let i = 0; i < pkgs.length; i += batchSize) {
133
- const batch = pkgs.slice(i, i + batchSize);
134
- try {
135
- const json = await fetchJson(fetcher, OSV_BATCH_URL, {
136
- method: "POST",
137
- headers: { "Content-Type": "application/json" },
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, pkgList] of wanted) {
166
- try {
167
- const rec = await fetchJson(fetcher, OSV_VULN_URL + encodeURIComponent(id), {}, timeoutMs);
168
- if (!rec || typeof rec.id !== "string" || !rec.id) throw new Error("OSV detail response is missing its advisory id");
169
- if (rec.id !== id) throw new Error(`OSV detail id mismatch (expected ${id}, received ${rec.id})`);
170
- let normalized = 0;
171
- for (const p of pkgList) {
172
- const row = normalizeRecord(rec, p.ecosystem, p.name);
173
- if (!row) {
174
- errors.push(`${id}: OSV detail does not describe ${p.ecosystem}:${p.name} reported by querybatch`);
175
- continue;
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
- // Count a detail response as fetched only after at least one package-specific row was
184
- // validated. A malformed or unrelated response must never make coverage look complete.
185
- if (normalized > 0) fetched++;
186
- } catch (error) {
187
- errors.push(`${id}: ${error.message}`);
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
- // A refresh where NOTHING was fetched but errors occurred (offline, OSV blocked) must NOT stamp
192
- // fetched_at that would turn an empty cache into "No known vulnerabilities as of <today>".
193
- if (errors.length && queriedOk === 0) {
194
- return { ok: false, queried: pkgs.length, unsupported, error: `advisory refresh failed: ${errors[0]}${errors.length > 1 ? ` (+${errors.length - 1} more)` : ""}`, errors };
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 = store.meta.repos || {};
204
- for (const k of stampRepos) store.meta.repos[k] = {
205
- fetched_at: store.meta.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: pkgs.length,
149
+ queried: packages.length,
208
150
  queried_ok: queriedOk,
209
151
  unsupported,
210
152
  error_count: errors.length,
211
- query_fingerprint: advisoryQueryFingerprint(pkgs),
212
- };
153
+ query_fingerprint: advisoryQueryFingerprint(packages),
154
+ }
213
155
  }
214
156
  try {
215
- mkdirSync(dirname(storePath), { recursive: true });
216
- writeFileSync(storePath, JSON.stringify(store), "utf8");
157
+ mkdirSync(dirname(storePath), {recursive: true})
158
+ writeFileSync(storePath, JSON.stringify(store), 'utf8')
217
159
  } catch (error) {
218
- return { ok: false, error: `store write failed: ${error.message}`, errors };
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
  }
@@ -22,6 +22,10 @@ function addTyposquatCoSignals(byPkg) {
22
22
  function visibleSignals(signals, allow) {
23
23
  const kept = signals.filter((s) => !(allow && s.noisy));
24
24
  if (!kept.length) return [];
25
+ // A literal URL, or even a network call to one, is ordinary package behavior without a second
26
+ // security signal. Keep those weak observations available as co-evidence (for example beside
27
+ // environment harvesting), but never create a standalone malware finding from them.
28
+ if (!kept.some((s) => !s.weak)) return [];
25
29
  const hasStrongerUrl = kept.some((s) => STRONG_URL_KEYS.has(s.key));
26
30
  return hasStrongerUrl ? kept.filter((s) => s.key !== "hardcoded-url") : kept;
27
31
  }
@@ -90,6 +90,7 @@ export const CONTENT_RULES = [
90
90
  severity: "low",
91
91
  nearZeroFp: false,
92
92
  noisy: true,
93
+ weak: true,
93
94
  pattern: "(fetch|axios\\.|XMLHttpRequest|sendBeacon|https?\\.request|request\\(|curl\\b|wget\\b).{0,220}https?://",
94
95
  re: new RegExp(`\\b(fetch|axios\\.(get|post|put|patch|request)|XMLHttpRequest|sendBeacon|https?\\.request|http\\.request|request\\(|curl\\b|wget\\b)[\\s\\S]{0,220}?${PLAIN_EXTERNAL_URL}`, "i"),
95
96
  what: "network call to hardcoded external URL",
@@ -172,9 +173,9 @@ export const CONTENT_RULES = [
172
173
  key: "hidden-unicode",
173
174
  severity: "medium",
174
175
  nearZeroFp: false,
175
- pattern: "[\\x{202A}-\\x{202E}\\x{2066}-\\x{2069}\\x{FE00}-\\x{FE0F}]",
176
- re: new RegExp("\\u202A|\\u202B|\\u202C|\\u202D|\\u202E|\\u2066|\\u2067|\\u2068|\\u2069|\\uFE00|\\uFE01|\\uFE02|\\uFE03|\\uFE04|\\uFE05|\\uFE06|\\uFE07|\\uFE08|\\uFE09|\\uFE0A|\\uFE0B|\\uFE0C|\\uFE0D|\\uFE0E|\\uFE0F", "u"),
177
- what: "hidden Unicode control/variation character in package source",
176
+ pattern: "[\\x{202A}-\\x{202E}\\x{2066}-\\x{2069}]",
177
+ re: new RegExp("\\u202A|\\u202B|\\u202C|\\u202D|\\u202E|\\u2066|\\u2067|\\u2068|\\u2069", "u"),
178
+ what: "hidden bidirectional Unicode control character in package source",
178
179
  },
179
180
  {
180
181
  key: "obfuscated-code",
@@ -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,41 +0,0 @@
1
- # Weavatrix 0.2.18
2
-
3
- 0.2.18 is the publishable repository-root and self-audit trust patch. The `v0.2.17` source tag was
4
- created, but its release validation exposed the nested-root discovery regression before an npm
5
- package was published. The tag remains immutable; 0.2.18 contains that release plus the fix.
6
-
7
- ## Repository discovery
8
-
9
- - The internal builder distinguishes an actual Git root from a directory that merely lives below
10
- another Git repository.
11
- - When the selected nested directory is ignored by its parent and Git returns an ambiguous empty
12
- file universe, Weavatrix uses its boundary-safe filesystem walker instead of building a zero-node
13
- graph.
14
- - A real Git root with an empty tracked/unignored universe stays empty, so ignored build output and
15
- secrets are not reintroduced.
16
- - The live stdio hot-reload regression now exercises the corrected path and passes independently,
17
- matching the Linux release runner rather than depending on full-suite ordering.
18
-
19
- ## Included 0.2.17 trust work
20
-
21
- - Drizzle configuration roots its schema modules in production reachability.
22
- - Scoped typosquat review compares only compatible scoped targets.
23
- - Executable regular expressions retain equality anchors for clone fingerprints without exposing
24
- their source text.
25
- - Obsolete test-only dependency parsers were removed and repeated dependency, graph and architecture
26
- helpers were consolidated under shared owners.
27
- - Hosted evidence applies the same production-first classification and recomputes bounded summaries
28
- after filtering.
29
-
30
- ## Product boundary
31
-
32
- - `weavatrix` remains MIT and offline-first. No license change is made here.
33
- - The accepted 0.3 split moves outbound HTTP into the separately versioned `weavatrix-online`
34
- connector, whose permission-required license remains a release gate.
35
-
36
- ## Verification
37
-
38
- Release validation covers the full Node test suite on supported Node versions, independent hot
39
- reload execution, release-manifest checks, npm/MCPB packing and runtime smoke tests. The paired
40
- Hosted deployment is rebuilt from this graph, then verified through its live Health and History
41
- views.
@@ -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
- }