weavatrix 0.1.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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/bin/weavatrix-mcp.mjs +6 -0
  4. package/package.json +50 -0
  5. package/skill/SKILL.md +56 -0
  6. package/src/analysis/coverage-reports.js +232 -0
  7. package/src/analysis/dead-check.js +136 -0
  8. package/src/analysis/dep-check.js +310 -0
  9. package/src/analysis/dep-rules.js +221 -0
  10. package/src/analysis/duplicates-worker.js +11 -0
  11. package/src/analysis/duplicates.compute.js +215 -0
  12. package/src/analysis/duplicates.js +15 -0
  13. package/src/analysis/duplicates.run.js +51 -0
  14. package/src/analysis/duplicates.tokenize.js +182 -0
  15. package/src/analysis/endpoints.js +156 -0
  16. package/src/analysis/findings.js +52 -0
  17. package/src/analysis/graph-analysis.aggregate.js +280 -0
  18. package/src/analysis/graph-analysis.js +7 -0
  19. package/src/analysis/graph-analysis.refs.js +146 -0
  20. package/src/analysis/graph-analysis.summaries.js +62 -0
  21. package/src/analysis/internal-audit.collect.js +117 -0
  22. package/src/analysis/internal-audit.js +9 -0
  23. package/src/analysis/internal-audit.reach.js +47 -0
  24. package/src/analysis/internal-audit.run.js +189 -0
  25. package/src/analysis/manifests.js +169 -0
  26. package/src/analysis/source-complexity.ast.js +97 -0
  27. package/src/analysis/source-complexity.constants.js +55 -0
  28. package/src/analysis/source-complexity.js +14 -0
  29. package/src/analysis/source-complexity.report.js +76 -0
  30. package/src/analysis/source-complexity.walk.js +174 -0
  31. package/src/build-graph.js +102 -0
  32. package/src/config.js +5 -0
  33. package/src/graph/build-worker.js +30 -0
  34. package/src/graph/builder/lang-csharp.js +40 -0
  35. package/src/graph/builder/lang-css.js +29 -0
  36. package/src/graph/builder/lang-go.js +53 -0
  37. package/src/graph/builder/lang-html.js +29 -0
  38. package/src/graph/builder/lang-java.js +29 -0
  39. package/src/graph/builder/lang-js.js +168 -0
  40. package/src/graph/builder/lang-python.js +78 -0
  41. package/src/graph/builder/lang-rust.js +45 -0
  42. package/src/graph/builder/spec-pkg.js +87 -0
  43. package/src/graph/graph-filter.js +44 -0
  44. package/src/graph/internal-builder.build.js +217 -0
  45. package/src/graph/internal-builder.js +12 -0
  46. package/src/graph/internal-builder.langs.js +86 -0
  47. package/src/graph/internal-builder.resolvers.js +116 -0
  48. package/src/graph/layout.js +35 -0
  49. package/src/infra/infra-items.js +255 -0
  50. package/src/infra/infra-registry.js +184 -0
  51. package/src/infra/infra.detect.js +119 -0
  52. package/src/infra/infra.js +38 -0
  53. package/src/infra/infra.match.js +102 -0
  54. package/src/infra/infra.scan.js +93 -0
  55. package/src/mcp/catalog.mjs +68 -0
  56. package/src/mcp/graph-context.mjs +276 -0
  57. package/src/mcp/tools-actions.mjs +132 -0
  58. package/src/mcp/tools-graph.mjs +274 -0
  59. package/src/mcp/tools-health.mjs +209 -0
  60. package/src/mcp/tools-impact.mjs +189 -0
  61. package/src/mcp-rg.mjs +51 -0
  62. package/src/mcp-server.mjs +171 -0
  63. package/src/mcp-source-tools.mjs +139 -0
  64. package/src/process.js +77 -0
  65. package/src/scan/discover.inventory.js +78 -0
  66. package/src/scan/discover.js +5 -0
  67. package/src/scan/discover.list.js +79 -0
  68. package/src/scan/discover.stack.js +227 -0
  69. package/src/scan/search.core.js +24 -0
  70. package/src/scan/search.git.js +102 -0
  71. package/src/scan/search.js +9 -0
  72. package/src/scan/search.node.js +83 -0
  73. package/src/scan/search.preview.js +49 -0
  74. package/src/scan/search.rg.js +145 -0
  75. package/src/security/advisory-store.js +177 -0
  76. package/src/security/installed.js +247 -0
  77. package/src/security/malware-file-heuristics.js +121 -0
  78. package/src/security/malware-heuristics.exclusions.js +134 -0
  79. package/src/security/malware-heuristics.js +15 -0
  80. package/src/security/malware-heuristics.roots.js +142 -0
  81. package/src/security/malware-heuristics.scan.js +87 -0
  82. package/src/security/malware-heuristics.sweep.js +122 -0
  83. package/src/security/malware-scoring.js +84 -0
  84. package/src/security/match.js +85 -0
  85. package/src/security/registry-sig.classify.js +136 -0
  86. package/src/security/registry-sig.js +18 -0
  87. package/src/security/registry-sig.rules.js +188 -0
  88. package/src/security/typosquat.js +72 -0
  89. package/src/util.js +27 -0
