@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.
Files changed (139) hide show
  1. package/README.md +3 -1
  2. package/dist/db.d.ts +21 -2
  3. package/dist/db.js +66 -16
  4. package/dist/doctor.js +5 -3
  5. package/dist/embeddings.d.ts +88 -0
  6. package/dist/embeddings.js +292 -0
  7. package/dist/index.d.ts +9 -9
  8. package/dist/index.js +21 -15
  9. package/dist/live/cvss.d.ts +16 -0
  10. package/dist/live/cvss.js +62 -0
  11. package/dist/live/index.d.ts +9 -1
  12. package/dist/live/index.js +40 -3
  13. package/dist/live/npm-registry.js +29 -6
  14. package/dist/live/osv-scanner.d.ts +8 -0
  15. package/dist/live/osv-scanner.js +115 -30
  16. package/dist/live/platform.d.ts +32 -0
  17. package/dist/live/platform.js +156 -0
  18. package/dist/live/semver-utils.d.ts +8 -0
  19. package/dist/live/semver-utils.js +24 -0
  20. package/dist/live/types.d.ts +31 -9
  21. package/dist/live/version-resolver.d.ts +12 -2
  22. package/dist/live/version-resolver.js +63 -20
  23. package/dist/llm.js +1 -1
  24. package/dist/project-scanner.d.ts +2 -0
  25. package/dist/project-scanner.js +34 -0
  26. package/dist/scheduler/core.d.ts +25 -0
  27. package/dist/scheduler/core.js +61 -0
  28. package/dist/scheduler/freshness.d.ts +17 -0
  29. package/dist/scheduler/freshness.js +41 -0
  30. package/dist/scheduler/index.d.ts +21 -0
  31. package/dist/scheduler/index.js +32 -0
  32. package/dist/scheduler/refreshers.d.ts +21 -0
  33. package/dist/scheduler/refreshers.js +21 -0
  34. package/dist/schemas/get-relevant-content.json +1 -1
  35. package/dist/schemas/upgrade-planner.json +1 -1
  36. package/dist/schemas/what-should-i-know.json +1 -1
  37. package/dist/tools/agent-memory.d.ts +11 -1
  38. package/dist/tools/agent-memory.js +120 -14
  39. package/dist/tools/decision-enforcement.d.ts +17 -1
  40. package/dist/tools/decision-enforcement.js +109 -81
  41. package/dist/tools/decision-memory.d.ts +1 -1
  42. package/dist/tools/decision-memory.js +58 -28
  43. package/dist/tools/decision-recall.d.ts +59 -0
  44. package/dist/tools/decision-recall.js +87 -0
  45. package/dist/tools/dependency-health.js +6 -4
  46. package/dist/tools/developer-dna.js +4 -2
  47. package/dist/tools/knowledge-gaps.js +16 -4
  48. package/dist/tools/recall.d.ts +33 -0
  49. package/dist/tools/recall.js +134 -0
  50. package/dist/tools/upgrade-planner.d.ts +25 -1
  51. package/dist/tools/upgrade-planner.js +111 -30
  52. package/dist/tools/vulnerability-scan.d.ts +46 -0
  53. package/dist/tools/vulnerability-scan.js +64 -24
  54. package/dist/tools/what-should-i-know.d.ts +3 -1
  55. package/dist/tools/what-should-i-know.js +210 -106
  56. package/dist/types.d.ts +9 -0
  57. package/package.json +8 -3
  58. package/dist/auth.d.ts.map +0 -1
  59. package/dist/auth.js.map +0 -1
  60. package/dist/db.d.ts.map +0 -1
  61. package/dist/db.js.map +0 -1
  62. package/dist/doctor.d.ts.map +0 -1
  63. package/dist/doctor.js.map +0 -1
  64. package/dist/http-transport.d.ts.map +0 -1
  65. package/dist/http-transport.js.map +0 -1
  66. package/dist/index.d.ts.map +0 -1
  67. package/dist/index.js.map +0 -1
  68. package/dist/live/cache.d.ts.map +0 -1
  69. package/dist/live/cache.js.map +0 -1
  70. package/dist/live/crates-registry.d.ts.map +0 -1
  71. package/dist/live/crates-registry.js.map +0 -1
  72. package/dist/live/go-registry.d.ts.map +0 -1
  73. package/dist/live/go-registry.js.map +0 -1
  74. package/dist/live/hn-fetcher.d.ts.map +0 -1
  75. package/dist/live/hn-fetcher.js.map +0 -1
  76. package/dist/live/http-utils.d.ts.map +0 -1
  77. package/dist/live/http-utils.js.map +0 -1
  78. package/dist/live/index.d.ts.map +0 -1
  79. package/dist/live/index.js.map +0 -1
  80. package/dist/live/npm-registry.d.ts.map +0 -1
  81. package/dist/live/npm-registry.js.map +0 -1
  82. package/dist/live/osv-scanner.d.ts.map +0 -1
  83. package/dist/live/osv-scanner.js.map +0 -1
  84. package/dist/live/pypi-registry.d.ts.map +0 -1
  85. package/dist/live/pypi-registry.js.map +0 -1
  86. package/dist/live/rate-limiter.d.ts.map +0 -1
  87. package/dist/live/rate-limiter.js.map +0 -1
  88. package/dist/live/semver-utils.d.ts.map +0 -1
  89. package/dist/live/semver-utils.js.map +0 -1
  90. package/dist/live/types.d.ts.map +0 -1
  91. package/dist/live/types.js.map +0 -1
  92. package/dist/live/version-resolver.d.ts.map +0 -1
  93. package/dist/live/version-resolver.js.map +0 -1
  94. package/dist/live-singleton.d.ts.map +0 -1
  95. package/dist/live-singleton.js.map +0 -1
  96. package/dist/llm.d.ts.map +0 -1
  97. package/dist/llm.js.map +0 -1
  98. package/dist/output-manager.d.ts.map +0 -1
  99. package/dist/output-manager.js.map +0 -1
  100. package/dist/project-scanner.d.ts.map +0 -1
  101. package/dist/project-scanner.js.map +0 -1
  102. package/dist/schema-registry.d.ts.map +0 -1
  103. package/dist/schema-registry.js.map +0 -1
  104. package/dist/setup.d.ts.map +0 -1
  105. package/dist/setup.js.map +0 -1
  106. package/dist/tool-dispatch.d.ts.map +0 -1
  107. package/dist/tool-dispatch.js.map +0 -1
  108. package/dist/tools/agent-memory.d.ts.map +0 -1
  109. package/dist/tools/agent-memory.js.map +0 -1
  110. package/dist/tools/decision-enforcement.d.ts.map +0 -1
  111. package/dist/tools/decision-enforcement.js.map +0 -1
  112. package/dist/tools/decision-memory.d.ts.map +0 -1
  113. package/dist/tools/decision-memory.js.map +0 -1
  114. package/dist/tools/dependency-health.d.ts.map +0 -1
  115. package/dist/tools/dependency-health.js.map +0 -1
  116. package/dist/tools/developer-dna.d.ts.map +0 -1
  117. package/dist/tools/developer-dna.js.map +0 -1
  118. package/dist/tools/ecosystem-pulse.d.ts.map +0 -1
  119. package/dist/tools/ecosystem-pulse.js.map +0 -1
  120. package/dist/tools/get-actionable-signals.d.ts.map +0 -1
  121. package/dist/tools/get-actionable-signals.js.map +0 -1
  122. package/dist/tools/get-context.d.ts.map +0 -1
  123. package/dist/tools/get-context.js.map +0 -1
  124. package/dist/tools/get-relevant-content.d.ts.map +0 -1
  125. package/dist/tools/get-relevant-content.js.map +0 -1
  126. package/dist/tools/index.d.ts.map +0 -1
  127. package/dist/tools/index.js.map +0 -1
  128. package/dist/tools/knowledge-gaps.d.ts.map +0 -1
  129. package/dist/tools/knowledge-gaps.js.map +0 -1
  130. package/dist/tools/record-feedback.d.ts.map +0 -1
  131. package/dist/tools/record-feedback.js.map +0 -1
  132. package/dist/tools/upgrade-planner.d.ts.map +0 -1
  133. package/dist/tools/upgrade-planner.js.map +0 -1
  134. package/dist/tools/vulnerability-scan.d.ts.map +0 -1
  135. package/dist/tools/vulnerability-scan.js.map +0 -1
  136. package/dist/tools/what-should-i-know.d.ts.map +0 -1
  137. package/dist/tools/what-should-i-know.js.map +0 -1
  138. package/dist/types.d.ts.map +0 -1
  139. 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 - Internal Use Only
