weavatrix 0.1.1 → 0.1.3
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 +117 -21
- package/SECURITY.md +31 -0
- package/package.json +16 -4
- package/skill/SKILL.md +69 -12
- package/src/analysis/coverage-reports.js +14 -11
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check.js +84 -8
- package/src/analysis/dep-rules.js +74 -8
- package/src/analysis/duplicates.compute.js +215 -215
- package/src/analysis/duplicates.js +15 -15
- package/src/analysis/duplicates.run.js +52 -51
- package/src/analysis/duplicates.tokenize.js +182 -182
- package/src/analysis/endpoints.js +50 -5
- package/src/analysis/graph-analysis.aggregate.js +16 -4
- package/src/analysis/internal-audit.collect.js +154 -31
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +72 -21
- package/src/build-graph.js +2 -1
- package/src/child-env.js +7 -0
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/internal-builder.build.js +48 -10
- package/src/graph/internal-builder.langs.js +49 -3
- package/src/graph/internal-builder.resolvers.js +96 -20
- package/src/mcp/catalog.mjs +10 -8
- package/src/mcp/graph-context.mjs +107 -17
- package/src/mcp/sync-payload.mjs +110 -0
- package/src/mcp/tools-actions.mjs +42 -18
- package/src/mcp/tools-graph.mjs +46 -12
- package/src/mcp/tools-health.mjs +42 -18
- package/src/mcp/tools-impact.mjs +55 -43
- package/src/mcp-rg.mjs +2 -1
- package/src/mcp-server.mjs +25 -16
- package/src/mcp-source-tools.mjs +16 -5
- package/src/process.js +4 -3
- package/src/repo-path.js +53 -0
- package/src/security/advisory-store.js +51 -7
- package/src/security/installed.js +44 -31
- package/src/security/malware-heuristics.exclusions.js +134 -134
- package/src/security/malware-heuristics.js +15 -15
- package/src/security/malware-heuristics.roots.js +142 -142
- package/src/security/malware-heuristics.scan.js +85 -85
- package/src/security/malware-heuristics.sweep.js +122 -122
|
@@ -1,142 +1,142 @@
|
|
|
1
|
-
// Content roots + path-to-package mapping for the malware scanner (split out of
|
|
2
|
-
// malware-heuristics.js): npm node_modules, Python site-packages, Go vendor/ and module cache.
|
|
3
|
-
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { delimiter, join, relative } from "node:path";
|
|
6
|
-
import { normPath } from "./malware-heuristics.exclusions.js";
|
|
7
|
-
|
|
8
|
-
const pep503 = (name) => String(name || "").toLowerCase().replace(/[-_.]+/g, "-");
|
|
9
|
-
|
|
10
|
-
// node_modules/@scope/name/... → @scope/name ; node_modules/name/... → name
|
|
11
|
-
export function pkgOfNodeModulesPath(p) {
|
|
12
|
-
const s = String(p).replace(/\\/g, "/");
|
|
13
|
-
const i = s.lastIndexOf("node_modules/");
|
|
14
|
-
if (i < 0) return "";
|
|
15
|
-
const rest = s.slice(i + "node_modules/".length).split("/");
|
|
16
|
-
return rest[0]?.startsWith("@") ? `${rest[0]}/${rest[1] || ""}` : rest[0] || "";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function existingDir(p) {
|
|
20
|
-
try { return p && existsSync(p) && statSync(p).isDirectory(); } catch { return false; }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function sitePackageRoots(repoPath) {
|
|
24
|
-
const roots = [];
|
|
25
|
-
for (const venv of [".venv", "venv", "env"]) {
|
|
26
|
-
const win = join(repoPath, venv, "Lib", "site-packages");
|
|
27
|
-
if (existingDir(win)) roots.push(win);
|
|
28
|
-
try {
|
|
29
|
-
for (const d of readdirSync(join(repoPath, venv, "lib"))) {
|
|
30
|
-
const posix = join(repoPath, venv, "lib", String(d), "site-packages");
|
|
31
|
-
if (/^python/i.test(String(d)) && existingDir(posix)) roots.push(posix);
|
|
32
|
-
}
|
|
33
|
-
} catch { /* no posix venv layout */ }
|
|
34
|
-
}
|
|
35
|
-
return [...new Set(roots.map((r) => join(r)))];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function pyTopLevelMap(sitePackagesRoot) {
|
|
39
|
-
const map = new Map();
|
|
40
|
-
let entries = [];
|
|
41
|
-
try { entries = readdirSync(sitePackagesRoot); } catch { return map; }
|
|
42
|
-
for (const e of entries) {
|
|
43
|
-
if (!/\.dist-info$/i.test(e)) continue;
|
|
44
|
-
const dist = e.replace(/-\d[\w.!+-]*\.dist-info$/i, "");
|
|
45
|
-
const canonical = pep503(dist);
|
|
46
|
-
try {
|
|
47
|
-
const top = readFileSync(join(sitePackagesRoot, e, "top_level.txt"), "utf8");
|
|
48
|
-
for (const line of top.split(/\r?\n/).map((x) => x.trim()).filter(Boolean)) map.set(line, canonical);
|
|
49
|
-
} catch { /* metadata often omits top_level.txt */ }
|
|
50
|
-
try {
|
|
51
|
-
const meta = readFileSync(join(sitePackagesRoot, e, "METADATA"), "utf8");
|
|
52
|
-
const m = meta.match(/^Name:\s*(.+)$/mi);
|
|
53
|
-
if (m) map.set(dist, pep503(m[1]));
|
|
54
|
-
} catch { /* no metadata */ }
|
|
55
|
-
}
|
|
56
|
-
return map;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function pkgOfSitePackagesPath(file, sitePackagesRoot, topMap) {
|
|
60
|
-
const rel = normPath(relative(sitePackagesRoot, file));
|
|
61
|
-
if (!rel || rel.startsWith("..")) return "";
|
|
62
|
-
const first = rel.split("/")[0] || "";
|
|
63
|
-
if (!first || first === "__pycache__") return "";
|
|
64
|
-
if (/\.dist-info$|\.egg-info$/i.test(first)) return "";
|
|
65
|
-
const top = first.replace(/\.(py|so|pyd|dll|pth)$/i, "");
|
|
66
|
-
return topMap.get(top) || pep503(top);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function vendorModuleNames(installed) {
|
|
70
|
-
return [...new Set((installed || []).filter((p) => p.ecosystem === "Go" && p.name).map((p) => String(p.name)))].sort((a, b) => b.length - a.length);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function pkgOfVendorPath(file, vendorRoot, moduleNames) {
|
|
74
|
-
const rel = normPath(relative(vendorRoot, file));
|
|
75
|
-
if (!rel || rel.startsWith("..")) return "";
|
|
76
|
-
for (const name of moduleNames) if (rel === name || rel.startsWith(`${name}/`)) return name;
|
|
77
|
-
const parts = rel.split("/");
|
|
78
|
-
if (parts[0]?.includes(".") && parts.length >= 3) return parts.slice(0, 3).join("/");
|
|
79
|
-
return parts.slice(0, 2).join("/") || parts[0] || "";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function goCacheRoots() {
|
|
83
|
-
const roots = [];
|
|
84
|
-
if (process.env.GOMODCACHE) roots.push(...String(process.env.GOMODCACHE).split(delimiter));
|
|
85
|
-
for (const gp of String(process.env.GOPATH || join(homedir(), "go")).split(delimiter)) roots.push(join(gp, "pkg", "mod"));
|
|
86
|
-
return [...new Set(roots.filter(existingDir))];
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function goModCacheEscape(modulePath) {
|
|
90
|
-
return String(modulePath || "").split("/").map((part) => part.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`)).join("/");
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function goModuleSourceRoots(installed) {
|
|
94
|
-
const roots = [];
|
|
95
|
-
const caches = goCacheRoots();
|
|
96
|
-
const goPkgs = (installed || []).filter((p) => p.ecosystem === "Go" && p.name && p.version);
|
|
97
|
-
for (const p of goPkgs) {
|
|
98
|
-
const mod = goModCacheEscape(p.name);
|
|
99
|
-
const ver = String(p.version).startsWith("v") ? String(p.version) : `v${p.version}`;
|
|
100
|
-
for (const cache of caches) {
|
|
101
|
-
const dir = join(cache, `${mod}@${ver}`);
|
|
102
|
-
if (existingDir(dir)) roots.push({ kind: "Go", root: dir, packages: 1, pathToPkg: () => p.name });
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return roots;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function buildContentRoots(repoPath, installed) {
|
|
109
|
-
const roots = [];
|
|
110
|
-
const nmDir = join(repoPath, "node_modules");
|
|
111
|
-
const jsPkgDirs = existingDir(nmDir) ? listPkgDirs(nmDir) : [];
|
|
112
|
-
if (jsPkgDirs.length) roots.push({ kind: "npm", root: nmDir, packages: jsPkgDirs.length, pathToPkg: pkgOfNodeModulesPath });
|
|
113
|
-
|
|
114
|
-
for (const sp of sitePackageRoots(repoPath)) {
|
|
115
|
-
const topMap = pyTopLevelMap(sp);
|
|
116
|
-
let packages = 0;
|
|
117
|
-
try { packages = readdirSync(sp).filter((e) => !e.startsWith(".") && !/\.dist-info$|\.egg-info$|__pycache__/i.test(e)).length; } catch { /* unreadable */ }
|
|
118
|
-
roots.push({ kind: "PyPI", root: sp, packages, pathToPkg: (file) => pkgOfSitePackagesPath(file, sp, topMap) });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const vendorRoot = join(repoPath, "vendor");
|
|
122
|
-
if (existingDir(vendorRoot)) {
|
|
123
|
-
const modules = vendorModuleNames(installed);
|
|
124
|
-
roots.push({ kind: "Go", root: vendorRoot, packages: modules.length || 1, pathToPkg: (file) => pkgOfVendorPath(file, vendorRoot, modules) });
|
|
125
|
-
}
|
|
126
|
-
roots.push(...goModuleSourceRoots(installed));
|
|
127
|
-
return roots;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function listPkgDirs(nmDir) {
|
|
131
|
-
const out = [];
|
|
132
|
-
let entries;
|
|
133
|
-
try { entries = readdirSync(nmDir); } catch { return out; }
|
|
134
|
-
for (const e of entries) {
|
|
135
|
-
if (e.startsWith(".")) continue;
|
|
136
|
-
if (e.startsWith("@")) {
|
|
137
|
-
let scoped; try { scoped = readdirSync(join(nmDir, e)); } catch { continue; }
|
|
138
|
-
for (const s of scoped) out.push({ pkg: `${e}/${s}`, dir: join(nmDir, e, s) });
|
|
139
|
-
} else out.push({ pkg: e, dir: join(nmDir, e) });
|
|
140
|
-
}
|
|
141
|
-
return out;
|
|
142
|
-
}
|
|
1
|
+
// Content roots + path-to-package mapping for the malware scanner (split out of
|
|
2
|
+
// malware-heuristics.js): npm node_modules, Python site-packages, Go vendor/ and module cache.
|
|
3
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { delimiter, join, relative } from "node:path";
|
|
6
|
+
import { normPath } from "./malware-heuristics.exclusions.js";
|
|
7
|
+
|
|
8
|
+
const pep503 = (name) => String(name || "").toLowerCase().replace(/[-_.]+/g, "-");
|
|
9
|
+
|
|
10
|
+
// node_modules/@scope/name/... → @scope/name ; node_modules/name/... → name
|
|
11
|
+
export function pkgOfNodeModulesPath(p) {
|
|
12
|
+
const s = String(p).replace(/\\/g, "/");
|
|
13
|
+
const i = s.lastIndexOf("node_modules/");
|
|
14
|
+
if (i < 0) return "";
|
|
15
|
+
const rest = s.slice(i + "node_modules/".length).split("/");
|
|
16
|
+
return rest[0]?.startsWith("@") ? `${rest[0]}/${rest[1] || ""}` : rest[0] || "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function existingDir(p) {
|
|
20
|
+
try { return p && existsSync(p) && statSync(p).isDirectory(); } catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sitePackageRoots(repoPath) {
|
|
24
|
+
const roots = [];
|
|
25
|
+
for (const venv of [".venv", "venv", "env"]) {
|
|
26
|
+
const win = join(repoPath, venv, "Lib", "site-packages");
|
|
27
|
+
if (existingDir(win)) roots.push(win);
|
|
28
|
+
try {
|
|
29
|
+
for (const d of readdirSync(join(repoPath, venv, "lib"))) {
|
|
30
|
+
const posix = join(repoPath, venv, "lib", String(d), "site-packages");
|
|
31
|
+
if (/^python/i.test(String(d)) && existingDir(posix)) roots.push(posix);
|
|
32
|
+
}
|
|
33
|
+
} catch { /* no posix venv layout */ }
|
|
34
|
+
}
|
|
35
|
+
return [...new Set(roots.map((r) => join(r)))];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pyTopLevelMap(sitePackagesRoot) {
|
|
39
|
+
const map = new Map();
|
|
40
|
+
let entries = [];
|
|
41
|
+
try { entries = readdirSync(sitePackagesRoot); } catch { return map; }
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (!/\.dist-info$/i.test(e)) continue;
|
|
44
|
+
const dist = e.replace(/-\d[\w.!+-]*\.dist-info$/i, "");
|
|
45
|
+
const canonical = pep503(dist);
|
|
46
|
+
try {
|
|
47
|
+
const top = readFileSync(join(sitePackagesRoot, e, "top_level.txt"), "utf8");
|
|
48
|
+
for (const line of top.split(/\r?\n/).map((x) => x.trim()).filter(Boolean)) map.set(line, canonical);
|
|
49
|
+
} catch { /* metadata often omits top_level.txt */ }
|
|
50
|
+
try {
|
|
51
|
+
const meta = readFileSync(join(sitePackagesRoot, e, "METADATA"), "utf8");
|
|
52
|
+
const m = meta.match(/^Name:\s*(.+)$/mi);
|
|
53
|
+
if (m) map.set(dist, pep503(m[1]));
|
|
54
|
+
} catch { /* no metadata */ }
|
|
55
|
+
}
|
|
56
|
+
return map;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pkgOfSitePackagesPath(file, sitePackagesRoot, topMap) {
|
|
60
|
+
const rel = normPath(relative(sitePackagesRoot, file));
|
|
61
|
+
if (!rel || rel.startsWith("..")) return "";
|
|
62
|
+
const first = rel.split("/")[0] || "";
|
|
63
|
+
if (!first || first === "__pycache__") return "";
|
|
64
|
+
if (/\.dist-info$|\.egg-info$/i.test(first)) return "";
|
|
65
|
+
const top = first.replace(/\.(py|so|pyd|dll|pth)$/i, "");
|
|
66
|
+
return topMap.get(top) || pep503(top);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function vendorModuleNames(installed) {
|
|
70
|
+
return [...new Set((installed || []).filter((p) => p.ecosystem === "Go" && p.name).map((p) => String(p.name)))].sort((a, b) => b.length - a.length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function pkgOfVendorPath(file, vendorRoot, moduleNames) {
|
|
74
|
+
const rel = normPath(relative(vendorRoot, file));
|
|
75
|
+
if (!rel || rel.startsWith("..")) return "";
|
|
76
|
+
for (const name of moduleNames) if (rel === name || rel.startsWith(`${name}/`)) return name;
|
|
77
|
+
const parts = rel.split("/");
|
|
78
|
+
if (parts[0]?.includes(".") && parts.length >= 3) return parts.slice(0, 3).join("/");
|
|
79
|
+
return parts.slice(0, 2).join("/") || parts[0] || "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function goCacheRoots() {
|
|
83
|
+
const roots = [];
|
|
84
|
+
if (process.env.GOMODCACHE) roots.push(...String(process.env.GOMODCACHE).split(delimiter));
|
|
85
|
+
for (const gp of String(process.env.GOPATH || join(homedir(), "go")).split(delimiter)) roots.push(join(gp, "pkg", "mod"));
|
|
86
|
+
return [...new Set(roots.filter(existingDir))];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function goModCacheEscape(modulePath) {
|
|
90
|
+
return String(modulePath || "").split("/").map((part) => part.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`)).join("/");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function goModuleSourceRoots(installed) {
|
|
94
|
+
const roots = [];
|
|
95
|
+
const caches = goCacheRoots();
|
|
96
|
+
const goPkgs = (installed || []).filter((p) => p.ecosystem === "Go" && p.name && p.version);
|
|
97
|
+
for (const p of goPkgs) {
|
|
98
|
+
const mod = goModCacheEscape(p.name);
|
|
99
|
+
const ver = String(p.version).startsWith("v") ? String(p.version) : `v${p.version}`;
|
|
100
|
+
for (const cache of caches) {
|
|
101
|
+
const dir = join(cache, `${mod}@${ver}`);
|
|
102
|
+
if (existingDir(dir)) roots.push({ kind: "Go", root: dir, packages: 1, pathToPkg: () => p.name });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return roots;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildContentRoots(repoPath, installed) {
|
|
109
|
+
const roots = [];
|
|
110
|
+
const nmDir = join(repoPath, "node_modules");
|
|
111
|
+
const jsPkgDirs = existingDir(nmDir) ? listPkgDirs(nmDir) : [];
|
|
112
|
+
if (jsPkgDirs.length) roots.push({ kind: "npm", root: nmDir, packages: jsPkgDirs.length, pathToPkg: pkgOfNodeModulesPath });
|
|
113
|
+
|
|
114
|
+
for (const sp of sitePackageRoots(repoPath)) {
|
|
115
|
+
const topMap = pyTopLevelMap(sp);
|
|
116
|
+
let packages = 0;
|
|
117
|
+
try { packages = readdirSync(sp).filter((e) => !e.startsWith(".") && !/\.dist-info$|\.egg-info$|__pycache__/i.test(e)).length; } catch { /* unreadable */ }
|
|
118
|
+
roots.push({ kind: "PyPI", root: sp, packages, pathToPkg: (file) => pkgOfSitePackagesPath(file, sp, topMap) });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const vendorRoot = join(repoPath, "vendor");
|
|
122
|
+
if (existingDir(vendorRoot)) {
|
|
123
|
+
const modules = vendorModuleNames(installed);
|
|
124
|
+
roots.push({ kind: "Go", root: vendorRoot, packages: modules.length || 1, pathToPkg: (file) => pkgOfVendorPath(file, vendorRoot, modules) });
|
|
125
|
+
}
|
|
126
|
+
roots.push(...goModuleSourceRoots(installed));
|
|
127
|
+
return roots;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function listPkgDirs(nmDir) {
|
|
131
|
+
const out = [];
|
|
132
|
+
let entries;
|
|
133
|
+
try { entries = readdirSync(nmDir); } catch { return out; }
|
|
134
|
+
for (const e of entries) {
|
|
135
|
+
if (e.startsWith(".")) continue;
|
|
136
|
+
if (e.startsWith("@")) {
|
|
137
|
+
let scoped; try { scoped = readdirSync(join(nmDir, e)); } catch { continue; }
|
|
138
|
+
for (const s of scoped) out.push({ pkg: `${e}/${s}`, dir: join(nmDir, e, s) });
|
|
139
|
+
} else out.push({ pkg: e, dir: join(nmDir, e) });
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
@@ -1,87 +1,87 @@
|
|
|
1
|
-
// scanMalware orchestration (split out of malware-heuristics.js): phase A install scripts +
|
|
2
|
-
// lockfile origins, phase B content sweep, path-aware file heuristics, then scoring.
|
|
3
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { resolveRg } from "../scan/search.js";
|
|
6
|
-
import { classifyInstallScript, classifyResolvedUrl, isCloudSdkMetadataUse } from "./registry-sig.js";
|
|
7
|
-
import { fileHeuristicSweep } from "./malware-file-heuristics.js";
|
|
8
|
-
import { buildMalwareFindings } from "./malware-scoring.js";
|
|
9
|
-
import { parseMalwareExclusions, isManifestUrlNoise, isDocUrlNoise, isMalwareSignalExcluded, lifecycleScriptSnippet } from "./malware-heuristics.exclusions.js";
|
|
10
|
-
import { buildContentRoots, listPkgDirs } from "./malware-heuristics.roots.js";
|
|
11
|
-
import { rgSweep, nodeSweep } from "./malware-heuristics.sweep.js";
|
|
12
|
-
|
|
13
|
-
// → { findings, scanMode } ; importedPkgs = Set of packages the repo's own code imports (evidence note).
|
|
14
|
-
export async function scanMalware(repoPath, { installed = [], importedPkgs = new Set(), rgPath = "", forceNode = false, timeoutMs = 60000, malwareExclusions = {} } = {}) {
|
|
15
|
-
const nmDir = join(repoPath, "node_modules");
|
|
16
|
-
const contentRoots = buildContentRoots(repoPath, installed);
|
|
17
|
-
const byPkg = new Map(); // pkg -> [{rule signal, file, line, snippet}]
|
|
18
|
-
const exclusions = parseMalwareExclusions(malwareExclusions);
|
|
19
|
-
let excludedSignals = 0;
|
|
20
|
-
const add = (pkg, sig, file = "", line = 0, snippet = "") => {
|
|
21
|
-
if (!pkg) return;
|
|
22
|
-
const signal = { ...sig, file, line, snippet: snippet || sig.snippet || "" };
|
|
23
|
-
if (isManifestUrlNoise(file, signal) || isDocUrlNoise(file, signal) || isCloudSdkMetadataUse(pkg, signal.key) || isMalwareSignalExcluded(pkg, signal, exclusions)) {
|
|
24
|
-
excludedSignals++;
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
(byPkg.get(pkg) || byPkg.set(pkg, []).get(pkg)).push(signal);
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// ---- phase A: install scripts (from node_modules package.jsons) + lockfile resolved origins ----
|
|
31
|
-
const pkgDirs = existsSync(nmDir) ? listPkgDirs(nmDir) : [];
|
|
32
|
-
const lockScriptMeta = new Map(
|
|
33
|
-
(installed || [])
|
|
34
|
-
.filter((p) => p.ecosystem === "npm" && p.source === "package-lock" && p.hasInstallScript !== undefined)
|
|
35
|
-
.map((p) => [`${p.name}@${p.version}`, !!p.hasInstallScript])
|
|
36
|
-
);
|
|
37
|
-
for (const { pkg, dir } of pkgDirs) {
|
|
38
|
-
try {
|
|
39
|
-
const pj = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
40
|
-
const scriptSnippet = lifecycleScriptSnippet(pj.scripts);
|
|
41
|
-
if (scriptSnippet && lockScriptMeta.get(`${pkg}@${pj.version || ""}`) === false) {
|
|
42
|
-
add(pkg, {
|
|
43
|
-
key: "install-script-drift",
|
|
44
|
-
severity: "high",
|
|
45
|
-
nearZeroFp: false,
|
|
46
|
-
what: "installed package has lifecycle scripts absent from package-lock metadata",
|
|
47
|
-
snippet: scriptSnippet,
|
|
48
|
-
}, join(dir, "package.json"));
|
|
49
|
-
}
|
|
50
|
-
for (const sig of classifyInstallScript(pj.scripts)) add(pkg, sig, join(dir, "package.json"));
|
|
51
|
-
} catch { /* not a package */ }
|
|
52
|
-
}
|
|
53
|
-
for (const p of installed) {
|
|
54
|
-
const sig = p.resolved ? classifyResolvedUrl(p.resolved) : null;
|
|
55
|
-
if (sig) add(p.name, sig, "package-lock.json");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ---- phase B: content sweep ----
|
|
59
|
-
let scanMode = "none";
|
|
60
|
-
if (contentRoots.length) {
|
|
61
|
-
const rg = forceNode ? null : await resolveRg("auto", rgPath);
|
|
62
|
-
let swept = false;
|
|
63
|
-
if (rg) {
|
|
64
|
-
const { hits, errored, total } = await rgSweep(rg, contentRoots, timeoutMs);
|
|
65
|
-
if (errored < total) {
|
|
66
|
-
// honest label: full when every rule ran, partial when some rules failed (don't claim
|
|
67
|
-
// full coverage we didn't get); every-rule-failed falls through to the Node sweep below.
|
|
68
|
-
const kinds = [...new Set(contentRoots.map((r) => r.kind))].join("+");
|
|
69
|
-
scanMode = errored ? `ripgrep-partial (${total - errored}/${total} sweeps; ${kinds})` : `ripgrep-full (${kinds})`;
|
|
70
|
-
swept = true;
|
|
71
|
-
for (const h of hits) add(h.pkg, h.rule, h.file, h.line, h.text);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
if (!swept) {
|
|
75
|
-
scanMode = rg ? "node-limited (ripgrep failed)" : "node-limited";
|
|
76
|
-
for (const h of nodeSweep(nmDir, pkgDirs)) add(h.pkg, h.rule, h.file, h.line, h.text);
|
|
77
|
-
}
|
|
78
|
-
} else {
|
|
79
|
-
scanMode = "skipped";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ---- path-aware cheap checks: things that need file names or magic bytes, not just regex text.
|
|
83
|
-
// These run regardless of rg availability and stay bounded; content regex still does the broad sweep.
|
|
84
|
-
for (const h of fileHeuristicSweep(contentRoots, pkgDirs)) add(h.pkg, h.rule, h.file, h.line, h.text);
|
|
85
|
-
|
|
1
|
+
// scanMalware orchestration (split out of malware-heuristics.js): phase A install scripts +
|
|
2
|
+
// lockfile origins, phase B content sweep, path-aware file heuristics, then scoring.
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveRg } from "../scan/search.js";
|
|
6
|
+
import { classifyInstallScript, classifyResolvedUrl, isCloudSdkMetadataUse } from "./registry-sig.js";
|
|
7
|
+
import { fileHeuristicSweep } from "./malware-file-heuristics.js";
|
|
8
|
+
import { buildMalwareFindings } from "./malware-scoring.js";
|
|
9
|
+
import { parseMalwareExclusions, isManifestUrlNoise, isDocUrlNoise, isMalwareSignalExcluded, lifecycleScriptSnippet } from "./malware-heuristics.exclusions.js";
|
|
10
|
+
import { buildContentRoots, listPkgDirs } from "./malware-heuristics.roots.js";
|
|
11
|
+
import { rgSweep, nodeSweep } from "./malware-heuristics.sweep.js";
|
|
12
|
+
|
|
13
|
+
// → { findings, scanMode } ; importedPkgs = Set of packages the repo's own code imports (evidence note).
|
|
14
|
+
export async function scanMalware(repoPath, { installed = [], importedPkgs = new Set(), rgPath = "", forceNode = false, timeoutMs = 60000, malwareExclusions = {} } = {}) {
|
|
15
|
+
const nmDir = join(repoPath, "node_modules");
|
|
16
|
+
const contentRoots = buildContentRoots(repoPath, installed);
|
|
17
|
+
const byPkg = new Map(); // pkg -> [{rule signal, file, line, snippet}]
|
|
18
|
+
const exclusions = parseMalwareExclusions(malwareExclusions);
|
|
19
|
+
let excludedSignals = 0;
|
|
20
|
+
const add = (pkg, sig, file = "", line = 0, snippet = "") => {
|
|
21
|
+
if (!pkg) return;
|
|
22
|
+
const signal = { ...sig, file, line, snippet: snippet || sig.snippet || "" };
|
|
23
|
+
if (isManifestUrlNoise(file, signal) || isDocUrlNoise(file, signal) || isCloudSdkMetadataUse(pkg, signal.key) || isMalwareSignalExcluded(pkg, signal, exclusions)) {
|
|
24
|
+
excludedSignals++;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
(byPkg.get(pkg) || byPkg.set(pkg, []).get(pkg)).push(signal);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ---- phase A: install scripts (from node_modules package.jsons) + lockfile resolved origins ----
|
|
31
|
+
const pkgDirs = existsSync(nmDir) ? listPkgDirs(nmDir) : [];
|
|
32
|
+
const lockScriptMeta = new Map(
|
|
33
|
+
(installed || [])
|
|
34
|
+
.filter((p) => p.ecosystem === "npm" && p.source === "package-lock" && p.hasInstallScript !== undefined)
|
|
35
|
+
.map((p) => [`${p.name}@${p.version}`, !!p.hasInstallScript])
|
|
36
|
+
);
|
|
37
|
+
for (const { pkg, dir } of pkgDirs) {
|
|
38
|
+
try {
|
|
39
|
+
const pj = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
40
|
+
const scriptSnippet = lifecycleScriptSnippet(pj.scripts);
|
|
41
|
+
if (scriptSnippet && lockScriptMeta.get(`${pkg}@${pj.version || ""}`) === false) {
|
|
42
|
+
add(pkg, {
|
|
43
|
+
key: "install-script-drift",
|
|
44
|
+
severity: "high",
|
|
45
|
+
nearZeroFp: false,
|
|
46
|
+
what: "installed package has lifecycle scripts absent from package-lock metadata",
|
|
47
|
+
snippet: scriptSnippet,
|
|
48
|
+
}, join(dir, "package.json"));
|
|
49
|
+
}
|
|
50
|
+
for (const sig of classifyInstallScript(pj.scripts)) add(pkg, sig, join(dir, "package.json"));
|
|
51
|
+
} catch { /* not a package */ }
|
|
52
|
+
}
|
|
53
|
+
for (const p of installed) {
|
|
54
|
+
const sig = p.resolved ? classifyResolvedUrl(p.resolved) : null;
|
|
55
|
+
if (sig) add(p.name, sig, "package-lock.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- phase B: content sweep ----
|
|
59
|
+
let scanMode = "none";
|
|
60
|
+
if (contentRoots.length) {
|
|
61
|
+
const rg = forceNode ? null : await resolveRg("auto", rgPath);
|
|
62
|
+
let swept = false;
|
|
63
|
+
if (rg) {
|
|
64
|
+
const { hits, errored, total } = await rgSweep(rg, contentRoots, timeoutMs);
|
|
65
|
+
if (errored < total) {
|
|
66
|
+
// honest label: full when every rule ran, partial when some rules failed (don't claim
|
|
67
|
+
// full coverage we didn't get); every-rule-failed falls through to the Node sweep below.
|
|
68
|
+
const kinds = [...new Set(contentRoots.map((r) => r.kind))].join("+");
|
|
69
|
+
scanMode = errored ? `ripgrep-partial (${total - errored}/${total} sweeps; ${kinds})` : `ripgrep-full (${kinds})`;
|
|
70
|
+
swept = true;
|
|
71
|
+
for (const h of hits) add(h.pkg, h.rule, h.file, h.line, h.text);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!swept) {
|
|
75
|
+
scanMode = rg ? "node-limited (ripgrep failed)" : "node-limited";
|
|
76
|
+
for (const h of nodeSweep(nmDir, pkgDirs)) add(h.pkg, h.rule, h.file, h.line, h.text);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
scanMode = "skipped";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- path-aware cheap checks: things that need file names or magic bytes, not just regex text.
|
|
83
|
+
// These run regardless of rg availability and stay bounded; content regex still does the broad sweep.
|
|
84
|
+
for (const h of fileHeuristicSweep(contentRoots, pkgDirs)) add(h.pkg, h.rule, h.file, h.line, h.text);
|
|
85
|
+
|
|
86
86
|
return buildMalwareFindings({ byPkg, importedPkgs, contentRoots, scanMode, excludedSignals });
|
|
87
87
|
}
|