@4da/mcp-server 6.2.3 → 6.2.5

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 (123) hide show
  1. package/CHANGELOG.md +520 -0
  2. package/README.md +22 -6
  3. package/dist/db.d.ts +38 -0
  4. package/dist/db.js +78 -23
  5. package/dist/doctor.js +6 -3
  6. package/dist/embeddings.d.ts +1 -1
  7. package/dist/embeddings.js +16 -14
  8. package/dist/index.js +73 -103
  9. package/dist/live/advisory-groups.d.ts +68 -0
  10. package/dist/live/advisory-groups.js +131 -0
  11. package/dist/live/cache.js +11 -3
  12. package/dist/live/cargo-platform.d.ts +6 -0
  13. package/dist/live/cargo-platform.js +8 -2
  14. package/dist/live/changelog.js +38 -6
  15. package/dist/live/dependency-set.d.ts +9 -1
  16. package/dist/live/dependency-set.js +10 -2
  17. package/dist/live/fix-paths.d.ts +289 -16
  18. package/dist/live/fix-paths.js +876 -93
  19. package/dist/live/github-releases.js +3 -0
  20. package/dist/live/held-dependencies.d.ts +93 -0
  21. package/dist/live/held-dependencies.js +212 -0
  22. package/dist/live/held-targets.d.ts +30 -0
  23. package/dist/live/held-targets.js +107 -0
  24. package/dist/live/http-utils.js +13 -3
  25. package/dist/live/index.d.ts +7 -4
  26. package/dist/live/index.js +24 -19
  27. package/dist/live/install-state.js +6 -2
  28. package/dist/live/js-lockfile-readers.d.ts +8 -1
  29. package/dist/live/js-lockfile-readers.js +43 -6
  30. package/dist/live/lockfile-parents.d.ts +15 -0
  31. package/dist/live/lockfile-parents.js +85 -10
  32. package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
  33. package/dist/live/lockfile-parsers-pygo.js +230 -12
  34. package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
  35. package/dist/live/lockfile-parsers-rbphp.js +123 -0
  36. package/dist/live/lockfile-parsers.d.ts +6 -0
  37. package/dist/live/lockfile-parsers.js +116 -4
  38. package/dist/live/lockfile-types.d.ts +28 -1
  39. package/dist/live/maintenance.d.ts +20 -4
  40. package/dist/live/maintenance.js +31 -2
  41. package/dist/live/malware.d.ts +20 -0
  42. package/dist/live/malware.js +31 -0
  43. package/dist/live/npm-packument.d.ts +4 -1
  44. package/dist/live/npm-packument.js +5 -2
  45. package/dist/live/offered-release.d.ts +53 -0
  46. package/dist/live/offered-release.js +117 -0
  47. package/dist/live/osv-ranges.d.ts +111 -0
  48. package/dist/live/osv-ranges.js +173 -14
  49. package/dist/live/osv-scanner.d.ts +87 -5
  50. package/dist/live/osv-scanner.js +310 -59
  51. package/dist/live/pinned-advisories.d.ts +2 -2
  52. package/dist/live/pinned-advisories.js +3 -3
  53. package/dist/live/pypi-advice.d.ts +36 -0
  54. package/dist/live/pypi-advice.js +158 -0
  55. package/dist/live/range-satisfies.d.ts +4 -2
  56. package/dist/live/range-satisfies.js +7 -5
  57. package/dist/live/rate-limiter.d.ts +7 -0
  58. package/dist/live/rate-limiter.js +22 -1
  59. package/dist/live/refresh-rule.d.ts +36 -0
  60. package/dist/live/refresh-rule.js +49 -0
  61. package/dist/live/release-metadata.d.ts +5 -0
  62. package/dist/live/resolution.d.ts +3 -0
  63. package/dist/live/resolution.js +6 -3
  64. package/dist/live/scan-coverage.d.ts +26 -0
  65. package/dist/live/scan-coverage.js +70 -0
  66. package/dist/live/semver-utils.d.ts +8 -0
  67. package/dist/live/semver-utils.js +26 -3
  68. package/dist/live/severity-scope.d.ts +26 -1
  69. package/dist/live/severity-scope.js +32 -0
  70. package/dist/live/shell-safe.d.ts +19 -0
  71. package/dist/live/shell-safe.js +36 -0
  72. package/dist/live/types.d.ts +134 -8
  73. package/dist/live/version-compare.d.ts +51 -0
  74. package/dist/live/version-compare.js +152 -1
  75. package/dist/live/withdrawn-releases.d.ts +50 -0
  76. package/dist/live/withdrawn-releases.js +125 -0
  77. package/dist/project-manifests.d.ts +7 -0
  78. package/dist/project-manifests.js +45 -5
  79. package/dist/project-scanner.js +6 -4
  80. package/dist/project-tree.d.ts +56 -0
  81. package/dist/project-tree.js +174 -5
  82. package/dist/schema-registry.d.ts +47 -4
  83. package/dist/schema-registry.js +76 -9
  84. package/dist/server-instructions.js +4 -2
  85. package/dist/setup.d.ts +8 -0
  86. package/dist/setup.js +21 -2
  87. package/dist/tool-dispatch.js +3 -3
  88. package/dist/tools/agent-memory.d.ts +5 -0
  89. package/dist/tools/agent-memory.js +9 -0
  90. package/dist/tools/briefing-task-scope.js +5 -2
  91. package/dist/tools/decision-memory.js +2 -0
  92. package/dist/tools/dependency-check-held.d.ts +44 -0
  93. package/dist/tools/dependency-check-held.js +98 -0
  94. package/dist/tools/dependency-check-signals.d.ts +19 -2
  95. package/dist/tools/dependency-check-signals.js +84 -13
  96. package/dist/tools/dependency-check-versions.d.ts +37 -4
  97. package/dist/tools/dependency-check-versions.js +48 -13
  98. package/dist/tools/dependency-check.d.ts +11 -1
  99. package/dist/tools/dependency-check.js +81 -29
  100. package/dist/tools/dependency-health.js +209 -45
  101. package/dist/tools/get-context.d.ts +3 -0
  102. package/dist/tools/get-context.js +4 -0
  103. package/dist/tools/project-scope.d.ts +8 -0
  104. package/dist/tools/project-scope.js +21 -0
  105. package/dist/tools/upgrade-impact-features.d.ts +37 -0
  106. package/dist/tools/upgrade-impact-features.js +176 -0
  107. package/dist/tools/upgrade-impact-literals.js +26 -4
  108. package/dist/tools/upgrade-impact-report.d.ts +5 -1
  109. package/dist/tools/upgrade-impact-report.js +26 -5
  110. package/dist/tools/upgrade-impact.js +10 -3
  111. package/dist/tools/upgrade-planner-fixpaths.d.ts +164 -5
  112. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  113. package/dist/tools/upgrade-planner.d.ts +70 -7
  114. package/dist/tools/upgrade-planner.js +237 -61
  115. package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
  116. package/dist/tools/vulnerability-scan-concise.js +37 -3
  117. package/dist/tools/vulnerability-scan-format.d.ts +126 -24
  118. package/dist/tools/vulnerability-scan-format.js +193 -26
  119. package/dist/tools/vulnerability-scan.d.ts +224 -63
  120. package/dist/tools/vulnerability-scan.js +50 -15
  121. package/dist/tools/what-should-i-know.d.ts +6 -1
  122. package/dist/tools/what-should-i-know.js +72 -15
  123. package/package.json +2 -2
