nativekeel 0.1.16 → 0.1.18
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 +1 -1
- package/package.json +1 -1
- package/src/analyze.js +14 -4
- package/src/compat.js +2 -0
- package/src/expo-pins.js +34 -0
- package/src/plan.js +44 -29
- package/src/report-html.js +3 -3
- package/src/report-text.js +8 -1
- package/src/security.js +23 -10
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Health check for React Native and Expo apps. One command, no install, no account
|
|
|
6
6
|
npx nativekeel
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
**What's new in 0.1.
|
|
9
|
+
**What's new in 0.1.18:** the upgrade plan only suggests library versions your current React Native supports, moves React Navigation and RN Firebase packages together, and says plainly when a vulnerable package has no fixed release. **0.1.17:** every vulnerable package shows the version that fixes it; Expo apps get one line for the packages the SDK upgrade moves. Recent releases added a *Start here* list in every report, hacking risks mapped to OWASP MASVS, crash and performance checks. Every check is tuned on 450+ open-source React Native and Expo apps to keep false alarms out. [All releases →](https://nativekeel.com/changelog)
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
Summary 4 critical · 5 high · 4 medium · 6 low
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nativekeel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "Health check for React Native and Expo apps: upgrade blockers, store deadlines, crash risks, vulnerable dependencies and leaked secrets, with a step-by-step upgrade plan.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/analyze.js
CHANGED
|
@@ -8,6 +8,7 @@ import { stabilityChecks } from './stability.js';
|
|
|
8
8
|
import { performanceChecks } from './performance.js';
|
|
9
9
|
import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls, cloudRules, insecureTls, weakCrypto, tokenStorage, reverseEngineering, expoConfigSecrets, deepLinks, masvsOf, MASVS_GROUPS } from './security.js';
|
|
10
10
|
import { lockedVersions } from './lockfile.js';
|
|
11
|
+
import { expoPinnedNames, movesWithExpoSdk } from './expo-pins.js';
|
|
11
12
|
import { findUnused, filesImportingWith } from './usage.js';
|
|
12
13
|
import { matchKnownIssues, compareVersions } from './known-issues.js';
|
|
13
14
|
import { COMPAT, bestRange, checkCompat } from './compat.js';
|
|
@@ -54,6 +55,11 @@ function isNativeModule(root, name, dir) {
|
|
|
54
55
|
return false;
|
|
55
56
|
}
|
|
56
57
|
}
|
|
58
|
+
// Known without looking: React Navigation is JavaScript only (the directory reports its
|
|
59
|
+
// monorepo as native), and the renamed community packages all contain native code (some
|
|
60
|
+
// were removed from the directory).
|
|
61
|
+
if (/^@react-navigation\//.test(name)) return false;
|
|
62
|
+
if (RENAMED[name]) return true;
|
|
57
63
|
// Without node_modules, ask React Native Directory. Its ios/android flags mean "works on",
|
|
58
64
|
// not "has native code" (pure JS packages set them too), so prefer its hasNativeCode.
|
|
59
65
|
if (dir && dir.github && typeof dir.github.hasNativeCode === 'boolean') return dir.github.hasNativeCode;
|
|
@@ -232,6 +238,8 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
232
238
|
const codegen = forcedOffline ? {} : await registry.codegenSupport(archSuspects.map((n) => ({ name: n, version: versionOf(n) })));
|
|
233
239
|
|
|
234
240
|
const deps = [];
|
|
241
|
+
let pinnedCache = null;
|
|
242
|
+
const expoPinned = () => (pinnedCache ||= expoPinnedNames(project.root));
|
|
235
243
|
for (const name of names) {
|
|
236
244
|
const dir = directory[name];
|
|
237
245
|
const version = versionOf(name);
|
|
@@ -296,13 +304,15 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
296
304
|
});
|
|
297
305
|
}
|
|
298
306
|
if (!noNewArch && !dep.unmaintained && !stale && version && dep.latest && lineOf(version) !== lineOf(dep.latest)) {
|
|
307
|
+
// In Expo apps the SDK decides these versions: the SDK upgrade moves them, no separate work.
|
|
308
|
+
const expoManaged = !!project.expoVersion && movesWithExpoSdk(name, expoPinned());
|
|
299
309
|
add({
|
|
300
310
|
id: `dep-major:${name}`,
|
|
301
|
-
severity: 'low',
|
|
311
|
+
severity: expoManaged ? 'info' : 'low',
|
|
302
312
|
area: 'dependency',
|
|
303
313
|
title: `${name} ${version} → ${dep.latest}`,
|
|
304
|
-
detail: 'Major version behind.',
|
|
305
|
-
fix: { kind: 'bump-dep', name, from: version, to: dep.latest, native },
|
|
314
|
+
detail: expoManaged ? 'Major version behind; the Expo SDK upgrade moves it.' : 'Major version behind.',
|
|
315
|
+
fix: { kind: 'bump-dep', name, from: version, to: dep.latest, native, expoManaged },
|
|
306
316
|
});
|
|
307
317
|
}
|
|
308
318
|
}
|
|
@@ -421,7 +431,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
421
431
|
const v = installedVersion(project.root, name) || locked[name];
|
|
422
432
|
if (v && /^\d+\.\d+\.\d+/.test(v)) exact[name] = v;
|
|
423
433
|
}
|
|
424
|
-
const vulns = await vulnerableDependencies(registry, exact);
|
|
434
|
+
const vulns = await vulnerableDependencies(registry, exact, latest);
|
|
425
435
|
if (vulns === null) net.failed++;
|
|
426
436
|
for (const f of vulns || []) add(f);
|
|
427
437
|
}
|
package/src/compat.js
CHANGED
|
@@ -108,6 +108,8 @@ const rnLabel = (row) => (row.rnMax ? `0.${row.rnMin}–0.${row.rnMax}` : `0.${r
|
|
|
108
108
|
function versionLabel(row) {
|
|
109
109
|
const from = row.from.replace(/\.0$/, '');
|
|
110
110
|
if (!row.to) return `${from}+`;
|
|
111
|
+
// "2.99.99" means the rest of that major line.
|
|
112
|
+
if (/^\d+\.99\.99$/.test(row.to)) return `${from}+ (${row.to.split('.')[0]}.x line)`;
|
|
111
113
|
const to = row.to.replace(/\.99$/, '');
|
|
112
114
|
return from === to ? `${from}.x` : `${from}–${to}.x`;
|
|
113
115
|
}
|
package/src/expo-pins.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Libraries whose version an Expo SDK decides (from Expo's bundledNativeModules.json). When the
|
|
5
|
+
// app's own copy of that file is installed it is used instead; this list covers scans without
|
|
6
|
+
// node_modules.
|
|
7
|
+
const EXPO_PINNED = new Set([
|
|
8
|
+
'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', 'react-native-screens',
|
|
9
|
+
'react-native-safe-area-context', 'react-native-webview', 'react-native-svg', 'react-native-pager-view',
|
|
10
|
+
'react-native-maps', 'react-native-view-shot', 'react-native-get-random-values', 'react-native-keyboard-controller',
|
|
11
|
+
'@react-native-async-storage/async-storage', '@react-native-community/datetimepicker', '@react-native-community/slider',
|
|
12
|
+
'@react-native-community/netinfo', '@react-native-picker/picker', '@react-native-masked-view/masked-view',
|
|
13
|
+
'lottie-react-native', '@shopify/flash-list', '@shopify/react-native-skia', '@stripe/stripe-react-native',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function expoPinnedNames(root) {
|
|
17
|
+
try {
|
|
18
|
+
let dir = root;
|
|
19
|
+
while (true) {
|
|
20
|
+
const file = path.join(dir, 'node_modules', 'expo', 'bundledNativeModules.json');
|
|
21
|
+
if (fs.existsSync(file)) return new Set(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))));
|
|
22
|
+
const parent = path.dirname(dir);
|
|
23
|
+
if (parent === dir) return EXPO_PINNED;
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
return EXPO_PINNED;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Packages an Expo SDK upgrade moves on its own (`npx expo install --fix`): no separate work.
|
|
32
|
+
export function movesWithExpoSdk(name, pinned) {
|
|
33
|
+
return /^(expo-|@expo\/)/.test(name) || ['expo', 'jest-expo', 'react-native', 'react', 'react-dom'].includes(name) || pinned.has(name);
|
|
34
|
+
}
|
package/src/plan.js
CHANGED
|
@@ -3,6 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { UPGRADE_HELPER_URL, NEW_ARCH_ONLY_MINOR } from './rules.js';
|
|
4
4
|
import { SITE_URL } from './config.js';
|
|
5
5
|
import { COMPAT, bestRange } from './compat.js';
|
|
6
|
+
import { compareVersions } from './known-issues.js';
|
|
7
|
+
import { expoPinnedNames, movesWithExpoSdk } from './expo-pins.js';
|
|
6
8
|
|
|
7
9
|
// Turns a scan result into an ordered upgrade plan. The order matters more than the steps:
|
|
8
10
|
// stop leaks first, then remove blockers, switch architecture on the current version,
|
|
@@ -145,31 +147,27 @@ function safetyNetPhase(tool) {
|
|
|
145
147
|
};
|
|
146
148
|
}
|
|
147
149
|
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const parent = path.dirname(dir);
|
|
167
|
-
if (parent === dir) return EXPO_PINNED;
|
|
168
|
-
dir = parent;
|
|
150
|
+
// Package families that must share a major version: updating one alone crashes at runtime.
|
|
151
|
+
const FAMILIES = [
|
|
152
|
+
{ re: /^@react-navigation\//, label: 'React Navigation' },
|
|
153
|
+
{ re: /^@react-native-firebase\//, label: 'React Native Firebase' },
|
|
154
|
+
];
|
|
155
|
+
function bumpSteps(list, extra = () => '') {
|
|
156
|
+
const steps = [];
|
|
157
|
+
const done = new Set();
|
|
158
|
+
for (const d of list) {
|
|
159
|
+
if (done.has(d.name)) continue;
|
|
160
|
+
const fam = FAMILIES.find((f) => f.re.test(d.name));
|
|
161
|
+
const members = fam ? list.filter((x) => fam.re.test(x.name)) : [];
|
|
162
|
+
if (members.length > 1) {
|
|
163
|
+
for (const m of members) done.add(m.name);
|
|
164
|
+
steps.push(`${fam.label}, all together in one change (mixed majors crash at runtime): ${members.map((m) => `\`${m.name}\` ${m.from} → ${m.to}${extra(m)}`).join(', ')}.`);
|
|
165
|
+
} else {
|
|
166
|
+
done.add(d.name);
|
|
167
|
+
steps.push(`\`${d.name}\` ${d.from} → ${d.to}${extra(d)}`);
|
|
169
168
|
}
|
|
170
|
-
} catch {
|
|
171
|
-
return EXPO_PINNED;
|
|
172
169
|
}
|
|
170
|
+
return steps;
|
|
173
171
|
}
|
|
174
172
|
|
|
175
173
|
export function buildPlan(result) {
|
|
@@ -178,7 +176,7 @@ export function buildPlan(result) {
|
|
|
178
176
|
// In Expo projects `npx expo install` picks the version that matches the SDK.
|
|
179
177
|
const pm = isExpo ? { ...basePm, add: 'npx expo install' } : basePm;
|
|
180
178
|
const pinned = isExpo ? expoPinnedNames(result.project.root) : new Set();
|
|
181
|
-
const movesWithExpo = (name) => isExpo && (
|
|
179
|
+
const movesWithExpo = (name) => isExpo && movesWithExpoSdk(name, pinned);
|
|
182
180
|
const installed = new Set(result.deps.map((d) => d.name));
|
|
183
181
|
const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
|
|
184
182
|
const phases = [];
|
|
@@ -286,10 +284,13 @@ export function buildPlan(result) {
|
|
|
286
284
|
} else if (f.fix.kind === 'ats') {
|
|
287
285
|
sec.push({ sev, step: `In \`${f.fix.file}\`, set \`NSAllowsArbitraryLoads\` to false and list only the hosts that need HTTP under \`NSExceptionDomains\`.` });
|
|
288
286
|
} else if (f.fix.kind === 'security') {
|
|
289
|
-
|
|
287
|
+
// Hermes gets its own, more detailed step under legacy tooling.
|
|
288
|
+
const step = byKind('enable-hermes').length ? f.fix.step.replace('Turn on Hermes. ', '') : f.fix.step;
|
|
289
|
+
sec.push({ sev, step });
|
|
290
290
|
} else if (f.fix.kind === 'vuln-dep' && (f.fix.severity === 'critical' || f.fix.severity === 'high')) {
|
|
291
291
|
const v = f.fix;
|
|
292
|
-
sec.push({ sev, step:
|
|
292
|
+
if (v.noFix) sec.push({ sev, step: `\`${v.name}\` ${v.from} has ${v.severity} known vulnerabilities and no fixed release (the latest is ${v.latest}): replace it, or make sure it never handles untrusted input.` });
|
|
293
|
+
else sec.push({ sev, step: `Update \`${v.name}\` ${v.from}${v.to ? ` to ${v.to}${v.to.startsWith('a release') ? '' : ' or later'}` : ''}: it has ${v.severity} known vulnerabilities. ${movesWithExpo(v.name) ? 'Expo pins it: the SDK upgrade below moves it.' : 'Check its changelog for breaking changes.'}` });
|
|
293
294
|
}
|
|
294
295
|
}
|
|
295
296
|
const securitySteps = sec.sort((a, b) => a.sev - b.sev).map((x) => x.step);
|
|
@@ -364,12 +365,26 @@ export function buildPlan(result) {
|
|
|
364
365
|
}
|
|
365
366
|
|
|
366
367
|
const expoMoves = byKind('bump-dep').filter((d) => movesWithExpo(d.name));
|
|
367
|
-
|
|
368
|
+
// Libraries with a compatibility table: only as far as the current React Native supports.
|
|
369
|
+
// The React Native steps move them further.
|
|
370
|
+
const curMinor = result.project.reactNative ? Number(result.project.reactNative.split('.')[1]) : null;
|
|
371
|
+
const archNow = !byKind('enable-new-arch').length;
|
|
372
|
+
const nativeBumps = byKind('bump-dep')
|
|
373
|
+
.filter((d) => d.native && !movesWithExpo(d.name))
|
|
374
|
+
.map((d) => {
|
|
375
|
+
if (!COMPAT[d.name] || curMinor === null) return d;
|
|
376
|
+
const best = bestRange(d.name, curMinor, archNow);
|
|
377
|
+
if (!best) return d;
|
|
378
|
+
if (compareVersions(d.from, best.from) >= 0 && (!best.to || compareVersions(d.from, best.to) <= 0)) return null;
|
|
379
|
+
if (compareVersions(d.from, best.from) > 0) return null;
|
|
380
|
+
return { ...d, to: best.range, capped: `0.${curMinor}` };
|
|
381
|
+
})
|
|
382
|
+
.filter(Boolean);
|
|
368
383
|
if (nativeBumps.length) {
|
|
369
384
|
phases.push({
|
|
370
385
|
title: 'Update native modules on the current React Native version',
|
|
371
386
|
why: 'Newer releases of native modules usually add New Architecture support. Pick the newest release that still supports your current React Native version; check each changelog for its minimum.',
|
|
372
|
-
steps: nativeBumps
|
|
387
|
+
steps: bumpSteps(nativeBumps, (d) => (d.capped ? ` (the newest for React Native ${d.capped}; later versions come with the React Native steps below)` : '')),
|
|
373
388
|
});
|
|
374
389
|
}
|
|
375
390
|
|
|
@@ -541,7 +556,7 @@ export function buildPlan(result) {
|
|
|
541
556
|
phases.push({
|
|
542
557
|
title: 'Update JavaScript-only packages',
|
|
543
558
|
why: 'These do not block the upgrade. Do them last, one major at a time, following each migration guide.',
|
|
544
|
-
steps: jsBumps
|
|
559
|
+
steps: bumpSteps(jsBumps),
|
|
545
560
|
});
|
|
546
561
|
}
|
|
547
562
|
|
package/src/report-html.js
CHANGED
|
@@ -5,7 +5,7 @@ import { MASVS_GROUPS } from './security.js';
|
|
|
5
5
|
|
|
6
6
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
7
7
|
// Turns `code` spans from finding text into <code>.
|
|
8
|
-
const rich = (s) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
8
|
+
const rich = (s) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
9
9
|
|
|
10
10
|
// A single self-contained file that can be emailed or attached to a ticket.
|
|
11
11
|
// `brand` replaces NativeKeel branding with an agency's name.
|
|
@@ -26,8 +26,8 @@ export function htmlReport(result, { brand = null, plan = null } = {}) {
|
|
|
26
26
|
).join('\n');
|
|
27
27
|
const bumps = result.findings.filter(isBump);
|
|
28
28
|
const bumpTable = bumps.length
|
|
29
|
-
? `<details class="bumps"><summary>${bumps.length} package${bumps.length === 1 ? ' is' : 's are'} a major version behind <span class="muted">(low)</span
|
|
30
|
-
.map((f) => `<tr><td>${esc(f.fix.name)}</td><td>${esc(f.fix.from || '')}</td><td>${esc(f.fix.to || '')}</td><td class="muted">${f.fix.native
|
|
29
|
+
? `<details class="bumps"><summary>${bumps.length} package${bumps.length === 1 ? ' is' : 's are'} a major version behind${bumps.some((f) => f.fix.expoManaged) ? ` <span class="muted">(${bumps.filter((f) => f.fix.expoManaged).length} move with the Expo SDK upgrade)</span>` : ' <span class="muted">(low)</span>'}</summary><table><thead><tr><th>Package</th><th>Installed</th><th>Latest</th><th></th></tr></thead><tbody>${bumps
|
|
30
|
+
.map((f) => `<tr><td>${esc(f.fix.name)}</td><td>${esc(f.fix.from || '')}</td><td>${esc(f.fix.to || '')}</td><td class="muted">${[f.fix.native && 'native', f.fix.expoManaged && 'moves with Expo SDK'].filter(Boolean).join(', ')}</td></tr>`)
|
|
31
31
|
.join('')}</tbody></table></details>`
|
|
32
32
|
: '';
|
|
33
33
|
|
package/src/report-text.js
CHANGED
|
@@ -50,10 +50,17 @@ export function textReport(result, { color = true, verbose = false } = {}) {
|
|
|
50
50
|
out.push('');
|
|
51
51
|
continue;
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
// Packages the Expo SDK upgrade moves: one line, they are not separate work.
|
|
54
|
+
const expoMoves = group.filter((f) => f.fix && f.fix.expoManaged);
|
|
55
|
+
for (const f of group.filter((x) => !(x.fix && x.fix.expoManaged))) {
|
|
54
56
|
out.push(`${label[sev]} ${c.bold(f.title)}`);
|
|
55
57
|
out.push(c.gray(indent + f.detail));
|
|
56
58
|
}
|
|
59
|
+
if (expoMoves.length) {
|
|
60
|
+
const names = expoMoves.map((f) => f.fix.name);
|
|
61
|
+
out.push(`${label[sev]} ${c.bold(`${expoMoves.length} package${expoMoves.length === 1 ? '' : 's'} move with the Expo SDK upgrade`)}`);
|
|
62
|
+
out.push(c.gray(`${indent}${(verbose ? names : names.slice(0, 6)).join(', ')}${!verbose && names.length > 6 ? `, … (${names.length - 6} more with --verbose)` : ''}. \`npx expo install --fix\` sets the versions the new SDK expects.`));
|
|
63
|
+
}
|
|
57
64
|
out.push('');
|
|
58
65
|
}
|
|
59
66
|
|
package/src/security.js
CHANGED
|
@@ -33,12 +33,12 @@ const read = (file) => {
|
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
const SERVER_ONLY = /node\.?js|server[- ]side|\bhttp adapter\b|\bssr\b/i;
|
|
36
|
+
const SERVER_ONLY = /node\.?js|server[- ]side|\bhttp adapter\b|\bhttp\/2\b|\bssr\b|\bssrf\b|no_proxy|proxy-authorization|cloud metadata/i;
|
|
37
37
|
const SEVERITY = { critical: 'critical', high: 'high', moderate: 'medium', low: 'low', info: 'low' };
|
|
38
38
|
const RANK = ['low', 'medium', 'high', 'critical'];
|
|
39
39
|
|
|
40
40
|
// Advisories for the exact versions of runtime dependencies (devDependencies never ship).
|
|
41
|
-
export async function vulnerableDependencies(registry, versions) {
|
|
41
|
+
export async function vulnerableDependencies(registry, versions, latestOf = {}) {
|
|
42
42
|
const names = Object.keys(versions);
|
|
43
43
|
if (!names.length) return [];
|
|
44
44
|
const advisories = await registry.advisories(versions);
|
|
@@ -55,7 +55,18 @@ export async function vulnerableDependencies(registry, versions) {
|
|
|
55
55
|
const sevOf = (a) => (SERVER_ONLY.test(a.title || '') ? 'low' : SEVERITY[a.severity] || 'low');
|
|
56
56
|
const severity = relevant.map(sevOf).sort((a, b) => RANK.indexOf(b) - RANK.indexOf(a))[0];
|
|
57
57
|
const serverOnly = relevant.filter((a) => SERVER_ONLY.test(a.title || '')).length;
|
|
58
|
-
|
|
58
|
+
let fixedIn = minimumFixed(relevant);
|
|
59
|
+
// Compare with what is published: "<=2.0.2" when 2.0.2 is the latest release means no fix exists.
|
|
60
|
+
const newest = latestOf[name];
|
|
61
|
+
let noFix = false;
|
|
62
|
+
if (fixedIn && newest) {
|
|
63
|
+
const after = fixedIn.startsWith('a release after ') ? fixedIn.slice('a release after '.length) : null;
|
|
64
|
+
if (after) {
|
|
65
|
+
if (compareVersions(newest, after) > 0) fixedIn = newest;
|
|
66
|
+
else noFix = true;
|
|
67
|
+
} else if (compareVersions(newest, fixedIn) < 0) noFix = true;
|
|
68
|
+
if (noFix) fixedIn = null;
|
|
69
|
+
}
|
|
59
70
|
const top = relevant
|
|
60
71
|
.slice()
|
|
61
72
|
.sort((a, b) => RANK.indexOf(sevOf(b)) - RANK.indexOf(sevOf(a)))
|
|
@@ -65,9 +76,9 @@ export async function vulnerableDependencies(registry, versions) {
|
|
|
65
76
|
id: `vuln:${name}`,
|
|
66
77
|
severity,
|
|
67
78
|
area: 'security',
|
|
68
|
-
title: `${name} ${version}: ${relevant.length} known vulnerabilit${relevant.length === 1 ? 'y' : 'ies'}`,
|
|
69
|
-
detail: `${top.join('; ')}${relevant.length > 3 ? `; and ${relevant.length - 3} more` : ''}. ${fixedIn ? `Fixed in ${fixedIn} or later.` : 'Check the advisories for a fixed version.'}${serverOnly ? ` ${serverOnly} of them concern Node.js/server use only and are counted as low.` : ''} Source: GitHub Advisory Database (via npm).`,
|
|
70
|
-
fix: { kind: 'vuln-dep', name, from: version, to: fixedIn, severity },
|
|
79
|
+
title: `${name} ${version}: ${relevant.length} known vulnerabilit${relevant.length === 1 ? 'y' : 'ies'}${fixedIn ? ` (fixed in ${fixedIn})` : noFix ? ' (no fixed release)' : ''}`,
|
|
80
|
+
detail: `${top.join('; ')}${relevant.length > 3 ? `; and ${relevant.length - 3} more` : ''}. ${fixedIn ? `Fixed in ${fixedIn}${fixedIn.startsWith('a release') ? '' : ' or later'}.` : noFix ? `No fixed release exists yet (the latest is ${newest}): replace it, or make sure it never handles untrusted input.` : 'Check the advisories for a fixed version.'}${serverOnly ? ` ${serverOnly} of them concern Node.js/server use only and are counted as low.` : ''} Source: GitHub Advisory Database (via npm).`,
|
|
81
|
+
fix: { kind: 'vuln-dep', name, from: version, to: fixedIn, noFix, latest: newest || null, severity },
|
|
71
82
|
});
|
|
72
83
|
}
|
|
73
84
|
return findings;
|
|
@@ -87,15 +98,17 @@ function applies(version, range) {
|
|
|
87
98
|
});
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
// The lowest version outside every advisory's range. "<1.2.3" is fixed in 1.2.3; "<=1.2.2" only
|
|
102
|
+
// says "after 1.2.2", which counts when it is the highest bound.
|
|
90
103
|
function minimumFixed(advisories) {
|
|
91
104
|
let fixed = null;
|
|
92
105
|
for (const a of advisories) {
|
|
93
|
-
const bounds = [...String(a.vulnerable_versions || '').matchAll(
|
|
106
|
+
const bounds = [...String(a.vulnerable_versions || '').matchAll(/<(=?)\s*(\d+\.\d+\.\d+)/g)].map((m) => ({ v: m[2], after: m[1] === '=' }));
|
|
94
107
|
if (!bounds.length) return null; // an advisory without a fixed version
|
|
95
|
-
const hi = bounds.sort(compareVersions).at(-1);
|
|
96
|
-
if (!fixed || compareVersions(hi, fixed) > 0) fixed = hi;
|
|
108
|
+
const hi = bounds.sort((x, y) => compareVersions(x.v, y.v) || (x.after ? 1 : -1)).at(-1);
|
|
109
|
+
if (!fixed || compareVersions(hi.v, fixed.v) > 0 || (compareVersions(hi.v, fixed.v) === 0 && hi.after)) fixed = hi;
|
|
97
110
|
}
|
|
98
|
-
return fixed;
|
|
111
|
+
return fixed && (fixed.after ? `a release after ${fixed.v}` : fixed.v);
|
|
99
112
|
}
|
|
100
113
|
|
|
101
114
|
// A password (or the variables built from it) passed to a log, crash report, analytics event,
|