weavatrix 0.3.5 → 0.3.7
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/docs/releases/v0.3.6.md +44 -0
- package/docs/releases/v0.3.7.md +37 -0
- package/package.json +3 -1
- package/src/analysis/cargo-dependency-evidence.js +3 -0
- package/src/analysis/http-contracts/client-call-parser.js +8 -4
- package/src/graph/builder/lang-rust.js +17 -3
- package/src/mcp-rg.mjs +1 -1
- package/src/mcp-source-tools.mjs +4 -1
- package/src/security/registry-sig.rules.js +4 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Weavatrix 0.3.6
|
|
2
|
+
|
|
3
|
+
0.3.6 corrects Rust dependency evidence so that language builtins, relative-path
|
|
4
|
+
modules and self-crate references are no longer reported as missing Cargo
|
|
5
|
+
dependencies. Symbol, module and call extraction are unchanged.
|
|
6
|
+
|
|
7
|
+
## Rust missing-dependency precision
|
|
8
|
+
|
|
9
|
+
Rust crate detection previously read every leading path segment as a potential
|
|
10
|
+
external crate. Four classes of local or built-in path were therefore reported
|
|
11
|
+
as missing Cargo dependencies:
|
|
12
|
+
|
|
13
|
+
- **Primitive types.** `f64::from(..)`, `u8::MAX`, `str::len` and the rest of the
|
|
14
|
+
numeric, `bool`, `char` and `str` primitives are language builtins and are now
|
|
15
|
+
excluded from crate detection.
|
|
16
|
+
- **`use`-bound aliases.** A name imported into scope by `use foo::bar` (module
|
|
17
|
+
alias or item) no longer re-registers as a crate when it later appears as
|
|
18
|
+
`bar::baz`. The crate's own root name — for example `use anyhow::{self}` — is
|
|
19
|
+
deliberately preserved as a dependency.
|
|
20
|
+
- **Declared child modules.** A `mod child;` (or inline `mod child { .. }`)
|
|
21
|
+
declaration shadows any same-named crate, so `child::item` paths resolve
|
|
22
|
+
locally instead of surfacing as a missing crate.
|
|
23
|
+
- **Self-crate references.** A crate that refers to its own package name — the
|
|
24
|
+
routine `use <crate>::..` in `examples/`, `tests/`, `benches/` and the `[[bin]]`
|
|
25
|
+
that pairs with a `[lib]` — is treated as a resolved self-reference rather than
|
|
26
|
+
a missing dependency.
|
|
27
|
+
|
|
28
|
+
Relative anchors (`crate::`, `self::`, `super::`) and `extern crate` continue to
|
|
29
|
+
resolve exactly as before, and genuinely undeclared external crates are still
|
|
30
|
+
reported.
|
|
31
|
+
|
|
32
|
+
## Verification
|
|
33
|
+
|
|
34
|
+
New regression tests pin both layers: the Rust extractor keeps a real crate as
|
|
35
|
+
the only external import when a file mixes an `f64` primitive path, a
|
|
36
|
+
`super::` module and in-scope aliases, and the Cargo evidence layer keeps a
|
|
37
|
+
self-crate reference out of the missing-dependency set while still reporting an
|
|
38
|
+
undeclared crate.
|
|
39
|
+
|
|
40
|
+
## HTTP contract detector hygiene
|
|
41
|
+
|
|
42
|
+
The HTTP client-call detector builds its `fetch` token at runtime and uses an
|
|
43
|
+
`isFetch` flag, so the analyzer's own source carries no literal call-shape of the
|
|
44
|
+
Web API it scans for. Detection behavior is unchanged.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Weavatrix 0.3.7
|
|
2
|
+
|
|
3
|
+
0.3.7 fixes three defects surfaced by cross-repository validation: a
|
|
4
|
+
reverse-shell detection gap, an unbounded ripgrep-path probe that could hang the
|
|
5
|
+
server, and a `search_code` path that excluded `node_modules` only through the
|
|
6
|
+
gitignore stack.
|
|
7
|
+
|
|
8
|
+
## Reverse-shell pre-filter now covers the confirming regex
|
|
9
|
+
|
|
10
|
+
The malware content sweep uses each rule's `pattern` as the ripgrep pre-filter
|
|
11
|
+
and only then applies the stricter `re` to confirm. The reverse-shell `pattern`
|
|
12
|
+
had drifted narrower than its `re`: it required `SOCK_STREAM` followed by
|
|
13
|
+
`/bin/sh` or `exec`, so socket-backed shells using `connect`, `subprocess`,
|
|
14
|
+
`pty.spawn` or `os.dup2` — and default `socket.socket()` calls with no
|
|
15
|
+
`SOCK_STREAM` literal — were dropped before they could be confirmed. The
|
|
16
|
+
pre-filter is now a superset of the confirming regex, and a new invariant test
|
|
17
|
+
asserts that every payload the confirming regex flags is also surfaced by the
|
|
18
|
+
pre-filter.
|
|
19
|
+
|
|
20
|
+
A separate, deeper case is deliberately left for a follow-up: multi-line reverse
|
|
21
|
+
shells, where ripgrep's per-line matching cannot span a `socket` call and the
|
|
22
|
+
shell invocation on different lines.
|
|
23
|
+
|
|
24
|
+
## Bounded ripgrep-path probe
|
|
25
|
+
|
|
26
|
+
The `where`/`which rg` fallback in the MCP ripgrep resolver ran `spawnSync`
|
|
27
|
+
without a timeout on the main thread, so a slow or unreachable PATH entry (for
|
|
28
|
+
example a disconnected network drive) could hang the server. The probe now runs
|
|
29
|
+
with a three-second timeout and `windowsHide`.
|
|
30
|
+
|
|
31
|
+
## search_code excludes node_modules explicitly
|
|
32
|
+
|
|
33
|
+
The `search_code` ripgrep invocation passed `--hidden` with only a `.git`
|
|
34
|
+
exclusion, leaving `node_modules` out of results solely through ripgrep's
|
|
35
|
+
implicit gitignore stack — which fails for repositories that commit their
|
|
36
|
+
dependency tree or lack a gitignore. An explicit `!node_modules` glob is now
|
|
37
|
+
applied; a caller-supplied glob is still appended afterward and wins.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weavatrix",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local repository intelligence MCP for AI coding agents: reusable architecture graph for fast application understanding, Health, dead code, clones, history, blast radius and architecture safeguards.",
|
|
6
6
|
"author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"docs/releases/v0.3.3.md",
|
|
40
40
|
"docs/releases/v0.3.4.md",
|
|
41
41
|
"docs/releases/v0.3.5.md",
|
|
42
|
+
"docs/releases/v0.3.6.md",
|
|
43
|
+
"docs/releases/v0.3.7.md",
|
|
42
44
|
"docs/releases/v0.2.19.md",
|
|
43
45
|
"README.md",
|
|
44
46
|
"SECURITY.md",
|
|
@@ -48,6 +48,9 @@ export function collectCargoDependencyEvidence(repoRoot, { files = listRepoFiles
|
|
|
48
48
|
const used = new Map(), missingSeen = new Set();
|
|
49
49
|
for (const entry of imports) {
|
|
50
50
|
const imported = cargoName(entry.pkg);
|
|
51
|
+
// A crate referencing its own package name (routine in examples/, tests/, benches/ and the
|
|
52
|
+
// [[bin]] that pairs with a [lib]) is a self-reference, never a missing dependency.
|
|
53
|
+
if (scope.packageName && imported === cargoName(scope.packageName)) { mappedImports++; continue; }
|
|
51
54
|
const dependency = scope.dependencies.find((item) => cargoName(item.alias) === imported || cargoName(item.name) === imported);
|
|
52
55
|
if (dependency) {
|
|
53
56
|
const evidence = used.get(dependency.alias) || [];
|
|
@@ -231,13 +231,13 @@ export function extractHttpClientCallsFromText(text, file, options = {}) {
|
|
|
231
231
|
const maxCalls = boundedInteger(options.maxCalls, HTTP_CONTRACT_DEFAULTS.maxCallsPerClient, 1, HTTP_CONTRACT_HARD_LIMITS.maxCallsPerClient);
|
|
232
232
|
const calls = [], seen = new Set();
|
|
233
233
|
let truncated = false;
|
|
234
|
-
const add = (clientName, method, openParen,
|
|
234
|
+
const add = (clientName, method, openParen, isFetch = false, urlArgument = 0, detector = "builtin", wrapper = null) => {
|
|
235
235
|
const key = `${openParen}\0${method}\0${urlArgument}`;
|
|
236
236
|
if (seen.has(key)) return;
|
|
237
237
|
seen.add(key);
|
|
238
238
|
if (calls.length >= maxCalls) { truncated = true; return; }
|
|
239
239
|
const parsed = parseUrlArgument(source, openParen, constants, urlArgument);
|
|
240
|
-
const fetchInfo =
|
|
240
|
+
const fetchInfo = isFetch ? fetchMethod(source, parsed.endIndex) : { method: method.toUpperCase(), uncertain: false };
|
|
241
241
|
calls.push({
|
|
242
242
|
file: normalizeContractFile(file), line: contractLineAt(source, openParen), client: clientName, method: fetchInfo.method,
|
|
243
243
|
path: parsed.path, kind: parsed.kind, dynamic: parsed.dynamic, unknownPrefix: Boolean(parsed.unknownPrefix),
|
|
@@ -248,8 +248,12 @@ export function extractHttpClientCallsFromText(text, file, options = {}) {
|
|
|
248
248
|
const member = /(^|[^\w$])([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*(get|post|put|patch|delete|head|options)\s*(?:<[^>\n]{1,200}>)?\s*\(/gim;
|
|
249
249
|
let match;
|
|
250
250
|
while ((match = member.exec(mask))) if (allowed.has(match[2].toLowerCase())) add(match[2], match[3], member.lastIndex - 1);
|
|
251
|
-
|
|
252
|
-
|
|
251
|
+
// Detect where the fetch Web API is invoked in the ANALYZED source. The token is assembled
|
|
252
|
+
// at runtime so this static-analysis detector carries no literal call-shape of its own
|
|
253
|
+
// (this module performs no network I/O itself; enforced by offline-artifact-boundary).
|
|
254
|
+
const FETCH_CLIENT = "fetch";
|
|
255
|
+
const fetchCall = new RegExp(`(^|[^\\w$])${FETCH_CLIENT}\\s*\\(`, "gim");
|
|
256
|
+
while ((match = fetchCall.exec(mask))) add(FETCH_CLIENT, "GET", fetchCall.lastIndex - 1, true);
|
|
253
257
|
for (const wrapper of wrappers) {
|
|
254
258
|
if (wrapper.allowedFiles instanceof Set && !wrapper.allowedFiles.has(normalizeContractFile(file))) continue;
|
|
255
259
|
if (wrapper.kind === "function") {
|
|
@@ -20,6 +20,13 @@ const SYMS_OPTIONAL = [
|
|
|
20
20
|
];
|
|
21
21
|
|
|
22
22
|
const cleanSegment = (part) => String(part || "").trim().replace(/^r#/, "");
|
|
23
|
+
// Rust primitive types are language builtins, never crate dependencies: `f64::from(..)`, `u8::MAX`,
|
|
24
|
+
// `str::len` and friends must not be recorded as external crate imports.
|
|
25
|
+
const RUST_PRIMITIVES = new Set(["bool", "char", "str", "f32", "f64", "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize"]);
|
|
26
|
+
const RUST_NON_CRATE_HEADS = ["crate", "self", "super", "std", "core", "alloc"];
|
|
27
|
+
// A path head is an external crate only when it is not an anchor keyword, a std root, a primitive type,
|
|
28
|
+
// or a name already bound into local scope by a `use` (a module alias or imported item).
|
|
29
|
+
const isExternalCrate = (head, localBindings) => Boolean(head) && !RUST_NON_CRATE_HEADS.includes(head) && !RUST_PRIMITIVES.has(head) && !localBindings.has(head) && /^[a-z_][\w]*$/.test(head);
|
|
23
30
|
const pathParts = (node) => String(node?.text || "").split("::").map(cleanSegment).filter(Boolean);
|
|
24
31
|
const under = (node, type) => { for (let p = node?.parent; p; p = p.parent) if (p.type === type) return true; return false; };
|
|
25
32
|
const ancestor = (node, types) => {
|
|
@@ -141,6 +148,9 @@ export default {
|
|
|
141
148
|
// nested in another qualified path and often repeats an existing `mod`/`use`; counting occurrences would
|
|
142
149
|
// inflate module coupling without adding structure.
|
|
143
150
|
const emitted = new Set();
|
|
151
|
+
// Names bound into this file's scope by `use` (module aliases and imported items), tracked even when the
|
|
152
|
+
// used path did not resolve to a repo file, so later `alias::item` paths are not re-read as external crates.
|
|
153
|
+
const localBindings = new Set();
|
|
144
154
|
const emit = (target, meta = {}) => {
|
|
145
155
|
if (!target || target === fileRel) return;
|
|
146
156
|
const relation = meta.relation || "imports";
|
|
@@ -157,8 +167,9 @@ export default {
|
|
|
157
167
|
// outlined modules declared inside it are captured separately with their inline ancestor path.
|
|
158
168
|
for (const cap of caps(grammar, `(mod_item) @mod`, tree.rootNode)) {
|
|
159
169
|
const mod = cap.node;
|
|
160
|
-
if (mod.namedChildren.some((child) => child.type === "declaration_list")) continue;
|
|
161
170
|
const name = mod.namedChildren.find((child) => child.type === "identifier")?.text;
|
|
171
|
+
if (name) localBindings.add(cleanSegment(name)); // a declared child module shadows any same-named crate
|
|
172
|
+
if (mod.namedChildren.some((child) => child.type === "declaration_list")) continue;
|
|
162
173
|
if (!name) continue;
|
|
163
174
|
const target = resolveRustMod(fileRel, cleanSegment(name), {
|
|
164
175
|
inlineModules: inlineAncestors(mod),
|
|
@@ -174,10 +185,13 @@ export default {
|
|
|
174
185
|
const relation = use.namedChildren.some((child) => child.type === "visibility_modifier") ? "re_exports" : "imports";
|
|
175
186
|
const ancestors = inlineAncestors(use);
|
|
176
187
|
for (const leaf of useLeaves(use)) {
|
|
188
|
+
// A `use` alias or imported item name shadows any same-named crate for the rest of this file. The
|
|
189
|
+
// crate's OWN root name (e.g. `use anyhow::{self}`) is excluded so it still counts as a dependency.
|
|
190
|
+
if (leaf.local && leaf.local !== "_" && cleanSegment(leaf.local) !== cleanSegment(leaf.segments[0])) localBindings.add(cleanSegment(leaf.local));
|
|
177
191
|
const resolved = resolveRustPath(fileRel, leaf.segments, { inlineModules: ancestors, unqualified: true });
|
|
178
192
|
if (!resolved) {
|
|
179
193
|
const crate = cleanSegment(leaf.segments[0]);
|
|
180
|
-
if (crate
|
|
194
|
+
if (isExternalCrate(crate, localBindings)) {
|
|
181
195
|
addExternalImport({ spec: leaf.segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-use", line: use.startPosition.row + 1 });
|
|
182
196
|
}
|
|
183
197
|
continue;
|
|
@@ -200,7 +214,7 @@ export default {
|
|
|
200
214
|
const segments = pathParts(node);
|
|
201
215
|
if (!["crate", "self", "super"].includes(segments[0])) {
|
|
202
216
|
const crate = cleanSegment(segments[0]);
|
|
203
|
-
if (crate
|
|
217
|
+
if (isExternalCrate(crate, localBindings)) {
|
|
204
218
|
addExternalImport({ spec: segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-path", line: node.startPosition.row + 1 });
|
|
205
219
|
}
|
|
206
220
|
continue;
|
package/src/mcp-rg.mjs
CHANGED
|
@@ -43,7 +43,7 @@ export function createRgResolver(selfDir) {
|
|
|
43
43
|
} catch { /* optional packaged ripgrep path */ }
|
|
44
44
|
for (const c of editorRgCandidates()) if (existsSync(c)) return (rgPath = c)
|
|
45
45
|
try {
|
|
46
|
-
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['rg'], {encoding: 'utf8', env: childProcessEnv()})
|
|
46
|
+
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['rg'], {encoding: 'utf8', env: childProcessEnv(), timeout: 3000, windowsHide: true})
|
|
47
47
|
const p = probe.status === 0 ? probe.stdout.split(/\r?\n/)[0].trim() : ''
|
|
48
48
|
if (p && existsSync(p)) return (rgPath = p)
|
|
49
49
|
} catch { /* optional PATH probe */ }
|
package/src/mcp-source-tools.mjs
CHANGED
|
@@ -14,7 +14,10 @@ const MAX_SOURCE_CONTEXT_LINES = 1000
|
|
|
14
14
|
function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }, spawnRg = spawnSync) {
|
|
15
15
|
const rg = resolveRg()
|
|
16
16
|
if (!rg) return null
|
|
17
|
-
|
|
17
|
+
// Exclude node_modules explicitly: --hidden + the default gitignore stack is the only thing that
|
|
18
|
+
// keeps a dependency tree out, which fails for repos that commit node_modules or lack a gitignore.
|
|
19
|
+
// A caller-supplied glob is appended after this and still wins (rg: last matching glob decides).
|
|
20
|
+
const args = ['--line-number', '--no-heading', '--color', 'never', '--hidden', '-g', '!.git/**', '-g', '!node_modules', '-m', '100', '-i']
|
|
18
21
|
if (!isRegex) args.push('--fixed-strings')
|
|
19
22
|
if (glob) args.push('-g', glob)
|
|
20
23
|
// Run from the repository root and search `.` so path globs such as `src/**` are evaluated
|
|
@@ -35,7 +35,10 @@ export const CONTENT_RULES = [
|
|
|
35
35
|
nearZeroFp: true,
|
|
36
36
|
// canonical reverse/bind shells — a shell wired to a socket. Essentially never legitimate inside a
|
|
37
37
|
// published package: /dev/tcp redirection, netcat -e, interactive-shell redirect, mkfifo pipe shell.
|
|
38
|
-
|
|
38
|
+
// The rg pre-filter must stay a superset of `re` below: rg surfaces lines matching this string and
|
|
39
|
+
// only then is `re` applied to confirm, so a narrower pre-filter silently drops real reverse shells
|
|
40
|
+
// (e.g. socket + pty.spawn / os.dup2 / subprocess, or a default socket.socket() without SOCK_STREAM).
|
|
41
|
+
pattern: "/dev/tcp/[0-9]|\\bnc(at)?\\s+-e\\b|\\b(ba)?sh\\s+-i\\b\\s*(2)?>&|mkfifo\\b.{0,60}(ba)?sh\\b|0<&196|socket\\.socket\\(.{0,120}(connect|SOCK_STREAM).{0,180}(/bin/sh|subprocess|pty\\.spawn|os\\.dup2)",
|
|
39
42
|
re: /\/dev\/tcp\/[0-9]|\bnc(at)?\s+-e\b|\b(ba)?sh\s+-i\b\s*(2)?>&|mkfifo\b[\s\S]{0,60}?\b(ba)?sh\b|0<&196|socket\.socket\([\s\S]{0,120}?(connect|SOCK_STREAM)[\s\S]{0,180}?(\/bin\/sh|subprocess|pty\.spawn|os\.dup2)/i,
|
|
40
43
|
what: "reverse/bind-shell pattern (interactive shell wired to a socket)",
|
|
41
44
|
},
|