weavatrix 0.1.0 → 0.1.2
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 +59 -18
- package/SECURITY.md +31 -0
- package/package.json +16 -4
- package/skill/SKILL.md +16 -10
- package/src/analysis/coverage-reports.js +14 -11
- package/src/analysis/dead-check.js +7 -2
- package/src/analysis/duplicates.compute.js +221 -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 +5 -3
- package/src/analysis/graph-analysis.aggregate.js +5 -1
- package/src/analysis/internal-audit.collect.js +37 -11
- package/src/analysis/internal-audit.run.js +8 -5
- package/src/build-graph.js +2 -1
- package/src/child-env.js +7 -0
- package/src/graph/internal-builder.build.js +6 -3
- package/src/graph/internal-builder.langs.js +17 -6
- package/src/graph/internal-builder.resolvers.js +10 -3
- package/src/mcp/catalog.mjs +9 -7
- package/src/mcp/graph-context.mjs +8 -5
- package/src/mcp/sync-payload.mjs +102 -0
- package/src/mcp/tools-actions.mjs +22 -13
- package/src/mcp/tools-impact.mjs +3 -2
- package/src/mcp-rg.mjs +2 -1
- package/src/mcp-server.mjs +26 -17
- package/src/mcp-source-tools.mjs +16 -5
- package/src/process.js +4 -3
- package/src/repo-path.js +53 -0
- 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,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
|
}
|
|
@@ -1,122 +1,122 @@
|
|
|
1
|
-
// Content sweeps for the malware scanner (split out of malware-heuristics.js): a ripgrep sweep
|
|
2
|
-
// over all content roots when rg is available, else a bounded Node fallback over entry files.
|
|
3
|
-
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { runCommand } from "../process.js";
|
|
6
|
-
import { CONTENT_RULES, classifyContent } from "./registry-sig.js";
|
|
7
|
-
import { normPath } from "./malware-heuristics.exclusions.js";
|
|
8
|
-
|
|
9
|
-
const SKIP_GLOBS = [
|
|
10
|
-
"!**/*.map", "!**/*.md", "!**/*.mdx", "!**/*.markdown", "!**/*.mdown", "!**/*.mkd", "!**/*.rst", "!**/*.d.ts",
|
|
11
|
-
"!**/doc/**", "!**/docs/**", "!**/documentation/**",
|
|
12
|
-
"!**/test/**", "!**/tests/**", "!**/__tests__/**", "!**/fixtures/**", "!**/example/**", "!**/examples/**"
|
|
13
|
-
];
|
|
14
|
-
|
|
15
|
-
function globalRuleRegex(rule) {
|
|
16
|
-
const flags = rule.re.flags.includes("g") ? rule.re.flags : `${rule.re.flags}g`;
|
|
17
|
-
return new RegExp(rule.re.source, flags);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function matchRuleOffsets(rule, lineText, subText = "") {
|
|
21
|
-
const text = String(lineText || "");
|
|
22
|
-
if (text) {
|
|
23
|
-
const offsets = [];
|
|
24
|
-
const re = globalRuleRegex(rule);
|
|
25
|
-
let guard = 0;
|
|
26
|
-
let m;
|
|
27
|
-
while ((m = re.exec(text)) && guard++ < 24) {
|
|
28
|
-
offsets.push(m.index);
|
|
29
|
-
if (!m[0].length) re.lastIndex++;
|
|
30
|
-
}
|
|
31
|
-
if (!offsets.length && rule.key === "obfuscated-code") {
|
|
32
|
-
const at = subText ? text.indexOf(subText) : 0;
|
|
33
|
-
if (at >= 0) offsets.push(at);
|
|
34
|
-
}
|
|
35
|
-
return offsets;
|
|
36
|
-
}
|
|
37
|
-
if (subText && (rule.key === "obfuscated-code" || rule.re.test(subText))) return [0];
|
|
38
|
-
return [];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function rootOfFile(file, roots) {
|
|
42
|
-
const f = normPath(file).toLowerCase();
|
|
43
|
-
return roots.find((r) => {
|
|
44
|
-
const root = normPath(r.root).toLowerCase();
|
|
45
|
-
return f === root || f.startsWith(`${root}/`);
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function chunks(list, size) {
|
|
50
|
-
const out = [];
|
|
51
|
-
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
|
|
52
|
-
return out;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export async function rgSweep(rg, contentRoots, timeoutMs) {
|
|
56
|
-
const hits = []; // {pkg, file, line, text, rule}
|
|
57
|
-
let errored = 0; // a failed/broken rule must not masquerade as "scanned, 0 hits"
|
|
58
|
-
let total = 0;
|
|
59
|
-
for (const rule of CONTENT_RULES) {
|
|
60
|
-
for (const group of chunks(contentRoots, 40)) {
|
|
61
|
-
total++;
|
|
62
|
-
const args = ["--json", "-i", "--no-ignore", "--hidden", "--follow", "--max-filesize", "2M", "--max-count", "20", ...SKIP_GLOBS.flatMap((g) => ["-g", g]), "-e", rule.pattern, "--", ...group.map((r) => r.root)];
|
|
63
|
-
let r;
|
|
64
|
-
try { r = await runCommand(rg, args, { timeoutMs }); } catch { errored++; continue; }
|
|
65
|
-
if (r.exitCode === 2 && !String(r.stdout || "").trim()) { errored++; continue; }
|
|
66
|
-
for (const line of String(r.stdout || "").split(/\r?\n/)) {
|
|
67
|
-
if (!line) continue;
|
|
68
|
-
let obj; try { obj = JSON.parse(line); } catch { continue; }
|
|
69
|
-
if (obj.type !== "match") continue;
|
|
70
|
-
const file = obj.data?.path?.text || "";
|
|
71
|
-
const root = rootOfFile(file, group);
|
|
72
|
-
const pkg = root?.pathToPkg(file) || "";
|
|
73
|
-
if (!pkg) continue;
|
|
74
|
-
const lineText = String(obj.data?.lines?.text || "");
|
|
75
|
-
const subText = String(obj.data?.submatches?.[0]?.match?.text || "");
|
|
76
|
-
for (const at of matchRuleOffsets(rule, lineText, subText)) {
|
|
77
|
-
const text = (lineText ? lineText.slice(Math.max(0, at - 60), at + 180) : subText).trim().slice(0, 240);
|
|
78
|
-
hits.push({ pkg, file, line: obj.data?.line_number || 0, text, rule });
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return { hits, errored, total };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// no-rg fallback: each package's package.json main/bin entries + root JS files, bounded
|
|
87
|
-
export function nodeSweep(nmDir, pkgDirs, capFiles = 3000) {
|
|
88
|
-
const hits = [];
|
|
89
|
-
let seenFiles = 0;
|
|
90
|
-
for (const { pkg, dir } of pkgDirs) {
|
|
91
|
-
if (seenFiles >= capFiles) break;
|
|
92
|
-
let pj = null;
|
|
93
|
-
try { pj = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); } catch { continue; }
|
|
94
|
-
const cands = new Set();
|
|
95
|
-
for (const f of [pj.main, pj.module, pj.browser]) if (typeof f === "string") cands.add(f);
|
|
96
|
-
if (pj.bin) for (const b of typeof pj.bin === "string" ? [pj.bin] : Object.values(pj.bin)) cands.add(b);
|
|
97
|
-
let rootJs = [];
|
|
98
|
-
try { rootJs = readdirSync(dir).filter((f) => /\.(c|m)?js$/.test(f)).slice(0, 6); } catch { /* gone */ }
|
|
99
|
-
for (const f of rootJs) cands.add(f);
|
|
100
|
-
if (!cands.size) cands.add("index.js");
|
|
101
|
-
for (const rel of cands) {
|
|
102
|
-
if (seenFiles >= capFiles) break;
|
|
103
|
-
const full = join(dir, String(rel));
|
|
104
|
-
try {
|
|
105
|
-
if (!existsSync(full) || statSync(full).size > 2_000_000) continue;
|
|
106
|
-
seenFiles++;
|
|
107
|
-
const text = readFileSync(full, "utf8");
|
|
108
|
-
for (const rule of classifyContent(text)) {
|
|
109
|
-
for (const at of matchRuleOffsets(rule, text)) {
|
|
110
|
-
// snippet keeps up to 60 chars of same-line PRE-context (like the rg path) so the
|
|
111
|
-
// comment/doc-context noise filter can see "// see" markers before the match
|
|
112
|
-
const lineStart = text.lastIndexOf("\n", at) + 1;
|
|
113
|
-
const nl = text.indexOf("\n", at);
|
|
114
|
-
const snippet = text.slice(Math.max(lineStart, at - 60), Math.min(nl < 0 ? text.length : nl, at + 200)).trim().slice(0, 240);
|
|
115
|
-
hits.push({ pkg, file: full, line: at >= 0 ? text.slice(0, at).split("\n").length : 0, text: snippet, rule });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
} catch { /* unreadable */ }
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return hits;
|
|
122
|
-
}
|
|
1
|
+
// Content sweeps for the malware scanner (split out of malware-heuristics.js): a ripgrep sweep
|
|
2
|
+
// over all content roots when rg is available, else a bounded Node fallback over entry files.
|
|
3
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { runCommand } from "../process.js";
|
|
6
|
+
import { CONTENT_RULES, classifyContent } from "./registry-sig.js";
|
|
7
|
+
import { normPath } from "./malware-heuristics.exclusions.js";
|
|
8
|
+
|
|
9
|
+
const SKIP_GLOBS = [
|
|
10
|
+
"!**/*.map", "!**/*.md", "!**/*.mdx", "!**/*.markdown", "!**/*.mdown", "!**/*.mkd", "!**/*.rst", "!**/*.d.ts",
|
|
11
|
+
"!**/doc/**", "!**/docs/**", "!**/documentation/**",
|
|
12
|
+
"!**/test/**", "!**/tests/**", "!**/__tests__/**", "!**/fixtures/**", "!**/example/**", "!**/examples/**"
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function globalRuleRegex(rule) {
|
|
16
|
+
const flags = rule.re.flags.includes("g") ? rule.re.flags : `${rule.re.flags}g`;
|
|
17
|
+
return new RegExp(rule.re.source, flags);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function matchRuleOffsets(rule, lineText, subText = "") {
|
|
21
|
+
const text = String(lineText || "");
|
|
22
|
+
if (text) {
|
|
23
|
+
const offsets = [];
|
|
24
|
+
const re = globalRuleRegex(rule);
|
|
25
|
+
let guard = 0;
|
|
26
|
+
let m;
|
|
27
|
+
while ((m = re.exec(text)) && guard++ < 24) {
|
|
28
|
+
offsets.push(m.index);
|
|
29
|
+
if (!m[0].length) re.lastIndex++;
|
|
30
|
+
}
|
|
31
|
+
if (!offsets.length && rule.key === "obfuscated-code") {
|
|
32
|
+
const at = subText ? text.indexOf(subText) : 0;
|
|
33
|
+
if (at >= 0) offsets.push(at);
|
|
34
|
+
}
|
|
35
|
+
return offsets;
|
|
36
|
+
}
|
|
37
|
+
if (subText && (rule.key === "obfuscated-code" || rule.re.test(subText))) return [0];
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rootOfFile(file, roots) {
|
|
42
|
+
const f = normPath(file).toLowerCase();
|
|
43
|
+
return roots.find((r) => {
|
|
44
|
+
const root = normPath(r.root).toLowerCase();
|
|
45
|
+
return f === root || f.startsWith(`${root}/`);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function chunks(list, size) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function rgSweep(rg, contentRoots, timeoutMs) {
|
|
56
|
+
const hits = []; // {pkg, file, line, text, rule}
|
|
57
|
+
let errored = 0; // a failed/broken rule must not masquerade as "scanned, 0 hits"
|
|
58
|
+
let total = 0;
|
|
59
|
+
for (const rule of CONTENT_RULES) {
|
|
60
|
+
for (const group of chunks(contentRoots, 40)) {
|
|
61
|
+
total++;
|
|
62
|
+
const args = ["--json", "-i", "--no-ignore", "--hidden", "--follow", "--max-filesize", "2M", "--max-count", "20", ...SKIP_GLOBS.flatMap((g) => ["-g", g]), "-e", rule.pattern, "--", ...group.map((r) => r.root)];
|
|
63
|
+
let r;
|
|
64
|
+
try { r = await runCommand(rg, args, { timeoutMs }); } catch { errored++; continue; }
|
|
65
|
+
if (r.exitCode === 2 && !String(r.stdout || "").trim()) { errored++; continue; }
|
|
66
|
+
for (const line of String(r.stdout || "").split(/\r?\n/)) {
|
|
67
|
+
if (!line) continue;
|
|
68
|
+
let obj; try { obj = JSON.parse(line); } catch { continue; }
|
|
69
|
+
if (obj.type !== "match") continue;
|
|
70
|
+
const file = obj.data?.path?.text || "";
|
|
71
|
+
const root = rootOfFile(file, group);
|
|
72
|
+
const pkg = root?.pathToPkg(file) || "";
|
|
73
|
+
if (!pkg) continue;
|
|
74
|
+
const lineText = String(obj.data?.lines?.text || "");
|
|
75
|
+
const subText = String(obj.data?.submatches?.[0]?.match?.text || "");
|
|
76
|
+
for (const at of matchRuleOffsets(rule, lineText, subText)) {
|
|
77
|
+
const text = (lineText ? lineText.slice(Math.max(0, at - 60), at + 180) : subText).trim().slice(0, 240);
|
|
78
|
+
hits.push({ pkg, file, line: obj.data?.line_number || 0, text, rule });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { hits, errored, total };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// no-rg fallback: each package's package.json main/bin entries + root JS files, bounded
|
|
87
|
+
export function nodeSweep(nmDir, pkgDirs, capFiles = 3000) {
|
|
88
|
+
const hits = [];
|
|
89
|
+
let seenFiles = 0;
|
|
90
|
+
for (const { pkg, dir } of pkgDirs) {
|
|
91
|
+
if (seenFiles >= capFiles) break;
|
|
92
|
+
let pj = null;
|
|
93
|
+
try { pj = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); } catch { continue; }
|
|
94
|
+
const cands = new Set();
|
|
95
|
+
for (const f of [pj.main, pj.module, pj.browser]) if (typeof f === "string") cands.add(f);
|
|
96
|
+
if (pj.bin) for (const b of typeof pj.bin === "string" ? [pj.bin] : Object.values(pj.bin)) cands.add(b);
|
|
97
|
+
let rootJs = [];
|
|
98
|
+
try { rootJs = readdirSync(dir).filter((f) => /\.(c|m)?js$/.test(f)).slice(0, 6); } catch { /* gone */ }
|
|
99
|
+
for (const f of rootJs) cands.add(f);
|
|
100
|
+
if (!cands.size) cands.add("index.js");
|
|
101
|
+
for (const rel of cands) {
|
|
102
|
+
if (seenFiles >= capFiles) break;
|
|
103
|
+
const full = join(dir, String(rel));
|
|
104
|
+
try {
|
|
105
|
+
if (!existsSync(full) || statSync(full).size > 2_000_000) continue;
|
|
106
|
+
seenFiles++;
|
|
107
|
+
const text = readFileSync(full, "utf8");
|
|
108
|
+
for (const rule of classifyContent(text)) {
|
|
109
|
+
for (const at of matchRuleOffsets(rule, text)) {
|
|
110
|
+
// snippet keeps up to 60 chars of same-line PRE-context (like the rg path) so the
|
|
111
|
+
// comment/doc-context noise filter can see "// see" markers before the match
|
|
112
|
+
const lineStart = text.lastIndexOf("\n", at) + 1;
|
|
113
|
+
const nl = text.indexOf("\n", at);
|
|
114
|
+
const snippet = text.slice(Math.max(lineStart, at - 60), Math.min(nl < 0 ? text.length : nl, at + 200)).trim().slice(0, 240);
|
|
115
|
+
hits.push({ pkg, file: full, line: at >= 0 ? text.slice(0, at).split("\n").length : 0, text: snippet, rule });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch { /* unreadable */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return hits;
|
|
122
|
+
}
|