4
+ * 4DA MCP Server
5
5
  *
6
- * This MCP server provides tool and resource access to 4DA's local database
7
- * for use with Claude Desktop and other MCP-compatible hosts.
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
- * IMPORTANT: This server is designed for LOCAL use only. It reads from the
10
- * user's local 4DA SQLite database. It does NOT implement authentication
11
- * or authorization - it trusts the local environment.
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.0 — Dependency Intelligence for AI Coding Agents
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.0",
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
- liveIntel.initFromMultiEcosystem(cwd, scan.depsByEcosystem);
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.0");
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.0 started — ${toolCount} tools, stdio transport`);
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
@@ -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
  */
@@ -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
- // Batch into groups of DOWNLOADS_BATCH_SIZE
82
- for (let i = 0; i < names.length; i += DOWNLOADS_BATCH_SIZE) {
83
- const batch = names.slice(i, i + DOWNLOADS_BATCH_SIZE);
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 scopedNames = batch.map((n) => encodeURIComponent(n));
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;
@@ -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
- const results = [];
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 dep = chunk[i];
105
- const osvResult = data.results[i];
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 = extractCvssScore(vuln.severity);
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
- // OSV commonly returns a CVSS vector rather than a numeric base score.
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 (!/^\d+(?:\.\d+)?$/.test(raw))
154
- continue;
155
- const num = Number(raw);
156
- if (!isNaN(num) && num >= 0 && num <= 10)
157
- return num;
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