@@ -0,0 +1,145 @@
1
+ // ripgrep engine for the cross-repo search: resolve a usable rg binary (bundled / VS Code / Cursor /
2
+ // PATH) and run the ripgrep-backed filename + content search. Split out of search.js.
3
+ import { existsSync, readdirSync } from "node:fs";
4
+ import { join, sep } from "node:path";
5
+ import { runCommand } from "../process.js";
6
+ import { repoBaseName } from "./discover.js";
7
+
8
+ // ---- ripgrep resolution (no bare `rg` — a packaged app's PATH usually lacks it) -----------------
9
+ function rgInInstall(base) {
10
+ return [
11
+ join(base, "resources", "app", "node_modules", "@vscode", "ripgrep", "bin", "rg.exe"),
12
+ join(base, "resources", "app", "node_modules", "@vscode", "ripgrep-universal", "bin", "win32-x64", "rg.exe")
13
+ ];
14
+ }
15
+ function editorRgCandidates() {
16
+ const local = process.env.LOCALAPPDATA || "";
17
+ const pf = process.env.PROGRAMFILES || "";
18
+ const roots = [local && join(local, "Programs", "Microsoft VS Code"), local && join(local, "Programs", "cursor"), pf && join(pf, "Microsoft VS Code")].filter(Boolean);
19
+ const out = [];
20
+ for (const root of roots) {
21
+ if (!existsSync(root)) continue;
22
+ out.push(...rgInInstall(root)); // non-versioned install
23
+ try {
24
+ for (const d of readdirSync(root, { withFileTypes: true })) if (d.isDirectory()) out.push(...rgInInstall(join(root, d.name))); // version-hashed dir
25
+ } catch {
26
+ /* ignore */
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ function extensionRgCandidates() {
32
+ const home = process.env.USERPROFILE || process.env.HOME || "";
33
+ const roots = [".vscode", ".vscode-insiders", ".cursor"].map((d) => home && join(home, d, "extensions")).filter(Boolean);
34
+ const out = [];
35
+ const walk = (dir, depth = 0) => {
36
+ if (!dir || depth > 5 || out.length >= 20 || !existsSync(dir)) return;
37
+ let entries = [];
38
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
39
+ for (const ent of entries) {
40
+ if (out.length >= 20) break;
41
+ const full = join(dir, ent.name);
42
+ if (ent.isFile() && /^rg(\.exe)?$/i.test(ent.name)) out.push(full);
43
+ else if (ent.isDirectory()) walk(full, depth + 1);
44
+ }
45
+ };
46
+ for (const root of roots) walk(root);
47
+ return out;
48
+ }
49
+ async function whereRg() {
50
+ if (process.platform !== "win32") return [];
51
+ try {
52
+ const r = await runCommand("where.exe", ["rg"], { timeoutMs: 4000 });
53
+ return String(r.stdout || "").split(/\r?\n/).map((l) => l.trim()).filter((l) => /\.exe$/i.test(l));
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+ // engine (settings.searchEngine): "auto" (default — custom path → env → editor-bundled rg → PATH →
59
+ // git grep → Node), "system" (ONLY the system-installed rg from PATH, then Node), "git" (git grep /
60
+ // git ls-files, then Node), "node" (skip native tools entirely, pure-Node scanner).
61
+ const _rgCache = new Map(); // engine|rgPath -> { info, at }
62
+ // exported: the malware scanner (security/malware-heuristics.js) reuses the same rg resolution
63
+ export async function resolveRgInfo(engine = "auto", rgPath = "") {
64
+ if (engine === "node" || engine === "git") return null;
65
+ const key = `${engine}|${rgPath}`;
66
+ const hit = _rgCache.get(key);
67
+ if (hit && Date.now() - hit.at < 60000) return hit.info;
68
+ const env = process.env.WEAVATRIX_RG_CMD && process.env.WEAVATRIX_RG_CMD.replace(/^"|"$/g, "");
69
+ const pathCands = (await whereRg()).map((path) => ({ path, source: "PATH" }));
70
+ const cands = [
71
+ rgPath && { path: rgPath, source: "custom path" },
72
+ engine === "auto" && env && { path: env, source: "WEAVATRIX_RG_CMD" },
73
+ ...editorRgCandidates().map((path) => ({ path, source: "VS Code/Cursor bundle" })),
74
+ ...extensionRgCandidates().map((path) => ({ path, source: "editor extension" })),
75
+ ...pathCands,
76
+ process.platform === "win32" ? null : { path: "rg", source: "PATH" },
77
+ ].filter(Boolean);
78
+ let info = null;
79
+ for (const c of cands) {
80
+ if (c.path === "rg" || existsSync(c.path)) {
81
+ info = { path: c.path, source: c.source, detail: `${c.source}: ${c.path}` };
82
+ break;
83
+ }
84
+ }
85
+ _rgCache.set(key, { info, at: Date.now() });
86
+ return info;
87
+ }
88
+
89
+ export async function resolveRg(engine = "auto", rgPath = "") {
90
+ return (await resolveRgInfo(engine, rgPath))?.path || null;
91
+ }
92
+
93
+ function repoOfPath(roots, p) {
94
+ const m = roots.find((r) => p === r || p.startsWith(r + sep) || p.startsWith(r + "/"));
95
+ return m ? repoBaseName(m) : "";
96
+ }
97
+
98
+ // ---- ripgrep search -----------------------------------------------------------------------------
99
+ export async function rgSearch(rg, roots, query, mode, cap) {
100
+ if (mode === "filename") {
101
+ const term = query.replace(/[[\]{}]/g, ""); // keep it a plain substring glob
102
+ const args = ["--files", "--hidden", "-g", "!.git", "-g", "!node_modules", "-g", `*${term}*`, "--", ...roots];
103
+ const r = await runCommand(rg, args, { timeoutMs: 15000 });
104
+ if (r.exitCode === 2 && !r.stdout) return { ok: false, error: (r.stderr || "ripgrep error").slice(0, 300) };
105
+ const lines = String(r.stdout || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
106
+ const results = [];
107
+ for (const p of lines) {
108
+ if (results.length >= cap) break;
109
+ results.push({ repo: repoOfPath(roots, p), path: p, line: 0, column: 0, preview: p.split(/[\\/]/).pop() });
110
+ }
111
+ return { ok: true, engine: "ripgrep", truncated: lines.length > cap, results };
112
+ }
113
+ // content (literal/fixed-string smart-case)
114
+ const args = ["--json", "-F", "--smart-case", "--max-count", "40", "--max-columns", "400", "--max-columns-preview", "--threads", "0", "-g", "!.git", "-g", "!node_modules", "-e", query, "--", ...roots];
115
+ const r = await runCommand(rg, args, { timeoutMs: 20000 });
116
+ if (r.exitCode === 2 && !r.stdout) return { ok: false, error: (r.stderr || "ripgrep error").slice(0, 300) };
117
+ const results = [];
118
+ let truncated = false;
119
+ for (const line of String(r.stdout || "").split(/\r?\n/)) {
120
+ if (!line) continue;
121
+ if (results.length >= cap) {
122
+ truncated = true;
123
+ break;
124
+ }
125
+ let obj;
126
+ try {
127
+ obj = JSON.parse(line);
128
+ } catch {
129
+ continue;
130
+ }
131
+ if (obj.type !== "match") continue;
132
+ const d = obj.data || {};
133
+ const path = d.path?.text || "";
134
+ if (!path) continue;
135
+ const sub = (d.submatches || [])[0];
136
+ results.push({
137
+ repo: repoOfPath(roots, path),
138
+ path,
139
+ line: d.line_number || 0,
140
+ column: sub ? sub.start : 0,
141
+ preview: String(d.lines?.text || "").replace(/\r?\n$/, "").slice(0, 400)
142
+ });
143
+ }
144
+ return { ok: true, engine: "ripgrep", truncated, results };
145
+ }
@@ -0,0 +1,177 @@
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 { uniqueBy } from "../util.js";
14
+
15
+ export const DEFAULT_STORE = join(homedir(), ".weavatrix", "advisories.json");
16
+ const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
17
+ const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
18
+ const DEFAULT_FETCH_TIMEOUT_MS = Number(process.env.WEAVATRIX_OSV_TIMEOUT_MS || 20000);
19
+ export const OSV_SUPPORTED_ECOSYSTEMS = new Set(["npm", "PyPI", "Go"]);
20
+
21
+ const keyOf = (ecosystem, name) => `${ecosystem}|${ecosystem === "PyPI" ? String(name).toLowerCase().replace(/[-_.]+/g, "-") : name}`;
22
+
23
+ const uniquePackages = (pkgs) => uniqueBy(pkgs, (p) => `${p.ecosystem}|${p.name}|${p.version}`);
24
+
25
+ async function fetchJson(fetcher, url, options, timeoutMs) {
26
+ const timeout = Math.max(50, Number(timeoutMs) || DEFAULT_FETCH_TIMEOUT_MS);
27
+ const ctrl = typeof AbortController === "function" ? new AbortController() : null;
28
+ let timer;
29
+ try {
30
+ const request = fetcher(url, ctrl ? { ...options, signal: ctrl.signal } : options);
31
+ const timeoutPromise = new Promise((_, reject) => {
32
+ timer = setTimeout(() => {
33
+ try { ctrl?.abort(); } catch { /* ignore */ }
34
+ reject(new Error(`OSV request timed out after ${Math.round(timeout / 1000)}s`));
35
+ }, timeout);
36
+ });
37
+ const res = await Promise.race([request, timeoutPromise]);
38
+ if (!res || typeof res.json !== "function") throw new Error("invalid response from OSV");
39
+ if (res.ok === false) throw new Error(`HTTP ${res.status || "error"} from OSV`);
40
+ return await res.json();
41
+ } catch (error) {
42
+ if (error?.name === "AbortError") throw new Error(`OSV request timed out after ${Math.round(timeout / 1000)}s`);
43
+ throw error;
44
+ } finally {
45
+ if (timer) clearTimeout(timer);
46
+ }
47
+ }
48
+
49
+ export function loadStore(storePath = DEFAULT_STORE) {
50
+ try {
51
+ const s = JSON.parse(readFileSync(storePath, "utf8"));
52
+ if (s && typeof s === "object" && s.records) return s;
53
+ } catch { /* missing/corrupt → empty */ }
54
+ return { meta: { fetched_at: null }, records: {} };
55
+ }
56
+
57
+ export function queryStore(store, ecosystem, name) {
58
+ return (store && store.records && store.records[keyOf(ecosystem, name)]) || [];
59
+ }
60
+
61
+ export function storeMeta(storePath = DEFAULT_STORE) {
62
+ const s = loadStore(storePath);
63
+ return { fetchedAt: s.meta?.fetched_at || null, advisoryCount: Object.values(s.records || {}).reduce((n, l) => n + l.length, 0) };
64
+ }
65
+
66
+ // GHSA-style labels + CVSS score → our severity scale. MAL-* records are always critical.
67
+ function severityOf(rec) {
68
+ if (String(rec.id || "").startsWith("MAL-")) return "critical";
69
+ const label = String(rec.database_specific?.severity || "").toLowerCase();
70
+ if (label === "critical") return "critical";
71
+ if (label === "high") return "high";
72
+ if (label === "moderate" || label === "medium") return "medium";
73
+ if (label === "low") return "low";
74
+ let best = 0;
75
+ for (const s of rec.severity || []) {
76
+ const m = String(s.score || "").match(/CVSS:[\d.]+\/.*?\bA[VC]?:/i) ? null : String(s.score || "").match(/^(\d+(\.\d+)?)$/);
77
+ if (m) best = Math.max(best, Number(m[1]));
78
+ }
79
+ if (best >= 9) return "critical";
80
+ if (best >= 7) return "high";
81
+ if (best >= 4) return "medium";
82
+ return "medium"; // unknown → medium, never silently info
83
+ }
84
+
85
+ // One normalized row per (record × matching affected entry): everything the matcher/UI needs, nothing else.
86
+ function normalizeRecord(rec, ecosystem, name) {
87
+ const affected = (rec.affected || []).find((a) => a?.package && a.package.ecosystem === ecosystem && keyOf(ecosystem, a.package.name) === keyOf(ecosystem, name));
88
+ if (!affected) return null;
89
+ const fixed = [];
90
+ for (const r of affected.ranges || []) for (const e of r.events || []) if (e.fixed) fixed.push(e.fixed);
91
+ return {
92
+ id: rec.id,
93
+ kind: String(rec.id || "").startsWith("MAL-") ? "malicious" : "vuln",
94
+ severity: severityOf(rec),
95
+ summary: String(rec.summary || rec.details || "").slice(0, 300),
96
+ url: `https://osv.dev/vulnerability/${rec.id}`,
97
+ modified: rec.modified || "",
98
+ aliases: (rec.aliases || []).slice(0, 6),
99
+ fixedIn: [...new Set(fixed)].slice(0, 4),
100
+ affected: { versions: affected.versions || [], ranges: affected.ranges || [] },
101
+ };
102
+ }
103
+
104
+ // Refresh the cache from OSV for the given installed set. fetcher is injectable for tests.
105
+ // Returns { queried, vulnerable, fetched, saved, errors }.
106
+ export async function refreshAdvisories({ installed = [], storePath = DEFAULT_STORE, fetcher = globalThis.fetch, batchSize = 100, repoKey = "", repoKeys = [], timeoutMs = DEFAULT_FETCH_TIMEOUT_MS } = {}) {
107
+ if (typeof fetcher !== "function") return { ok: false, error: "no fetch available" };
108
+ const withVersions = installed.filter((p) => p && p.ecosystem && p.name && p.version);
109
+ const unsupported = withVersions.filter((p) => !OSV_SUPPORTED_ECOSYSTEMS.has(p.ecosystem)).length;
110
+ const pkgs = uniquePackages(withVersions.filter((p) => OSV_SUPPORTED_ECOSYSTEMS.has(p.ecosystem)));
111
+ if (!pkgs.length) {
112
+ return {
113
+ ok: false,
114
+ queried: 0,
115
+ unsupported,
116
+ error: "No OSV-supported pinned package versions found to check. weavatrix currently queries OSV for npm, PyPI, and Go packages with concrete versions.",
117
+ };
118
+ }
119
+ const store = loadStore(storePath);
120
+ const idsByPkg = new Map(); // pkgIndex -> [vuln ids]
121
+ const errors = [];
122
+
123
+ for (let i = 0; i < pkgs.length; i += batchSize) {
124
+ const batch = pkgs.slice(i, i + batchSize);
125
+ try {
126
+ const json = await fetchJson(fetcher, OSV_BATCH_URL, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({ queries: batch.map((p) => ({ package: { ecosystem: p.ecosystem, name: p.name }, version: p.version })) }),
130
+ }, timeoutMs);
131
+ (json.results || []).forEach((r, j) => { if (r && Array.isArray(r.vulns) && r.vulns.length) idsByPkg.set(i + j, r.vulns.map((v) => v.id)); });
132
+ } catch (error) {
133
+ errors.push(`querybatch ${Math.floor(i / batchSize) + 1}/${Math.ceil(pkgs.length / batchSize)}: ${error.message}`);
134
+ }
135
+ }
136
+
137
+ const wanted = new Map(); // id -> [pkg,...] (an id can hit several packages)
138
+ for (const [pi, ids] of idsByPkg) for (const id of ids) (wanted.get(id) || wanted.set(id, []).get(id)).push(pkgs[pi]);
139
+
140
+ let fetched = 0;
141
+ for (const [id, pkgList] of wanted) {
142
+ try {
143
+ const rec = await fetchJson(fetcher, OSV_VULN_URL + encodeURIComponent(id), {}, timeoutMs);
144
+ if (!rec || !rec.id) continue;
145
+ fetched++;
146
+ for (const p of pkgList) {
147
+ const row = normalizeRecord(rec, p.ecosystem, p.name);
148
+ if (!row) continue;
149
+ const key = keyOf(p.ecosystem, p.name);
150
+ const list = store.records[key] || (store.records[key] = []);
151
+ const at = list.findIndex((x) => x.id === row.id);
152
+ if (at >= 0) list[at] = row; else list.push(row);
153
+ }
154
+ } catch (error) {
155
+ errors.push(`${id}: ${error.message}`);
156
+ }
157
+ }
158
+
159
+ // A refresh where NOTHING was fetched but errors occurred (offline, OSV blocked) must NOT stamp
160
+ // fetched_at — that would turn an empty cache into "No known vulnerabilities as of <today>".
161
+ if (errors.length && fetched === 0 && (idsByPkg.size === 0 || wanted.size > 0)) {
162
+ return { ok: false, queried: pkgs.length, unsupported, error: `advisory refresh failed: ${errors[0]}${errors.length > 1 ? ` (+${errors.length - 1} more)` : ""}`, errors };
163
+ }
164
+ store.meta.fetched_at = new Date().toISOString();
165
+ // per-repo stamp: the cache only covers packages that were QUERIED — a repo that never refreshed must
166
+ // show "fetch advisories", not "0 vulnerabilities as of <someone else's date>" (false assurance).
167
+ // repoKeys[] lets one online pass (a "refresh all repos") stamp every repo whose packages it covered.
168
+ const stampRepos = [...new Set([repoKey, ...repoKeys].filter(Boolean))];
169
+ if (stampRepos.length) { store.meta.repos = store.meta.repos || {}; for (const k of stampRepos) store.meta.repos[k] = store.meta.fetched_at; }
170
+ try {
171
+ mkdirSync(dirname(storePath), { recursive: true });
172
+ writeFileSync(storePath, JSON.stringify(store), "utf8");
173
+ } catch (error) {
174
+ return { ok: false, error: `store write failed: ${error.message}`, errors };
175
+ }
176
+ return { ok: true, queried: pkgs.length, unsupported, vulnerable: wanted.size, fetched, saved: existsSync(storePath), errors };
177
+ }
@@ -0,0 +1,247 @@
1
+ // Installed-package enumeration for supply-chain scanning: EXACT versions from lockfiles first
2
+ // (package-lock v1/v2/v3, basic yarn.lock, requirements.txt ==pins, go.sum), plus a top-level
3
+ // node_modules walk — which also yields the lockfile-DRIFT signal (installed ≠ locked → tampering or
4
+ // stale install). Parsers are pure + exported for tests; collectInstalled is the thin fs wrapper.
5
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { parseGoMod } from "../analysis/manifests.js";
8
+ import { uniqueBy } from "../util.js";
9
+
10
+ const pep503 = (name) => String(name).toLowerCase().replace(/[-_.]+/g, "-"); // PyPI canonical name
11
+
12
+ const dedupe = (list) => uniqueBy(list, (p) => `${p.ecosystem}|${p.name}|${p.version}`);
13
+
14
+ export function parsePackageLock(json) {
15
+ const out = [];
16
+ const lockTracksScripts = Number(json?.lockfileVersion || 0) >= 2;
17
+ if (json && json.packages && typeof json.packages === "object") { // v2/v3
18
+ for (const [key, v] of Object.entries(json.packages)) {
19
+ if (!key.startsWith("node_modules/") || !v || !v.version) continue;
20
+ const name = key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length); // nested a/node_modules/b → b
21
+ if (!name || name.startsWith(".")) continue;
22
+ const depth = key.split("node_modules/").length - 1; // 1 = hoisted top-level, 2+ = nested duplicate
23
+ out.push({ ecosystem: "npm", name, version: v.version, dev: !!v.dev, integrity: v.integrity || "", resolved: v.resolved || "", source: "package-lock", depth, hasInstallScript: lockTracksScripts ? !!v.hasInstallScript : undefined });
24
+ }
25
+ } else if (json && json.dependencies) { // v1 recursive tree
26
+ const walk = (deps, depth) => {
27
+ for (const [name, v] of Object.entries(deps || {})) {
28
+ if (v && v.version) out.push({ ecosystem: "npm", name, version: v.version, dev: !!v.dev, integrity: v.integrity || "", source: "package-lock", depth });
29
+ if (v && v.dependencies) walk(v.dependencies, depth + 1);
30
+ }
31
+ };
32
+ walk(json.dependencies, 1);
33
+ }
34
+ return dedupe(out);
35
+ }
36
+
37
+ // yarn.lock selector → the REAL package name. Handles classic `name@range`, `@scope/name@range`, the
38
+ // `@npm:` protocol (`name@npm:range`), AND aliases (`react-is-18@npm:react-is@18.3.1` → react-is) —
39
+ // yarn installs aliased majors in `react-is-18/` dirs whose package.json name is still "react-is", so
40
+ // the lockfile must resolve to that real name or drift/vuln checks miss the aliased versions.
41
+ export function yarnSelectorName(sel) {
42
+ const s = String(sel || "").trim().replace(/^"|"$/g, "");
43
+ const npm = s.indexOf("@npm:");
44
+ if (npm >= 0) {
45
+ const after = s.slice(npm + 5); // text after "@npm:" — either "realname@range" (alias) or just "range"
46
+ if (/^[a-z@]/i.test(after)) { // starts with a letter/scope → it's the real package name
47
+ const at = after.startsWith("@") ? after.indexOf("@", 1) : after.indexOf("@");
48
+ return at > 0 ? after.slice(0, at) : after;
49
+ }
50
+ return s.slice(0, npm); // "range" only → the real name is before @npm:
51
+ }
52
+ const at = s.lastIndexOf("@"); // classic name@range ; leading @ of a scope is index 0, ignored
53
+ return at > 0 ? s.slice(0, at) : "";
54
+ }
55
+
56
+ // yarn.lock (classic v1 format): `"name@^1.0.0", "name@~1.2":\n version "1.2.3"`
57
+ export function parseYarnLock(text) {
58
+ const out = [];
59
+ const re = /^((?:"[^\n]*")|(?:[^\s#"][^\n]*?)):\r?\n\s+version\s+"([^"]+)"/gm; // greedy quote span — selector lists like "a@^1", "a@~2": keep the full line
60
+ for (const m of String(text || "").matchAll(re)) {
61
+ const name = yarnSelectorName(m[1].split(",")[0]);
62
+ if (name) out.push({ ecosystem: "npm", name, version: m[2], dev: false, integrity: "", source: "yarn-lock" });
63
+ }
64
+ return dedupe(out);
65
+ }
66
+
67
+ // requirements.txt: pins that carry a concrete version — exact `==`/`===` AND compatible-release `~=`
68
+ // (`~=1.26.8` floors at 1.26.8 within the 1.26.* series, a solid check target). Loose `>=`/`>`/`<`
69
+ // have no single version, so they're skipped (unknown-installed, not a false pin).
70
+ export function parseRequirements(text) {
71
+ const out = [];
72
+ for (const raw of String(text || "").split(/\r?\n/)) {
73
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
74
+ if (!line || line.startsWith("-")) continue;
75
+ const m = line.match(/^([A-Za-z0-9][\w.-]*)\s*(===?|~=)\s*([\w.!+*-]+)/);
76
+ if (m) out.push({ ecosystem: "PyPI", name: pep503(m[1]), version: String(m[3]).replace(/\.\*$/, ""), dev: false, integrity: "", source: "requirements" });
77
+ }
78
+ return dedupe(out);
79
+ }
80
+
81
+ // A repo's virtualenv site-packages *.dist-info = the ACTUALLY-installed Python versions (the PyPI
82
+ // equivalent of node_modules). Directory names are "<name>-<version>.dist-info". Windows Lib\ + posix lib\pythonX\.
83
+ export function collectVenvPackages(repoPath, { readdir = readdirSync } = {}) {
84
+ const out = [];
85
+ for (const venv of [".venv", "venv", "env"]) {
86
+ const roots = [join(repoPath, venv, "Lib", "site-packages")]; // Windows layout
87
+ try { for (const d of readdir(join(repoPath, venv, "lib"))) if (/^python/i.test(String(d))) roots.push(join(repoPath, venv, "lib", String(d), "site-packages")); } catch { /* posix layout absent */ }
88
+ for (const sp of roots) {
89
+ let entries;
90
+ try { entries = readdir(sp); } catch { continue; }
91
+ for (const e of entries) {
92
+ const m = String(e).match(/^(.+?)-(\d[\w.!+-]*)\.dist-info$/);
93
+ if (m) out.push({ ecosystem: "PyPI", name: pep503(m[1]), version: m[2], dev: false, integrity: "", source: "venv" });
94
+ }
95
+ }
96
+ }
97
+ return dedupe(out);
98
+ }
99
+
100
+ // poetry.lock / uv.lock — same TOML shape: [[package]] blocks with name/version lines
101
+ export function parseTomlLockPackages(text, source) {
102
+ const out = [];
103
+ let name = "", inPkg = false;
104
+ for (const raw of String(text || "").split(/\r?\n/)) {
105
+ const line = raw.trim();
106
+ if (/^\[\[package\]\]$/.test(line)) { inPkg = true; name = ""; continue; }
107
+ if (/^\[/.test(line)) { inPkg = false; continue; } // any other table ends the package header block
108
+ if (!inPkg) continue;
109
+ let m = line.match(/^name\s*=\s*"([^"]+)"/);
110
+ if (m) { name = m[1]; continue; }
111
+ m = line.match(/^version\s*=\s*"([^"]+)"/);
112
+ if (m && name) { out.push({ ecosystem: "PyPI", name: pep503(name), version: m[1], dev: false, integrity: "", source }); name = ""; }
113
+ }
114
+ return dedupe(out);
115
+ }
116
+
117
+ // Pipfile.lock: JSON { default: { name: {version:"==1.2.3"} }, develop: {…} }
118
+ export function parsePipfileLock(json) {
119
+ const out = [];
120
+ for (const [section, dev] of [["default", false], ["develop", true]]) {
121
+ for (const [name, v] of Object.entries((json && json[section]) || {})) {
122
+ const ver = String((v && v.version) || "").replace(/^==/, "");
123
+ if (ver) out.push({ ecosystem: "PyPI", name: pep503(name), version: ver, dev, integrity: "", source: "pipfile-lock" });
124
+ }
125
+ }
126
+ return dedupe(out);
127
+ }
128
+
129
+ // go.sum: "module v1.2.3 h1:hash" (skip /go.mod hash lines). OSV Go versions have no leading v.
130
+ export function parseGoSum(text) {
131
+ const out = [];
132
+ for (const raw of String(text || "").split(/\r?\n/)) {
133
+ const m = raw.trim().match(/^(\S+)\s+v([\w.+-]+?)(\/go\.mod)?\s+h1:/);
134
+ if (m && !m[3]) out.push({ ecosystem: "Go", name: m[1], version: m[2], dev: false, integrity: "", source: "go-sum" });
135
+ }
136
+ return dedupe(out);
137
+ }
138
+
139
+ // go.mod require versions — the fallback when there is no go.sum (fresh checkout / `go mod download`
140
+ // not run). Pseudo-versions (v0.0.0-<ts>-<commit>) won't match OSV cleanly, but exact tags do; go.sum
141
+ // (when present) supersedes these via dedupe. Leading `v` stripped to match OSV's Go versions.
142
+ export function parseGoModPackages(text) {
143
+ return dedupe(parseGoMod(text).requires.map((r) => ({ ecosystem: "Go", name: r.path, version: String(r.version).replace(/^v/, ""), dev: !!r.indirect, integrity: "", source: "go-mod" })));
144
+ }
145
+
146
+ // Top-level node_modules walk (incl. @scopes): the ground truth of what is REALLY on disk.
147
+ function walkNodeModules(repoPath) {
148
+ const out = [];
149
+ const nm = join(repoPath, "node_modules");
150
+ const readPkg = (dir, name) => {
151
+ try {
152
+ const pj = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
153
+ if (pj && pj.version) out.push({ ecosystem: "npm", name: pj.name || name, version: pj.version, dev: false, integrity: "", source: "node_modules" });
154
+ } catch { /* not a package dir */ }
155
+ };
156
+ let entries;
157
+ try { entries = readdirSync(nm); } catch { return out; }
158
+ for (const e of entries) {
159
+ if (e.startsWith(".")) continue;
160
+ if (e.startsWith("@")) {
161
+ let scoped; try { scoped = readdirSync(join(nm, e)); } catch { continue; }
162
+ for (const s of scoped) readPkg(join(nm, e, s), `${e}/${s}`);
163
+ } else readPkg(join(nm, e), e);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ const readText = (p) => { try { return readFileSync(p, "utf8"); } catch { return null; } };
169
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
170
+
171
+ const SUBPROJECT_SKIP = new Set([".git", ".idea", ".vscode", ".venv", "venv", "env", "node_modules", "vendor", "dist", "build", "coverage", "__pycache__", ".tox", "testdata"]);
172
+
173
+ function projectDirs(repoPath) {
174
+ const dirs = [repoPath];
175
+ let entries = [];
176
+ try { entries = readdirSync(repoPath, { withFileTypes: true }); } catch { return dirs; }
177
+ for (const e of entries) {
178
+ if (!e.isDirectory() || e.name.startsWith(".") || SUBPROJECT_SKIP.has(e.name)) continue;
179
+ dirs.push(join(repoPath, e.name));
180
+ if (dirs.length >= 101) break;
181
+ }
182
+ return dirs;
183
+ }
184
+
185
+ function collectReqFiles(dir) {
186
+ const reqFiles = [];
187
+ try { for (const n of readdirSync(dir)) if (/^requirements[\w.-]*\.(txt|in)$/i.test(n)) reqFiles.push(join(dir, n)); } catch { /* unreadable root */ }
188
+ try { for (const n of readdirSync(join(dir, "requirements"))) if (/\.(txt|in)$/i.test(n)) reqFiles.push(join(dir, "requirements", n)); } catch { /* no requirements/ dir */ }
189
+ return reqFiles;
190
+ }
191
+
192
+ // → { installed: [{ecosystem,name,version,dev,source}], drift: [{name, locked, installed}] }
193
+ export function collectInstalled(repoPath) {
194
+ const dirs = projectDirs(repoPath);
195
+ const lock = [];
196
+ const yarn = [];
197
+ const disk = [];
198
+ for (const dir of dirs) {
199
+ const lockHere = parsePackageLock(readJson(join(dir, "package-lock.json")) || {});
200
+ lock.push(...lockHere);
201
+ if (!lockHere.length) yarn.push(...parseYarnLock(readText(join(dir, "yarn.lock")) || ""));
202
+ disk.push(...walkNodeModules(dir));
203
+ }
204
+ // Python: venv site-packages = the ground truth (exact installed versions); requirements*.txt (root +
205
+ // a requirements/ dir) and poetry/uv/Pipfile locks fill in when there's no venv. venv wins per name.
206
+ const venvPy = dirs.flatMap((dir) => collectVenvPackages(dir));
207
+ const reqFiles = dirs.flatMap((dir) => collectReqFiles(dir));
208
+ const venvNames = new Set(venvPy.map((p) => p.name));
209
+ const pyDeclared = [
210
+ ...reqFiles.flatMap((f) => parseRequirements(readText(f) || "")),
211
+ ...dirs.flatMap((dir) => parseTomlLockPackages(readText(join(dir, "poetry.lock")) || "", "poetry-lock")),
212
+ ...dirs.flatMap((dir) => parseTomlLockPackages(readText(join(dir, "uv.lock")) || "", "uv-lock")),
213
+ ...dirs.flatMap((dir) => parsePipfileLock(readJson(join(dir, "Pipfile.lock")) || null)),
214
+ ].filter((p) => !venvNames.has(p.name)); // installed version supersedes the declared pin
215
+ const py = [...venvPy, ...pyDeclared];
216
+ // Go: go.sum (exact, hashed) first, then go.mod require versions as a fallback. Walk the repo root
217
+ // AND its immediate subdirectories so a Go MONOREPO (per-folder modules, no root go.mod — e.g. gpro)
218
+ // still scans; dedupe() collapses the go.sum/go.mod overlap and cross-module shared deps.
219
+ const goFromDir = (dir) => [...parseGoSum(readText(join(dir, "go.sum")) || ""), ...parseGoModPackages(readText(join(dir, "go.mod")) || "")];
220
+ const go = [...goFromDir(repoPath)];
221
+ if (!go.length || existsSync(join(repoPath, "go.work"))) {
222
+ let subs = [];
223
+ try { subs = readdirSync(repoPath, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && !["node_modules", "vendor", "dist", "build", "testdata"].includes(e.name)); } catch { /* unreadable root */ }
224
+ for (const s of subs.slice(0, 100)) go.push(...goFromDir(join(repoPath, s.name)));
225
+ }
226
+
227
+ // lockfile wins as the version source; disk fills gaps + powers the drift signal. A package
228
+ // legitimately appears at SEVERAL versions in a lockfile (yarn/npm nest transitive duplicates), so
229
+ // track the FULL SET of locked versions per name. "Drift" = the installed version matches NONE of
230
+ // them — the old code compared against ONE arbitrarily-picked entry, which faked drift (a top-level
231
+ // @babel/code-frame@7.29.7 flagged against a nested 8.0.0 that is ALSO legitimately locked+installed).
232
+ const locked = new Map(); // name -> representative entry (hoisted/lowest-depth) = the version source
233
+ const lockedVers = new Map(); // name -> Set(every locked version)
234
+ for (const p of [...yarn, ...lock]) {
235
+ const prev = locked.get(p.name);
236
+ if (!prev || (p.depth || 1) < (prev.depth || 1)) locked.set(p.name, p); // strictly-lower depth = hoisted
237
+ (lockedVers.get(p.name) || lockedVers.set(p.name, new Set()).get(p.name)).add(p.version);
238
+ }
239
+ const merged = [...locked.values()];
240
+ const drift = [];
241
+ for (const d of disk) {
242
+ const vers = lockedVers.get(d.name);
243
+ if (!vers) merged.push(d);
244
+ else if (!vers.has(d.version)) drift.push({ name: d.name, locked: locked.get(d.name).version, installed: d.version });
245
+ }
246
+ return { installed: dedupe([...merged, ...py, ...go]), drift };
247
+ }