@rainy-updates/cli 0.5.2-rc.1 → 0.5.2

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 (56) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +34 -1
  3. package/dist/bin/cli.js +128 -3
  4. package/dist/cache/cache.d.ts +1 -0
  5. package/dist/cache/cache.js +9 -2
  6. package/dist/commands/audit/fetcher.d.ts +2 -6
  7. package/dist/commands/audit/fetcher.js +2 -79
  8. package/dist/commands/audit/mapper.d.ts +8 -1
  9. package/dist/commands/audit/mapper.js +105 -9
  10. package/dist/commands/audit/parser.js +36 -2
  11. package/dist/commands/audit/runner.js +186 -15
  12. package/dist/commands/audit/sources/github.d.ts +2 -0
  13. package/dist/commands/audit/sources/github.js +125 -0
  14. package/dist/commands/audit/sources/index.d.ts +6 -0
  15. package/dist/commands/audit/sources/index.js +99 -0
  16. package/dist/commands/audit/sources/osv.d.ts +2 -0
  17. package/dist/commands/audit/sources/osv.js +131 -0
  18. package/dist/commands/audit/sources/types.d.ts +21 -0
  19. package/dist/commands/audit/sources/types.js +1 -0
  20. package/dist/commands/audit/targets.d.ts +20 -0
  21. package/dist/commands/audit/targets.js +314 -0
  22. package/dist/commands/changelog/fetcher.d.ts +9 -0
  23. package/dist/commands/changelog/fetcher.js +130 -0
  24. package/dist/commands/doctor/parser.d.ts +2 -0
  25. package/dist/commands/doctor/parser.js +92 -0
  26. package/dist/commands/doctor/runner.d.ts +2 -0
  27. package/dist/commands/doctor/runner.js +13 -0
  28. package/dist/commands/resolve/runner.js +3 -0
  29. package/dist/commands/review/parser.d.ts +2 -0
  30. package/dist/commands/review/parser.js +174 -0
  31. package/dist/commands/review/runner.d.ts +2 -0
  32. package/dist/commands/review/runner.js +30 -0
  33. package/dist/config/loader.d.ts +3 -0
  34. package/dist/core/check.js +39 -5
  35. package/dist/core/errors.d.ts +11 -0
  36. package/dist/core/errors.js +6 -0
  37. package/dist/core/options.d.ts +8 -1
  38. package/dist/core/options.js +43 -0
  39. package/dist/core/review-model.d.ts +5 -0
  40. package/dist/core/review-model.js +382 -0
  41. package/dist/core/summary.js +11 -2
  42. package/dist/core/upgrade.d.ts +1 -0
  43. package/dist/core/upgrade.js +27 -21
  44. package/dist/core/warm-cache.js +28 -4
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.js +1 -0
  47. package/dist/output/format.d.ts +4 -1
  48. package/dist/output/format.js +29 -3
  49. package/dist/output/github.js +5 -0
  50. package/dist/output/sarif.js +11 -0
  51. package/dist/registry/npm.d.ts +20 -0
  52. package/dist/registry/npm.js +27 -4
  53. package/dist/types/index.d.ts +91 -1
  54. package/dist/ui/tui.d.ts +2 -0
  55. package/dist/ui/tui.js +107 -0
  56. package/package.json +12 -2
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
3
  const SEVERITY_LEVELS = ["critical", "high", "medium", "low"];
