weavatrix 0.2.5 → 0.2.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/README.md +77 -14
- package/SECURITY.md +14 -0
- package/docs/agent-task-benchmark.md +58 -0
- package/docs/releases/v0.2.7.md +93 -0
- package/package.json +6 -1
- package/scripts/run-agent-task-benchmark.mjs +119 -0
- package/skill/SKILL.md +31 -10
- package/src/analysis/allowed-test-runner.js +59 -0
- package/src/analysis/data-flow-evidence.js +114 -0
- package/src/analysis/dead-code-review.js +51 -0
- package/src/analysis/dep-check.js +42 -3
- package/src/analysis/duplicate-groups.js +84 -0
- package/src/analysis/graph-analysis.aggregate.js +3 -1
- package/src/analysis/graph-analysis.refs.js +19 -11
- package/src/analysis/hot-path-review.js +20 -2
- package/src/analysis/internal-audit.run.js +6 -1
- package/src/analysis/task-retrieval.js +116 -0
- package/src/bounds.js +4 -0
- package/src/graph/builder/lang-js.js +52 -17
- package/src/graph/freshness-probe.js +4 -2
- package/src/graph/incremental-refresh.js +4 -2
- package/src/graph/internal-builder.barrels.js +39 -2
- package/src/graph/internal-builder.build.js +58 -12
- package/src/mcp/catalog.mjs +28 -6
- package/src/mcp/graph-context.mjs +5 -1
- package/src/mcp/graph-diff.mjs +1 -1
- package/src/mcp/sync-payload.mjs +1 -1
- package/src/mcp/tools-actions.mjs +3 -1
- package/src/mcp/tools-context.mjs +164 -0
- package/src/mcp/tools-graph.mjs +48 -3
- package/src/mcp/tools-health.mjs +15 -28
- package/src/mcp/tools-impact-change.mjs +1 -0
- package/src/mcp/tools-source.mjs +7 -6
- package/src/mcp/tools-verified-change.mjs +169 -0
- package/src/precision/lsp-client.js +1 -1
- package/src/precision/lsp-overlay.js +1 -5
- package/src/precision/symbol-query.js +1 -5
- package/src/security/malware-heuristics.exclusions.js +3 -3
- package/src/security/malware-heuristics.roots.js +4 -1
- package/src/security/malware-heuristics.scan.js +4 -2
- package/src/security/malware-scoring.js +22 -5
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {findSeeds, rawGraph} from './graph-context.mjs'
|
|
2
|
+
import {toolResult} from './tool-result.mjs'
|
|
3
|
+
import {expandTaskQuery, retrieveTaskContext} from '../analysis/task-retrieval.js'
|
|
4
|
+
import {extractCallArgumentEvidence} from '../analysis/data-flow-evidence.js'
|
|
5
|
+
import {runAllowedTests, validateTestRequests} from '../analysis/allowed-test-runner.js'
|
|
6
|
+
import {compareDuplicateGroups} from '../analysis/duplicate-groups.js'
|
|
7
|
+
import {buildGraphAtGitRef} from '../analysis/git-ref-graph.js'
|
|
8
|
+
import {diffGraphs, formatGraphDiff} from './graph-diff.mjs'
|
|
9
|
+
|
|
10
|
+
const richResult = (value) => value?.__weavatrixToolResult === true ? value.result : null
|
|
11
|
+
const normalizeFile = (value) => String(value || '').replace(/\\/g, '/')
|
|
12
|
+
const changedFilesOf = (impact) => [...new Set((impact?.changes || []).flatMap((change) => [change.path, change.oldPath, change.newPath]).map(normalizeFile).filter((file) => file && file !== '(diff unavailable)'))]
|
|
13
|
+
const targetTests = (impact) => [...new Set([
|
|
14
|
+
...(impact?.testEvidence?.changedFiles || []).map((item) => item.staticTestReachability?.test),
|
|
15
|
+
...(impact?.blastRadius?.nodes || []).map((node) => node.testEvidence?.staticTestReachability?.test),
|
|
16
|
+
].filter(Boolean))].slice(0, 30)
|
|
17
|
+
|
|
18
|
+
function testCoverage(proof, requests, suggested) {
|
|
19
|
+
if (!suggested.length) return {state: 'NOT_APPLICABLE', covered: [], missing: []}
|
|
20
|
+
if (proof.state !== 'PASS') return {state: 'PENDING', covered: [], missing: suggested}
|
|
21
|
+
if ((requests || []).some((request) => request?.script === 'test' && (!request.args || !request.args.length))) {
|
|
22
|
+
return {state: 'COMPLETE', kind: 'full-test-script', covered: suggested, missing: []}
|
|
23
|
+
}
|
|
24
|
+
const args = (requests || []).flatMap((request) => request?.args || []).map(normalizeFile)
|
|
25
|
+
const covered = suggested.filter((file) => args.some((arg) => arg === file || arg.endsWith(`/${file}`)))
|
|
26
|
+
return {state: covered.length === suggested.length ? 'COMPLETE' : 'PARTIAL', kind: 'explicit-test-paths', covered, missing: suggested.filter((file) => !covered.includes(file))}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function graphProof(diff) {
|
|
30
|
+
const runtimeCycles = diff?.cycles?.runtime?.introduced || []
|
|
31
|
+
return {
|
|
32
|
+
state: runtimeCycles.length ? 'BLOCKED' : diff.schemaMigration ? 'UNKNOWN' : 'PASS',
|
|
33
|
+
...(diff.schemaMigration ? {reason: 'graph extractor/schema versions differ, so structural ratchets are not comparable'} : {}),
|
|
34
|
+
summary: formatGraphDiff(diff),
|
|
35
|
+
counts: {
|
|
36
|
+
nodesAdded: diff.nodes.added.length, nodesRemoved: diff.nodes.removed.length,
|
|
37
|
+
edgesAdded: diff.edges.added, edgesRemoved: diff.edges.removed,
|
|
38
|
+
moduleDependenciesAdded: diff.moduleEdges.added.length, orphaned: diff.orphaned.length,
|
|
39
|
+
runtimeCyclesIntroduced: runtimeCycles.length,
|
|
40
|
+
},
|
|
41
|
+
runtimeCycles: runtimeCycles.slice(0, 20), moduleDependenciesAdded: diff.moduleEdges.added.slice(0, 20),
|
|
42
|
+
orphaned: diff.orphaned.slice(0, 20), schemaMigration: diff.schemaMigration,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function baselineProof(ctx, baseRef, currentGraph) {
|
|
47
|
+
const mode = ['full', 'no-tests', 'tests-only'].includes(currentGraph?.graphBuildMode) ? currentGraph.graphBuildMode : 'full'
|
|
48
|
+
const built = await buildGraphAtGitRef(ctx.repoRoot, baseRef, {mode})
|
|
49
|
+
if (!built.ok) return {state: 'UNKNOWN', reason: built.error}
|
|
50
|
+
return {...graphProof(diffGraphs(built.graph, currentGraph)), baseline: {ref: built.ref, commit: built.commit}}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function apiState(result) {
|
|
54
|
+
if (!result || result.status !== 'COMPLETE' || result.completeness?.complete !== true) return 'UNKNOWN'
|
|
55
|
+
if (['HTTP_METHOD_MISMATCH', 'CLIENTS_AT_RISK_WITH_METHOD_MISMATCHES'].includes(result.verdict?.code)) return 'BLOCKED'
|
|
56
|
+
return 'UNKNOWN'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function architectureState(result) {
|
|
60
|
+
if (!result || result.state === 'NOT_CONFIGURED' || result.state === 'ERROR') return 'UNKNOWN'
|
|
61
|
+
if (!result.verification) return result.state === 'READY' ? 'PASS' : 'UNKNOWN'
|
|
62
|
+
return result.verification.new?.length || String(result.verification.status).toUpperCase() === 'FAIL' ? 'BLOCKED' : 'PASS'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function decide({phase, impact, graph, architecture, duplicates, api, tests, suggestedTests, testCoverageState}) {
|
|
66
|
+
if (phase === 'plan') return tests.state === 'BLOCKED'
|
|
67
|
+
? {verdict: 'BLOCKED', blockers: [`targeted test plan was rejected: ${tests.reason || 'invalid request'}`], unknowns: []}
|
|
68
|
+
: {verdict: 'UNKNOWN', blockers: [], unknowns: ['verification has not run; apply the edit and call verified_change with phase=verify']}
|
|
69
|
+
if (impact.status === 'COMPLETE' && !impact.changes?.length) return {verdict: 'PASS', blockers: [], unknowns: []}
|
|
70
|
+
const blockers = [], unknowns = []
|
|
71
|
+
if (impact.status !== 'COMPLETE') unknowns.push('change impact is partial or has unmapped evidence')
|
|
72
|
+
if (graph.state === 'BLOCKED') blockers.push('the change introduces a runtime dependency cycle')
|
|
73
|
+
if (graph.state === 'UNKNOWN') unknowns.push(`Git graph baseline is unavailable: ${graph.reason || 'unknown reason'}`)
|
|
74
|
+
if (architecture.state === 'BLOCKED') blockers.push('new architecture-contract violations were found')
|
|
75
|
+
if (architecture.state === 'UNKNOWN') unknowns.push('architecture contract is not configured or verification is incomplete')
|
|
76
|
+
if (duplicates.state === 'BLOCKED') blockers.push('new duplicate groups intersect the changed files')
|
|
77
|
+
if (duplicates.state === 'UNKNOWN') unknowns.push(`duplicate ratchet is incomplete: ${duplicates.reason || 'health capability unavailable'}`)
|
|
78
|
+
if (api.state === 'BLOCKED') blockers.push('cross-repository API evidence contains HTTP method mismatches')
|
|
79
|
+
if (api.state === 'UNKNOWN') unknowns.push(`API contract evidence is incomplete: ${api.reason || 'no bounded proof'}`)
|
|
80
|
+
if (tests.state === 'FAIL' || tests.state === 'BLOCKED') {
|
|
81
|
+
const failed = (tests.results || []).filter((result) => result.status !== 'PASS').map((result) => `${result.script} (${result.status}${result.exitCode == null ? '' : `, exit ${result.exitCode}`})`)
|
|
82
|
+
blockers.push(`targeted tests failed or were rejected: ${tests.reason || failed.join(', ') || 'unknown failure'}`)
|
|
83
|
+
}
|
|
84
|
+
if (tests.state === 'DISABLED') unknowns.push('targeted test execution was requested but runtime permission is disabled')
|
|
85
|
+
if (tests.state === 'NOT_REQUESTED' && suggestedTests.length) unknowns.push('affected tests were identified but no allowlisted package test was requested')
|
|
86
|
+
if (tests.state === 'PASS' && testCoverageState.state === 'PARTIAL') unknowns.push(`targeted test run did not cover ${testCoverageState.missing.length} suggested test path(s)`)
|
|
87
|
+
return {verdict: blockers.length ? 'BLOCKED' : unknowns.length ? 'UNKNOWN' : 'PASS', blockers, unknowns}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function tVerifiedChange(g, args = {}, ctx = {}, tools = {}, permissions = {}) {
|
|
91
|
+
const phase = args.phase === 'verify' ? 'verify' : 'plan'
|
|
92
|
+
const currentGraph = rawGraph(ctx)
|
|
93
|
+
const impactValue = await tools.impact(g, {base: args.base_ref, diff: args.diff, files: args.files, depth: args.impact_depth, max_nodes: args.max_impact_nodes}, ctx)
|
|
94
|
+
const impact = richResult(impactValue) || {status: 'PARTIAL', changes: [], seeds: {ids: []}, blastRadius: {nodes: []}}
|
|
95
|
+
const changedFiles = changedFilesOf(impact)
|
|
96
|
+
const retrieval = retrieveTaskContext(g, {
|
|
97
|
+
task: args.task, semanticSeeds: findSeeds(g, expandTaskQuery(args.task), 12, {repoRoot: ctx.repoRoot}),
|
|
98
|
+
changedSeedIds: impact.seeds?.ids, maxSymbols: args.max_symbols, repoRoot: ctx.repoRoot,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
let contexts = []
|
|
102
|
+
if (permissions.source) contexts = await Promise.all(retrieval.selected.map(async (symbol) => {
|
|
103
|
+
const value = await tools.context(g, {label: symbol.id, precision: args.precision, max_related: 8, max_source_files: 3, context_lines: 4}, ctx, tools.inspect)
|
|
104
|
+
const result = richResult(value)
|
|
105
|
+
return result ? {symbol: symbol.id, status: result.status, definition: result.definition, evidence: result.evidence, references: result.references, inbound: result.inbound, outbound: result.outbound, reExports: result.reExports, source: result.source} : {symbol: symbol.id, status: 'UNKNOWN'}
|
|
106
|
+
}))
|
|
107
|
+
const dataFlow = permissions.source ? extractCallArgumentEvidence({
|
|
108
|
+
graph: g, repoRoot: ctx.repoRoot, seedIds: retrieval.selected.map((item) => item.id),
|
|
109
|
+
depth: Math.max(1, Math.min(3, Number(args.data_flow_depth) || 2)),
|
|
110
|
+
maxEdges: Math.max(1, Math.min(60, Number(args.max_data_flow_edges) || 30)),
|
|
111
|
+
})
|
|
112
|
+
: {model: 'bounded call-argument-to-parameter evidence (not CFG or taint analysis)', status: 'UNAVAILABLE', reason: 'source capability is not enabled', edges: [], unsupportedEdges: 0, capped: false}
|
|
113
|
+
const suggestedTests = targetTests(impact)
|
|
114
|
+
const checkedTests = validateTestRequests(ctx.repoRoot, args.tests || [])
|
|
115
|
+
const testProof = phase === 'verify'
|
|
116
|
+
? await runAllowedTests(ctx.repoRoot, args.tests || [], {enabled: args.run_tests === true, timeoutMs: args.test_timeout_ms})
|
|
117
|
+
: {state: checkedTests.ok ? 'PLANNED' : 'BLOCKED', reason: checkedTests.reason, plan: checkedTests.tests || [], results: []}
|
|
118
|
+
const testCoverageState = testCoverage(testProof, args.tests || [], suggestedTests)
|
|
119
|
+
|
|
120
|
+
const architectureValue = phase === 'verify'
|
|
121
|
+
? (permissions.health ? tools.verifyArchitecture(g, {}, ctx) : null)
|
|
122
|
+
: tools.prepareChange(g, {intent: args.task, files: changedFiles}, ctx)
|
|
123
|
+
const architectureResult = richResult(architectureValue)
|
|
124
|
+
const architecture = {state: architectureState(architectureResult), evidence: architectureResult}
|
|
125
|
+
const baseRef = String(args.base_ref || 'HEAD').trim()
|
|
126
|
+
const graph = phase === 'verify' ? await baselineProof(ctx, baseRef, currentGraph) : {state: 'PLANNED', baseline: baseRef}
|
|
127
|
+
|
|
128
|
+
let duplicates = {state: 'SKIPPED', reason: 'duplicate ratchet disabled'}
|
|
129
|
+
if (phase === 'verify' && args.duplicate_ratchet !== false) duplicates = permissions.health
|
|
130
|
+
? await compareDuplicateGroups({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, currentGraph, baseRef, changedFiles, args: {mode: 'renamed', min_similarity: 80, min_tokens: 50}})
|
|
131
|
+
: {state: 'UNKNOWN', reason: 'health capability is not enabled'}
|
|
132
|
+
|
|
133
|
+
let api = {state: 'SKIPPED', reason: 'no api_contract scope was requested'}
|
|
134
|
+
if (args.api_contract) {
|
|
135
|
+
if (!permissions.crossrepo) api = {state: 'UNKNOWN', reason: 'crossrepo capability is not enabled'}
|
|
136
|
+
else {
|
|
137
|
+
const result = richResult(await tools.traceApi(g, {
|
|
138
|
+
...args.api_contract, changed_files: args.api_contract.changed_files || changedFiles,
|
|
139
|
+
max_endpoints: Math.min(100, Number(args.api_contract.max_endpoints) || 100),
|
|
140
|
+
max_matches: Math.min(500, Number(args.api_contract.max_matches) || 500),
|
|
141
|
+
max_affected_files: Math.min(100, Number(args.api_contract.max_affected_files) || 100),
|
|
142
|
+
top_n: Math.min(10, Number(args.api_contract.top_n) || 10),
|
|
143
|
+
}, ctx))
|
|
144
|
+
api = {state: apiState(result), evidence: result}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const decision = decide({phase, impact, graph, architecture, duplicates, api, tests: testProof, suggestedTests, testCoverageState})
|
|
149
|
+
if (!permissions.source && retrieval.selected.length) decision.unknowns.push('source capability is disabled; exact LSP/source edit contexts were not collected')
|
|
150
|
+
if (phase === 'verify' && contexts.some((item) => item.status !== 'OK' || item.evidence?.state !== 'EXACT' || item.references?.capped)) {
|
|
151
|
+
decision.unknowns.push('one or more exact edit contexts are incomplete')
|
|
152
|
+
}
|
|
153
|
+
if (phase === 'verify' && args.diff) decision.unknowns.push('a supplied diff was classified, but equivalence between that patch and the active graph is not proven; verify on a checked-out change without diff')
|
|
154
|
+
if (decision.verdict === 'PASS' && decision.unknowns.length) decision.verdict = 'UNKNOWN'
|
|
155
|
+
const result = {
|
|
156
|
+
schemaVersion: 'weavatrix.verified-change.v1', verdict: decision.verdict, phase, task: String(args.task),
|
|
157
|
+
blockers: decision.blockers, unknowns: decision.unknowns,
|
|
158
|
+
retrieval, editContexts: contexts, dataFlow, changeImpact: impact, graphBaseline: graph,
|
|
159
|
+
architecture, duplicates, apiContract: api, tests: {...testProof, suggestedFiles: suggestedTests, coverage: testCoverageState},
|
|
160
|
+
}
|
|
161
|
+
const text = [
|
|
162
|
+
`${decision.verdict} — verified_change ${phase}`, `Task: ${String(args.task).slice(0, 500)}`,
|
|
163
|
+
`Change: ${changedFiles.length} file(s), ${impact.seeds?.ids?.length || 0} exact seed(s), blast radius ${impact.blastRadius?.impacted || 0}.`,
|
|
164
|
+
`Edit context: ${retrieval.selected.length} symbol(s); ${contexts.length} exact bundle(s); data-flow ${dataFlow.status} (${dataFlow.edges.length} call edge(s)).`,
|
|
165
|
+
`Ratchets: graph ${graph.state}; architecture ${architecture.state}; duplicates ${duplicates.state}; API ${api.state}; tests ${testProof.state}.`,
|
|
166
|
+
...decision.blockers.map((item) => `BLOCKER: ${item}`), ...decision.unknowns.map((item) => `UNKNOWN: ${item}`),
|
|
167
|
+
].join('\n')
|
|
168
|
+
return toolResult(text, result, {completeness: {status: decision.verdict === 'UNKNOWN' ? 'PARTIAL' : 'COMPLETE'}})
|
|
169
|
+
}
|
|
@@ -425,7 +425,7 @@ export class StdioLspClient {
|
|
|
425
425
|
await this.writeMessage({jsonrpc: JSON_RPC_VERSION, method, params})
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
-
async initialize({capabilities = {}, initializationOptions, clientInfo = {name: 'weavatrix', version: '0.2.
|
|
428
|
+
async initialize({capabilities = {}, initializationOptions, clientInfo = {name: 'weavatrix', version: '0.2.6'}} = {}) {
|
|
429
429
|
if (this.state !== 'running') throw new Error(`LSP initialize is invalid in state=${this.state}`)
|
|
430
430
|
const result = await this.request('initialize', {
|
|
431
431
|
processId: process.pid,
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { boundedInteger } from "../bounds.js";
|
|
5
6
|
import { atomicWriteFileSync } from "../graph/file-lock.js";
|
|
6
7
|
import { edgeProvenance } from "../graph/edge-provenance.js";
|
|
7
8
|
import { isStructuralRelation } from "../graph/relations.js";
|
|
@@ -450,11 +451,6 @@ function publicSemanticSafetyReason(reason) {
|
|
|
450
451
|
: "TypeScript project configuration could not be verified safely";
|
|
451
452
|
}
|
|
452
453
|
|
|
453
|
-
function boundedInteger(value, fallback, minimum, maximum) {
|
|
454
|
-
const number = Number(value);
|
|
455
|
-
return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback));
|
|
456
|
-
}
|
|
457
|
-
|
|
458
454
|
export async function buildLspPrecisionOverlay({
|
|
459
455
|
repoRoot,
|
|
460
456
|
graph,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {existsSync, readFileSync, statSync} from 'node:fs'
|
|
2
2
|
import {dirname, resolve} from 'node:path'
|
|
3
|
+
import {boundedInteger} from '../bounds.js'
|
|
3
4
|
import {atomicWriteFileSync, withFileLock} from '../graph/file-lock.js'
|
|
4
5
|
import {
|
|
5
6
|
buildLspPrecisionOverlay,
|
|
@@ -15,11 +16,6 @@ const MAX_CACHE_BYTES = 8 * 1024 * 1024
|
|
|
15
16
|
const MAX_CACHE_READ_BYTES = 16 * 1024 * 1024
|
|
16
17
|
const inFlight = new Map()
|
|
17
18
|
|
|
18
|
-
const boundedInteger = (value, fallback, minimum, maximum) => {
|
|
19
|
-
const number = Number(value)
|
|
20
|
-
return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback))
|
|
21
|
-
}
|
|
22
|
-
|
|
23
19
|
export function symbolPrecisionCachePath(graphPath) {
|
|
24
20
|
return resolve(dirname(graphPath), SYMBOL_PRECISION_CACHE_FILE)
|
|
25
21
|
}
|
|
@@ -8,7 +8,7 @@ const URL_RE = /https?:\/\/[^\s'"`)\\<>]+/gi;
|
|
|
8
8
|
const PACKAGE_JSON_SCRIPT_RE = /["']?(preinstall|install|postinstall|prepare|prepack|postpack|prepublish|prepublishonly)["']?\s*:/i;
|
|
9
9
|
const PACKAGE_JSON_METADATA_URL_RE = /["']?(bugs|homepage|repository|funding|author|contributors|maintainers|publishConfig|license)["']?\s*:|["']?url["']?\s*:/i;
|
|
10
10
|
const NETWORK_CALL_RE = /\b(fetch|axios\.|XMLHttpRequest|sendBeacon|https?\.request|request\(|curl\b|wget\b|iwr\b|invoke-webrequest)\b/i;
|
|
11
|
-
const
|
|
11
|
+
const INSTALL_SCRIPT_HOOKS = ["preinstall", "install", "postinstall"];
|
|
12
12
|
|
|
13
13
|
function cleanUrlToken(value) {
|
|
14
14
|
return String(value || "").trim().replace(/[.,;:!?]+$/g, "").replace(/[)\]}]+$/g, "");
|
|
@@ -102,8 +102,8 @@ export function isManifestUrlNoise(file, signal) {
|
|
|
102
102
|
return /(^|\/)(pkg-info|metadata)$/.test(f);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
export function
|
|
106
|
-
for (const hook of
|
|
105
|
+
export function installScriptSnippet(scripts = {}) {
|
|
106
|
+
for (const hook of INSTALL_SCRIPT_HOOKS) {
|
|
107
107
|
const s = String(scripts?.[hook] || "").trim();
|
|
108
108
|
if (s) return `${hook}: ${s}`.slice(0, 200);
|
|
109
109
|
}
|
|
@@ -109,7 +109,10 @@ export function buildContentRoots(repoPath, installed) {
|
|
|
109
109
|
const roots = [];
|
|
110
110
|
const nmDir = join(repoPath, "node_modules");
|
|
111
111
|
const jsPkgDirs = existingDir(nmDir) ? listPkgDirs(nmDir) : [];
|
|
112
|
-
|
|
112
|
+
// Scan installed package roots, not the whole node_modules container. Package-manager caches,
|
|
113
|
+
// hosted release snapshots and other hidden metadata are not installed dependencies and may
|
|
114
|
+
// legitimately contain malware-signature fixtures (including Weavatrix's own rule source).
|
|
115
|
+
for (const item of jsPkgDirs) roots.push({ kind: "npm", root: item.dir, packages: 1, pathToPkg: pkgOfNodeModulesPath });
|
|
113
116
|
|
|
114
117
|
for (const sp of sitePackageRoots(repoPath)) {
|
|
115
118
|
const topMap = pyTopLevelMap(sp);
|
|
@@ -6,7 +6,7 @@ import { resolveRg } from "../scan/search.js";
|
|
|
6
6
|
import { classifyInstallScript, classifyResolvedUrl, isCloudSdkMetadataUse } from "./registry-sig.js";
|
|
7
7
|
import { fileHeuristicSweep } from "./malware-file-heuristics.js";
|
|
8
8
|
import { buildMalwareFindings } from "./malware-scoring.js";
|
|
9
|
-
import { parseMalwareExclusions, isManifestUrlNoise, isDocUrlNoise, isMalwareSignalExcluded,
|
|
9
|
+
import { parseMalwareExclusions, isManifestUrlNoise, isDocUrlNoise, isMalwareSignalExcluded, installScriptSnippet } from "./malware-heuristics.exclusions.js";
|
|
10
10
|
import { buildContentRoots, listPkgDirs } from "./malware-heuristics.roots.js";
|
|
11
11
|
import { rgSweep, nodeSweep } from "./malware-heuristics.sweep.js";
|
|
12
12
|
|
|
@@ -37,7 +37,9 @@ export async function scanMalware(repoPath, { installed = [], importedPkgs = new
|
|
|
37
37
|
for (const { pkg, dir } of pkgDirs) {
|
|
38
38
|
try {
|
|
39
39
|
const pj = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
40
|
-
|
|
40
|
+
// package-lock hasInstallScript describes install-time hooks. Development and
|
|
41
|
+
// publication hooks such as prepare do not constitute drift from that flag.
|
|
42
|
+
const scriptSnippet = installScriptSnippet(pj.scripts);
|
|
41
43
|
if (scriptSnippet && lockScriptMeta.get(`${pkg}@${pj.version || ""}`) === false) {
|
|
42
44
|
add(pkg, {
|
|
43
45
|
key: "install-script-drift",
|
|
@@ -26,8 +26,11 @@ function visibleSignals(signals, allow) {
|
|
|
26
26
|
return hasStrongerUrl ? kept.filter((s) => s.key !== "hardcoded-url") : kept;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Heuristic source matches can justify urgent review, but they cannot prove that code executed or
|
|
30
|
+
// credentials left the machine. Reserve `critical` for independently confirmed advisories/runtime
|
|
31
|
+
// evidence; this scanner therefore has an explicit HIGH ceiling.
|
|
32
|
+
function heuristicSeverity(baseSeverity, smoking, multi) {
|
|
33
|
+
if (baseSeverity === "critical") return "high";
|
|
31
34
|
if ((smoking || multi) && (baseSeverity === "medium" || baseSeverity === "low")) return "high";
|
|
32
35
|
return baseSeverity;
|
|
33
36
|
}
|
|
@@ -63,20 +66,34 @@ export function buildMalwareFindings({ byPkg, importedPkgs, contentRoots, scanMo
|
|
|
63
66
|
for (const key of keys) {
|
|
64
67
|
const ofKey = visible.filter((s) => s.key === key);
|
|
65
68
|
const base = ofKey[0];
|
|
66
|
-
const severity =
|
|
69
|
+
const severity = heuristicSeverity(base.severity, smoking, multi);
|
|
67
70
|
findings.push(makeFinding({
|
|
68
71
|
category: "malware",
|
|
69
72
|
rule: key,
|
|
70
73
|
severity,
|
|
71
74
|
confidence: base.nearZeroFp ? "high" : multi ? "medium" : "low",
|
|
75
|
+
assessment: "SUSPICIOUS_STATIC_EVIDENCE",
|
|
76
|
+
confirmedExecution: false,
|
|
77
|
+
requiresRuntimeConfirmation: true,
|
|
78
|
+
severityCeiling: "high",
|
|
72
79
|
title: `${base.what}: ${pkg}`,
|
|
73
|
-
detail: `${base.what} in installed package "${pkg}"${multi ? ` - co-occurs with ${[...keys].filter((k) => k !== key).join(", ")} (escalated)` : ""}${importedPkgs.has(pkg) ? ". This package IS imported by the repo's own code." : ". Transitive install (
|
|
80
|
+
detail: `${base.what} in installed package "${pkg}"${multi ? ` - co-occurs with ${[...keys].filter((k) => k !== key).join(", ")} (escalated)` : ""}${importedPkgs.has(pkg) ? ". This package IS imported by the repo's own code." : ". Transitive install (its install scripts may have run)."} Static heuristic evidence only: execution, package compromise, and credential exposure are NOT confirmed. Inspect the cited file and verify origin/lockfile/runtime evidence before acting.`,
|
|
74
81
|
package: pkg,
|
|
75
82
|
file: base.file ? String(base.file).replace(/\\/g, "/") : "",
|
|
76
83
|
line: base.line || 0,
|
|
77
84
|
evidence: evidenceRows(ofKey),
|
|
78
85
|
source: "heuristic",
|
|
79
|
-
|
|
86
|
+
verification: {
|
|
87
|
+
staticPattern: "MATCHED",
|
|
88
|
+
runtimeExecution: "NOT_VERIFIED",
|
|
89
|
+
packageOrigin: "NOT_VERIFIED",
|
|
90
|
+
lockfileIntegrity: "NOT_VERIFIED",
|
|
91
|
+
credentialExposure: "NOT_VERIFIED",
|
|
92
|
+
decision: "MANUAL_CONFIRMATION_REQUIRED",
|
|
93
|
+
},
|
|
94
|
+
fixHint: smoking
|
|
95
|
+
? `quarantine and inspect ${pkg}; verify package origin and lockfile integrity before removal; rotate secrets only if execution or credential exposure is confirmed`
|
|
96
|
+
: `review the cited code in ${pkg}`,
|
|
80
97
|
}));
|
|
81
98
|
}
|
|
82
99
|
}
|