@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
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * One advisory, many records. OSV publishes the same issue as a GHSA record
4
+ * and as an ecosystem record (PYSEC, RUSTSEC, GO), each naming the other in
5
+ * `aliases`. Counting records counted one issue two or three times:
6
+ * - tokio 1.18.0 showed "6 advisories" for 3 (audit A9);
7
+ * - pygoat's django "96 known vulnerabilities" for 51 (readiness audit M1, 2026-10-16).
8
+ * Every count of advisories goes through here.
9
+ */
10
+ import { isMaintenanceNotice } from "./maintenance.js";
11
+ import { isMalware } from "./malware.js";
12
+ const RANK = { unknown: 0, low: 1, medium: 2, high: 3, critical: 4 };
13
+ /**
14
+ * Group the records of ONE installed copy by shared ids. The grouping is transitive: a record
15
+ * naming ids of two groups joins them.
16
+ */
17
+ export function groupAdvisories(entries) {
18
+ let groups = [];
19
+ for (const v of entries ?? []) {
20
+ const ids = [v.vulnId, ...(v.aliases ?? [])];
21
+ const hit = groups.filter((g) => ids.some((id) => g.ids.has(id)));
22
+ const merged = { ids: new Set(ids), best: v, members: [v] };
23
+ for (const g of hit) {
24
+ for (const id of g.ids)
25
+ merged.ids.add(id);
26
+ merged.members.push(...g.members);
27
+ if ((RANK[g.best.severity] ?? 0) > (RANK[merged.best.severity] ?? 0))
28
+ merged.best = g.best;
29
+ }
30
+ groups = [...groups.filter((g) => !hit.includes(g)), merged];
31
+ }
32
+ return groups;
33
+ }
34
+ /**
35
+ * THE grade of one alias group, shared with the 4DA app (`fourda_depengine::severity`, vectors
36
+ * severity.jsonl sv-001..sv-019): the MOST severe OWN grade among the group's records; only when no
37
+ * record has one, the highest grade among their GHSA aliases that can be read (`aliasGrades`, null =
38
+ * held but ungraded), the smallest id on a tie. An unmaintained-package notice is never graded
39
+ * from an alias.
40
+ */
41
+ export function gradeGroup(records, aliasGrades) {
42
+ let own = null;
43
+ for (const r of records) {
44
+ if (r.own === "unknown")
45
+ continue;
46
+ if (!own || RANK[r.own] > RANK[own.grade])
47
+ own = { id: r.id, grade: r.own };
48
+ }
49
+ let severity = null;
50
+ let inheritedFrom = null;
51
+ if (own) {
52
+ severity = own.grade;
53
+ }
54
+ else {
55
+ const ids = new Set(records.map((r) => r.id));
56
+ const ghsas = [...new Set(records.filter((r) => !r.maintenance).flatMap((r) => r.aliases.filter((a) => /^GHSA-/i.test(a) && !ids.has(a))))].sort();
57
+ let best = null;
58
+ for (const id of ghsas) {
59
+ const g = aliasGrades.get(id);
60
+ if (!g || g === "unknown")
61
+ continue;
62
+ if (!best || RANK[g] > RANK[best.grade])
63
+ best = { id, grade: g };
64
+ }
65
+ if (best) {
66
+ severity = best.grade;
67
+ inheritedFrom = best.id;
68
+ }
69
+ }
70
+ // A malicious-package record (malware.ts) is critical (the 4DA lead, 2026-10-18; sv-010 read high).
71
+ const malicious = records.some((r) => isMalware(r.id, r.aliases, r.cweIds ?? [], r.summary ?? ""));
72
+ const urgency = malicious
73
+ ? "critical"
74
+ : severity === "critical" || severity === "high" || severity === "medium"
75
+ ? severity
76
+ : severity === "low"
77
+ ? "watch"
78
+ : records.every((r) => r.maintenance)
79
+ ? "watch"
80
+ : "medium";
81
+ return { severity, inheritedFrom, ownFrom: own?.id ?? null, urgency };
82
+ }
83
+ /**
84
+ * One severity per advisory, in every tool (the rule shared with the 4DA app, gradeGroup): within
85
+ * one alias group of one installed copy, every record carries the group's grade. vulnerability_scan
86
+ * kept the "richest" record's grade while dependency_check, dependency_health and upgrade_planner
87
+ * kept the most severe, so CVE-2026-4277 (GHSA-pwjp-ccjc-ghwg rated LOW, PYSEC-2026-52 CVSS 9.8) was
88
+ * low in one tool and critical in the others (final release audit of 6.2.5, 2026-10-18). A record
89
+ * that does not carry the grade itself names where it came from (`severityFrom`: the group record,
90
+ * or the GHSA alias of an inherited grade). A grade a record took from its alias
91
+ * (osv-scanner.ts rateFromAliases) never overrides an own grade in its group.
92
+ */
93
+ export function gradeAliasGroups(entries) {
94
+ const byCopy = new Map();
95
+ for (const v of entries) {
96
+ const key = `${v.ecosystem}\u0000${v.package}\u0000${v.currentVersion}\u0000${v.installDriftOf ?? ""}`;
97
+ byCopy.set(key, [...(byCopy.get(key) ?? []), v]);
98
+ }
99
+ const regraded = new Map();
100
+ for (const copy of byCopy.values()) {
101
+ for (const group of groupAdvisories(copy)) {
102
+ if (group.members.length < 2)
103
+ continue;
104
+ const inherited = (m) => Boolean(m.severityFrom) && m.severityFrom !== m.vulnId;
105
+ const aliasGrades = new Map();
106
+ for (const m of group.members)
107
+ if (inherited(m))
108
+ aliasGrades.set(m.severityFrom, m.severity);
109
+ const grade = gradeGroup(group.members.map((m) => ({ id: m.vulnId, own: inherited(m) ? "unknown" : m.severity, aliases: [...m.aliases, ...(inherited(m) ? [m.severityFrom] : [])], maintenance: isMaintenanceNotice(m) })), aliasGrades);
110
+ const severity = grade.severity ?? "unknown";
111
+ const from = grade.inheritedFrom ?? grade.ownFrom;
112
+ for (const m of group.members) {
113
+ if (isMaintenanceNotice(m) && grade.inheritedFrom)
114
+ continue;
115
+ if (m.severity === severity && (!inherited(m) || m.severityFrom === from))
116
+ continue;
117
+ const next = { ...m, severity };
118
+ if (from && from !== m.vulnId)
119
+ next.severityFrom = from;
120
+ else
121
+ delete next.severityFrom;
122
+ regraded.set(m, next);
123
+ }
124
+ }
125
+ }
126
+ return regraded.size === 0 ? [...entries] : entries.map((e) => regraded.get(e) ?? e);
127
+ }
128
+ /** Distinct advisories among the records of one installed copy. */
129
+ export function distinctAdvisoryCount(entries) {
130
+ return groupAdvisories(entries).length;
131
+ }
@@ -37,6 +37,14 @@ export function closeServerCacheDb() {
37
37
  serverCache.close();
38
38
  serverCache = null;
39
39
  }
