@rainy-updates/cli 0.5.1 → 0.5.2-rc.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 (61) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +88 -25
  3. package/dist/bin/cli.js +50 -1
  4. package/dist/commands/audit/fetcher.d.ts +2 -6
  5. package/dist/commands/audit/fetcher.js +2 -79
  6. package/dist/commands/audit/mapper.d.ts +8 -1
  7. package/dist/commands/audit/mapper.js +106 -10
  8. package/dist/commands/audit/parser.js +36 -2
  9. package/dist/commands/audit/runner.js +179 -15
  10. package/dist/commands/audit/sources/github.d.ts +2 -0
  11. package/dist/commands/audit/sources/github.js +125 -0
  12. package/dist/commands/audit/sources/index.d.ts +6 -0
  13. package/dist/commands/audit/sources/index.js +92 -0
  14. package/dist/commands/audit/sources/osv.d.ts +2 -0
  15. package/dist/commands/audit/sources/osv.js +131 -0
  16. package/dist/commands/audit/sources/types.d.ts +21 -0
  17. package/dist/commands/audit/sources/types.js +1 -0
  18. package/dist/commands/audit/targets.d.ts +20 -0
  19. package/dist/commands/audit/targets.js +314 -0
  20. package/dist/commands/changelog/fetcher.d.ts +9 -0
  21. package/dist/commands/changelog/fetcher.js +130 -0
  22. package/dist/commands/licenses/parser.d.ts +2 -0
  23. package/dist/commands/licenses/parser.js +116 -0
  24. package/dist/commands/licenses/runner.d.ts +9 -0
  25. package/dist/commands/licenses/runner.js +163 -0
  26. package/dist/commands/licenses/sbom.d.ts +10 -0
  27. package/dist/commands/licenses/sbom.js +70 -0
  28. package/dist/commands/resolve/graph/builder.d.ts +20 -0
  29. package/dist/commands/resolve/graph/builder.js +183 -0
  30. package/dist/commands/resolve/graph/conflict.d.ts +20 -0
  31. package/dist/commands/resolve/graph/conflict.js +52 -0
  32. package/dist/commands/resolve/graph/resolver.d.ts +17 -0
  33. package/dist/commands/resolve/graph/resolver.js +71 -0
  34. package/dist/commands/resolve/parser.d.ts +2 -0
  35. package/dist/commands/resolve/parser.js +89 -0
  36. package/dist/commands/resolve/runner.d.ts +13 -0
  37. package/dist/commands/resolve/runner.js +136 -0
  38. package/dist/commands/snapshot/parser.d.ts +2 -0
  39. package/dist/commands/snapshot/parser.js +80 -0
  40. package/dist/commands/snapshot/runner.d.ts +11 -0
  41. package/dist/commands/snapshot/runner.js +115 -0
  42. package/dist/commands/snapshot/store.d.ts +35 -0
  43. package/dist/commands/snapshot/store.js +158 -0
  44. package/dist/commands/unused/matcher.d.ts +22 -0
  45. package/dist/commands/unused/matcher.js +95 -0
  46. package/dist/commands/unused/parser.d.ts +2 -0
  47. package/dist/commands/unused/parser.js +95 -0
  48. package/dist/commands/unused/runner.d.ts +11 -0
  49. package/dist/commands/unused/runner.js +113 -0
  50. package/dist/commands/unused/scanner.d.ts +18 -0
  51. package/dist/commands/unused/scanner.js +129 -0
  52. package/dist/core/impact.d.ts +36 -0
  53. package/dist/core/impact.js +82 -0
  54. package/dist/core/options.d.ts +13 -1
  55. package/dist/core/options.js +35 -13
  56. package/dist/types/index.d.ts +187 -1
  57. package/dist/ui/tui.d.ts +6 -0
  58. package/dist/ui/tui.js +50 -0
  59. package/dist/utils/semver.d.ts +18 -0
  60. package/dist/utils/semver.js +88 -3
  61. package/package.json +8 -1
