@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.
- package/CHANGELOG.md +93 -1
- package/README.md +88 -25
- package/dist/bin/cli.js +50 -1
- package/dist/commands/audit/fetcher.d.ts +2 -6
- package/dist/commands/audit/fetcher.js +2 -79
- package/dist/commands/audit/mapper.d.ts +8 -1
- package/dist/commands/audit/mapper.js +106 -10
- package/dist/commands/audit/parser.js +36 -2
- package/dist/commands/audit/runner.js +179 -15
- package/dist/commands/audit/sources/github.d.ts +2 -0
- package/dist/commands/audit/sources/github.js +125 -0
- package/dist/commands/audit/sources/index.d.ts +6 -0
- package/dist/commands/audit/sources/index.js +92 -0
- package/dist/commands/audit/sources/osv.d.ts +2 -0
- package/dist/commands/audit/sources/osv.js +131 -0
- package/dist/commands/audit/sources/types.d.ts +21 -0
- package/dist/commands/audit/sources/types.js +1 -0
- package/dist/commands/audit/targets.d.ts +20 -0
- package/dist/commands/audit/targets.js +314 -0
- package/dist/commands/changelog/fetcher.d.ts +9 -0
- package/dist/commands/changelog/fetcher.js +130 -0
- package/dist/commands/licenses/parser.d.ts +2 -0
- package/dist/commands/licenses/parser.js +116 -0
- package/dist/commands/licenses/runner.d.ts +9 -0
- package/dist/commands/licenses/runner.js +163 -0
- package/dist/commands/licenses/sbom.d.ts +10 -0
- package/dist/commands/licenses/sbom.js +70 -0
- package/dist/commands/resolve/graph/builder.d.ts +20 -0
- package/dist/commands/resolve/graph/builder.js +183 -0
- package/dist/commands/resolve/graph/conflict.d.ts +20 -0
- package/dist/commands/resolve/graph/conflict.js +52 -0
- package/dist/commands/resolve/graph/resolver.d.ts +17 -0
- package/dist/commands/resolve/graph/resolver.js +71 -0
- package/dist/commands/resolve/parser.d.ts +2 -0
- package/dist/commands/resolve/parser.js +89 -0
- package/dist/commands/resolve/runner.d.ts +13 -0
- package/dist/commands/resolve/runner.js +136 -0
- package/dist/commands/snapshot/parser.d.ts +2 -0
- package/dist/commands/snapshot/parser.js +80 -0
- package/dist/commands/snapshot/runner.d.ts +11 -0
- package/dist/commands/snapshot/runner.js +115 -0
- package/dist/commands/snapshot/store.d.ts +35 -0
- package/dist/commands/snapshot/store.js +158 -0
- package/dist/commands/unused/matcher.d.ts +22 -0
- package/dist/commands/unused/matcher.js +95 -0
- package/dist/commands/unused/parser.d.ts +2 -0
- package/dist/commands/unused/parser.js +95 -0
- package/dist/commands/unused/runner.d.ts +11 -0
- package/dist/commands/unused/runner.js +113 -0
- package/dist/commands/unused/scanner.d.ts +18 -0
- package/dist/commands/unused/scanner.js +129 -0
- package/dist/core/impact.d.ts +36 -0
- package/dist/core/impact.js +82 -0
- package/dist/core/options.d.ts +13 -1
- package/dist/core/options.js +35 -13
- package/dist/types/index.d.ts +187 -1
- package/dist/ui/tui.d.ts +6 -0
- package/dist/ui/tui.js +50 -0
- package/dist/utils/semver.d.ts +18 -0
- package/dist/utils/semver.js +88 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
packageNames.add(dep.name);
|
|
38
|
-
}
|
|
47
|
+
depsByDir.set(dir, deps);
|
|
39
48
|
}
|
|
40
|
-
|
|
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
|
|
45
|
-
|
|
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 === "
|
|
53
|
-
process.stdout.write(
|
|
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({
|
|
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 &&
|
|
60
|
-
|
|
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,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,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
|
+
}
|