40
+ /**
41
+ * A row fetched "in the future" was written under a clock that was ahead, or the clock has
42
+ * since gone back (an RTC reset). Its expires_at came from that clock too, so it could keep a
43
+ * year-old "no advisories" answer live: scan 0 vulnerable, dependency_check proceed,
44
+ * what_should_i_know safe_to_delegate (audit 6.2.5 B-3). Such a row is never an answer, fresh or
45
+ * stale, and is purged at start. Five minutes of skew between processes is tolerated.
46
+ */
47
+ const SANE_FETCHED_AT = "fetched_at <= datetime('now', '+5 minutes')";
40
48
  /**
41
49
  * Registry, OSV and changelog responses with a TTL.
42
50
  *
@@ -82,7 +90,7 @@ export class LiveCache {
82
90
  const hit = this.memory.get(key);
83
91
  return hit && this.live(hit) ? JSON.parse(hit.data) : null;
84
92
  }
85
- const row = this.db.prepare("SELECT data FROM live_cache WHERE cache_key = ? AND expires_at > datetime('now')").get(key);
93
+ const row = this.db.prepare(`SELECT data FROM live_cache WHERE cache_key = ? AND expires_at > datetime('now') AND ${SANE_FETCHED_AT}`).get(key);
86
94
  if (!row)
87
95
  return null;
88
96
  try {
@@ -97,7 +105,7 @@ export class LiveCache {
97
105
  const hit = this.memory.get(key);
98
106
  return hit ? { data: JSON.parse(hit.data), fetchedAt: hit.fetched_at } : null;
99
107
  }
100
- const row = this.db.prepare("SELECT data, fetched_at FROM live_cache WHERE cache_key = ?").get(key);
108
+ const row = this.db.prepare(`SELECT data, fetched_at FROM live_cache WHERE cache_key = ? AND ${SANE_FETCHED_AT}`).get(key);
101
109
  if (!row)
102
110
  return null;
103
111
  try {
@@ -150,7 +158,7 @@ export class LiveCache {
150
158
  purged++;
151
159
  return purged;
152
160
  }
153
- const result = this.db.prepare("DELETE FROM live_cache WHERE expires_at <= datetime('now')").run();
161
+ const result = this.db.prepare(`DELETE FROM live_cache WHERE expires_at <= datetime('now') OR NOT (${SANE_FETCHED_AT})`).run();
154
162
  return result.changes;
155
163
  }
156
164
  invalidateSource(source) {
@@ -22,6 +22,12 @@ export declare function activeCratesForHost(dir: string): Set<string> | null;
22
22
  * untrusted input. `CARGO_TARGET_DIR` points at the same scratch directory so
23
23
  * cargo's metadata writes never land in the user's tree either.
24
24
  *
25
+ * The scratch directory lives in the server's per-user data directory, not
26
+ * the system temp directory. On a shared machine `/tmp` is writable by every
27
+ * user, so a `/tmp/.cargo/config.toml` or a pre-created
28
+ * `/tmp/4da-mcp-cargo-resolve/.cargo/config.toml` planted by another account
29
+ * could set `build.rustc-wrapper` and run code as this user (audit 6.2.5 C-2).
30
+ *
25
31
  * Exported for tests.
26
32
  */
