@4da/mcp-server 4.6.1 → 4.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/db.d.ts +21 -2
- package/dist/db.js +66 -16
- package/dist/doctor.js +5 -3
- package/dist/embeddings.d.ts +88 -0
- package/dist/embeddings.js +292 -0
- package/dist/index.d.ts +9 -9
- package/dist/index.js +21 -15
- package/dist/live/cvss.d.ts +16 -0
- package/dist/live/cvss.js +62 -0
- package/dist/live/index.d.ts +9 -1
- package/dist/live/index.js +40 -3
- package/dist/live/npm-registry.js +29 -6
- package/dist/live/osv-scanner.d.ts +8 -0
- package/dist/live/osv-scanner.js +115 -30
- package/dist/live/platform.d.ts +32 -0
- package/dist/live/platform.js +156 -0
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +24 -0
- package/dist/live/types.d.ts +31 -9
- package/dist/live/version-resolver.d.ts +12 -2
- package/dist/live/version-resolver.js +63 -20
- package/dist/llm.js +1 -1
- package/dist/project-scanner.d.ts +2 -0
- package/dist/project-scanner.js +34 -0
- package/dist/scheduler/core.d.ts +25 -0
- package/dist/scheduler/core.js +61 -0
- package/dist/scheduler/freshness.d.ts +17 -0
- package/dist/scheduler/freshness.js +41 -0
- package/dist/scheduler/index.d.ts +21 -0
- package/dist/scheduler/index.js +32 -0
- package/dist/scheduler/refreshers.d.ts +21 -0
- package/dist/scheduler/refreshers.js +21 -0
- package/dist/schemas/get-relevant-content.json +1 -1
- package/dist/schemas/upgrade-planner.json +1 -1
- package/dist/schemas/what-should-i-know.json +1 -1
- package/dist/tools/agent-memory.d.ts +11 -1
- package/dist/tools/agent-memory.js +120 -14
- package/dist/tools/decision-enforcement.d.ts +17 -1
- package/dist/tools/decision-enforcement.js +109 -81
- package/dist/tools/decision-memory.d.ts +1 -1
- package/dist/tools/decision-memory.js +58 -28
- package/dist/tools/decision-recall.d.ts +59 -0
- package/dist/tools/decision-recall.js +87 -0
- package/dist/tools/dependency-health.js +6 -4
- package/dist/tools/developer-dna.js +4 -2
- package/dist/tools/knowledge-gaps.js +16 -4
- package/dist/tools/recall.d.ts +33 -0
- package/dist/tools/recall.js +134 -0
- package/dist/tools/upgrade-planner.d.ts +25 -1
- package/dist/tools/upgrade-planner.js +111 -30
- package/dist/tools/vulnerability-scan.d.ts +46 -0
- package/dist/tools/vulnerability-scan.js +64 -24
- package/dist/tools/what-should-i-know.d.ts +3 -1
- package/dist/tools/what-should-i-know.js +210 -106
- package/dist/types.d.ts +9 -0
- package/package.json +8 -3
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js.map +0 -1
- package/dist/doctor.d.ts.map +0 -1
- package/dist/doctor.js.map +0 -1
- package/dist/http-transport.d.ts.map +0 -1
- package/dist/http-transport.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/live/cache.d.ts.map +0 -1
- package/dist/live/cache.js.map +0 -1
- package/dist/live/crates-registry.d.ts.map +0 -1
- package/dist/live/crates-registry.js.map +0 -1
- package/dist/live/go-registry.d.ts.map +0 -1
- package/dist/live/go-registry.js.map +0 -1
- package/dist/live/hn-fetcher.d.ts.map +0 -1
- package/dist/live/hn-fetcher.js.map +0 -1
- package/dist/live/http-utils.d.ts.map +0 -1
- package/dist/live/http-utils.js.map +0 -1
- package/dist/live/index.d.ts.map +0 -1
- package/dist/live/index.js.map +0 -1
- package/dist/live/npm-registry.d.ts.map +0 -1
- package/dist/live/npm-registry.js.map +0 -1
- package/dist/live/osv-scanner.d.ts.map +0 -1
- package/dist/live/osv-scanner.js.map +0 -1
- package/dist/live/pypi-registry.d.ts.map +0 -1
- package/dist/live/pypi-registry.js.map +0 -1
- package/dist/live/rate-limiter.d.ts.map +0 -1
- package/dist/live/rate-limiter.js.map +0 -1
- package/dist/live/semver-utils.d.ts.map +0 -1
- package/dist/live/semver-utils.js.map +0 -1
- package/dist/live/types.d.ts.map +0 -1
- package/dist/live/types.js.map +0 -1
- package/dist/live/version-resolver.d.ts.map +0 -1
- package/dist/live/version-resolver.js.map +0 -1
- package/dist/live-singleton.d.ts.map +0 -1
- package/dist/live-singleton.js.map +0 -1
- package/dist/llm.d.ts.map +0 -1
- package/dist/llm.js.map +0 -1
- package/dist/output-manager.d.ts.map +0 -1
- package/dist/output-manager.js.map +0 -1
- package/dist/project-scanner.d.ts.map +0 -1
- package/dist/project-scanner.js.map +0 -1
- package/dist/schema-registry.d.ts.map +0 -1
- package/dist/schema-registry.js.map +0 -1
- package/dist/setup.d.ts.map +0 -1
- package/dist/setup.js.map +0 -1
- package/dist/tool-dispatch.d.ts.map +0 -1
- package/dist/tool-dispatch.js.map +0 -1
- package/dist/tools/agent-memory.d.ts.map +0 -1
- package/dist/tools/agent-memory.js.map +0 -1
- package/dist/tools/decision-enforcement.d.ts.map +0 -1
- package/dist/tools/decision-enforcement.js.map +0 -1
- package/dist/tools/decision-memory.d.ts.map +0 -1
- package/dist/tools/decision-memory.js.map +0 -1
- package/dist/tools/dependency-health.d.ts.map +0 -1
- package/dist/tools/dependency-health.js.map +0 -1
- package/dist/tools/developer-dna.d.ts.map +0 -1
- package/dist/tools/developer-dna.js.map +0 -1
- package/dist/tools/ecosystem-pulse.d.ts.map +0 -1
- package/dist/tools/ecosystem-pulse.js.map +0 -1
- package/dist/tools/get-actionable-signals.d.ts.map +0 -1
- package/dist/tools/get-actionable-signals.js.map +0 -1
- package/dist/tools/get-context.d.ts.map +0 -1
- package/dist/tools/get-context.js.map +0 -1
- package/dist/tools/get-relevant-content.d.ts.map +0 -1
- package/dist/tools/get-relevant-content.js.map +0 -1
- package/dist/tools/index.d.ts.map +0 -1
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/knowledge-gaps.d.ts.map +0 -1
- package/dist/tools/knowledge-gaps.js.map +0 -1
- package/dist/tools/record-feedback.d.ts.map +0 -1
- package/dist/tools/record-feedback.js.map +0 -1
- package/dist/tools/upgrade-planner.d.ts.map +0 -1
- package/dist/tools/upgrade-planner.js.map +0 -1
- package/dist/tools/vulnerability-scan.d.ts.map +0 -1
- package/dist/tools/vulnerability-scan.js.map +0 -1
- package/dist/tools/what-should-i-know.d.ts.map +0 -1
- package/dist/tools/what-should-i-know.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
/**
|
|
4
|
-
* 4DA MCP Server
|
|
4
|
+
* 4DA MCP Server
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Provides dependency-intelligence tools (CVE scanning, dependency health,
|
|
7
|
+
* upgrade planning, ecosystem news, decision/agent memory) to Claude Code,
|
|
8
|
+
* Claude Desktop, and other MCP hosts. Runs locally; the only data leaving the
|
|
9
|
+
* machine is public package names/versions sent to registries (OSV, npm, etc.).
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Do NOT expose this server over a network without adding proper auth.
|
|
11
|
+
* SECURITY: the server trusts its local environment and does NOT implement
|
|
12
|
+
* authentication. The optional --http transport is intended for localhost use;
|
|
13
|
+
* do NOT expose it over a network without putting your own auth in front.
|
|
14
14
|
*/
|
|
15
15
|
/**
|
|
16
|
-
* 4DA MCP Server v4.6.
|
|
16
|
+
* 4DA MCP Server v4.6.3 — Dependency Intelligence for AI Coding Agents
|
|
17
17
|
*
|
|
18
18
|
* 14 tools across 5 categories. Live vulnerability scanning (OSV.dev),
|
|
19
19
|
* ecosystem news, persistent memory, and tech stack awareness for any MCP host.
|
|
@@ -51,7 +51,7 @@ import { createDatabase, FourDADatabase } from "./db.js";
|
|
|
51
51
|
// =============================================================================
|
|
52
52
|
const server = new Server({
|
|
53
53
|
name: "4da-server",
|
|
54
|
-
version: "4.6.
|
|
54
|
+
version: "4.6.3",
|
|
55
55
|
}, {
|
|
56
56
|
capabilities: {
|
|
57
57
|
tools: { listChanged: true },
|
|
@@ -86,7 +86,9 @@ function getDatabase() {
|
|
|
86
86
|
if (detected.length > 0) {
|
|
87
87
|
console.error(`[4DA] Detected: ${detected.join(", ")} | ${scan.dependencies.length} deps, ${scan.devDependencies.length} dev deps`);
|
|
88
88
|
// Initialize live intelligence with per-ecosystem resolved versions
|
|
89
|
-
|
|
89
|
+
// (depTargets carries platform-gated dep info so advisories can be
|
|
90
|
+
// flagged platform-relevant for the host).
|
|
91
|
+
liveIntel.initFromMultiEcosystem(cwd, scan.depsByEcosystem, scan.depTargets);
|
|
90
92
|
if (liveIntel.isEnabled()) {
|
|
91
93
|
console.error(`[4DA] Live intelligence: enabled (OSV.dev + HN)`);
|
|
92
94
|
// Background prefetch — non-blocking, warms cache for first tool call
|
|
@@ -142,8 +144,12 @@ function getDatabase() {
|
|
|
142
144
|
liveIntel.initFromDependencyGroups([...groups.values()]);
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
|
-
catch {
|
|
146
|
-
// Non-fatal — live intel just won't have version data
|
|
147
|
+
catch (err) {
|
|
148
|
+
// Non-fatal — live intel just won't have version data — but log to stderr
|
|
149
|
+
// so a silent empty scan (e.g. project_dependencies schema drift) is
|
|
150
|
+
// diagnosable rather than looking like "no vulnerabilities".
|
|
151
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
152
|
+
console.error(`[4da] dependency-group init from 4DA DB failed (continuing without version data): ${msg}`);
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
155
|
}
|
|
@@ -287,7 +293,7 @@ async function main() {
|
|
|
287
293
|
const args = process.argv.slice(2);
|
|
288
294
|
// Version
|
|
289
295
|
if (args.includes("--version") || args.includes("-v")) {
|
|
290
|
-
console.log("@4da/mcp-server 4.6.
|
|
296
|
+
console.log("@4da/mcp-server 4.6.3");
|
|
291
297
|
return;
|
|
292
298
|
}
|
|
293
299
|
// Help
|
|
@@ -376,7 +382,7 @@ async function main() {
|
|
|
376
382
|
process.exit(0);
|
|
377
383
|
});
|
|
378
384
|
const toolCount = getSlimToolList().length;
|
|
379
|
-
console.error(`4DA MCP Server v4.6.
|
|
385
|
+
console.error(`4DA MCP Server v4.6.3 started — ${toolCount} tools, stdio transport`);
|
|
380
386
|
console.error(" Use --http for Streamable HTTP, --setup to configure editors, --doctor to check health");
|
|
381
387
|
}
|
|
382
388
|
main().catch((error) => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CVSS v3.0 / v3.1 base-score calculator.
|
|
3
|
+
*
|
|
4
|
+
* OSV advisories frequently expose severity only as a CVSS vector string
|
|
5
|
+
* (e.g. "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L") rather than a numeric
|
|
6
|
+
* base score. Without computing the score we cannot bucket the advisory into
|
|
7
|
+
* critical/high/medium/low — so this implements the published base-score
|
|
8
|
+
* formula. CVSS v4.0 vectors are not yet handled (returns null).
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Parse a CVSS vector string and return its base score (0.0–10.0), or null if
|
|
12
|
+
* the vector is not a recognizable v3.x base vector (missing required metrics,
|
|
13
|
+
* v2/v4, or malformed).
|
|
14
|
+
*/
|
|
15
|
+
export declare function cvssBaseScore(vector: string): number | null;
|
|
16
|
+
//# sourceMappingURL=cvss.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* CVSS v3.0 / v3.1 base-score calculator.
|
|
4
|
+
*
|
|
5
|
+
* OSV advisories frequently expose severity only as a CVSS vector string
|
|
6
|
+
* (e.g. "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L") rather than a numeric
|
|
7
|
+
* base score. Without computing the score we cannot bucket the advisory into
|
|
8
|
+
* critical/high/medium/low — so this implements the published base-score
|
|
9
|
+
* formula. CVSS v4.0 vectors are not yet handled (returns null).
|
|
10
|
+
*/
|
|
11
|
+
const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 };
|
|
12
|
+
const AC = { L: 0.77, H: 0.44 };
|
|
13
|
+
const UI = { N: 0.85, R: 0.62 };
|
|
14
|
+
const CIA = { N: 0, L: 0.22, H: 0.56 };
|
|
15
|
+
// Privileges Required is scope-dependent.
|
|
16
|
+
const PR_UNCHANGED = { N: 0.85, L: 0.62, H: 0.27 };
|
|
17
|
+
const PR_CHANGED = { N: 0.85, L: 0.68, H: 0.5 };
|
|
18
|
+
/** CVSS v3.1 "Roundup": smallest one-decimal value >= input (with float-safe integer math). */
|
|
19
|
+
function roundUp(input) {
|
|
20
|
+
const intInput = Math.round(input * 100000);
|
|
21
|
+
if (intInput % 10000 === 0)
|
|
22
|
+
return intInput / 100000;
|
|
23
|
+
return (Math.floor(intInput / 10000) + 1) / 10;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Parse a CVSS vector string and return its base score (0.0–10.0), or null if
|
|
27
|
+
* the vector is not a recognizable v3.x base vector (missing required metrics,
|
|
28
|
+
* v2/v4, or malformed).
|
|
29
|
+
*/
|
|
30
|
+
export function cvssBaseScore(vector) {
|
|
31
|
+
if (!vector || !/CVSS:3\.[01]/i.test(vector) && !vector.includes("AV:"))
|
|
32
|
+
return null;
|
|
33
|
+
const metrics = {};
|
|
34
|
+
for (const part of vector.split("/")) {
|
|
35
|
+
const [k, v] = part.split(":");
|
|
36
|
+
if (k && v)
|
|
37
|
+
metrics[k.trim().toUpperCase()] = v.trim().toUpperCase();
|
|
38
|
+
}
|
|
39
|
+
const scope = metrics.S; // U (unchanged) | C (changed)
|
|
40
|
+
const prTable = scope === "C" ? PR_CHANGED : PR_UNCHANGED;
|
|
41
|
+
const av = AV[metrics.AV];
|
|
42
|
+
const ac = AC[metrics.AC];
|
|
43
|
+
const pr = prTable[metrics.PR];
|
|
44
|
+
const ui = UI[metrics.UI];
|
|
45
|
+
const c = CIA[metrics.C];
|
|
46
|
+
const i = CIA[metrics.I];
|
|
47
|
+
const a = CIA[metrics.A];
|
|
48
|
+
// All eight base metrics are mandatory; bail if any is unrecognized.
|
|
49
|
+
if ([av, ac, pr, ui, c, i, a].some((x) => x === undefined) || (scope !== "U" && scope !== "C")) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const iscBase = 1 - (1 - c) * (1 - i) * (1 - a);
|
|
53
|
+
const impact = scope === "C"
|
|
54
|
+
? 7.52 * (iscBase - 0.029) - 3.25 * Math.pow(iscBase - 0.02, 15)
|
|
55
|
+
: 6.42 * iscBase;
|
|
56
|
+
if (impact <= 0)
|
|
57
|
+
return 0;
|
|
58
|
+
const exploitability = 8.22 * av * ac * pr * ui;
|
|
59
|
+
const raw = scope === "C" ? 1.08 * (impact + exploitability) : impact + exploitability;
|
|
60
|
+
return roundUp(Math.min(raw, 10));
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=cvss.js.map
|
package/dist/live/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export declare class LiveIntelligence {
|
|
|
25
25
|
private resolvedDeps;
|
|
26
26
|
private auditDeps;
|
|
27
27
|
private initialized;
|
|
28
|
+
private projectRoot;
|
|
28
29
|
constructor(db: Database.Database);
|
|
29
30
|
/**
|
|
30
31
|
* Initialize with project data. Call once after project scan.
|
|
@@ -39,7 +40,7 @@ export declare class LiveIntelligence {
|
|
|
39
40
|
initFromMultiEcosystem(projectPath: string, depsByEcosystem: Record<string, {
|
|
40
41
|
deps: string[];
|
|
41
42
|
devDeps: string[];
|
|
42
|
-
}>): void;
|
|
43
|
+
}>, depTargets?: Record<string, string>): void;
|
|
43
44
|
/**
|
|
44
45
|
* Initialize from dependency groups that each carry their own resolution
|
|
45
46
|
* directory. Used in 4DA database mode, where dependencies span multiple
|
|
@@ -56,6 +57,13 @@ export declare class LiveIntelligence {
|
|
|
56
57
|
deps: string[];
|
|
57
58
|
devDeps: string[];
|
|
58
59
|
}>): void;
|
|
60
|
+
/**
|
|
61
|
+
* Root directory the dependency set was resolved from, or null before init.
|
|
62
|
+
* For multi-manifest (grouped) init this is the deepest common ancestor of
|
|
63
|
+
* the group directories — honest scope reporting for tool output, instead
|
|
64
|
+
* of whatever `process.cwd()` happens to be.
|
|
65
|
+
*/
|
|
66
|
+
getProjectRoot(): string | null;
|
|
59
67
|
/**
|
|
60
68
|
* Run vulnerability scan (returns cached if fresh, fetches otherwise).
|
|
61
69
|
*/
|
package/dist/live/index.js
CHANGED
|
@@ -32,6 +32,7 @@ export class LiveIntelligence {
|
|
|
32
32
|
resolvedDeps = [];
|
|
33
33
|
auditDeps = [];
|
|
34
34
|
initialized = false;
|
|
35
|
+
projectRoot = null;
|
|
35
36
|
constructor(db) {
|
|
36
37
|
this.enabled = process.env.FOURDA_OFFLINE !== "true";
|
|
37
38
|
this.cache = new LiveCache(db);
|
|
@@ -50,6 +51,7 @@ export class LiveIntelligence {
|
|
|
50
51
|
initFromProject(projectPath, deps, devDeps, language) {
|
|
51
52
|
this.resolvedDeps = resolveVersions(projectPath, deps, devDeps, language);
|
|
52
53
|
this.auditDeps = resolveAuditVersions(projectPath, deps, devDeps, language);
|
|
54
|
+
this.projectRoot = projectPath;
|
|
53
55
|
this.initialized = true;
|
|
54
56
|
}
|
|
55
57
|
/**
|
|
@@ -57,15 +59,16 @@ export class LiveIntelligence {
|
|
|
57
59
|
* so Rust crates go to crates.io, npm packages to npm, etc.
|
|
58
60
|
* Call once after project scan.
|
|
59
61
|
*/
|
|
60
|
-
initFromMultiEcosystem(projectPath, depsByEcosystem) {
|
|
62
|
+
initFromMultiEcosystem(projectPath, depsByEcosystem, depTargets = {}) {
|
|
61
63
|
const allResolved = [];
|
|
62
64
|
const allAudit = [];
|
|
63
65
|
for (const [language, { deps, devDeps }] of Object.entries(depsByEcosystem)) {
|
|
64
|
-
allResolved.push(...resolveVersions(projectPath, deps, devDeps, language));
|
|
65
|
-
allAudit.push(...resolveAuditVersions(projectPath, deps, devDeps, language));
|
|
66
|
+
allResolved.push(...resolveVersions(projectPath, deps, devDeps, language, depTargets));
|
|
67
|
+
allAudit.push(...resolveAuditVersions(projectPath, deps, devDeps, language, depTargets));
|
|
66
68
|
}
|
|
67
69
|
this.resolvedDeps = dedupeDependencies(allResolved);
|
|
68
70
|
this.auditDeps = dedupeDependencies(allAudit);
|
|
71
|
+
this.projectRoot = projectPath;
|
|
69
72
|
this.initialized = true;
|
|
70
73
|
}
|
|
71
74
|
/**
|
|
@@ -87,8 +90,18 @@ export class LiveIntelligence {
|
|
|
87
90
|
}
|
|
88
91
|
this.resolvedDeps = dedupeDependencies(allResolved);
|
|
89
92
|
this.auditDeps = dedupeDependencies(allAudit);
|
|
93
|
+
this.projectRoot = commonPathRoot(groups.map((g) => g.dir));
|
|
90
94
|
this.initialized = true;
|
|
91
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Root directory the dependency set was resolved from, or null before init.
|
|
98
|
+
* For multi-manifest (grouped) init this is the deepest common ancestor of
|
|
99
|
+
* the group directories — honest scope reporting for tool output, instead
|
|
100
|
+
* of whatever `process.cwd()` happens to be.
|
|
101
|
+
*/
|
|
102
|
+
getProjectRoot() {
|
|
103
|
+
return this.projectRoot;
|
|
104
|
+
}
|
|
92
105
|
/**
|
|
93
106
|
* Run vulnerability scan (returns cached if fresh, fetches otherwise).
|
|
94
107
|
*/
|
|
@@ -227,6 +240,26 @@ export class LiveIntelligence {
|
|
|
227
240
|
};
|
|
228
241
|
}
|
|
229
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Deepest common ancestor of a set of directories (segment-wise, both slash
|
|
245
|
+
* styles). Null for an empty set; a single dir is its own root.
|
|
246
|
+
*/
|
|
247
|
+
function commonPathRoot(dirs) {
|
|
248
|
+
if (dirs.length === 0)
|
|
249
|
+
return null;
|
|
250
|
+
const split = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
|
|
251
|
+
let common = split(dirs[0]);
|
|
252
|
+
for (const dir of dirs.slice(1)) {
|
|
253
|
+
const parts = split(dir);
|
|
254
|
+
let i = 0;
|
|
255
|
+
while (i < common.length && i < parts.length && common[i].toLowerCase() === parts[i].toLowerCase())
|
|
256
|
+
i++;
|
|
257
|
+
common = common.slice(0, i);
|
|
258
|
+
if (common.length === 0)
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
return common.length > 0 ? common.join("/") : null;
|
|
262
|
+
}
|
|
230
263
|
function dedupeDependencies(deps) {
|
|
231
264
|
const unique = new Map();
|
|
232
265
|
for (const dep of deps) {
|
|
@@ -236,6 +269,9 @@ function dedupeDependencies(deps) {
|
|
|
236
269
|
existing.isDirect ||= dep.isDirect;
|
|
237
270
|
existing.devScopeKnown &&= dep.devScopeKnown;
|
|
238
271
|
existing.isDev = existing.devScopeKnown && existing.isDev && dep.isDev;
|
|
272
|
+
// A crate reachable via ANY active path is active; keep a target label if present.
|
|
273
|
+
existing.platformActive ||= dep.platformActive;
|
|
274
|
+
existing.target = existing.target ?? dep.target;
|
|
239
275
|
}
|
|
240
276
|
else {
|
|
241
277
|
unique.set(key, { ...dep });
|
|
@@ -250,6 +286,7 @@ function emptyVulnResult(projectPath, offline) {
|
|
|
250
286
|
ecosystemsScanned: [],
|
|
251
287
|
totalScanned: 0,
|
|
252
288
|
totalVulnerable: 0,
|
|
289
|
+
platformInactiveVulnerable: 0,
|
|
253
290
|
bySeverity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 },
|
|
254
291
|
vulnerabilities: [],
|
|
255
292
|
cleanCount: 0,
|
|
@@ -78,15 +78,20 @@ export class NpmRegistry {
|
|
|
78
78
|
const result = new Map();
|
|
79
79
|
if (names.length === 0)
|
|
80
80
|
return result;
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// npm's bulk endpoint rejects scoped packages outright — a single "@scope/pkg"
|
|
82
|
+
// in a comma-joined request fails the WHOLE batch ("scoped packages are not
|
|
83
|
+
// currently supported in bulk lookups"), which previously zeroed downloads for
|
|
84
|
+
// every package in the group. Split them: unscoped go through the bulk endpoint,
|
|
85
|
+
// scoped are fetched individually (the single-package endpoint accepts them).
|
|
86
|
+
const scoped = names.filter((n) => n.startsWith("@"));
|
|
87
|
+
const unscoped = names.filter((n) => !n.startsWith("@"));
|
|
88
|
+
for (let i = 0; i < unscoped.length; i += DOWNLOADS_BATCH_SIZE) {
|
|
84
89
|
if (!this.rateLimiter.canProceed("npm"))
|
|
85
90
|
break;
|
|
91
|
+
const batch = unscoped.slice(i, i + DOWNLOADS_BATCH_SIZE);
|
|
86
92
|
try {
|
|
87
93
|
this.rateLimiter.consume("npm");
|
|
88
|
-
const
|
|
89
|
-
const url = `${NPM_DOWNLOADS_URL}/${scopedNames.join(",")}`;
|
|
94
|
+
const url = `${NPM_DOWNLOADS_URL}/${batch.map((n) => encodeURIComponent(n)).join(",")}`;
|
|
90
95
|
const response = await fetchWithTimeout(url, {}, NPM_TIMEOUT_MS);
|
|
91
96
|
if (!response.ok)
|
|
92
97
|
continue;
|
|
@@ -98,7 +103,25 @@ export class NpmRegistry {
|
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
catch {
|
|
101
|
-
// Batch failure — continue with remaining batches
|
|
106
|
+
// Batch failure — continue with remaining batches.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Scoped packages, one request each. The "@scope/pkg" path is sent literally
|
|
110
|
+
// (encoding the "/" breaks it); the single endpoint returns { downloads, ... }.
|
|
111
|
+
for (const name of scoped) {
|
|
112
|
+
if (!this.rateLimiter.canProceed("npm"))
|
|
113
|
+
break;
|
|
114
|
+
try {
|
|
115
|
+
this.rateLimiter.consume("npm");
|
|
116
|
+
const response = await fetchWithTimeout(`${NPM_DOWNLOADS_URL}/${name}`, {}, NPM_TIMEOUT_MS);
|
|
117
|
+
if (!response.ok)
|
|
118
|
+
continue;
|
|
119
|
+
const data = (await response.json());
|
|
120
|
+
if (typeof data.downloads === "number")
|
|
121
|
+
result.set(name, data.downloads);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Skip this package's downloads — best-effort signal.
|
|
102
125
|
}
|
|
103
126
|
}
|
|
104
127
|
return result;
|
|
@@ -15,6 +15,14 @@ export declare class OsvScanner {
|
|
|
15
15
|
constructor(cache: LiveCache, rateLimiter: RateLimiter);
|
|
16
16
|
scan(deps: ResolvedDependency[], projectPath: string): Promise<VulnerabilityScanResult>;
|
|
17
17
|
private batchQuery;
|
|
18
|
+
/**
|
|
19
|
+
* Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
|
|
20
|
+
* misses from `/v1/vulns/{id}` with bounded concurrency. A failed fetch is
|
|
21
|
+
* skipped (not fatal) — the caller falls back to the index-only shape so a
|
|
22
|
+
* real advisory is never silently dropped just because its detail 404'd.
|
|
23
|
+
*/
|
|
24
|
+
private hydrateDetails;
|
|
25
|
+
private fetchDetail;
|
|
18
26
|
}
|
|
19
27
|
export declare function extractCvssScore(severity: Array<{
|
|
20
28
|
type: string;
|
package/dist/live/osv-scanner.js
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Privacy: sends only package names + versions (public manifest data).
|
|
9
9
|
*/
|
|
10
|
-
import { fetchWithTimeout } from "./http-utils.js";
|
|
10
|
+
import { fetchWithTimeout, fetchJson } from "./http-utils.js";
|
|
11
|
+
import { cvssBaseScore } from "./cvss.js";
|
|
11
12
|
const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
|
|
13
|
+
const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
|
|
12
14
|
const OSV_TIMEOUT_MS = 15_000;
|
|
13
15
|
const OSV_CACHE_TTL = 3600; // 1 hour
|
|
16
|
+
const OSV_DETAIL_TTL = 86_400; // 24 hours — advisory details change rarely
|
|
14
17
|
const MAX_BATCH_SIZE = 1000;
|
|
18
|
+
// Bounded concurrency for advisory-detail hydration. Only the *vulnerable*
|
|
19
|
+
// subset is hydrated (not every scanned dep), so this stays small in practice.
|
|
20
|
+
const HYDRATE_CONCURRENCY = 8;
|
|
15
21
|
export class OsvScanner {
|
|
16
22
|
cache;
|
|
17
23
|
rateLimiter;
|
|
@@ -61,6 +67,7 @@ export class OsvScanner {
|
|
|
61
67
|
}
|
|
62
68
|
const allVulns = [...cachedVulns, ...fetchedVulns];
|
|
63
69
|
const vulnerablePackages = new Set(allVulns.map((v) => v.package));
|
|
70
|
+
const platformInactiveVulnerable = new Set(allVulns.filter((v) => !v.platformActive).map((v) => v.package)).size;
|
|
64
71
|
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
|
65
72
|
for (const v of allVulns) {
|
|
66
73
|
bySeverity[v.severity]++;
|
|
@@ -71,6 +78,7 @@ export class OsvScanner {
|
|
|
71
78
|
ecosystemsScanned: ecosystems,
|
|
72
79
|
totalScanned: scannable.length,
|
|
73
80
|
totalVulnerable: vulnerablePackages.size,
|
|
81
|
+
platformInactiveVulnerable,
|
|
74
82
|
bySeverity,
|
|
75
83
|
vulnerabilities: allVulns,
|
|
76
84
|
cleanCount: offline ? 0 : scannable.length - vulnerablePackages.size,
|
|
@@ -80,7 +88,10 @@ export class OsvScanner {
|
|
|
80
88
|
};
|
|
81
89
|
}
|
|
82
90
|
async batchQuery(deps) {
|
|
83
|
-
|
|
91
|
+
// Phase 1 — enumerate advisory IDs per dep via the lightweight batch index.
|
|
92
|
+
// querybatch returns only `{ id, modified }`; the rich fields (severity,
|
|
93
|
+
// fixed version, summary, references) come from per-advisory hydration below.
|
|
94
|
+
const matches = [];
|
|
84
95
|
for (let offset = 0; offset < deps.length; offset += MAX_BATCH_SIZE) {
|
|
85
96
|
if (!this.rateLimiter.canProceed("osv")) {
|
|
86
97
|
throw new Error("OSV rate limit reached before all dependency batches completed");
|
|
@@ -101,26 +112,68 @@ export class OsvScanner {
|
|
|
101
112
|
this.rateLimiter.consume("osv");
|
|
102
113
|
const data = (await response.json());
|
|
103
114
|
for (let i = 0; i < chunk.length; i++) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
const depVulns = [];
|
|
107
|
-
if (osvResult?.vulns && osvResult.vulns.length > 0) {
|
|
108
|
-
for (const vuln of osvResult.vulns) {
|
|
109
|
-
depVulns.push(mapVulnerability(vuln, dep));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
// Cache per-dep (even empty results to avoid re-fetching clean deps)
|
|
113
|
-
const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
114
|
-
this.cache.set(cacheKey, depVulns, "osv", OSV_CACHE_TTL);
|
|
115
|
-
results.push(...depVulns);
|
|
115
|
+
const ids = (data.results[i]?.vulns ?? []).map((v) => v.id);
|
|
116
|
+
matches.push({ dep: chunk[i], ids });
|
|
116
117
|
}
|
|
117
118
|
}
|
|
119
|
+
// Phase 2 — hydrate each unique advisory once (cached, bounded concurrency).
|
|
120
|
+
const uniqueIds = [...new Set(matches.flatMap((m) => m.ids))];
|
|
121
|
+
const detailById = await this.hydrateDetails(uniqueIds);
|
|
122
|
+
// Phase 3 — map to entries and cache per-dep (empty results cached too, so
|
|
123
|
+
// clean deps are not re-queried).
|
|
124
|
+
const results = [];
|
|
125
|
+
for (const { dep, ids } of matches) {
|
|
126
|
+
// Drop advisories OSV has withdrawn/retracted — surfacing them is a false
|
|
127
|
+
// positive. Un-hydrated ids (detail fetch failed) are kept: we can't know,
|
|
128
|
+
// and dropping a real advisory is worse than keeping an un-enriched one.
|
|
129
|
+
const liveIds = ids.filter((id) => !detailById.get(id)?.withdrawn);
|
|
130
|
+
const depVulns = liveIds.map((id) => mapVulnerability(detailById.get(id) ?? { id }, dep));
|
|
131
|
+
const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
132
|
+
this.cache.set(cacheKey, depVulns, "osv", OSV_CACHE_TTL);
|
|
133
|
+
results.push(...depVulns);
|
|
134
|
+
}
|
|
118
135
|
return results;
|
|
119
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
|
|
139
|
+
* misses from `/v1/vulns/{id}` with bounded concurrency. A failed fetch is
|
|
140
|
+
* skipped (not fatal) — the caller falls back to the index-only shape so a
|
|
141
|
+
* real advisory is never silently dropped just because its detail 404'd.
|
|
142
|
+
*/
|
|
143
|
+
async hydrateDetails(ids) {
|
|
144
|
+
const out = new Map();
|
|
145
|
+
const toFetch = [];
|
|
146
|
+
for (const id of ids) {
|
|
147
|
+
const cached = this.cache.get(`osv:detail:${id}`);
|
|
148
|
+
if (cached)
|
|
149
|
+
out.set(id, cached);
|
|
150
|
+
else
|
|
151
|
+
toFetch.push(id);
|
|
152
|
+
}
|
|
153
|
+
for (let i = 0; i < toFetch.length; i += HYDRATE_CONCURRENCY) {
|
|
154
|
+
const slice = toFetch.slice(i, i + HYDRATE_CONCURRENCY);
|
|
155
|
+
const settled = await Promise.allSettled(slice.map((id) => this.fetchDetail(id)));
|
|
156
|
+
settled.forEach((res, j) => {
|
|
157
|
+
if (res.status === "fulfilled" && res.value) {
|
|
158
|
+
const id = slice[j];
|
|
159
|
+
out.set(id, res.value);
|
|
160
|
+
this.cache.set(`osv:detail:${id}`, res.value, "osv", OSV_DETAIL_TTL);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
async fetchDetail(id) {
|
|
167
|
+
try {
|
|
168
|
+
return await fetchJson(`${OSV_VULN_URL}${encodeURIComponent(id)}`, {}, OSV_TIMEOUT_MS);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
120
174
|
}
|
|
121
175
|
function mapVulnerability(vuln, dep) {
|
|
122
|
-
const cvssScore =
|
|
123
|
-
const severity = cvssScore !== null ? cvssToSeverity(cvssScore) : guessSeverityFromId(vuln.id);
|
|
176
|
+
const { severity, cvssScore } = deriveSeverity(vuln);
|
|
124
177
|
const fixedVersion = extractFixedVersion(vuln.affected, dep.name, dep.ecosystem);
|
|
125
178
|
return {
|
|
126
179
|
package: dep.name,
|
|
@@ -140,21 +193,59 @@ function mapVulnerability(vuln, dep) {
|
|
|
140
193
|
.filter((r) => r.type === "ADVISORY" || r.type === "WEB")
|
|
141
194
|
.map((r) => r.url)
|
|
142
195
|
.slice(0, 3),
|
|
196
|
+
target: dep.target,
|
|
197
|
+
platformActive: dep.platformActive,
|
|
143
198
|
};
|
|
144
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Derive a severity bucket honestly: a CVSS base score (numeric or computed
|
|
202
|
+
* from a vector) wins; otherwise the GitHub-advisory label; otherwise unknown.
|
|
203
|
+
* Never fabricates a default bucket — an advisory with no severity signal is
|
|
204
|
+
* reported as `unknown`, not silently labeled "medium".
|
|
205
|
+
*/
|
|
206
|
+
function deriveSeverity(vuln) {
|
|
207
|
+
const cvssScore = extractCvssScore(vuln.severity);
|
|
208
|
+
if (cvssScore !== null)
|
|
209
|
+
return { severity: cvssToSeverity(cvssScore), cvssScore };
|
|
210
|
+
const label = labelToSeverity(vuln.database_specific?.severity);
|
|
211
|
+
if (label !== "unknown")
|
|
212
|
+
return { severity: label, cvssScore: null };
|
|
213
|
+
return { severity: "unknown", cvssScore: null };
|
|
214
|
+
}
|
|
215
|
+
function labelToSeverity(label) {
|
|
216
|
+
switch ((label || "").toUpperCase()) {
|
|
217
|
+
case "CRITICAL":
|
|
218
|
+
return "critical";
|
|
219
|
+
case "HIGH":
|
|
220
|
+
return "high";
|
|
221
|
+
case "MODERATE":
|
|
222
|
+
case "MEDIUM":
|
|
223
|
+
return "medium";
|
|
224
|
+
case "LOW":
|
|
225
|
+
return "low";
|
|
226
|
+
default:
|
|
227
|
+
return "unknown";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
145
230
|
export function extractCvssScore(severity) {
|
|
146
231
|
if (!severity || severity.length === 0)
|
|
147
232
|
return null;
|
|
148
|
-
//
|
|
149
|
-
// Without a full CVSS calculator, treating the vector's "3.1" version as
|
|
150
|
-
// the score is worse than leaving severity unknown.
|
|
233
|
+
// Prefer an explicit numeric base score when present.
|
|
151
234
|
for (const s of severity) {
|
|
152
235
|
const raw = s.score.trim();
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
236
|
+
if (/^\d+(?:\.\d+)?$/.test(raw)) {
|
|
237
|
+
const num = Number(raw);
|
|
238
|
+
if (!isNaN(num) && num >= 0 && num <= 10)
|
|
239
|
+
return num;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// OSV most often gives a CVSS *vector* string — compute its base score.
|
|
243
|
+
for (const s of severity) {
|
|
244
|
+
if (s.score.includes("AV:")) {
|
|
245
|
+
const score = cvssBaseScore(s.score);
|
|
246
|
+
if (score !== null)
|
|
247
|
+
return score;
|
|
248
|
+
}
|
|
158
249
|
}
|
|
159
250
|
return null;
|
|
160
251
|
}
|
|
@@ -169,12 +260,6 @@ function cvssToSeverity(score) {
|
|
|
169
260
|
return "low";
|
|
170
261
|
return "unknown";
|
|
171
262
|
}
|
|
172
|
-
function guessSeverityFromId(id) {
|
|
173
|
-
// GHSA severity is sometimes encoded in the advisory
|
|
174
|
-
if (id.startsWith("GHSA-"))
|
|
175
|
-
return "medium"; // conservative default
|
|
176
|
-
return "unknown";
|
|
177
|
-
}
|
|
178
263
|
function extractFixedVersion(affected, packageName, ecosystem) {
|
|
179
264
|
if (!affected)
|
|
180
265
|
return null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target/platform awareness for dependency relevance.
|
|
3
|
+
*
|
|
4
|
+
* Cargo (and other ecosystems) gate dependencies behind a target: e.g.
|
|
5
|
+
* `[target.'cfg(windows)'.dependencies]` or `[target.x86_64-pc-windows-msvc.dependencies]`.
|
|
6
|
+
* A dependency that is only active on a platform the user does not build is NOT
|
|
7
|
+
* relevant to them - surfacing its advisory would be noise (the classic
|
|
8
|
+
* "GTK/webkit vuln shown to a Windows user" problem).
|
|
9
|
+
*
|
|
10
|
+
* This module evaluates a target spec against the HOST platform so the scanner
|
|
11
|
+
* can tag each dependency `platformActive` and let consumers suppress or label
|
|
12
|
+
* the irrelevant ones. It is deliberately conservative: anything it cannot
|
|
13
|
+
* confidently evaluate is treated as ACTIVE, so we never hide a real advisory.
|
|
14
|
+
*/
|
|
15
|
+
export interface HostTarget {
|
|
16
|
+
/** rustc-style os: "windows" | "macos" | "linux" | ... */
|
|
17
|
+
os: string;
|
|
18
|
+
/** "windows" | "unix" */
|
|
19
|
+
family: string;
|
|
20
|
+
/** rustc-style arch: "x86_64" | "aarch64" | ... */
|
|
21
|
+
arch: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function hostTarget(): HostTarget;
|
|
24
|
+
/**
|
|
25
|
+
* Is a dependency's target spec active on the host?
|
|
26
|
+
* `target` is null/empty for unconditional deps (always active).
|
|
27
|
+
* Returns true when active OR when the spec can't be confidently evaluated.
|
|
28
|
+
*/
|
|
29
|
+
export declare function targetActiveOnHost(target: string | null | undefined, host?: HostTarget): boolean;
|
|
30
|
+
/** Evaluate a cfg predicate body (already unwrapped from `cfg( ... )`). */
|
|
31
|
+
export declare function evalCfg(expr: string, host?: HostTarget): boolean;
|
|
32
|
+
//# sourceMappingURL=platform.d.ts.map
|