@@ -9,6 +9,15 @@
9
9
  import type { LiveCache } from "./cache.js";
10
10
  import type { RateLimiter } from "./rate-limiter.js";
11
11
  import type { ResolvedDependency, OsvVulnerability, VulnerabilityEntry, VulnerabilityScanResult } from "./types.js";
12
+ /** GHSA alias records read per scan to rate records that carry no severity (rateFromAliases). */
13
+ export declare const MAX_ALIAS_LOOKUPS = 50;
14
+ /** The whole alias lookup's time budget per scan; past it the records stay unknown, said so. */
15
+ export declare const ALIAS_BUDGET_MS = 5000;
16
+ /** Per-dependency advisory ids, null where there is no answer, and why. */
17
+ export interface AdvisoryAnswer {
18
+ ids: Array<string[] | null>;
19
+ failure: string | null;
20
+ }
12
21
  /**
13
22
  * Where advisories come from. The scanner asks which advisory ids affect each
14
23
  * exact (ecosystem, name, version), then for the records behind those ids.
@@ -22,8 +31,13 @@ import type { ResolvedDependency, OsvVulnerability, VulnerabilityEntry, Vulnerab
22
31
  export interface AdvisorySource {
23
32
  /** True when answers come over the network: rate-limited, and cached per dependency. */
24
33
  readonly network: boolean;
25
- /** The advisory ids affecting each dependency, in input order. Throws when it cannot answer for all of them. */
26
- query(deps: ResolvedDependency[]): Promise<string[][]>;
34
+ /**
35
+ * The advisory ids affecting each dependency, in input order. An entry is
36
+ * null when the source has NO answer for that dependency (unreachable,
37
+ * failed, timed out, out of query budget): never an empty list, which means
38
+ * "checked, nothing found". `failure` says why when any entry is null.
39
+ */
40
+ query(deps: ResolvedDependency[]): Promise<AdvisoryAnswer>;
27
41
  /** The full record of each id it can find; a missing id is left out, never fatal. */
28
42
  details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
29
43
  }