4
+ const SOURCE_MODES = ["auto", "osv", "github", "all"];
4
5
  export function parseSeverity(value) {
5
6
  if (SEVERITY_LEVELS.includes(value)) {
6
7
  return value;
@@ -14,7 +15,10 @@ export function parseAuditArgs(args) {
14
15
  severity: undefined,
15
16
  fix: false,
16
17
  dryRun: false,
18
+ commit: false,
19
+ packageManager: "auto",
17
20
  reportFormat: "table",
21
+ sourceMode: "auto",
18
22
  jsonFile: undefined,
19
23
  concurrency: 16,
20
24
  registryTimeoutMs: 8000,
@@ -52,9 +56,24 @@ export function parseAuditArgs(args) {
52
56
  index += 1;
53
57
  continue;
54
58
  }
59
+ if (current === "--commit") {
60
+ options.commit = true;
61
+ index += 1;
62
+ continue;
63
+ }
64
+ if (current === "--pm" && next) {
65
+ const valid = ["auto", "npm", "pnpm", "bun", "yarn"];
66
+ if (!valid.includes(next))
67
+ throw new Error(`--pm must be one of: ${valid.join(", ")}`);
68
+ options.packageManager = next;
69
+ index += 2;
70
+ continue;
71
+ }
72
+ if (current === "--pm")
73
+ throw new Error("Missing value for --pm");
55
74
  if (current === "--report" && next) {
56
- if (next !== "table" && next !== "json") {
57
- throw new Error("--report must be table or json");
75
+ if (next !== "table" && next !== "json" && next !== "summary") {
76
+ throw new Error("--report must be table, summary, or json");
58
77
  }
59
78
  options.reportFormat = next;
60
79
  index += 2;
@@ -62,6 +81,21 @@ export function parseAuditArgs(args) {
62
81
  }
63
82
  if (current === "--report")
64
83
  throw new Error("Missing value for --report");
84
+ if (current === "--summary") {
85
+ options.reportFormat = "summary";
86
+ index += 1;
87
+ continue;
88
+ }
89
+ if (current === "--source" && next) {
90
+ if (!SOURCE_MODES.includes(next)) {
91
+ throw new Error(`--source must be one of: ${SOURCE_MODES.join(", ")}`);
92
+ }
93
+ options.sourceMode = next;
94
+ index += 2;
95
+ continue;
96
+ }
97
+ if (current === "--source")
98
+ throw new Error("Missing value for --source");
65
99
  if (current === "--json-file" && next) {
66
100
  options.jsonFile = path.resolve(options.cwd, next);
67
101
  index += 2;
@@ -1,9 +1,14 @@
1
+ import { spawn } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
1
4
  import { collectDependencies, readManifest, } from "../../parsers/package-json.js";
2
5
  import { discoverPackageDirs } from "../../workspace/discover.js";
3
6
  import { writeFileAtomic } from "../../utils/io.js";
4
7
  import { stableStringify } from "../../utils/stable-json.js";
5
8
  import { fetchAdvisories } from "./fetcher.js";
6
- import { filterBySeverity, buildPatchMap, renderAuditTable } from "./mapper.js";
9
+ import { resolveAuditTargets } from "./targets.js";
10
+ import { filterBySeverity, buildPatchMap, renderAuditSourceHealth, renderAuditSummary, renderAuditTable, summarizeAdvisories, } from "./mapper.js";
11
+ import { formatClassifiedMessage } from "../../core/errors.js";
7
12
  /**
8
13
  * Entry point for `rup audit`. Lazy-loaded by cli.ts.
9
14
  * Discovers packages, fetches CVE advisories, filters by severity, and
@@ -12,13 +17,20 @@ import { filterBySeverity, buildPatchMap, renderAuditTable } from "./mapper.js";
12
17
  export async function runAudit(options) {
13
18
  const result = {
14
19
  advisories: [],
20
+ packages: [],
15
21
  autoFixable: 0,
16
22
  errors: [],
17
23
  warnings: [],
24
+ sourcesUsed: [],
25
+ sourceHealth: [],
26
+ resolution: {
27
+ lockfile: 0,
28
+ manifest: 0,
29
+ unresolved: 0,
30
+ },
18
31
  };
19
32
  const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
20
- // Collect all unique package names
21
- const packageNames = new Set();
33
+ const depsByDir = new Map();
22
34
  for (const dir of packageDirs) {
23
35
  let manifest;
24
36
  try {
@@ -33,32 +45,191 @@ export async function runAudit(options) {
33
45
  "devDependencies",
34
46
  "optionalDependencies",
35
47
  ]);
36
- for (const dep of deps) {
37
- packageNames.add(dep.name);
38
- }
48
+ depsByDir.set(dir, deps);
39
49
  }
40
- if (packageNames.size === 0) {
50
+ const targetResolution = await resolveAuditTargets(options.cwd, packageDirs, depsByDir);
51
+ result.warnings.push(...targetResolution.warnings);
52
+ result.resolution = targetResolution.resolution;
53
+ if (targetResolution.targets.length === 0) {
41
54
  result.warnings.push("No dependencies found to audit.");
42
55
  return result;
43
56
  }
44
- process.stderr.write(`[audit] Querying OSV.dev for ${packageNames.size} packages...\n`);
45
- let advisories = await fetchAdvisories([...packageNames], {
57
+ process.stderr.write(`[audit] Querying ${describeSourceMode(options.sourceMode)} for ${targetResolution.targets.length} dependency version${targetResolution.targets.length === 1 ? "" : "s"}...\n`);
58
+ const fetched = await fetchAdvisories(targetResolution.targets, {
46
59
  concurrency: options.concurrency,
47
60
  registryTimeoutMs: options.registryTimeoutMs,
61
+ sourceMode: options.sourceMode,
48
62
  });
63
+ result.sourcesUsed = fetched.sourcesUsed;
64
+ result.sourceHealth = fetched.sourceHealth;
65
+ result.warnings.push(...fetched.warnings);
66
+ if (fetched.sourceHealth.every((item) => item.status === "failed")) {
67
+ result.errors.push(formatClassifiedMessage({
68
+ code: "ADVISORY_SOURCE_DOWN",
69
+ whatFailed: "All advisory sources failed.",
70
+ intact: "Dependency target resolution completed, but no advisory coverage was returned.",
71
+ validity: "invalid",
72
+ next: "Retry `rup audit` later or select a single healthy source with --source.",
73
+ }));
74
+ }
75
+ let advisories = fetched.advisories;
49
76
  advisories = filterBySeverity(advisories, options.severity);
50
77
  result.advisories = advisories;
78
+ result.packages = summarizeAdvisories(advisories);
51
79
  result.autoFixable = advisories.filter((a) => a.patchedVersion !== null).length;
52
- if (options.reportFormat === "table" || !options.jsonFile) {
53
- process.stdout.write(renderAuditTable(advisories) + "\n");
80
+ if (options.reportFormat === "summary") {
81
+ process.stdout.write(renderAuditSummary(result.packages) +
82
+ renderAuditSourceHealth(result.sourceHealth) +
83
+ "\n");
84
+ }
85
+ else if (options.reportFormat === "table" || !options.jsonFile) {
86
+ process.stdout.write(renderAuditTable(advisories) +
87
+ renderAuditSourceHealth(result.sourceHealth) +
88
+ "\n");
54
89
  }
55
90
  if (options.jsonFile) {
56
- await writeFileAtomic(options.jsonFile, stableStringify({ advisories, errors: result.errors, warnings: result.warnings }, 2) + "\n");
91
+ await writeFileAtomic(options.jsonFile, stableStringify({
92
+ advisories,
93
+ packages: result.packages,
94
+ sourcesUsed: result.sourcesUsed,
95
+ sourceHealth: result.sourceHealth,
96
+ resolution: result.resolution,
97
+ errors: result.errors,
98
+ warnings: result.warnings,
99
+ }, 2) + "\n");
57
100
  process.stderr.write(`[audit] JSON report written to ${options.jsonFile}\n`);
58
101
  }
59
- if (options.fix && !options.dryRun && result.autoFixable > 0) {
60
- const patchMap = buildPatchMap(advisories);
61
- process.stderr.write(`[audit] --fix: ${patchMap.size} packages have available patches. Apply with: npm install ${[...patchMap.entries()].map(([n, v]) => `${n}@${v}`).join(" ")}\n`);
102
+ if (options.fix && result.autoFixable > 0) {
103
+ await applyFix(advisories, options);
62
104
  }
63
105
  return result;
64
106
  }
107
+ function describeSourceMode(mode) {
108
+ if (mode === "osv")
109
+ return "OSV.dev";
110
+ if (mode === "github")
111
+ return "GitHub Advisory DB";
112
+ return "OSV.dev + GitHub Advisory DB";
113
+ }
114
+ // ─── Fix application ──────────────────────────────────────────────────────────
115
+ async function applyFix(advisories, options) {
116
+ const patchMap = buildPatchMap(advisories);
117
+ if (patchMap.size === 0)
118
+ return;
119
+ const pm = await detectPackageManager(options.cwd, options.packageManager);
120
+ const installArgs = buildInstallArgs(pm, patchMap);
121
+ const installCmd = `${pm} ${installArgs.join(" ")}`;
122
+ if (options.dryRun) {
123
+ process.stderr.write(`[audit] --dry-run: would execute:\n ${installCmd}\n`);
124
+ if (options.commit) {
125
+ const msg = buildCommitMessage(patchMap);
126
+ process.stderr.write(`[audit] --dry-run: would commit:\n git commit -m "${msg}"\n`);
127
+ }
128
+ return;
129
+ }
130
+ process.stderr.write(`[audit] Applying ${patchMap.size} fix(es)...\n`);
131
+ process.stderr.write(` → ${installCmd}\n`);
132
+ try {
133
+ await runCommand(pm, installArgs, options.cwd);
134
+ }
135
+ catch (err) {
136
+ process.stderr.write(`[audit] Install failed: ${String(err)}\n`);
137
+ return;
138
+ }
139
+ process.stderr.write(`[audit] ✔ Patches applied successfully.\n`);
140
+ if (options.commit) {
141
+ await commitFix(patchMap, options.cwd);
142
+ }
143
+ else {
144
+ process.stderr.write(`[audit] Tip: run with --commit to automatically commit the changes.\n`);
145
+ }
146
+ }
147
+ function buildInstallArgs(pm, patchMap) {
148
+ const packages = [...patchMap.entries()].map(([n, v]) => `${n}@${v}`);
149
+ switch (pm) {
150
+ case "pnpm":
151
+ return ["add", ...packages];
152
+ case "bun":
153
+ return ["add", ...packages];
154
+ case "yarn":
155
+ return ["add", ...packages];
156
+ default:
157
+ return ["install", ...packages]; // npm
158
+ }
159
+ }
160
+ async function commitFix(patchMap, cwd) {
161
+ const msg = buildCommitMessage(patchMap);
162
+ try {
163
+ // Stage all modified files (package.json + lockfiles)
164
+ await runCommand("git", [
165
+ "add",
166
+ "package.json",
167
+ "package-lock.json",
168
+ "pnpm-lock.yaml",
169
+ "yarn.lock",
170
+ "bun.lock",
171
+ "bun.lockb",
172
+ ], cwd, true);
173
+ await runCommand("git", ["commit", "-m", msg], cwd);
174
+ process.stderr.write(`[audit] ✔ Committed: "${msg}"\n`);
175
+ }
176
+ catch (err) {
177
+ process.stderr.write(`[audit] Git commit failed: ${String(err)}\n`);
178
+ process.stderr.write(`[audit] Changes are applied — commit manually with:\n`);
179
+ process.stderr.write(` git add -A && git commit -m "${msg}"\n`);
180
+ }
181
+ }
182
+ function buildCommitMessage(patchMap) {
183
+ const items = [...patchMap.entries()];
184
+ if (items.length === 1) {
185
+ const [name, version] = items[0];
186
+ return `fix(security): patch ${name} to ${version} (rup audit)`;
187
+ }
188
+ const names = items.map(([n]) => n).join(", ");
189
+ return `fix(security): patch ${items.length} vulnerabilities — ${names} (rup audit)`;
190
+ }
191
+ /** Detects the package manager in use by checking for lockfiles. */
192
+ async function detectPackageManager(cwd, explicit) {
193
+ if (explicit !== "auto")
194
+ return explicit;
195
+ const checks = [
196
+ ["bun.lock", "bun"],
197
+ ["bun.lockb", "bun"],
198
+ ["pnpm-lock.yaml", "pnpm"],
199
+ ["yarn.lock", "yarn"],
200
+ ];
201
+ for (const [lockfile, pm] of checks) {
202
+ try {
203
+ await fs.access(path.join(cwd, lockfile));
204
+ return pm;
205
+ }
206
+ catch {
207
+ // not found, try next
208
+ }
209
+ }
210
+ return "npm"; // default
211
+ }
212
+ /** Spawns a subprocess, pipes stdio live to the terminal. */
213
+ function runCommand(cmd, args, cwd, ignoreErrors = false) {
214
+ return new Promise((resolve, reject) => {
215
+ const child = spawn(cmd, args, {
216
+ cwd,
217
+ stdio: "inherit", // stream stdout/stderr live
218
+ shell: process.platform === "win32",
219
+ });
220
+ child.on("close", (code) => {
221
+ if (code === 0 || ignoreErrors) {
222
+ resolve();
223
+ }
224
+ else {
225
+ reject(new Error(`${cmd} exited with code ${code}`));
226
+ }
227
+ });
228
+ child.on("error", (err) => {
229
+ if (ignoreErrors)
230
+ resolve();
231
+ else
232
+ reject(err);
233
+ });
234
+ });
235
+ }
@@ -0,0 +1,2 @@
1
+ import type { AuditSourceAdapter } from "./types.js";
2
+ export declare const githubAuditSource: AuditSourceAdapter;
@@ -0,0 +1,125 @@
1
+ import { asyncPool } from "../../../utils/async-pool.js";
2
+ const GITHUB_ADVISORY_API = "https://api.github.com/advisories";
3
+ export const githubAuditSource = {
4
+ name: "github",
5
+ async fetch(targets, options) {
6
+ const tasks = targets.map((target) => () => queryGitHub(target, options.registryTimeoutMs));
7
+ const results = await asyncPool(options.concurrency, tasks);
8
+ const advisories = [];
9
+ let successfulTargets = 0;
10
+ let failedTargets = 0;
11
+ const errorCounts = new Map();
12
+ for (const result of results) {
13
+ if (result instanceof Error) {
14
+ failedTargets += 1;
15
+ incrementCount(errorCounts, "internal-error");
16
+ continue;
17
+ }
18
+ advisories.push(...result.advisories);
19
+ if (result.ok) {
20
+ successfulTargets += 1;
21
+ }
22
+ else {
23
+ failedTargets += 1;
24
+ incrementCount(errorCounts, result.error ?? "request-failed");
25
+ }
26
+ }
27
+ const status = failedTargets === 0
28
+ ? "ok"
29
+ : successfulTargets === 0
30
+ ? "failed"
31
+ : "partial";
32
+ return {
33
+ advisories,
34
+ warnings: createSourceWarnings("GitHub Advisory DB", targets.length, successfulTargets, failedTargets),
35
+ health: {
36
+ source: "github",
37
+ status,
38
+ attemptedTargets: targets.length,
39
+ successfulTargets,
40
+ failedTargets,
41
+ advisoriesFound: advisories.length,
42
+ message: formatDominantError(errorCounts),
43
+ },
44
+ };
45
+ },
46
+ };
47
+ async function queryGitHub(target, timeoutMs) {
48
+ const url = new URL(GITHUB_ADVISORY_API);
49
+ url.searchParams.set("ecosystem", "npm");
50
+ url.searchParams.set("affects", `${target.name}@${target.version}`);
51
+ url.searchParams.set("per_page", "100");
52
+ let response;
53
+ try {
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
56
+ response = await fetch(url, {
57
+ headers: {
58
+ Accept: "application/vnd.github+json",
59
+ "User-Agent": "rainy-updates-cli",
60
+ },
61
+ signal: controller.signal,
62
+ });
63
+ clearTimeout(timer);
64
+ }
65
+ catch (error) {
66
+ return { advisories: [], ok: false, error: classifyFetchError(error) };
67
+ }
68
+ if (!response.ok) {
69
+ return { advisories: [], ok: false, error: `http-${response.status}` };
70
+ }
71
+ const data = (await response.json());
72
+ const advisories = [];
73
+ for (const item of data) {
74
+ const vulnerability = item.vulnerabilities?.find((entry) => entry.package?.name === target.name);
75
+ const severity = normalizeSeverity(item.severity);
76
+ advisories.push({
77
+ cveId: item.ghsa_id ?? item.cve_id ?? "UNKNOWN",
78
+ packageName: target.name,
79
+ currentVersion: target.version,
80
+ severity,
81
+ vulnerableRange: vulnerability?.vulnerable_version_range ?? "*",
82
+ patchedVersion: vulnerability?.first_patched_version?.identifier?.trim() || null,
83
+ title: item.summary ?? item.ghsa_id ?? "GitHub Advisory",
84
+ url: item.html_url ?? `https://github.com/advisories/${item.ghsa_id}`,
85
+ sources: ["github"],
86
+ });
87
+ }
88
+ return { advisories, ok: true };
89
+ }
90
+ function normalizeSeverity(value) {
91
+ const normalized = (value ?? "medium").toLowerCase();
92
+ if (normalized === "critical" ||
93
+ normalized === "high" ||
94
+ normalized === "medium" ||
95
+ normalized === "low") {
96
+ return normalized;
97
+ }
98
+ if (normalized === "moderate")
99
+ return "medium";
100
+ return "medium";
101
+ }
102
+ function createSourceWarnings(label, attemptedTargets, successfulTargets, failedTargets) {
103
+ if (failedTargets === 0)
104
+ return [];
105
+ if (successfulTargets === 0) {
106
+ return [
107
+ `${label} unavailable for all ${attemptedTargets} audit target${attemptedTargets === 1 ? "" : "s"}.`,
108
+ ];
109
+ }
110
+ return [
111
+ `${label} partially unavailable: ${failedTargets}/${attemptedTargets} audit target${attemptedTargets === 1 ? "" : "s"} failed.`,
112
+ ];
113
+ }
114
+ function classifyFetchError(error) {
115
+ if (error instanceof Error && error.name === "AbortError")
116
+ return "timeout";
117
+ return "network";
118
+ }
119
+ function incrementCount(map, key) {
120
+ map.set(key, (map.get(key) ?? 0) + 1);
121
+ }
122
+ function formatDominantError(errorCounts) {
123
+ const sorted = [...errorCounts.entries()].sort((a, b) => b[1] - a[1]);
124
+ return sorted[0]?.[0];
125
+ }
@@ -0,0 +1,6 @@
1
+ import type { AuditOptions, AuditSourceName } from "../../../types/index.js";
2
+ import type { AuditTarget } from "../targets.js";
3
+ import type { AuditSourceAdapter, AuditSourceAggregateResult } from "./types.js";
4
+ export declare function fetchAdvisoriesFromSources(targets: AuditTarget[], options: Pick<AuditOptions, "concurrency" | "registryTimeoutMs" | "sourceMode">, sourceMap?: Record<AuditSourceName, AuditSourceAdapter>): Promise<AuditSourceAggregateResult & {
5
+ sourcesUsed: AuditSourceName[];
6
+ }>;
@@ -0,0 +1,99 @@
1
+ import { compareVersions, parseVersion } from "../../../utils/semver.js";
2
+ import { githubAuditSource } from "./github.js";
3
+ import { osvAuditSource } from "./osv.js";
4
+ import { formatClassifiedMessage } from "../../../core/errors.js";
5
+ const SOURCE_MAP = {
6
+ osv: osvAuditSource,
7
+ github: githubAuditSource,
8
+ };
9
+ export async function fetchAdvisoriesFromSources(targets, options, sourceMap = SOURCE_MAP) {
10
+ const selected = selectSources(options.sourceMode);
11
+ const results = await Promise.all(selected.map((name) => sourceMap[name].fetch(targets, options)));
12
+ const warnings = normalizeSourceWarnings(results.flatMap((result) => result.warnings), results.map((result) => result.health));
13
+ const merged = mergeAdvisories(results.flatMap((result) => result.advisories));
14
+ return {
15
+ advisories: merged,
16
+ warnings,
17
+ sourcesUsed: selected,
18
+ sourceHealth: results.map((result) => result.health),
19
+ };
20
+ }
21
+ function selectSources(mode) {
22
+ if (mode === "osv")
23
+ return ["osv"];
24
+ if (mode === "github")
25
+ return ["github"];
26
+ return ["osv", "github"];
27
+ }
28
+ function mergeAdvisories(advisories) {
29
+ const merged = new Map();
30
+ for (const advisory of advisories) {
31
+ const key = [
32
+ advisory.packageName,
33
+ advisory.currentVersion ?? "?",
34
+ advisory.cveId,
35
+ ].join("|");
36
+ const existing = merged.get(key);
37
+ if (!existing) {
38
+ merged.set(key, advisory);
39
+ continue;
40
+ }
41
+ merged.set(key, {
42
+ ...existing,
43
+ severity: severityRank(advisory.severity) > severityRank(existing.severity)
44
+ ? advisory.severity
45
+ : existing.severity,
46
+ vulnerableRange: existing.vulnerableRange === "*" && advisory.vulnerableRange !== "*"
47
+ ? advisory.vulnerableRange
48
+ : existing.vulnerableRange,
49
+ patchedVersion: choosePreferredPatch(existing.patchedVersion, advisory.patchedVersion),
50
+ title: existing.title.length >= advisory.title.length ? existing.title : advisory.title,
51
+ url: existing.url.length >= advisory.url.length ? existing.url : advisory.url,
52
+ sources: [...new Set([...existing.sources, ...advisory.sources])].sort(),
53
+ });
54
+ }
55
+ return [...merged.values()];
56
+ }
57
+ function normalizeSourceWarnings(warnings, sourceHealth) {
58
+ const normalized = [...warnings];
59
+ const successful = sourceHealth.filter((item) => item.status !== "failed");
60
+ const failed = sourceHealth.filter((item) => item.status === "failed");
61
+ if (failed.length > 0 && successful.length > 0) {
62
+ const failedNames = failed.map((item) => formatSourceName(item.source)).join(", ");
63
+ const successfulNames = successful
64
+ .map((item) => formatSourceName(item.source))
65
+ .join(", ");
66
+ normalized.push(formatClassifiedMessage({
67
+ code: "ADVISORY_SOURCE_DEGRADED",
68
+ whatFailed: `${failedNames} advisory source(s) failed during the audit query.`,
69
+ intact: `${successfulNames} still returned advisory results.`,
70
+ validity: "partial",
71
+ next: "Retry `rup audit` later or pin `--source` to a healthy backend.",
72
+ }));
73
+ }
74
+ return normalized;
75
+ }
76
+ function formatSourceName(source) {
77
+ return source === "osv" ? "OSV.dev" : "GitHub Advisory DB";
78
+ }
79
+ function choosePreferredPatch(current, next) {
80
+ if (!current)
81
+ return next;
82
+ if (!next)
83
+ return current;
84
+ const currentParsed = parseVersion(current);
85
+ const nextParsed = parseVersion(next);
86
+ if (currentParsed && nextParsed) {
87
+ return compareVersions(currentParsed, nextParsed) <= 0 ? current : next;
88
+ }
89
+ return current <= next ? current : next;
90
+ }
91
+ function severityRank(value) {
92
+ if (value === "critical")
93
+ return 4;
94
+ if (value === "high")
95
+ return 3;
96
+ if (value === "medium")
97
+ return 2;
98
+ return 1;
99
+ }
@@ -0,0 +1,2 @@
1
+ import type { AuditSourceAdapter } from "./types.js";
2
+ export declare const osvAuditSource: AuditSourceAdapter;
@@ -0,0 +1,131 @@
1
+ import { asyncPool } from "../../../utils/async-pool.js";
2
+ const OSV_API = "https://api.osv.dev/v1/query";
3
+ export const osvAuditSource = {
4
+ name: "osv",
5
+ async fetch(targets, options) {
6
+ const tasks = targets.map((target) => () => queryOsv(target, options.registryTimeoutMs));
7
+ const results = await asyncPool(options.concurrency, tasks);
8
+ const advisories = [];
9
+ let successfulTargets = 0;
10
+ let failedTargets = 0;
11
+ const errorCounts = new Map();
12
+ for (const result of results) {
13
+ if (result instanceof Error) {
14
+ failedTargets += 1;
15
+ incrementCount(errorCounts, "internal-error");
16
+ continue;
17
+ }
18
+ advisories.push(...result.advisories);
19
+ if (result.ok) {
20
+ successfulTargets += 1;
21
+ }
22
+ else {
23
+ failedTargets += 1;
24
+ incrementCount(errorCounts, result.error ?? "request-failed");
25
+ }
26
+ }
27
+ const status = failedTargets === 0
28
+ ? "ok"
29
+ : successfulTargets === 0
30
+ ? "failed"
31
+ : "partial";
32
+ return {
33
+ advisories,
34
+ warnings: createSourceWarnings("OSV.dev", targets.length, successfulTargets, failedTargets),
35
+ health: {
36
+ source: "osv",
37
+ status,
38
+ attemptedTargets: targets.length,
39
+ successfulTargets,
40
+ failedTargets,
41
+ advisoriesFound: advisories.length,
42
+ message: formatDominantError(errorCounts),
43
+ },
44
+ };
45
+ },
46
+ };
47
+ async function queryOsv(target, timeoutMs) {
48
+ const body = JSON.stringify({
49
+ package: { name: target.name, ecosystem: "npm" },
50
+ version: target.version,
51
+ });
52
+ let response;
53
+ try {
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
56
+ response = await fetch(OSV_API, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body,
60
+ signal: controller.signal,
61
+ });
62
+ clearTimeout(timer);
63
+ }
64
+ catch (error) {
65
+ return { advisories: [], ok: false, error: classifyFetchError(error) };
66
+ }
67
+ if (!response.ok) {
68
+ return { advisories: [], ok: false, error: `http-${response.status}` };
69
+ }
70
+ const data = (await response.json());
71
+ const advisories = [];
72
+ for (const vuln of data.vulns ?? []) {
73
+ const cveId = vuln.id ?? "UNKNOWN";
74
+ const rawSeverity = (vuln.database_specific?.severity ?? "medium").toLowerCase();
75
+ const severity = (["critical", "high", "medium", "low"].includes(rawSeverity)
76
+ ? rawSeverity
77
+ : "medium");
78
+ let patchedVersion = null;
79
+ let vulnerableRange = "*";
80
+ for (const affected of vuln.affected ?? []) {
81
+ if (affected.package?.name !== target.name)
82
+ continue;
83
+ for (const range of affected.ranges ?? []) {
84
+ const fixedEvent = range.events?.find((event) => event.fixed);
85
+ if (fixedEvent?.fixed) {
86
+ patchedVersion = fixedEvent.fixed;
87
+ const introducedEvent = range.events?.find((event) => event.introduced);
88
+ vulnerableRange = introducedEvent?.introduced
89
+ ? `>=${introducedEvent.introduced} <${patchedVersion}`
90
+ : `<${patchedVersion}`;
91
+ }
92
+ }
93
+ }
94
+ advisories.push({
95
+ cveId,
96
+ packageName: target.name,
97
+ currentVersion: target.version,
98
+ severity,
99
+ vulnerableRange,
100
+ patchedVersion,
101
+ title: vuln.summary ?? cveId,
102
+ url: vuln.references?.[0]?.url ?? `https://osv.dev/vulnerability/${cveId}`,
103
+ sources: ["osv"],
104
+ });
105
+ }
106
+ return { advisories, ok: true };
107
+ }
108
+ function createSourceWarnings(label, attemptedTargets, successfulTargets, failedTargets) {
109
+ if (failedTargets === 0)
110
+ return [];
111
+ if (successfulTargets === 0) {
112
+ return [
113
+ `${label} unavailable for all ${attemptedTargets} audit target${attemptedTargets === 1 ? "" : "s"}.`,
114
+ ];
115
+ }
116
+ return [
117
+ `${label} partially unavailable: ${failedTargets}/${attemptedTargets} audit target${attemptedTargets === 1 ? "" : "s"} failed.`,
118
+ ];
119
+ }
120
+ function classifyFetchError(error) {
121
+ if (error instanceof Error && error.name === "AbortError")
122
+ return "timeout";
123
+ return "network";
124
+ }
125
+ function incrementCount(map, key) {
126
+ map.set(key, (map.get(key) ?? 0) + 1);
127
+ }
128
+ function formatDominantError(errorCounts) {
129
+ const sorted = [...errorCounts.entries()].sort((a, b) => b[1] - a[1]);
130
+ return sorted[0]?.[0];
131
+ }