@@ -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,13 @@
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";
7
11
  /**
8
12
  * Entry point for `rup audit`. Lazy-loaded by cli.ts.
9
13
  * Discovers packages, fetches CVE advisories, filters by severity, and
@@ -12,13 +16,20 @@ import { filterBySeverity, buildPatchMap, renderAuditTable } from "./mapper.js";
12
16
  export async function runAudit(options) {
13
17
  const result = {
14
18
  advisories: [],
19
+ packages: [],
15
20
  autoFixable: 0,
16
21
  errors: [],
17
22
  warnings: [],
23
+ sourcesUsed: [],
24
+ sourceHealth: [],
25
+ resolution: {
26
+ lockfile: 0,
27
+ manifest: 0,
28
+ unresolved: 0,
29
+ },
18
30
  };
19
31
  const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
20
- // Collect all unique package names
21
- const packageNames = new Set();
32
+ const depsByDir = new Map();
22
33
  for (const dir of packageDirs) {
23
34
  let manifest;
24
35
  try {
@@ -33,32 +44,185 @@ export async function runAudit(options) {
33
44
  "devDependencies",
34
45
  "optionalDependencies",
35
46
  ]);
36
- for (const dep of deps) {
37
- packageNames.add(dep.name);
38
- }
47
+ depsByDir.set(dir, deps);
39
48
  }
40
- if (packageNames.size === 0) {
49
+ const targetResolution = await resolveAuditTargets(options.cwd, packageDirs, depsByDir);
50
+ result.warnings.push(...targetResolution.warnings);
51
+ result.resolution = targetResolution.resolution;
52
+ if (targetResolution.targets.length === 0) {
41
53
  result.warnings.push("No dependencies found to audit.");
42
54
  return result;
43
55
  }
44
- process.stderr.write(`[audit] Querying OSV.dev for ${packageNames.size} packages...\n`);
45
- let advisories = await fetchAdvisories([...packageNames], {
56
+ process.stderr.write(`[audit] Querying ${describeSourceMode(options.sourceMode)} for ${targetResolution.targets.length} dependency version${targetResolution.targets.length === 1 ? "" : "s"}...\n`);
57
+ const fetched = await fetchAdvisories(targetResolution.targets, {
46
58
  concurrency: options.concurrency,
47
59
  registryTimeoutMs: options.registryTimeoutMs,
60
+ sourceMode: options.sourceMode,
48
61
  });
62
+ result.sourcesUsed = fetched.sourcesUsed;
63
+ result.sourceHealth = fetched.sourceHealth;
64
+ result.warnings.push(...fetched.warnings);
65
+ if (fetched.sourceHealth.every((item) => item.status === "failed")) {
66
+ result.errors.push("All advisory sources failed. Audit coverage is unavailable for this run.");
67
+ }
68
+ let advisories = fetched.advisories;
49
69
  advisories = filterBySeverity(advisories, options.severity);
50
70
  result.advisories = advisories;
71
+ result.packages = summarizeAdvisories(advisories);
51
72
  result.autoFixable = advisories.filter((a) => a.patchedVersion !== null).length;
52
- if (options.reportFormat === "table" || !options.jsonFile) {
53
- process.stdout.write(renderAuditTable(advisories) + "\n");
73
+ if (options.reportFormat === "summary") {
74
+ process.stdout.write(renderAuditSummary(result.packages) +
75
+ renderAuditSourceHealth(result.sourceHealth) +
76
+ "\n");
77
+ }
78
+ else if (options.reportFormat === "table" || !options.jsonFile) {
79
+ process.stdout.write(renderAuditTable(advisories) +
80
+ renderAuditSourceHealth(result.sourceHealth) +
81
+ "\n");
54
82
  }
55
83
  if (options.jsonFile) {
56
- await writeFileAtomic(options.jsonFile, stableStringify({ advisories, errors: result.errors, warnings: result.warnings }, 2) + "\n");
84
+ await writeFileAtomic(options.jsonFile, stableStringify({
85
+ advisories,
86
+ packages: result.packages,
87
+ sourcesUsed: result.sourcesUsed,
88
+ sourceHealth: result.sourceHealth,
89
+ resolution: result.resolution,
90
+ errors: result.errors,
91
+ warnings: result.warnings,
92
+ }, 2) + "\n");
57
93
  process.stderr.write(`[audit] JSON report written to ${options.jsonFile}\n`);
58
94
  }
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`);
95
+ if (options.fix && result.autoFixable > 0) {
96
+ await applyFix(advisories, options);
62
97
  }
63
98
  return result;
64
99
  }
100
+ function describeSourceMode(mode) {
101
+ if (mode === "osv")
102
+ return "OSV.dev";
103
+ if (mode === "github")
104
+ return "GitHub Advisory DB";
105
+ return "OSV.dev + GitHub Advisory DB";
106
+ }
107
+ // ─── Fix application ──────────────────────────────────────────────────────────
108
+ async function applyFix(advisories, options) {
109
+ const patchMap = buildPatchMap(advisories);
110
+ if (patchMap.size === 0)
111
+ return;
112
+ const pm = await detectPackageManager(options.cwd, options.packageManager);
113
+ const installArgs = buildInstallArgs(pm, patchMap);
114
+ const installCmd = `${pm} ${installArgs.join(" ")}`;
115
+ if (options.dryRun) {
116
+ process.stderr.write(`[audit] --dry-run: would execute:\n ${installCmd}\n`);
117
+ if (options.commit) {
118
+ const msg = buildCommitMessage(patchMap);
119
+ process.stderr.write(`[audit] --dry-run: would commit:\n git commit -m "${msg}"\n`);
120
+ }
121
+ return;
122
+ }
123
+ process.stderr.write(`[audit] Applying ${patchMap.size} fix(es)...\n`);
124
+ process.stderr.write(` → ${installCmd}\n`);
125
+ try {
126
+ await runCommand(pm, installArgs, options.cwd);
127
+ }
128
+ catch (err) {
129
+ process.stderr.write(`[audit] Install failed: ${String(err)}\n`);
130
+ return;
131
+ }
132
+ process.stderr.write(`[audit] ✔ Patches applied successfully.\n`);
133
+ if (options.commit) {
134
+ await commitFix(patchMap, options.cwd);
135
+ }
136
+ else {
137
+ process.stderr.write(`[audit] Tip: run with --commit to automatically commit the changes.\n`);
138
+ }
139
+ }
140
+ function buildInstallArgs(pm, patchMap) {
141
+ const packages = [...patchMap.entries()].map(([n, v]) => `${n}@${v}`);
142
+ switch (pm) {
143
+ case "pnpm":
144
+ return ["add", ...packages];
145
+ case "bun":
146
+ return ["add", ...packages];
147
+ case "yarn":
148
+ return ["add", ...packages];
149
+ default:
150
+ return ["install", ...packages]; // npm
151
+ }
152
+ }
153
+ async function commitFix(patchMap, cwd) {
154
+ const msg = buildCommitMessage(patchMap);
155
+ try {
156
+ // Stage all modified files (package.json + lockfiles)
157
+ await runCommand("git", [
158
+ "add",
159
+ "package.json",
160
+ "package-lock.json",
161
+ "pnpm-lock.yaml",
162
+ "yarn.lock",
163
+ "bun.lock",
164
+ "bun.lockb",
165
+ ], cwd, true);
166
+ await runCommand("git", ["commit", "-m", msg], cwd);
167
+ process.stderr.write(`[audit] ✔ Committed: "${msg}"\n`);
168
+ }
169
+ catch (err) {
170
+ process.stderr.write(`[audit] Git commit failed: ${String(err)}\n`);
171
+ process.stderr.write(`[audit] Changes are applied — commit manually with:\n`);
172
+ process.stderr.write(` git add -A && git commit -m "${msg}"\n`);
173
+ }
174
+ }
175
+ function buildCommitMessage(patchMap) {
176
+ const items = [...patchMap.entries()];
177
+ if (items.length === 1) {
178
+ const [name, version] = items[0];
179
+ return `fix(security): patch ${name} to ${version} (rup audit)`;
180
+ }
181
+ const names = items.map(([n]) => n).join(", ");
182
+ return `fix(security): patch ${items.length} vulnerabilities — ${names} (rup audit)`;
183
+ }
184
+ /** Detects the package manager in use by checking for lockfiles. */
185
+ async function detectPackageManager(cwd, explicit) {
186
+ if (explicit !== "auto")
187
+ return explicit;
188
+ const checks = [
189
+ ["bun.lock", "bun"],
190
+ ["bun.lockb", "bun"],
191
+ ["pnpm-lock.yaml", "pnpm"],
192
+ ["yarn.lock", "yarn"],
193
+ ];
194
+ for (const [lockfile, pm] of checks) {
195
+ try {
196
+ await fs.access(path.join(cwd, lockfile));
197
+ return pm;
198
+ }
199
+ catch {
200
+ // not found, try next
201
+ }
202
+ }
203
+ return "npm"; // default
204
+ }
205
+ /** Spawns a subprocess, pipes stdio live to the terminal. */
206
+ function runCommand(cmd, args, cwd, ignoreErrors = false) {
207
+ return new Promise((resolve, reject) => {
208
+ const child = spawn(cmd, args, {
209
+ cwd,
210
+ stdio: "inherit", // stream stdout/stderr live
211
+ shell: process.platform === "win32",
212
+ });
213
+ child.on("close", (code) => {
214
+ if (code === 0 || ignoreErrors) {
215
+ resolve();
216
+ }
217
+ else {
218
+ reject(new Error(`${cmd} exited with code ${code}`));
219
+ }
220
+ });
221
+ child.on("error", (err) => {
222
+ if (ignoreErrors)
223
+ resolve();
224
+ else
225
+ reject(err);
226
+ });
227
+ });
228
+ }
@@ -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,92 @@
1
+ import { compareVersions, parseVersion } from "../../../utils/semver.js";
2
+ import { githubAuditSource } from "./github.js";
3
+ import { osvAuditSource } from "./osv.js";
4
+ const SOURCE_MAP = {
5
+ osv: osvAuditSource,
6
+ github: githubAuditSource,
7
+ };
8
+ export async function fetchAdvisoriesFromSources(targets, options, sourceMap = SOURCE_MAP) {
9
+ const selected = selectSources(options.sourceMode);
10
+ const results = await Promise.all(selected.map((name) => sourceMap[name].fetch(targets, options)));
11
+ const warnings = normalizeSourceWarnings(results.flatMap((result) => result.warnings), results.map((result) => result.health));
12
+ const merged = mergeAdvisories(results.flatMap((result) => result.advisories));
13
+ return {
14
+ advisories: merged,
15
+ warnings,
16
+ sourcesUsed: selected,
17
+ sourceHealth: results.map((result) => result.health),
18
+ };
19
+ }
20
+ function selectSources(mode) {
21
+ if (mode === "osv")
22
+ return ["osv"];
23
+ if (mode === "github")
24
+ return ["github"];
25
+ return ["osv", "github"];
26
+ }
27
+ function mergeAdvisories(advisories) {
28
+ const merged = new Map();
29
+ for (const advisory of advisories) {
30
+ const key = [
31
+ advisory.packageName,
32
+ advisory.currentVersion ?? "?",
33
+ advisory.cveId,
34
+ ].join("|");
35
+ const existing = merged.get(key);
36
+ if (!existing) {
37
+ merged.set(key, advisory);
38
+ continue;
39
+ }
40
+ merged.set(key, {
41
+ ...existing,
42
+ severity: severityRank(advisory.severity) > severityRank(existing.severity)
43
+ ? advisory.severity
44
+ : existing.severity,
45
+ vulnerableRange: existing.vulnerableRange === "*" && advisory.vulnerableRange !== "*"
46
+ ? advisory.vulnerableRange
47
+ : existing.vulnerableRange,
48
+ patchedVersion: choosePreferredPatch(existing.patchedVersion, advisory.patchedVersion),
49
+ title: existing.title.length >= advisory.title.length ? existing.title : advisory.title,
50
+ url: existing.url.length >= advisory.url.length ? existing.url : advisory.url,
51
+ sources: [...new Set([...existing.sources, ...advisory.sources])].sort(),
52
+ });
53
+ }
54
+ return [...merged.values()];
55
+ }
56
+ function normalizeSourceWarnings(warnings, sourceHealth) {
57
+ const normalized = [...warnings];
58
+ const successful = sourceHealth.filter((item) => item.status !== "failed");
59
+ const failed = sourceHealth.filter((item) => item.status === "failed");
60
+ if (failed.length > 0 && successful.length > 0) {
61
+ const failedNames = failed.map((item) => formatSourceName(item.source)).join(", ");
62
+ const successfulNames = successful
63
+ .map((item) => formatSourceName(item.source))
64
+ .join(", ");
65
+ normalized.push(`Continuing with partial advisory coverage: ${failedNames} failed, ${successfulNames} still returned results.`);
66
+ }
67
+ return normalized;
68
+ }
69
+ function formatSourceName(source) {
70
+ return source === "osv" ? "OSV.dev" : "GitHub Advisory DB";
71
+ }
72
+ function choosePreferredPatch(current, next) {
73
+ if (!current)
74
+ return next;
75
+ if (!next)
76
+ return current;
77
+ const currentParsed = parseVersion(current);
78
+ const nextParsed = parseVersion(next);
79
+ if (currentParsed && nextParsed) {
80
+ return compareVersions(currentParsed, nextParsed) <= 0 ? current : next;
81
+ }
82
+ return current <= next ? current : next;
83
+ }
84
+ function severityRank(value) {
85
+ if (value === "critical")
86
+ return 4;
87
+ if (value === "high")
88
+ return 3;
89
+ if (value === "medium")
90
+ return 2;
91
+ return 1;
92
+ }
@@ -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
+ }