27
33
  export declare function cargoTreeInvocation(dir: string): {
@@ -42,8 +42,8 @@
42
42
  */
43
43
  import { execFileSync } from "node:child_process";
44
44
  import * as fs from "node:fs";
45
- import * as os from "node:os";
46
45
  import * as path from "node:path";
46
+ import { serverDataDir } from "../db.js";
47
47
  import { fileSignature } from "./file-signature.js";
48
48
  /** Rust target triple for the current host, or null for a platform we do not map. */
49
49
  export function hostTriple() {
@@ -133,10 +133,16 @@ function computeActiveCrates(dir) {
133
133
  * untrusted input. `CARGO_TARGET_DIR` points at the same scratch directory so
134
134
  * cargo's metadata writes never land in the user's tree either.
135
135
  *
136
+ * The scratch directory lives in the server's per-user data directory, not
137
+ * the system temp directory. On a shared machine `/tmp` is writable by every
138
+ * user, so a `/tmp/.cargo/config.toml` or a pre-created
139
+ * `/tmp/4da-mcp-cargo-resolve/.cargo/config.toml` planted by another account
140
+ * could set `build.rustc-wrapper` and run code as this user (audit 6.2.5 C-2).
141
+ *
136
142
  * Exported for tests.
137
143
  */
138
144
  export function cargoTreeInvocation(dir) {
139
- const scratch = path.join(os.tmpdir(), "4da-mcp-cargo-resolve");
145
+ const scratch = path.join(serverDataDir(), "cargo-resolve");
140
146
  return {
141
147
  args: [
142
148
  "tree",
@@ -107,7 +107,36 @@ export function parseVersionHeading(raw) {
107
107
  return null;
108
108
  return { version, date: extractDate(rest) };
109
109
  }
110
- const ATX = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
110
+ /**
111
+ * An ATX heading `## text ##` as [level, text], parsed by hand in linear time.
112
+ * The regex it replaces (`/^(#{1,6})\s+(.*?)\s*#*\s*$/`) was cubic on interior
113
+ * whitespace: a 1,000-space heading in a package's CHANGELOG took over 20 s on
114
+ * the event loop (audit 2026-10-12, B-10). Same result otherwise ("C#" reads "C").
115
+ */
116
+ function parseAtx(line) {
117
+ const open = /^#{1,6}/.exec(line);
118
+ if (!open)
119
+ return null;
120
+ const level = open[0].length;
121
+ const after = line.slice(level);
122
+ const body = after.trimStart();
123
+ if (body.length === after.length)
124
+ return null; // "#tag" or "#######": not a heading
125
+ let end = body.length;
126
+ while (end > 0 && /\s/.test(body[end - 1]))
127
+ end--;
128
+ while (end > 0 && body[end - 1] === "#")
129
+ end--;
130
+ while (end > 0 && /\s/.test(body[end - 1]))
131
+ end--;
132
+ return [level, body.slice(0, end)];
133
+ }
134
+ /**
135
+ * Lines longer than this are cut before the line-shape regexes run, so every
136
+ * one of them works on bounded input and a hostile line costs milliseconds.
137
+ * No real heading, label or entry comes near it.
138
+ */
139
+ const MAX_LINE = 4096;
111
140
  /**
112
141
  * A setext / reStructuredText underline. `=` and `-` at any length (Markdown);
113
142
  * the other RST adornments need three, so a stray `**` is not a heading rule.
@@ -279,7 +308,10 @@ function adornmentLevel(ch) {
279
308
  }
280
309
  /** Parse a changelog into version sections, in document order (usually newest first). */
281
310
  export function parseChangelog(text) {
282
- const lines = text.replace(/\r\n?/g, "\n").split("\n");
311
+ const lines = text
312
+ .replace(/\r\n?/g, "\n")
313
+ .split("\n")
314
+ .map((l) => (l.length > MAX_LINE ? l.slice(0, MAX_LINE) : l));
283
315
  const state = {
284
316
  sections: [],
285
317
  current: null,
@@ -318,14 +350,14 @@ export function parseChangelog(text) {
318
350
  }
319
351
  if (LINK_DEF.test(line))
320
352
  continue;
321
- const atx = ATX.exec(line);
353
+ const atx = parseAtx(line);
322
354
  if (atx) {
323
- const heading = parseVersionHeading(atx[2]);
324
- const level = atx[1].length;
355
+ const [level, text] = atx;
356
+ const heading = parseVersionHeading(text);
325
357
  if (heading)
326
358
  openSection(state, heading, level);
327
359
  else
328
- subHeading(state, atx[2], level);
360
+ subHeading(state, text, level);
329
361
  continue;
330
362
  }
331
363
  const next = lines[i + 1];
@@ -10,4 +10,12 @@ import type { ResolvedDependency, VulnerabilityScanResult } from "./types.js";
10
10
  */
11
11
  export declare function commonPathRoot(dirs: string[]): string | null;
12
12
  export declare function dedupeDependencies(deps: ResolvedDependency[]): ResolvedDependency[];
13
- export declare function emptyVulnResult(projectPath: string, offline: boolean): VulnerabilityScanResult;
13
+ /**
14
+ * A scan with no findings. `unchecked` dependencies (offline: none of them
15
+ * could be asked) are counted in `totalScanned` and reported as unchecked,
16
+ * never as clean.
17
+ */
18
+ export declare function emptyVulnResult(projectPath: string, offline: boolean, unchecked?: {
19
+ count: number;
20
+ reason: string;
21
+ } | null): VulnerabilityScanResult;
@@ -77,12 +77,17 @@ function mergeInstallContext(existing, dep) {
77
77
  existing.installedVersion = dep.installedVersion;
78
78
  }
79
79
  }
80
- export function emptyVulnResult(projectPath, offline) {
80
+ /**
81
+ * A scan with no findings. `unchecked` dependencies (offline: none of them
82
+ * could be asked) are counted in `totalScanned` and reported as unchecked,
83
+ * never as clean.
84
+ */
85
+ export function emptyVulnResult(projectPath, offline, unchecked = null) {
81
86
  return {
82
87
  scannedAt: new Date().toISOString(),
83
88
  projectPath,
84
89
  ecosystemsScanned: [],
85
- totalScanned: 0,
90
+ totalScanned: unchecked?.count ?? 0,
86
91
  totalVulnerable: 0,
87
92
  platformInactiveVulnerable: 0,
88
93
  bySeverity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 },
@@ -91,5 +96,8 @@ export function emptyVulnResult(projectPath, offline) {
91
96
  scanDurationMs: 0,
92
97
  cached: false,
93
98
  offline,
99
+ unchecked: unchecked?.count ?? 0,
100
+ uncheckedReason: unchecked?.reason ?? null,
101
+ staleAnswers: 0,
94
102
  };
95
103
  }