weavatrix 0.2.13 → 0.2.14
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 +83 -39
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.14.md +93 -0
- package/package.json +2 -2
- package/skill/SKILL.md +57 -38
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +10 -57
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/scoped-dependencies.js +40 -0
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.run.js +113 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +20 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +208 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +100 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -331
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp-server.mjs +35 -146
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/version.js +7 -0
- package/docs/releases/v0.2.13.md +0 -48
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readCoverageForRepo } from "../coverage-reports.js";
|
|
2
|
+
import { buildHealthCapabilityMatrix } from "../health-capabilities.js";
|
|
3
|
+
|
|
4
|
+
export function buildDependencyHealth({
|
|
5
|
+
repoPath,
|
|
6
|
+
graph,
|
|
7
|
+
repoFiles,
|
|
8
|
+
pyManifest,
|
|
9
|
+
dep,
|
|
10
|
+
goDep,
|
|
11
|
+
pyDep,
|
|
12
|
+
jvmDependencies,
|
|
13
|
+
externalImports,
|
|
14
|
+
findings,
|
|
15
|
+
packageScopes,
|
|
16
|
+
sourceFiles,
|
|
17
|
+
correctnessCoverage,
|
|
18
|
+
checks,
|
|
19
|
+
}) {
|
|
20
|
+
const dependencyFindings = findings.filter((finding) => ["unused-dep", "missing-dep", "duplicate-dep"].includes(finding.rule));
|
|
21
|
+
const graphComplete = !((graph.graphBuildMode && graph.graphBuildMode !== "full") || graph.graphBuildScope);
|
|
22
|
+
const npmManifests = repoFiles.filter((file) => /(^|\/)package\.json$/i.test(file));
|
|
23
|
+
const goManifests = repoFiles.filter((file) => /(^|\/)go\.mod$/i.test(file));
|
|
24
|
+
const pythonManifests = [...new Set((pyManifest.scopes || []).flatMap((scope) => scope.manifests || []))];
|
|
25
|
+
const ecosystems = {
|
|
26
|
+
npm: {
|
|
27
|
+
ecosystem: "npm",
|
|
28
|
+
present: npmManifests.length > 0,
|
|
29
|
+
status: npmManifests.length ? "CHECKED" : "NOT_PRESENT",
|
|
30
|
+
completeness: graphComplete ? "COMPLETE" : "PARTIAL",
|
|
31
|
+
manifests: npmManifests,
|
|
32
|
+
declared: dep.declared.size,
|
|
33
|
+
reason: npmManifests.length
|
|
34
|
+
? "package.json declarations were compared with indexed JavaScript/TypeScript imports, including per-finding manifest/source/config evidence."
|
|
35
|
+
: "No package.json was discovered.",
|
|
36
|
+
},
|
|
37
|
+
go: {
|
|
38
|
+
ecosystem: "go",
|
|
39
|
+
present: goManifests.length > 0,
|
|
40
|
+
status: goManifests.length ? "CHECKED" : "NOT_PRESENT",
|
|
41
|
+
completeness: goManifests.length ? "PARTIAL" : "NOT_APPLICABLE",
|
|
42
|
+
manifests: goManifests,
|
|
43
|
+
declared: goDep.declared.size,
|
|
44
|
+
reason: goManifests.length
|
|
45
|
+
? "Go dependency checks use indexed imports and the root go.mod; nested modules and build-tag-specific resolution are not fully modeled."
|
|
46
|
+
: "No go.mod was discovered.",
|
|
47
|
+
},
|
|
48
|
+
python: {
|
|
49
|
+
ecosystem: "python",
|
|
50
|
+
present: pyManifest.present,
|
|
51
|
+
status: pyManifest.present ? "CHECKED" : "NOT_PRESENT",
|
|
52
|
+
completeness: pyManifest.present ? "PARTIAL" : "NOT_APPLICABLE",
|
|
53
|
+
manifests: pythonManifests,
|
|
54
|
+
declared: pyDep.declared.size,
|
|
55
|
+
reason: pyManifest.present
|
|
56
|
+
? "Python declarations were compared with indexed imports; environment markers, extras and dynamic imports remain bounded static evidence."
|
|
57
|
+
: "No supported Python dependency manifest was discovered.",
|
|
58
|
+
},
|
|
59
|
+
maven: { ecosystem: "maven", ...jvmDependencies.maven },
|
|
60
|
+
gradle: { ecosystem: "gradle", ...jvmDependencies.gradle },
|
|
61
|
+
};
|
|
62
|
+
const present = Object.values(ecosystems).filter((item) => item.present);
|
|
63
|
+
const status = present.length === 0
|
|
64
|
+
? "NOT_CHECKED"
|
|
65
|
+
: graphComplete && present.every((item) => item.status === "CHECKED" && item.completeness === "COMPLETE")
|
|
66
|
+
? "COMPLETE"
|
|
67
|
+
: "PARTIAL";
|
|
68
|
+
const importedPackages = new Set(externalImports
|
|
69
|
+
.filter((entry) => entry?.pkg && !entry.builtin && !entry.unresolved)
|
|
70
|
+
.map((entry) => `${entry.ecosystem || (/\.java$/i.test(entry.file || "") ? "maven-unresolved" : "npm")}:${entry.pkg}`));
|
|
71
|
+
const supportedDeclared = dep.declared.size + goDep.declared.size + pyDep.declared.size;
|
|
72
|
+
const jvmDeclared = jvmDependencies.maven.declared + jvmDependencies.gradle.declared;
|
|
73
|
+
const measuredCoverage = readCoverageForRepo(repoPath, sourceFiles);
|
|
74
|
+
const healthCapabilities = buildHealthCapabilityMatrix({
|
|
75
|
+
graphComplete,
|
|
76
|
+
dependencyStatus: status,
|
|
77
|
+
dependencyEcosystems: ecosystems,
|
|
78
|
+
checks,
|
|
79
|
+
sourceFiles,
|
|
80
|
+
correctnessCoverage,
|
|
81
|
+
measuredCoverageFiles: measuredCoverage.size,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
manifestDeps: supportedDeclared + jvmDeclared,
|
|
85
|
+
healthCapabilities,
|
|
86
|
+
dependencyReport: {
|
|
87
|
+
status,
|
|
88
|
+
evidenceModel: "MANIFEST_PLUS_INDEXED_SOURCE",
|
|
89
|
+
perFindingVerification: present.some((item) => item.status === "CHECKED")
|
|
90
|
+
&& dependencyFindings.every((finding) => finding.verification != null),
|
|
91
|
+
verificationCoverage: Object.fromEntries(present.map((item) => [item.ecosystem, `${item.status}/${item.completeness}`])),
|
|
92
|
+
ecosystems,
|
|
93
|
+
declared: supportedDeclared + jvmDeclared,
|
|
94
|
+
importedPackages: importedPackages.size,
|
|
95
|
+
importRecords: externalImports.length,
|
|
96
|
+
unused: dependencyFindings.filter((finding) => finding.rule === "unused-dep").length,
|
|
97
|
+
missing: dependencyFindings.filter((finding) => finding.rule === "missing-dep").length,
|
|
98
|
+
duplicateDeclarations: dependencyFindings.filter((finding) => finding.rule === "duplicate-dep").length,
|
|
99
|
+
unusedRequiringReview: dependencyFindings.filter((finding) => finding.rule === "unused-dep" && finding.verification?.decision === "REVIEW_REQUIRED").length,
|
|
100
|
+
missingWithSourceEvidence: dependencyFindings.filter((finding) => finding.rule === "missing-dep" && finding.verification?.indexedSourceImports?.status === "FOUND").length,
|
|
101
|
+
packageScopes: packageScopes.length,
|
|
102
|
+
reason: status === "NOT_CHECKED"
|
|
103
|
+
? "No dependency manifest was discovered, so manifest-to-import verification did not run and no dependency verdict was produced."
|
|
104
|
+
: status === "COMPLETE"
|
|
105
|
+
? "Every discovered dependency ecosystem has complete supported manifest-to-import evidence for the indexed repository."
|
|
106
|
+
: present.some((item) => item.status === "NOT_SUPPORTED")
|
|
107
|
+
? `Dependency evidence is PARTIAL: ${present.filter((item) => item.status === "NOT_SUPPORTED").map((item) => item.ecosystem).join(", ")} manifests were counted, but package-to-artifact verification is NOT_SUPPORTED.`
|
|
108
|
+
: "Dependency checks ran at their documented evidence level, but at least one graph or ecosystem surface is partial; this is not a repository-wide clean bill.",
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {dirname} from 'node:path'
|
|
2
|
+
import {createRepoBoundary} from '../../repo-path.js'
|
|
3
|
+
import {parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps, pep503} from '../manifests.js'
|
|
4
|
+
import {listRepoFiles, readRepoText} from './repo-files.js'
|
|
5
|
+
|
|
6
|
+
const normalizeRoot = (root) => {
|
|
7
|
+
const value = String(root || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
|
|
8
|
+
return value === '.' ? '' : value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Python dependencies are owned by the nearest manifest root, including nested services.
|
|
12
|
+
export function collectPyManifest(repoRoot) {
|
|
13
|
+
const boundary = createRepoBoundary(repoRoot)
|
|
14
|
+
const scopes = new Map()
|
|
15
|
+
const scopeFor = (root) => {
|
|
16
|
+
const normalized = normalizeRoot(root)
|
|
17
|
+
if (!scopes.has(normalized)) scopes.set(normalized, {root: normalized, present: false, deps: [], manifests: []})
|
|
18
|
+
return scopes.get(normalized)
|
|
19
|
+
}
|
|
20
|
+
const addManifest = (root, manifest, parsedDeps, present = true) => {
|
|
21
|
+
if (!present) return
|
|
22
|
+
const scope = scopeFor(root)
|
|
23
|
+
scope.present = true
|
|
24
|
+
scope.manifests.push(manifest)
|
|
25
|
+
scope.deps.push(...parsedDeps.map((dep) => ({...dep, manifest})))
|
|
26
|
+
}
|
|
27
|
+
const files = listRepoFiles(repoRoot)
|
|
28
|
+
for (const file of files.filter((name) => /(^|\/)requirements[\w.-]*\.(?:txt|in)$/i.test(name)
|
|
29
|
+
|| /(^|\/)requirements\/[^/]+\.(?:txt|in)$/i.test(name))) {
|
|
30
|
+
const text = readRepoText(boundary, file)
|
|
31
|
+
if (text == null) continue
|
|
32
|
+
const parent = normalizeRoot(dirname(file))
|
|
33
|
+
const root = /(^|\/)requirements$/i.test(parent) ? normalizeRoot(dirname(parent)) : parent
|
|
34
|
+
const dev = /dev|test|lint|doc|ci/i.test(file.slice(file.lastIndexOf('/') + 1))
|
|
35
|
+
addManifest(root, file, parseRequirementsNames(text).map((dep) => ({...dep, dev})))
|
|
36
|
+
}
|
|
37
|
+
for (const file of files.filter((name) => /(^|\/)pyproject\.toml$/i.test(name))) {
|
|
38
|
+
const parsed = parsePyprojectDeps(readRepoText(boundary, file))
|
|
39
|
+
addManifest(dirname(file), file, parsed.deps, parsed.present)
|
|
40
|
+
}
|
|
41
|
+
for (const file of files.filter((name) => /(^|\/)Pipfile$/i.test(name))) {
|
|
42
|
+
const parsed = parsePipfileDeps(readRepoText(boundary, file))
|
|
43
|
+
addManifest(dirname(file), file, parsed.deps, parsed.present)
|
|
44
|
+
}
|
|
45
|
+
const normalizedScopes = [...scopes.values()]
|
|
46
|
+
.map((scope) => {
|
|
47
|
+
const seen = new Set()
|
|
48
|
+
return {
|
|
49
|
+
...scope,
|
|
50
|
+
manifests: [...new Set(scope.manifests)].sort(),
|
|
51
|
+
deps: scope.deps.filter((dep) => {
|
|
52
|
+
const key = pep503(dep.name)
|
|
53
|
+
if (!key || seen.has(key)) return false
|
|
54
|
+
seen.add(key)
|
|
55
|
+
return true
|
|
56
|
+
}),
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
.sort((left, right) => right.root.length - left.root.length || left.root.localeCompare(right.root))
|
|
60
|
+
return {
|
|
61
|
+
present: normalizedScopes.some((scope) => scope.present),
|
|
62
|
+
deps: normalizedScopes.flatMap((scope) => scope.deps),
|
|
63
|
+
scopes: normalizedScopes,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {readFileSync, readdirSync} from 'node:fs'
|
|
2
|
+
import {join} from 'node:path'
|
|
3
|
+
import {spawnSync} from 'node:child_process'
|
|
4
|
+
import {childProcessEnv} from '../../child-env.js'
|
|
5
|
+
import {filterWeavatrixIgnored} from '../../path-ignore.js'
|
|
6
|
+
|
|
7
|
+
export const readText = (path) => {
|
|
8
|
+
try { return readFileSync(path, 'utf8') } catch { return null }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const readJson = (path) => {
|
|
12
|
+
try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const readRepoText = (boundary, relativePath) => {
|
|
16
|
+
const resolved = boundary.resolve(relativePath)
|
|
17
|
+
return resolved.ok ? readText(resolved.path) : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const readRepoJson = (boundary, relativePath) => {
|
|
21
|
+
const resolved = boundary.resolve(relativePath)
|
|
22
|
+
return resolved.ok ? readJson(resolved.path) : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SKIP_DIRS = new Set([
|
|
26
|
+
'.git', '.hg', '.svn', 'node_modules', 'vendor', 'dist', 'build', 'coverage', '.next', 'out',
|
|
27
|
+
'release', 'weavatrix-graphs', '__pycache__', '.venv', 'venv', 'env', '.tox', 'site-packages',
|
|
28
|
+
'.mypy_cache', '.pytest_cache',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
export function listRepoFiles(repoRoot) {
|
|
32
|
+
try {
|
|
33
|
+
const result = spawnSync('git', ['-C', repoRoot, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
|
|
34
|
+
encoding: 'utf8', windowsHide: true, timeout: 15_000, maxBuffer: 32 * 1024 * 1024,
|
|
35
|
+
env: childProcessEnv(),
|
|
36
|
+
})
|
|
37
|
+
if (result.status === 0) {
|
|
38
|
+
const files = String(result.stdout || '').split('\0').filter(Boolean).map((file) => file.replace(/\\/g, '/'))
|
|
39
|
+
return filterWeavatrixIgnored(repoRoot, files)
|
|
40
|
+
}
|
|
41
|
+
} catch { /* non-Git repo or git unavailable: use the bounded walker below */ }
|
|
42
|
+
|
|
43
|
+
const files = []
|
|
44
|
+
const walk = (absolute, parts = []) => {
|
|
45
|
+
let entries = []
|
|
46
|
+
try { entries = readdirSync(absolute, {withFileTypes: true}) } catch { return }
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
if (!SKIP_DIRS.has(entry.name)) walk(join(absolute, entry.name), [...parts, entry.name])
|
|
50
|
+
} else if (entry.isFile()) files.push([...parts, entry.name].join('/'))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
walk(repoRoot)
|
|
54
|
+
return filterWeavatrixIgnored(repoRoot, files)
|
|
55
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { collectInstalled } from "../../security/installed.js";
|
|
2
|
+
import { advisoryQueryFingerprint, loadStore, queryStore } from "../../security/advisory-store.js";
|
|
3
|
+
import { matchAdvisories } from "../../security/match.js";
|
|
4
|
+
import { scanMalware } from "../../security/malware-heuristics.js";
|
|
5
|
+
import { classifyTyposquat } from "../../security/typosquat.js";
|
|
6
|
+
import { makeFinding } from "../findings.js";
|
|
7
|
+
import { packageReachability } from "../package-reachability.js";
|
|
8
|
+
|
|
9
|
+
const LOWER_SEVERITY = { critical: "high", high: "medium", medium: "low", low: "info", info: "info" };
|
|
10
|
+
|
|
11
|
+
const removalHint = (pkg) => {
|
|
12
|
+
if (pkg?.ecosystem === "npm") return `npm uninstall ${pkg.name} and audit what it touched`;
|
|
13
|
+
if (pkg?.ecosystem === "PyPI") return `remove ${pkg.name} from the Python environment/manifest and audit what it touched`;
|
|
14
|
+
if (pkg?.ecosystem === "Go") return `remove ${pkg.name} from the Go module graph, run go mod tidy, and audit what it touched`;
|
|
15
|
+
return `remove ${pkg?.name || "the package"} with its ecosystem's package manager and audit what it touched`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function runSupplyChainChecks(repoPath, {
|
|
19
|
+
externalImports = [],
|
|
20
|
+
packageScopes = [],
|
|
21
|
+
isNonProductPath = () => false,
|
|
22
|
+
advisoryStorePath,
|
|
23
|
+
skipMalwareScan = false,
|
|
24
|
+
malwareExclusions = {},
|
|
25
|
+
rgPath = "",
|
|
26
|
+
} = {}) {
|
|
27
|
+
const findings = [];
|
|
28
|
+
let advisoryDbDate = null;
|
|
29
|
+
let installedCount = 0;
|
|
30
|
+
let inst = { installed: [], drift: [] };
|
|
31
|
+
const checks = {
|
|
32
|
+
osv: { status: "NOT_CHECKED", detail: "Advisory cache was never refreshed for this repository. Enable the osv profile (or advisories capability), then call refresh_advisories explicitly to opt in to sending pinned package names and versions to OSV.dev." },
|
|
33
|
+
malware: { status: skipMalwareScan ? "NOT_CHECKED" : "PENDING", detail: skipMalwareScan ? "Installed-package malware scan is opt-in and was not requested." : "" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
inst = collectInstalled(repoPath);
|
|
38
|
+
installedCount = inst.installed.length;
|
|
39
|
+
const store = advisoryStorePath ? loadStore(advisoryStorePath) : loadStore();
|
|
40
|
+
const repoStamp = store.meta?.repos?.[repoPath] || null;
|
|
41
|
+
advisoryDbDate = typeof repoStamp === "string" ? repoStamp : repoStamp?.fetched_at || null;
|
|
42
|
+
if (advisoryDbDate) {
|
|
43
|
+
let status = typeof repoStamp === "object" && ["OK", "PARTIAL", "ERROR"].includes(repoStamp.status) ? repoStamp.status : "PARTIAL";
|
|
44
|
+
const fingerprintMatches = typeof repoStamp === "object" && repoStamp.query_fingerprint === advisoryQueryFingerprint(inst.installed);
|
|
45
|
+
if (!fingerprintMatches && status === "OK") status = "PARTIAL";
|
|
46
|
+
const coverage = typeof repoStamp === "object" && Number.isFinite(repoStamp.queried)
|
|
47
|
+
? ` (${repoStamp.queried_ok ?? repoStamp.queried}/${repoStamp.queried} package versions queried successfully)`
|
|
48
|
+
: "";
|
|
49
|
+
const drift = fingerprintMatches ? "" : " Dependency versions changed, or this is a legacy stamp without a package fingerprint; enable the osv profile (or advisories capability) and call refresh_advisories for complete coverage.";
|
|
50
|
+
checks.osv = {
|
|
51
|
+
status,
|
|
52
|
+
detail: `${status === "PARTIAL" ? "Partially matched" : "Matched"} installed packages against the cached OSV snapshot from ${advisoryDbDate}${coverage}.${drift}`,
|
|
53
|
+
checkedAt: advisoryDbDate,
|
|
54
|
+
};
|
|
55
|
+
for (const hit of matchAdvisories(inst.installed, (ecosystem, name) => queryStore(store, ecosystem, name))) {
|
|
56
|
+
const malicious = hit.adv.kind === "malicious";
|
|
57
|
+
const reachability = packageReachability(externalImports, hit.pkg.name, { isNonProductPath });
|
|
58
|
+
const observedInProduct = reachability.state === "DIRECT_RUNTIME_IMPORT";
|
|
59
|
+
const reachabilityDetail = observedInProduct
|
|
60
|
+
? ` Graph reachability: directly imported by product code in ${reachability.directRuntimeImports} callsite(s) across ${reachability.files.length} file(s).`
|
|
61
|
+
: ` Graph reachability: ${reachability.state}; ${reachability.note}`;
|
|
62
|
+
findings.push(makeFinding({
|
|
63
|
+
category: malicious ? "malware" : "vulnerability",
|
|
64
|
+
rule: malicious ? "malicious-package" : "known-vuln",
|
|
65
|
+
severity: malicious || observedInProduct ? (malicious ? "critical" : hit.adv.severity) : LOWER_SEVERITY[hit.adv.severity] || hit.adv.severity,
|
|
66
|
+
confidence: malicious || observedInProduct ? hit.confidence : "low",
|
|
67
|
+
title: `${malicious ? "Known-malicious package" : `Known vulnerability (${hit.adv.id})`}: ${hit.pkg.name}@${hit.pkg.version}`,
|
|
68
|
+
detail: `${hit.adv.summary || hit.adv.id}${hit.adv.fixedIn.length ? ` Fixed in: ${hit.adv.fixedIn.join(", ")}.` : malicious ? " Remove this package immediately and rotate any secrets it could reach." : ""} (matched by ${hit.matchedBy}${hit.adv.aliases.length ? `; aliases ${hit.adv.aliases.join(", ")}` : ""}).${reachabilityDetail}`,
|
|
69
|
+
package: hit.pkg.name,
|
|
70
|
+
version: hit.pkg.version,
|
|
71
|
+
reachability,
|
|
72
|
+
evidence: [
|
|
73
|
+
{ file: hit.adv.url, line: 0, snippet: `installed via ${hit.pkg.source}${hit.pkg.dev ? " (dev)" : ""}` },
|
|
74
|
+
...reachability.evidence.slice(0, 5).map((item) => ({ file: item.file, line: item.line, snippet: `${item.typeOnly ? "type-only " : ""}${item.kind}` })),
|
|
75
|
+
],
|
|
76
|
+
source: "osv",
|
|
77
|
+
fixHint: malicious
|
|
78
|
+
? removalHint(hit.pkg)
|
|
79
|
+
: hit.adv.fixedIn.length
|
|
80
|
+
? `upgrade ${hit.pkg.name} to ${hit.adv.fixedIn.at(-1)}+ with its ecosystem's package manager`
|
|
81
|
+
: "no fixed version published; consider replacing the package",
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const directDependencyNames = new Set(packageScopes.flatMap((scope) => Object.keys({ ...(scope.pkg?.dependencies || {}), ...(scope.pkg?.devDependencies || {}) })));
|
|
86
|
+
for (const name of directDependencyNames) {
|
|
87
|
+
const candidate = classifyTyposquat(name);
|
|
88
|
+
if (!candidate) continue;
|
|
89
|
+
findings.push(makeFinding({
|
|
90
|
+
category: "malware",
|
|
91
|
+
rule: "typosquat",
|
|
92
|
+
severity: "medium",
|
|
93
|
+
confidence: "low",
|
|
94
|
+
title: `Possible typosquat: ${name} (looks like "${candidate.nearest}")`,
|
|
95
|
+
detail: `Direct dependency "${name}" is edit-distance ${candidate.distance} from the popular package "${candidate.nearest}". Confirm you meant "${name}" and not "${candidate.nearest}" — name-confusion is a common supply-chain lure.`,
|
|
96
|
+
package: name,
|
|
97
|
+
source: "internal",
|
|
98
|
+
fixHint: `verify "${name}" is the intended package (not a typo of "${candidate.nearest}")`,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
for (const item of inst.drift.slice(0, 20)) {
|
|
102
|
+
findings.push(makeFinding({
|
|
103
|
+
category: "malware",
|
|
104
|
+
rule: "lockfile-drift",
|
|
105
|
+
severity: "low",
|
|
106
|
+
confidence: "medium",
|
|
107
|
+
title: `Lockfile drift: ${item.name} (locked ${item.locked}, installed ${item.installed})`,
|
|
108
|
+
detail: "The version on disk differs from the lockfile — a stale install, a manual edit, or (worst case) tampering. Reinstall from the lockfile to realign.",
|
|
109
|
+
package: item.name,
|
|
110
|
+
version: item.installed,
|
|
111
|
+
source: "internal",
|
|
112
|
+
fixHint: "npm ci (clean install from the lockfile)",
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
checks.osv = { status: "ERROR", detail: `Offline advisory matching failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let malwareScan = null;
|
|
120
|
+
if (!skipMalwareScan) {
|
|
121
|
+
try {
|
|
122
|
+
const importedPkgs = new Set(externalImports.filter((entry) => entry.pkg && !entry.builtin).map((entry) => entry.pkg));
|
|
123
|
+
const scan = await scanMalware(repoPath, { installed: inst.installed, importedPkgs, malwareExclusions, rgPath });
|
|
124
|
+
findings.push(...scan.findings);
|
|
125
|
+
malwareScan = { scanMode: scan.scanMode, packagesScanned: scan.packagesScanned, findings: scan.findings.length, excludedSignals: scan.excludedSignals || 0 };
|
|
126
|
+
checks.malware = { status: "OK", detail: `Scanned ${scan.packagesScanned} installed package(s) using ${scan.scanMode}.` };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
checks.malware = { status: "ERROR", detail: `Installed-package malware scan failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { findings, checks, installedCount, advisoryDbDate, malwareScan };
|
|
132
|
+
}
|
|
@@ -1,55 +1,13 @@
|
|
|
1
1
|
// internal-audit.collect.js — filesystem collection helpers for the internal audit: source/config
|
|
2
2
|
// text gathering, workspace package names, and the Python manifest reader. Split from internal-audit.js.
|
|
3
|
-
import {
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps, pep503 } from "./manifests.js";
|
|
7
5
|
import { createRepoBoundary } from "../repo-path.js";
|
|
8
|
-
import {
|
|
9
|
-
import { filterWeavatrixIgnored } from "../path-ignore.js";
|
|
6
|
+
import {listRepoFiles, readJson, readRepoJson, readRepoText, readText} from './internal-audit/repo-files.js'
|
|
10
7
|
|
|
11
|
-
export
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
const resolved = boundary.resolve(relativePath);
|
|
15
|
-
return resolved.ok ? readText(resolved.path) : null;
|
|
16
|
-
};
|
|
17
|
-
export const readRepoJson = (boundary, relativePath) => {
|
|
18
|
-
const resolved = boundary.resolve(relativePath);
|
|
19
|
-
return resolved.ok ? readJson(resolved.path) : null;
|
|
20
|
-
};
|
|
21
|
-
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?|py|go|vue|svelte)$/i;
|
|
22
|
-
const SOURCE_SKIP_DIRS = new Set([
|
|
23
|
-
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", "coverage", ".next", "out",
|
|
24
|
-
"release", "weavatrix-graphs", "__pycache__", ".venv", "venv", "env", ".tox", "site-packages",
|
|
25
|
-
".mypy_cache", ".pytest_cache",
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
// One file universe for every filesystem-backed audit. In Git repos this exactly matches tracked files
|
|
29
|
-
// plus untracked/non-ignored work, so release bundles and other .gitignore outputs cannot re-enter through
|
|
30
|
-
// text/config/manifest fallback collectors after the graph builder correctly omitted them.
|
|
31
|
-
export function listRepoFiles(repoRoot) {
|
|
32
|
-
try {
|
|
33
|
-
const r = spawnSync("git", ["-C", repoRoot, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
34
|
-
encoding: "utf8", windowsHide: true, timeout: 15_000, maxBuffer: 32 * 1024 * 1024,
|
|
35
|
-
env: childProcessEnv(),
|
|
36
|
-
});
|
|
37
|
-
if (r.status === 0) return filterWeavatrixIgnored(repoRoot, String(r.stdout || "").split("\0").filter(Boolean).map((f) => f.replace(/\\/g, "/")));
|
|
38
|
-
} catch { /* non-Git repo or git unavailable: use the bounded walker below */ }
|
|
39
|
-
|
|
40
|
-
const files = [];
|
|
41
|
-
const walk = (abs, parts = []) => {
|
|
42
|
-
let entries = [];
|
|
43
|
-
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
44
|
-
for (const entry of entries) {
|
|
45
|
-
if (entry.isDirectory()) {
|
|
46
|
-
if (!SOURCE_SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]);
|
|
47
|
-
} else if (entry.isFile()) files.push([...parts, entry.name].join("/"));
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
walk(repoRoot);
|
|
51
|
-
return filterWeavatrixIgnored(repoRoot, files);
|
|
52
|
-
}
|
|
8
|
+
export {listRepoFiles, readJson, readRepoJson, readRepoText, readText} from './internal-audit/repo-files.js'
|
|
9
|
+
export {collectPyManifest} from './internal-audit/python-manifests.js'
|
|
10
|
+
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|cs|vue|svelte)$/i;
|
|
53
11
|
|
|
54
12
|
const NON_RUNTIME_DIR_RE = /^(?:templates?|examples?|samples?|fixtures?|snippets?|__fixtures__)$/i;
|
|
55
13
|
const NON_RUNTIME_README_RE = /(?:\b(?:reusable|copyable|reference)\b[\s\S]{0,160}\b(?:templates?|snippets?|examples?|samples?)\b|\b(?:these|contents?)\s+are\s+templates?\b)/i;
|
|
@@ -253,62 +211,3 @@ export function workspacePkgNames(repoRoot, pkg) {
|
|
|
253
211
|
}
|
|
254
212
|
|
|
255
213
|
export const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i;
|
|
256
|
-
|
|
257
|
-
// Python declared deps are owned by the nearest manifest root, matching nested service/monorepo
|
|
258
|
-
// layouts. `requirements/*.txt` belongs to the directory above `requirements`; a colocated
|
|
259
|
-
// requirements.txt, pyproject.toml or Pipfile owns its own directory.
|
|
260
|
-
// present=false (no manifest at all) softens missing-dep findings instead of suppressing them.
|
|
261
|
-
export function collectPyManifest(repoRoot) {
|
|
262
|
-
const boundary = createRepoBoundary(repoRoot);
|
|
263
|
-
const scopes = new Map();
|
|
264
|
-
const scopeFor = (root) => {
|
|
265
|
-
const normalized = normRoot(root);
|
|
266
|
-
if (!scopes.has(normalized)) scopes.set(normalized, { root: normalized, present: false, deps: [], manifests: [] });
|
|
267
|
-
return scopes.get(normalized);
|
|
268
|
-
};
|
|
269
|
-
const addManifest = (root, manifest, parsedDeps, present = true) => {
|
|
270
|
-
if (!present) return;
|
|
271
|
-
const scope = scopeFor(root);
|
|
272
|
-
scope.present = true;
|
|
273
|
-
scope.manifests.push(manifest);
|
|
274
|
-
scope.deps.push(...parsedDeps.map((dep) => ({ ...dep, manifest })));
|
|
275
|
-
};
|
|
276
|
-
const files = listRepoFiles(repoRoot);
|
|
277
|
-
for (const file of files.filter((name) => /(^|\/)requirements[\w.-]*\.(?:txt|in)$/i.test(name)
|
|
278
|
-
|| /(^|\/)requirements\/[^/]+\.(?:txt|in)$/i.test(name))) {
|
|
279
|
-
const t = readRepoText(boundary, file);
|
|
280
|
-
if (t == null) continue;
|
|
281
|
-
const parent = normRoot(dirname(file));
|
|
282
|
-
const root = /(^|\/)requirements$/i.test(parent) ? normRoot(dirname(parent)) : parent;
|
|
283
|
-
const dev = /dev|test|lint|doc|ci/i.test(file.slice(file.lastIndexOf("/") + 1));
|
|
284
|
-
addManifest(root, file, parseRequirementsNames(t).map((dep) => ({ ...dep, dev })));
|
|
285
|
-
}
|
|
286
|
-
for (const file of files.filter((name) => /(^|\/)pyproject\.toml$/i.test(name))) {
|
|
287
|
-
const parsed = parsePyprojectDeps(readRepoText(boundary, file));
|
|
288
|
-
addManifest(dirname(file), file, parsed.deps, parsed.present);
|
|
289
|
-
}
|
|
290
|
-
for (const file of files.filter((name) => /(^|\/)Pipfile$/i.test(name))) {
|
|
291
|
-
const parsed = parsePipfileDeps(readRepoText(boundary, file));
|
|
292
|
-
addManifest(dirname(file), file, parsed.deps, parsed.present);
|
|
293
|
-
}
|
|
294
|
-
const normalizedScopes = [...scopes.values()]
|
|
295
|
-
.map((scope) => {
|
|
296
|
-
const seen = new Set();
|
|
297
|
-
return {
|
|
298
|
-
...scope,
|
|
299
|
-
manifests: [...new Set(scope.manifests)].sort(),
|
|
300
|
-
deps: scope.deps.filter((dep) => {
|
|
301
|
-
const key = pep503(dep.name);
|
|
302
|
-
if (!key || seen.has(key)) return false;
|
|
303
|
-
seen.add(key);
|
|
304
|
-
return true;
|
|
305
|
-
}),
|
|
306
|
-
};
|
|
307
|
-
})
|
|
308
|
-
.sort((left, right) => right.root.length - left.root.length || left.root.localeCompare(right.root));
|
|
309
|
-
return {
|
|
310
|
-
present: normalizedScopes.some((scope) => scope.present),
|
|
311
|
-
deps: normalizedScopes.flatMap((scope) => scope.deps),
|
|
312
|
-
scopes: normalizedScopes,
|
|
313
|
-
};
|
|
314
|
-
}
|