upgrade-interactive 1.0.0 → 1.1.0
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/README.md +82 -15
- package/package.json +5 -3
- package/src/cli.js +71 -7
- package/src/components/App.js +238 -98
- package/src/components/OverridePicker.js +44 -0
- package/src/components/Prompt.js +8 -2
- package/src/components/Row.js +80 -11
- package/src/links.js +17 -0
- package/src/lockfile.js +58 -0
- package/src/package-file.js +35 -5
- package/src/registry.js +49 -0
- package/src/vulnerabilities.js +260 -0
package/src/package-file.js
CHANGED
|
@@ -38,10 +38,15 @@ export async function loadManifest(cwd) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Apply a Map<name, newRange> of accepted upgrades
|
|
42
|
-
*
|
|
41
|
+
* Apply a Map<name, newRange> of accepted upgrades, an optional
|
|
42
|
+
* { name: version } map of npm `overrides` to add, and an optional list of
|
|
43
|
+
* override names to remove, then write the manifest back to disk.
|
|
44
|
+
*
|
|
45
|
+
* Returns { applied: {name,field,from,to}[], overrides: {name,to}[],
|
|
46
|
+
* removed: {name}[] }. Note: a top-level `overrides` entry forces *every*
|
|
47
|
+
* instance of that package (direct and transitive) to the pinned version.
|
|
43
48
|
*/
|
|
44
|
-
export async function applyUpgrades(manifest, selections) {
|
|
49
|
+
export async function applyUpgrades(manifest, selections, overrides = {}, removals = []) {
|
|
45
50
|
const applied = [];
|
|
46
51
|
|
|
47
52
|
for (const descriptor of manifest.descriptors) {
|
|
@@ -52,10 +57,35 @@ export async function applyUpgrades(manifest, selections) {
|
|
|
52
57
|
applied.push({ name: descriptor.name, field: descriptor.field, from: descriptor.range, to: newRange });
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
|
|
60
|
+
const appliedOverrides = [];
|
|
61
|
+
const overrideEntries = Object.entries(overrides || {});
|
|
62
|
+
if (overrideEntries.length > 0) {
|
|
63
|
+
if (!manifest.json.overrides || typeof manifest.json.overrides !== 'object') {
|
|
64
|
+
manifest.json.overrides = {};
|
|
65
|
+
}
|
|
66
|
+
for (const [name, version] of overrideEntries) {
|
|
67
|
+
if (!version || manifest.json.overrides[name] === version) continue;
|
|
68
|
+
manifest.json.overrides[name] = version;
|
|
69
|
+
appliedOverrides.push({ name, to: version });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const removed = [];
|
|
74
|
+
if (removals && removals.length > 0 && manifest.json.overrides && typeof manifest.json.overrides === 'object') {
|
|
75
|
+
for (const name of removals) {
|
|
76
|
+
if (manifest.json.overrides[name] == null) continue;
|
|
77
|
+
delete manifest.json.overrides[name];
|
|
78
|
+
removed.push({ name });
|
|
79
|
+
}
|
|
80
|
+
if (Object.keys(manifest.json.overrides).length === 0) delete manifest.json.overrides;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (applied.length === 0 && appliedOverrides.length === 0 && removed.length === 0) {
|
|
84
|
+
return { applied, overrides: appliedOverrides, removed };
|
|
85
|
+
}
|
|
56
86
|
|
|
57
87
|
const serialized = JSON.stringify(manifest.json, null, manifest.indent) + (manifest.trailingNewline ? '\n' : '');
|
|
58
88
|
await writeFile(manifest.filePath, serialized, 'utf8');
|
|
59
89
|
|
|
60
|
-
return applied;
|
|
90
|
+
return { applied, overrides: appliedOverrides, removed };
|
|
61
91
|
}
|
package/src/registry.js
CHANGED
|
@@ -44,6 +44,55 @@ export async function fetchPackageMeta(name) {
|
|
|
44
44
|
return promise;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Look up known vulnerabilities for a set of installed versions via npm's bulk
|
|
49
|
+
* advisories endpoint (the same one `npm audit` uses; no auth required).
|
|
50
|
+
*
|
|
51
|
+
* @param {Record<string, Iterable<string>>} versionsByName
|
|
52
|
+
* @returns {Promise<{ ok: boolean, advisories: Map<string, object[]> }>}
|
|
53
|
+
* `advisories` maps package name -> advisory objects that affect at least one
|
|
54
|
+
* submitted version. `ok` is false when a network/HTTP error occurred, so
|
|
55
|
+
* callers can distinguish "no vulnerabilities" from "couldn't check".
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchBulkAdvisories(versionsByName) {
|
|
58
|
+
const names = Object.keys(versionsByName);
|
|
59
|
+
const advisories = new Map();
|
|
60
|
+
if (names.length === 0) return { ok: true, advisories };
|
|
61
|
+
|
|
62
|
+
const CHUNK = 200;
|
|
63
|
+
let ok = true;
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < names.length; i += CHUNK) {
|
|
66
|
+
const slice = names.slice(i, i + CHUNK);
|
|
67
|
+
const body = {};
|
|
68
|
+
for (const name of slice) body[name] = Array.from(versionsByName[name]);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(`${REGISTRY}/-/npm/v1/security/advisories/bulk`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
Accept: 'application/json',
|
|
76
|
+
'User-Agent': 'upgrade-interactive',
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
ok = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const json = await res.json();
|
|
85
|
+
for (const [name, list] of Object.entries(json || {})) {
|
|
86
|
+
if (Array.isArray(list) && list.length > 0) advisories.set(name, list);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
ok = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { ok, advisories };
|
|
94
|
+
}
|
|
95
|
+
|
|
47
96
|
/**
|
|
48
97
|
* Run `worker` over `items` with at most `limit` in flight at once.
|
|
49
98
|
* Calls `onEach(result, item, index)` as each one resolves (out of order).
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Turns raw npm advisory data into a per-package vulnerability summary the UI
|
|
2
|
+
// can render, plus the list of safe versions the override picker offers.
|
|
3
|
+
|
|
4
|
+
import semver from 'semver';
|
|
5
|
+
import { fetchPackageMeta, fetchBulkAdvisories, mapWithConcurrency } from './registry.js';
|
|
6
|
+
|
|
7
|
+
// The four standard npm/GitHub severity levels, ranked worst-first, with the
|
|
8
|
+
// color used to render them. Centralized so Row.js and the picker agree.
|
|
9
|
+
export const SEVERITY = {
|
|
10
|
+
critical: { label: 'critical', color: 'red', rank: 4 },
|
|
11
|
+
high: { label: 'high', color: 'red', rank: 3 },
|
|
12
|
+
moderate: { label: 'moderate', color: 'yellow', rank: 2 },
|
|
13
|
+
low: { label: 'low', color: 'gray', rank: 1 },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const CONCURRENCY = 8;
|
|
17
|
+
|
|
18
|
+
function severityRank(sev) {
|
|
19
|
+
return (SEVERITY[sev] && SEVERITY[sev].rank) || 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function satisfiesAdvisory(version, advisory) {
|
|
23
|
+
try {
|
|
24
|
+
return semver.satisfies(version, advisory.vulnerable_versions, { includePrerelease: true });
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchesAny(version, advisories) {
|
|
31
|
+
return advisories.some((a) => satisfiesAdvisory(version, a));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Highest valid semver in a list, or null. */
|
|
35
|
+
function maxVersion(list) {
|
|
36
|
+
let max = null;
|
|
37
|
+
for (const v of list) {
|
|
38
|
+
if (!semver.valid(v)) continue;
|
|
39
|
+
if (!max || semver.gt(v, max)) max = v;
|
|
40
|
+
}
|
|
41
|
+
return max;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function advisoryCve(advisory) {
|
|
45
|
+
if (advisory && Array.isArray(advisory.cves) && advisory.cves[0]) return advisory.cves[0];
|
|
46
|
+
if (advisory && advisory.github_advisory_id) return advisory.github_advisory_id;
|
|
47
|
+
// The bulk endpoint doesn't return the CVE number directly, but its URL is a
|
|
48
|
+
// GitHub advisory (GHSA) page that lists it — use the GHSA id as the label.
|
|
49
|
+
const ghsa = advisory && advisory.url && advisory.url.match(/GHSA-[0-9a-z-]+/i);
|
|
50
|
+
if (ghsa) return ghsa[0];
|
|
51
|
+
if (advisory && advisory.id != null) return `advisory ${advisory.id}`;
|
|
52
|
+
return 'advisory';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Every semver range that some package in the installed tree declares for
|
|
56
|
+
// `name` — i.e. what would have to resolve if a manual override were removed.
|
|
57
|
+
const RANGE_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
|
|
58
|
+
function requiredRangesFor(packages, name) {
|
|
59
|
+
const ranges = new Set();
|
|
60
|
+
for (const info of Object.values(packages || {})) {
|
|
61
|
+
if (!info || typeof info !== 'object') continue;
|
|
62
|
+
for (const field of RANGE_FIELDS) {
|
|
63
|
+
const section = info[field];
|
|
64
|
+
if (section && typeof section === 'object' && section[name] != null) {
|
|
65
|
+
ranges.add(section[name]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...ranges];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function advisoryUrl(advisory) {
|
|
73
|
+
if (advisory && advisory.url) return advisory.url;
|
|
74
|
+
if (advisory && advisory.github_advisory_id) {
|
|
75
|
+
return `https://github.com/advisories/${advisory.github_advisory_id}`;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Decide which existing overrides are safe to drop from the resolved override
|
|
81
|
+
// info. A 'dead' override (nothing in the tree depends on it) needs no advisory
|
|
82
|
+
// data; a 'redundant' one is only flagged when we could reach the advisories
|
|
83
|
+
// (`ok`) and resolve every version its dependents would fall back to. We never
|
|
84
|
+
// flag when we couldn't check or resolve, to avoid suggesting the removal of an
|
|
85
|
+
// override that's still protecting the tree.
|
|
86
|
+
function collectRemovableOverrides(overrideInfo, ok, advisories) {
|
|
87
|
+
const removable = new Map();
|
|
88
|
+
for (const [name, info] of overrideInfo) {
|
|
89
|
+
if (info.reason === 'dead') {
|
|
90
|
+
removable.set(name, { pin: info.pin, reason: 'dead' });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!ok || !info.resolvable || info.candidates.length === 0) continue;
|
|
94
|
+
const adv = advisories.get(name) || [];
|
|
95
|
+
const stillVulnerable = info.candidates.some((v) => matchesAny(v, adv));
|
|
96
|
+
if (!stillVulnerable) removable.set(name, { pin: info.pin, reason: 'redundant' });
|
|
97
|
+
}
|
|
98
|
+
return removable;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Given the direct descriptors and the installed tree (from the lockfile),
|
|
103
|
+
* check every relevant version against npm's advisory database.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Promise<{ offline, vulns, removableOverrides }>}
|
|
106
|
+
* Each vuln entry: { advisories, severity, isDirect, cve, url, affectedRange,
|
|
107
|
+
* firstPatched, safeVersions }. `removableOverrides` maps an existing
|
|
108
|
+
* `overrides` package name -> { pin, reason: 'dead' | 'redundant' }.
|
|
109
|
+
*/
|
|
110
|
+
export async function computeVulnerabilities(
|
|
111
|
+
{ descriptors = [], installed = null, overrides = {} } = {},
|
|
112
|
+
deps = {}
|
|
113
|
+
) {
|
|
114
|
+
// Registry collaborators are injectable so this decision logic can be unit
|
|
115
|
+
// tested against fixed advisory/metadata fixtures instead of the live npm API.
|
|
116
|
+
const getMeta = deps.fetchPackageMeta || fetchPackageMeta;
|
|
117
|
+
const getAdvisories = deps.fetchBulkAdvisories || fetchBulkAdvisories;
|
|
118
|
+
|
|
119
|
+
const versionsByName = {};
|
|
120
|
+
const add = (name, version) => {
|
|
121
|
+
if (!version) return;
|
|
122
|
+
if (!versionsByName[name]) versionsByName[name] = new Set();
|
|
123
|
+
versionsByName[name].add(version);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Installed versions across the whole tree (direct + transitive).
|
|
127
|
+
if (installed && installed.versions) {
|
|
128
|
+
for (const [name, set] of installed.versions) {
|
|
129
|
+
for (const v of set) add(name, v);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Also check the version each direct range currently resolves to, in case a
|
|
134
|
+
// range points at a vulnerable version that isn't installed yet.
|
|
135
|
+
await mapWithConcurrency(descriptors, CONCURRENCY, async (d) => {
|
|
136
|
+
if (!d.range || !semver.validRange(d.range)) return;
|
|
137
|
+
const meta = await getMeta(d.name);
|
|
138
|
+
if (!meta) return;
|
|
139
|
+
const best = semver.maxSatisfying(meta.versions, d.range, { includePrerelease: false });
|
|
140
|
+
if (best) add(d.name, best);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// For each existing top-level override, work out what version(s) would be
|
|
144
|
+
// installed *without* it, so we can tell whether it's still doing anything.
|
|
145
|
+
const overrideEntries = Object.entries(overrides || {}).filter(
|
|
146
|
+
([, pin]) => typeof pin === 'string' && !pin.startsWith('$')
|
|
147
|
+
);
|
|
148
|
+
const overrideInfo = new Map();
|
|
149
|
+
await mapWithConcurrency(overrideEntries, CONCURRENCY, async ([name, pin]) => {
|
|
150
|
+
// Without a lockfile we can't see the tree, so we can't conclude the
|
|
151
|
+
// override is unneeded — leave it unresolvable rather than guess 'dead'.
|
|
152
|
+
if (!installed || !installed.packages) {
|
|
153
|
+
overrideInfo.set(name, { pin, candidates: [], resolvable: false });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const ranges = requiredRangesFor(installed.packages, name);
|
|
157
|
+
if (ranges.length === 0) {
|
|
158
|
+
// Nothing in the tree depends on it anymore — the override is dead weight.
|
|
159
|
+
overrideInfo.set(name, { pin, reason: 'dead', candidates: [], resolvable: true });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const meta = await getMeta(name);
|
|
163
|
+
if (!meta) {
|
|
164
|
+
overrideInfo.set(name, { pin, ranges, candidates: [], resolvable: false });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const candidates = [];
|
|
168
|
+
let resolvable = true;
|
|
169
|
+
for (const r of ranges) {
|
|
170
|
+
if (!semver.validRange(r)) {
|
|
171
|
+
resolvable = false;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const best = semver.maxSatisfying(meta.versions, r, { includePrerelease: false });
|
|
175
|
+
if (best) candidates.push(best);
|
|
176
|
+
else resolvable = false;
|
|
177
|
+
}
|
|
178
|
+
for (const c of candidates) add(name, c);
|
|
179
|
+
overrideInfo.set(name, { pin, ranges, candidates, resolvable });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (Object.keys(versionsByName).length === 0) {
|
|
183
|
+
// Nothing to check for vulnerabilities, but a 'dead' override needs no
|
|
184
|
+
// advisory data — still surface it instead of silently dropping it.
|
|
185
|
+
return {
|
|
186
|
+
offline: false,
|
|
187
|
+
vulns: new Map(),
|
|
188
|
+
removableOverrides: collectRemovableOverrides(overrideInfo, false, new Map()),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { ok, advisories } = await getAdvisories(versionsByName);
|
|
193
|
+
|
|
194
|
+
const directSet = new Set(descriptors.map((d) => d.name));
|
|
195
|
+
if (installed && installed.direct) for (const n of installed.direct) directSet.add(n);
|
|
196
|
+
|
|
197
|
+
const vulnNames = [...advisories.keys()].filter((name) => {
|
|
198
|
+
const versions = versionsByName[name] ? [...versionsByName[name]] : [];
|
|
199
|
+
return versions.some((v) => matchesAny(v, advisories.get(name)));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const vulns = new Map();
|
|
203
|
+
await mapWithConcurrency(vulnNames, CONCURRENCY, async (name) => {
|
|
204
|
+
const list = advisories.get(name);
|
|
205
|
+
const versions = versionsByName[name] ? [...versionsByName[name]] : [];
|
|
206
|
+
const flagged = versions.filter((v) => matchesAny(v, list));
|
|
207
|
+
if (flagged.length === 0) return;
|
|
208
|
+
|
|
209
|
+
// Keep only advisories that actually affect a version we have/resolve to.
|
|
210
|
+
const matching = list.filter((a) => flagged.some((v) => satisfiesAdvisory(v, a)));
|
|
211
|
+
|
|
212
|
+
// Worst severity across matching advisories drives the label + primary link.
|
|
213
|
+
let severity = 'low';
|
|
214
|
+
let primary = matching[0] || list[0];
|
|
215
|
+
for (const a of matching) {
|
|
216
|
+
const s = (a.severity || 'low').toLowerCase();
|
|
217
|
+
if (severityRank(s) > severityRank(severity)) {
|
|
218
|
+
severity = s;
|
|
219
|
+
primary = a;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!SEVERITY[severity]) severity = 'low';
|
|
223
|
+
|
|
224
|
+
// Safe versions = published versions affected by none of the matching
|
|
225
|
+
// advisories, at or above the newest version we currently have.
|
|
226
|
+
const reference = maxVersion(flagged);
|
|
227
|
+
let safeVersions = [];
|
|
228
|
+
const meta = await getMeta(name);
|
|
229
|
+
if (meta) {
|
|
230
|
+
safeVersions = meta.versions
|
|
231
|
+
.filter((v) => semver.valid(v) && !semver.prerelease(v))
|
|
232
|
+
.filter((v) => !matchesAny(v, matching))
|
|
233
|
+
.filter((v) => !reference || semver.gte(v, reference))
|
|
234
|
+
.sort(semver.compare);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let firstPatched = safeVersions.length > 0 ? safeVersions[0] : null;
|
|
238
|
+
if (!firstPatched && primary && primary.patched_versions && primary.patched_versions !== '<0.0.0') {
|
|
239
|
+
try {
|
|
240
|
+
const mv = semver.minVersion(primary.patched_versions);
|
|
241
|
+
if (mv) firstPatched = mv.version;
|
|
242
|
+
} catch {
|
|
243
|
+
// no derivable fix
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
vulns.set(name, {
|
|
248
|
+
advisories: matching,
|
|
249
|
+
severity,
|
|
250
|
+
isDirect: directSet.has(name),
|
|
251
|
+
cve: advisoryCve(primary),
|
|
252
|
+
url: advisoryUrl(primary),
|
|
253
|
+
affectedRange: (primary && primary.vulnerable_versions) || '',
|
|
254
|
+
firstPatched,
|
|
255
|
+
safeVersions,
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return { offline: !ok, vulns, removableOverrides: collectRemovableOverrides(overrideInfo, ok, advisories) };
|
|
260
|
+
}
|