@@ -31,9 +45,15 @@ export interface AdvisorySource {
31
45
  export declare class OsvHttpSource implements AdvisorySource {
32
46
  private readonly cache;
33
47
  private readonly rateLimiter;
48
+ /** Total wait for query budget per scan (tests shorten it). */
49
+ private readonly tokenWaitMs;
34
50
  readonly network = true;
35
- constructor(cache: LiveCache, rateLimiter: RateLimiter);
36
- query(deps: ResolvedDependency[]): Promise<string[][]>;
51
+ constructor(cache: LiveCache, rateLimiter: RateLimiter,
52
+ /** Total wait for query budget per scan (tests shorten it). */
53
+ tokenWaitMs?: number);
54
+ query(deps: ResolvedDependency[]): Promise<AdvisoryAnswer>;
55
+ /** One querybatch request, retried once after a throttle, a server error, a timeout or a network failure. */
56
+ private sendBatch;
37
57
  /**
38
58
  * Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
39
59
  * misses from `/v1/vulns/{id}` with bounded concurrency. A failed fetch is
@@ -48,8 +68,29 @@ export declare class OsvScanner {
48
68
  private cache;
49
69
  private rateLimiter;
50
70
  private source;
51
- constructor(cache: LiveCache, rateLimiter: RateLimiter, source?: AdvisorySource);
71
+ private aliasBudgetMs;
72
+ constructor(cache: LiveCache, rateLimiter: RateLimiter, source?: AdvisorySource, options?: {
73
+ aliasBudgetMs?: number;
74
+ });
52
75
  scan(deps: ResolvedDependency[], projectPath: string): Promise<VulnerabilityScanResult>;
76
+ /**
77
+ * A severity for records that carry none, from their GHSA alias: one
78
+ * advisory, one grade, in every tool. Go's vulnerability database rates
79
+ * nothing: GO-2024-3333 (golang.org/x/net) has no severity, its alias
80
+ * GHSA-w32m-9786-jp63 is HIGH, and OSV returns only the GO record for the
81
+ * module (the GHSA names golang.org/x/net/html). dependency_check said
82
+ * `avoid` while vulnerability_scan graded the same record unknown (review
83
+ * of 6.2.5 M6, 2026-10-18). The two records name each other, so the
84
+ * alias's rating applies; it is read by the same rule as any record and
85
+ * the entry says where it came from (`severityFrom`).
86
+ *
87
+ * Through this scanner's own source: a pinned source answers from its
88
+ * records, never the network; with live lookups off nothing is fetched.
89
+ * At most MAX_ALIAS_LOOKUPS aliases, within ALIAS_BUDGET_MS in total.
90
+ * Anything not rated stays unknown and is listed as unresolved, with why;
91
+ * unknown is never clean.
92
+ */
93
+ private rateFromAliases;
53
94
  private batchQuery;
54
95
  }
55
96
  /**
@@ -63,6 +104,33 @@ export declare class OsvScanner {
63
104
  * next hour repeated both errors while the fixed resolver sat idle.
64
105
  */
65
106
  export declare function restampDepContext(cached: VulnerabilityEntry, dep: ResolvedDependency): VulnerabilityEntry;
107
+ /**
108
+ * The shared rule for an unrated record (the 4DA app implements it
109
+ * identically): the HIGHEST grade among every GHSA alias that could be read;
110
+ * unknown when none could. `from` names the alias that gave it, the
111
+ * lexicographically smallest id on a tie. An alias that could not be read
112
+ * never lowers the grade; it is reported (`consulted`, grade "unreadable").
113
+ * Umbrella records name several: RUSTSEC-2020-0014 lists 8 GHSAs, so taking
114
+ * the first made the grade depend on alias order.
115
+ */
116
+ export declare function gradeFromAliases(ghsas: string[], read: ReadonlyMap<string, VulnerabilityEntry["severity"]>): {
117
+ severity: VulnerabilityEntry["severity"];
118
+ from: string | null;
119
+ consulted: Array<{
120
+ id: string;
121
+ grade: VulnerabilityEntry["severity"] | "unreadable";
122
+ }>;
123
+ };
124
+ /**
125
+ * Derive a severity bucket honestly: a CVSS base score (numeric or computed
126
+ * from a vector) wins; otherwise the GitHub-advisory label; otherwise unknown.
127
+ * Never fabricates a default bucket — an advisory with no severity signal is
128
+ * reported as `unknown`, not silently labeled "medium".
129
+ */
130
+ export declare function deriveSeverity(vuln: Partial<OsvVulnerability>): {
131
+ severity: VulnerabilityEntry["severity"];
132
+ cvssScore: number | null;
133
+ };
66
134
  export declare function extractCvssScore(severity: Array<{
67
135
  type: string;
68
136
  score: string;
@@ -86,3 +154,17 @@ export declare function extractCvssScore(severity: Array<{
86
154
  * Hard guard: the returned version is never below the installed version.
87
155
  */
88
156
  export declare function extractFixedVersion(affected: OsvVulnerability["affected"] | undefined, packageName: string, ecosystem: string, installedVersion: string | null): string | null;
157
+ /**
158
+ * npm's security holding placeholder: after a malicious package is taken down,
159
+ * npm republishes the name as an empty `0.0.<n>-security` release ("security
160
+ * holding package"). It has no code, so NO advisory applies to it, whichever
161
+ * database's id flags the name: crossenv 0.0.2-security under
162
+ * GHSA-c2m4-w5hm-vqjw read as installed malware when only MAL- ids were held
163
+ * (4DA audit L4, 2026-10-16). Only npm's own shape counts: a publisher's
164
+ * `1.0.0-security` prerelease is real code and stays affected. The same rule
165
+ * as the app's `fourda_depengine::matching::is_npm_security_holding`
166
+ * (shared vectors `holding.jsonl`).
167
+ */
168
+ export declare function isSecurityHoldingRelease(ecosystem: string, version: string): boolean;
169
+ /** A finding on an npm security holding placeholder (any advisory id): not installed code. */
170
+ export declare function isNpmSecurityHolding(v: Pick<VulnerabilityEntry, "ecosystem" | "currentVersion">): boolean;
@@ -9,8 +9,11 @@
9
9
  */
10
10
  import { fetchWithTimeout } from "./http-utils.js";
11
11
  import { cvssBaseScore } from "./cvss.js";
12
- import { compareVersions, isComparable, maxVersion } from "./version-compare.js";
12
+ import { compareToLowerBound, compareVersions, isComparable, maxVersion } from "./version-compare.js";
13
13
  import { collectAffectedSpans } from "./osv-ranges.js";
14
+ import { isMaintenanceNotice, isMaintenanceRecord } from "./maintenance.js";
15
+ import { gradeAliasGroups } from "./advisory-groups.js";
16
+ import { cweIdsOf, isMalware } from "./malware.js";
14
17
  const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
15
18
  const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
16
19
  const OSV_TIMEOUT_MS = 15_000;
@@ -29,6 +32,10 @@ const HYDRATE_CONCURRENCY = 24;
29
32
  /** querybatch chunks (1,000 dependencies each) sent at once. */
30
33
  const BATCH_CONCURRENCY = 4;
31
34
  const DETAIL_RETRY_DELAY_MS = 750;
35
+ /** GHSA alias records read per scan to rate records that carry no severity (rateFromAliases). */
36
+ export const MAX_ALIAS_LOOKUPS = 50;
37
+ /** The whole alias lookup's time budget per scan; past it the records stay unknown, said so. */
38
+ export const ALIAS_BUDGET_MS = 5_000;
32
39
  /**
33
40
  * Detail fetches in flight, shared by every scanner in the process. The
34
41
  * startup warm-up scan and the first tool call's scan used to run side by
@@ -49,14 +56,39 @@ async function pool(items, limit, work) {
49
56
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
50
57
  return out;
51
58
  }
59
+ /** How long a scan may wait, in total, for query-budget tokens before the rest of its batches go unanswered. */
60
+ const MAX_TOKEN_WAIT_MS = 60_000;
61
+ /** A failed querybatch request (network, timeout, 429, 5xx) is retried once after this. */
62
+ const BATCH_RETRY_DELAY_MS = 1_000;
63
+ /** Why one querybatch request failed, in words a reader can act on. */
64
+ function describeFailure(error) {
65
+ if (error instanceof OsvHttpError)
66
+ return `OSV.dev returned HTTP ${error.status}`;
67
+ const name = error?.name;
68
+ if (name === "TimeoutError" || name === "AbortError")
69
+ return `OSV.dev did not answer within ${OSV_TIMEOUT_MS / 1000} s`;
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ return `OSV.dev unreachable (${message.slice(0, 120)})`;
72
+ }
73
+ class OsvHttpError extends Error {
74
+ status;
75
+ constructor(status) {
76
+ super(`OSV API error: ${status}`);
77
+ this.status = status;
78
+ }
79
+ }
52
80
  /** OSV.dev over HTTP: querybatch for membership, per-id hydration for records. */
53
81
  export class OsvHttpSource {
54
82
  cache;
55
83
  rateLimiter;
84
+ tokenWaitMs;
56
85
  network = true;
57
- constructor(cache, rateLimiter) {
86
+ constructor(cache, rateLimiter,
87
+ /** Total wait for query budget per scan (tests shorten it). */
88
+ tokenWaitMs = MAX_TOKEN_WAIT_MS) {
58
89
  this.cache = cache;
59
90
  this.rateLimiter = rateLimiter;
91
+ this.tokenWaitMs = tokenWaitMs;
60
92
  }
61
93
  async query(deps) {
62
94
  // querybatch returns only `{ id, modified }`; the rich fields (severity,
@@ -64,31 +96,72 @@ export class OsvHttpSource {
64
96
  const chunks = [];
65
97
  for (let offset = 0; offset < deps.length; offset += MAX_BATCH_SIZE)
66
98
  chunks.push(deps.slice(offset, offset + MAX_BATCH_SIZE));
67
- // Every chunk is answered or the scan fails: a partial answer would call
68
- // the unanswered dependencies clean. The rate limit is checked for all of
69
- // them before any is sent.
70
- for (let i = 0; i < chunks.length; i++) {
71
- if (!this.rateLimiter.consume("osv")) {
72
- throw new Error("OSV rate limit reached before all dependency batches completed");
73
- }
74
- }
99
+ // Batches go out as query budget allows, waiting for it to refill (bounded),
100
+ // instead of the whole scan failing when a tree needs more batches than the
101
+ // bucket holds. A batch that gets no budget, or fails twice, is unanswered:
102
+ // its dependencies come back null, and the scan reports them as unchecked.
103
+ const deadline = Date.now() + this.tokenWaitMs;
104
+ let failure = null;
105
+ // A batch that failed twice means OSV.dev is down or refusing: batches not
106
+ // yet sent are not tried (an outage would otherwise cost a timeout each).
107
+ let outage = false;
75
108
  const answered = await pool(chunks, BATCH_CONCURRENCY, async (chunk) => {
76
- const queries = chunk.map((d) => ({
77
- package: { name: d.name, ecosystem: d.ecosystem },
78
- ...(d.version ? { version: d.version } : {}),
79
- }));
80
- const response = await fetchWithTimeout(OSV_BATCH_URL, {
81
- method: "POST",
82
- headers: { "Content-Type": "application/json" },
83
- body: JSON.stringify({ queries }),
84
- }, OSV_TIMEOUT_MS);
85
- if (!response.ok) {
86
- throw new Error(`OSV API error: ${response.status}`);
109
+ if (outage)
110
+ return chunk.map(() => null);
111
+ if (!(await this.rateLimiter.acquire("osv", deadline))) {
112
+ failure ??= `the OSV.dev query budget (${MAX_BATCH_SIZE.toLocaleString("en-US")} dependencies per request) ran out for ${Math.round(this.tokenWaitMs / 1000)} s`;
113
+ return chunk.map(() => null);
114
+ }
115
+ try {
116
+ return await this.sendBatch(chunk);
117
+ }
118
+ catch (error) {
119
+ failure = describeFailure(error);
120
+ outage = true;
121
+ return chunk.map(() => null);
87
122
  }
88
- const data = (await response.json());
89
- return chunk.map((_, i) => (data.results[i]?.vulns ?? []).map((v) => v.id));
90
123
  });
91
- return answered.flat();
124
+ return { ids: answered.flat(), failure };
125
+ }
126
+ /** One querybatch request, retried once after a throttle, a server error, a timeout or a network failure. */
127
+ async sendBatch(chunk) {
128
+ const queries = chunk.map((d) => ({
129
+ package: { name: d.name, ecosystem: d.ecosystem },
130
+ ...(d.version ? { version: d.version } : {}),
131
+ }));
132
+ let lastError = null;
133
+ for (let attempt = 0; attempt < 2; attempt++) {
134
+ if (attempt > 0)
135
+ await new Promise((resolve) => setTimeout(resolve, BATCH_RETRY_DELAY_MS));
136
+ try {
137
+ const response = await fetchWithTimeout(OSV_BATCH_URL, {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify({ queries }),
141
+ }, OSV_TIMEOUT_MS);
142
+ if (!response.ok) {
143
+ lastError = new OsvHttpError(response.status);
144
+ // A client error other than a throttle will not change on a retry.
145
+ if (response.status !== 429 && response.status < 500)
146
+ break;
147
+ continue;
148
+ }
149
+ const data = (await response.json());
150
+ if (!Array.isArray(data?.results) || data.results.length !== chunk.length) {
151
+ throw new Error(`OSV.dev answered ${Array.isArray(data?.results) ? data.results.length : "no"} results for ${chunk.length} queries`);
152
+ }
153
+ // `{}` is OSV's own "nothing found"; null, a string or a non-array `vulns` is not an answer
154
+ // and was read as clean, then cached for an hour (audit 6.2.5 B-4).
155
+ if (data.results.some((r) => r === null || typeof r !== "object" || ("vulns" in r && !Array.isArray(r.vulns)))) {
156
+ throw new Error("OSV.dev answered a malformed result");
157
+ }
158
+ return chunk.map((_, i) => (data.results[i]?.vulns ?? []).map((v) => v.id));
159
+ }
160
+ catch (error) {
161
+ lastError = error;
162
+ }
163
+ }
164
+ throw lastError ?? new Error("OSV.dev querybatch failed");
92
165
  }
93
166
  /**
94
167
  * Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
@@ -127,8 +200,14 @@ export class OsvHttpSource {
127
200
  for (let attempt = 0; attempt < 2; attempt++) {
128
201
  try {
129
202
  const response = await fetchWithTimeout(`${OSV_VULN_URL}${encodeURIComponent(id)}`, {}, OSV_TIMEOUT_MS);
130
- if (response.ok)
131
- return (await response.json());
203
+ if (response.ok) {
204
+ // Only the record asked for is an answer: a 200 `{}` (or any other record) crashed the
205
+ // scan downstream and was cached for 24 h (audit 6.2.5 B-8). Anything else is retried.
206
+ const record = (await response.json());
207
+ if (record && typeof record === "object" && record.id === id)
208
+ return record;
209
+ throw new Error(`OSV.dev answered a malformed record for ${id}`);
210
+ }
132
211
  // 404 and other client errors are answers, not failures to retry.
133
212
  if (response.status !== 429 && response.status < 500)
134
213
  return null;
@@ -146,7 +225,9 @@ export class OsvScanner {
146
225
  cache;
147
226
  rateLimiter;
148
227
  source;
149
- constructor(cache, rateLimiter, source) {
228
+ aliasBudgetMs;
229
+ constructor(cache, rateLimiter, source, options = {}) {
230
+ this.aliasBudgetMs = options.aliasBudgetMs ?? ALIAS_BUDGET_MS;
150
231
  this.cache = cache;
151
232
  this.rateLimiter = rateLimiter;
152
233
  this.source = source ?? new OsvHttpSource(cache, rateLimiter);
@@ -162,7 +243,7 @@ export class OsvScanner {
162
243
  const uncached = [];
163
244
  const cachedVulns = [];
164
245
  for (const dep of scannable) {
165
- const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
246
+ const cacheKey = `osv:v3:${dep.ecosystem}:${dep.name}:${dep.version}`;
166
247
  const cached = network ? this.cache.get(cacheKey) : null;
167
248
  if (cached !== null) {
168
249
  cachedVulns.push(...cached.map((v) => restampDepContext(v, dep)));
@@ -171,32 +252,51 @@ export class OsvScanner {
171
252
  uncached.push(dep);
172
253
  }
173
254
  }
174
- // Fetch uncached from OSV
255
+ // Fetch uncached from OSV. Dependencies it has no answer for fall back to
256
+ // an expired cached answer; with none they are UNCHECKED, counted and
257
+ // reported, and never counted clean.
175
258
  let fetchedVulns = [];
176
- let offline = false;
259
+ let unanswered = [];
260
+ let failure = null;
177
261
  if (uncached.length > 0) {
178
- if (network && !this.rateLimiter.canProceed("osv")) {
179
- offline = true;
262
+ try {
263
+ const outcome = await this.batchQuery(uncached);
264
+ fetchedVulns = outcome.vulns;
265
+ unanswered = outcome.unanswered;
266
+ failure = outcome.failure;
267
+ }
268
+ catch (error) {
269
+ // The source layer turns network failures into `outcome.failure`; an
270
+ // exception here is a fault in this server, not an OSV outage, and
271
+ // telling the agent to retry would not help (audit 6.2.5 D-2).
272
+ unanswered = uncached;
273
+ const detail = error instanceof Error ? error.message.slice(0, 120) : String(error).slice(0, 120);
274
+ failure = `an internal error in this server stopped the vulnerability lookup (${detail}); retrying will not help, please report it at https://github.com/4DA-Systems/4da-mcp-server/issues`;
275
+ }
276
+ }
277
+ let staleAnswers = 0;
278
+ let staleOldest = null;
279
+ const unchecked = [];
280
+ for (const dep of unanswered) {
281
+ const stale = network ? this.cache.getStale(`osv:v3:${dep.ecosystem}:${dep.name}:${dep.version}`) : null;
282
+ if (stale) {
283
+ staleAnswers++;
284
+ if (!staleOldest || stale.fetchedAt < staleOldest)
285
+ staleOldest = stale.fetchedAt;
286
+ cachedVulns.push(...stale.data.map((v) => restampDepContext(v, dep)));
180
287
  }
181
288
  else {
182
- try {
183
- fetchedVulns = await this.batchQuery(uncached);
184
- }
185
- catch {
186
- offline = true;
187
- // Try stale cache for uncached deps
188
- for (const dep of uncached) {
189
- if (!network)
190
- break;
191
- const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
192
- const stale = this.cache.getStale(cacheKey);
193
- if (stale)
194
- cachedVulns.push(...stale.data.map((v) => restampDepContext(v, dep)));
195
- }
196
- }
289
+ unchecked.push(dep);
197
290
  }
198
291
  }
199
- const allVulns = [...cachedVulns, ...fetchedVulns];
292
+ const offline = unchecked.length > 0;
293
+ // npm's security holding packages (`fs@0.0.1-security`) are empty
294
+ // placeholders npm publishes after taking a malicious package down; the
295
+ // malware advisory covering every version does not apply to them (the
296
+ // desktop app's osv::matching::is_npm_security_holding; audit A5).
297
+ const rated = await this.rateFromAliases([...cachedVulns, ...fetchedVulns].filter((v) => !isNpmSecurityHolding(v)));
298
+ // One severity per advisory: every record of an alias group carries the group's most severe grade.
299
+ const allVulns = gradeAliasGroups(rated.vulns);
200
300
  const vulnerablePackages = new Set(allVulns.map((v) => v.package));
201
301
  const platformInactiveVulnerable = new Set(allVulns.filter((v) => !v.platformActive).map((v) => v.package)).size;
202
302
  const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
@@ -212,16 +312,115 @@ export class OsvScanner {
212
312
  platformInactiveVulnerable,
213
313
  bySeverity,
214
314
  vulnerabilities: allVulns,
215
- cleanCount: offline ? 0 : scannable.length - vulnerablePackages.size,
315
+ // Answered dependencies with no advisory; an unchecked one is never clean.
316
+ cleanCount: Math.max(0, scannable.length - unchecked.length - vulnerableDependencyCount(allVulns)),
216
317
  scanDurationMs: Date.now() - start,
217
318
  cached: uncached.length === 0,
218
319
  offline,
320
+ unchecked: unchecked.length,
321
+ uncheckedReason: offline ? (failure ?? "OSV.dev could not be queried") : null,
322
+ staleAnswers,
323
+ // An expired answer is not a current one: every consumer says so (audit 6.2.5 B-2).
324
+ ...(staleAnswers > 0 ? { staleReason: failure ?? "OSV.dev could not be queried", staleOldestFetchedAt: staleOldest } : {}),
325
+ ...(rated.report ? { aliasSeverity: rated.report } : {}),
326
+ };
327
+ }
328
+ /**
329
+ * A severity for records that carry none, from their GHSA alias: one
330
+ * advisory, one grade, in every tool. Go's vulnerability database rates
331
+ * nothing: GO-2024-3333 (golang.org/x/net) has no severity, its alias
332
+ * GHSA-w32m-9786-jp63 is HIGH, and OSV returns only the GO record for the
333
+ * module (the GHSA names golang.org/x/net/html). dependency_check said
334
+ * `avoid` while vulnerability_scan graded the same record unknown (review
335
+ * of 6.2.5 M6, 2026-10-18). The two records name each other, so the
336
+ * alias's rating applies; it is read by the same rule as any record and
337
+ * the entry says where it came from (`severityFrom`).
338
+ *
339
+ * Through this scanner's own source: a pinned source answers from its
340
+ * records, never the network; with live lookups off nothing is fetched.
341
+ * At most MAX_ALIAS_LOOKUPS aliases, within ALIAS_BUDGET_MS in total.
342
+ * Anything not rated stays unknown and is listed as unresolved, with why;
343
+ * unknown is never clean.
344
+ */
345
+ async rateFromAliases(entries) {
346
+ // A GHSA alias that is itself one of this scan's records is graded already: no fetch.
347
+ const known = new Map();
348
+ for (const e of entries)
349
+ if (/^GHSA-/i.test(e.vulnId) && e.severity !== "unknown")
350
+ known.set(e.vulnId, e.severity);
351
+ const wanted = new Map(); // record id -> every GHSA alias it names
352
+ for (const e of entries) {
353
+ if (e.severity !== "unknown" || e.severityFrom || wanted.has(e.vulnId) || isMaintenanceNotice(e))
354
+ continue;
355
+ const ghsas = [...new Set(e.aliases.filter((a) => /^GHSA-/i.test(a) && a !== e.vulnId))];
356
+ if (ghsas.length > 0)
357
+ wanted.set(e.vulnId, ghsas);
358
+ }
359
+ if (wanted.size === 0)
360
+ return { vulns: entries, report: null };
361
+ // The cap and the budget count distinct GHSA ids.
362
+ const toRead = [...new Set([...wanted.values()].flat())].filter((id) => !known.has(id)).sort();
363
+ const asked = toRead.slice(0, MAX_ALIAS_LOOKUPS);
364
+ let records = new Map();
365
+ const reasons = toRead.length > asked.length ? [`more than ${MAX_ALIAS_LOOKUPS} aliases to read; the rest were not asked for`] : [];
366
+ if (asked.length > 0 && this.source.network && process.env.FOURDA_OFFLINE === "true") {
367
+ reasons.push("live lookups are disabled (FOURDA_OFFLINE=true)");
368
+ }
369
+ else if (asked.length > 0) {
370
+ let timer;
371
+ const deadline = new Promise((resolve) => {
372
+ timer = setTimeout(() => resolve(null), this.aliasBudgetMs);
373
+ });
374
+ try {
375
+ // Fetches past the deadline keep running and fill the detail cache for the next call.
376
+ const answer = await Promise.race([this.source.details(asked).catch(() => new Map()), deadline]);
377
+ if (answer === null)
378
+ reasons.push(`the alias records did not arrive within ${this.aliasBudgetMs / 1000} s`);
379
+ else
380
+ records = answer;
381
+ }
382
+ finally {
383
+ if (timer !== undefined)
384
+ clearTimeout(timer);
385
+ }
386
+ }
387
+ const read = new Map(known);
388
+ for (const [id, record] of records)
389
+ read.set(id, deriveSeverity(record).severity);
390
+ const unresolved = new Set();
391
+ const ungraded = [];
392
+ let ratedCount = 0;
393
+ const vulns = entries.map((e) => {
394
+ const ghsas = wanted.get(e.vulnId);
395
+ if (!ghsas)
396
+ return e;
397
+ const graded = gradeFromAliases(ghsas, read);
398
+ for (const c of graded.consulted)
399
+ if (c.grade === "unreadable")
400
+ unresolved.add(c.id);
401
+ if (graded.severity === "unknown") {
402
+ if (!ungraded.includes(e.vulnId))
403
+ ungraded.push(e.vulnId);
404
+ return e;
405
+ }
406
+ ratedCount++;
407
+ return { ...e, severity: graded.severity, severityFrom: graded.from };
408
+ });
409
+ if (unresolved.size > 0 && reasons.length === 0)
410
+ reasons.push("an alias record could not be read");
411
+ return {
412
+ vulns,
413
+ report: { rated: ratedCount, unresolved: [...unresolved].sort(), ungraded, reason: unresolved.size > 0 || ungraded.length > 0 ? reasons.join("; ") || "the aliases are unrated themselves" : null },
219
414
  };
220
415
  }
221
416
  async batchQuery(deps) {
222
417
  // Phase 1 — enumerate advisory IDs per dep (OSV: the lightweight batch index).
223
- const idsPerDep = await this.source.query(deps);
224
- const matches = deps.map((dep, i) => ({ dep, ids: idsPerDep[i] ?? [] }));
418
+ const answer = await this.source.query(deps);
419
+ const unanswered = deps.filter((_, i) => !Array.isArray(answer.ids[i]));
420
+ const matches = deps.flatMap((dep, i) => {
421
+ const ids = answer.ids[i];
422
+ return Array.isArray(ids) ? [{ dep, ids }] : [];
423
+ });
225
424
  // Phase 2 — the record behind each unique advisory, once.
226
425
  const uniqueIds = [...new Set(matches.flatMap((m) => m.ids))];
227
426
  const detailById = await this.source.details(uniqueIds);
@@ -235,14 +434,18 @@ export class OsvScanner {
235
434
  // and dropping a real advisory is worse than keeping an un-enriched one.
236
435
  const liveIds = ids.filter((id) => !detailById.get(id)?.withdrawn);
237
436
  const depVulns = liveIds.map((id) => mapVulnerability(detailById.get(id) ?? { id }, dep));
238
- rows.push([`osv:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
437
+ rows.push([`osv:v3:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
239
438
  results.push(...depVulns);
240
439
  }
241
440
  if (this.source.network)
242
441
  this.cache.setMany(rows, "osv", OSV_CACHE_TTL);
243
- return results;
442
+ return { vulns: results, unanswered, failure: unanswered.length > 0 ? (answer.failure ?? "OSV.dev gave no answer") : null };
244
443
  }
245
444
  }
445
+ /** Distinct (ecosystem, name, version) instances with at least one advisory row. */
446
+ function vulnerableDependencyCount(vulns) {
447
+ return new Set(vulns.map((v) => `${v.ecosystem}\0${v.package}\0${v.currentVersion}`)).size;
448
+ }
246
449
  /**
247
450
  * Overwrite the dep-context fields of a cached advisory entry with the CURRENT
248
451
  * resolution's context. The advisory facts (id, severity, summary, fix version)
@@ -272,7 +475,11 @@ export function restampDepContext(cached, dep) {
272
475
  };
273
476
  }
274
477
  function mapVulnerability(vuln, dep) {
275
- const { severity, cvssScore } = deriveSeverity(vuln);
478
+ const graded = deriveSeverity(vuln);
479
+ const malware = isMalware(vuln.id, vuln.aliases || [], cweIdsOf(vuln), vuln.summary ?? "");
480
+ // An installed malicious copy is critical in every tool, whatever the record grades (the 4DA lead, 2026-10-18).
481
+ const severity = malware ? "critical" : graded.severity;
482
+ const cvssScore = graded.cvssScore;
276
483
  const fixedVersion = extractFixedVersion(vuln.affected, dep.name, dep.ecosystem, dep.version);
277
484
  return {
278
485
  package: dep.name,
@@ -285,6 +492,8 @@ function mapVulnerability(vuln, dep) {
285
492
  aliases: vuln.aliases || [],
286
493
  severity,
287
494
  cvssScore,
495
+ ...(malware ? { malware: true } : {}),
496
+ maintenance: isMaintenanceRecord(vuln, dep.name, dep.ecosystem),
288
497
  summary: vuln.summary || vuln.details?.slice(0, 200) || vuln.id,
289
498
  fixedVersion,
290
499
  published: vuln.published || vuln.modified || "",
@@ -299,19 +508,43 @@ function mapVulnerability(vuln, dep) {
299
508
  installFix: dep.installFix,
300
509
  };
301
510
  }
511
+ const GRADE_RANK = { unknown: 0, low: 1, medium: 2, high: 3, critical: 4 };
512
+ /**
513
+ * The shared rule for an unrated record (the 4DA app implements it
514
+ * identically): the HIGHEST grade among every GHSA alias that could be read;
515
+ * unknown when none could. `from` names the alias that gave it, the
516
+ * lexicographically smallest id on a tie. An alias that could not be read
517
+ * never lowers the grade; it is reported (`consulted`, grade "unreadable").
518
+ * Umbrella records name several: RUSTSEC-2020-0014 lists 8 GHSAs, so taking
519
+ * the first made the grade depend on alias order.
520
+ */
521
+ export function gradeFromAliases(ghsas, read) {
522
+ const consulted = [...new Set(ghsas)].sort().map((id) => ({ id, grade: read.get(id) ?? "unreadable" }));
523
+ let best = null;
524
+ for (const c of consulted) {
525
+ if (c.grade === "unreadable" || c.grade === "unknown")
526
+ continue;
527
+ if (!best || GRADE_RANK[c.grade] > GRADE_RANK[best.grade])
528
+ best = { id: c.id, grade: c.grade };
529
+ }
530
+ return { severity: best?.grade ?? "unknown", from: best?.id ?? null, consulted };
531
+ }
302
532
  /**
303
533
  * Derive a severity bucket honestly: a CVSS base score (numeric or computed
304
534
  * from a vector) wins; otherwise the GitHub-advisory label; otherwise unknown.
305
535
  * Never fabricates a default bucket — an advisory with no severity signal is
306
536
  * reported as `unknown`, not silently labeled "medium".
307
537
  */
308
- function deriveSeverity(vuln) {
538
+ export function deriveSeverity(vuln) {
539
+ // The curated label first, the CVSS band second (the rule shared with the 4DA app, 2026-10-18,
540
+ // severity vectors sv-015..sv-017): on the pinned corpus 123 of 3,923 records disagree, all GHSA,
541
+ // 113 beside a CVSS v4 vector neither engine scores and the label follows. A CVSS 0.0 goes by its label.
309
542
  const cvssScore = extractCvssScore(vuln.severity);
310
- if (cvssScore !== null)
311
- return { severity: cvssToSeverity(cvssScore), cvssScore };
312
543
  const label = labelToSeverity(vuln.database_specific?.severity);
313
544
  if (label !== "unknown")
314
- return { severity: label, cvssScore: null };
545
+ return { severity: label, cvssScore };
546
+ if (cvssScore !== null)
547
+ return { severity: cvssToSeverity(cvssScore), cvssScore };
315
548
  return { severity: "unknown", cvssScore: null };
316
549
  }
317
550
  function labelToSeverity(label) {
@@ -399,7 +632,7 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
399
632
  // The interval whose release line the installed version sits on. An
400
633
  // unreadable bound compares as 0, which keeps those intervals eligible on
401
634
  // the introduced side only; "0" (since inception) orders below everything.
402
- const containing = intervals.find((i) => cmp(installedVersion, i.introduced) >= 0 &&
635
+ const containing = intervals.find((i) => compareToLowerBound(installedVersion, i.introduced, ecosystem) >= 0 &&
403
636
  (i.fixed !== null
404
637
  ? cmp(installedVersion, i.fixed) < 0
405
638
  : i.lastAffected === null || cmp(installedVersion, i.lastAffected) <= 0));
@@ -413,3 +646,21 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
413
646
  return null;
414
647
  return above.reduce((min, v) => (cmp(v, min) < 0 ? v : min), above[0]);
415
648
  }
649
+ /**
650
+ * npm's security holding placeholder: after a malicious package is taken down,
651
+ * npm republishes the name as an empty `0.0.<n>-security` release ("security
652
+ * holding package"). It has no code, so NO advisory applies to it, whichever
653
+ * database's id flags the name: crossenv 0.0.2-security under
654
+ * GHSA-c2m4-w5hm-vqjw read as installed malware when only MAL- ids were held
655
+ * (4DA audit L4, 2026-10-16). Only npm's own shape counts: a publisher's
656
+ * `1.0.0-security` prerelease is real code and stays affected. The same rule
657
+ * as the app's `fourda_depengine::matching::is_npm_security_holding`
658
+ * (shared vectors `holding.jsonl`).
659
+ */
660
+ export function isSecurityHoldingRelease(ecosystem, version) {
661
+ return ecosystem.trim().toLowerCase() === "npm" && /^0\.0\.\d+-security$/.test(version.trim());
662
+ }
663
+ /** A finding on an npm security holding placeholder (any advisory id): not installed code. */
664
+ export function isNpmSecurityHolding(v) {
665
+ return isSecurityHoldingRelease(v.ecosystem, v.currentVersion);
666
+ }
@@ -14,7 +14,7 @@
14
14
  * Withdrawn records are still returned by `query`: the scanner drops them
15
15
  * after reading the record, exactly as it does with OSV's answers.
16
16
  */
17
- import type { AdvisorySource } from "./osv-scanner.js";
17
+ import type { AdvisoryAnswer, AdvisorySource } from "./osv-scanner.js";
18
18
  import type { OsvVulnerability, ResolvedDependency } from "./types.js";
19
19
  export declare class PinnedRecordsSource implements AdvisorySource {
20
20
  readonly network = false;
@@ -26,6 +26,6 @@ export declare class PinnedRecordsSource implements AdvisorySource {
26
26
  get size(): number;
27
27
  /** The ids of the pinned records that affect this exact version (withdrawn ones included). */
28
28
  affecting(ecosystem: string, name: string, version: string): string[];
29
- query(deps: ResolvedDependency[]): Promise<string[][]>;
29
+ query(deps: ResolvedDependency[]): Promise<AdvisoryAnswer>;
30
30
  details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
31
31
  }