nativekeel 0.1.17 → 0.1.19
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 +6 -1
- package/src/compat.js +2 -0
- package/src/plan.js +52 -5
- package/src/report-html.js +1 -1
- package/src/security.js +16 -5
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.19:** better plans for apps that are far behind: library targets that fit the React Native you have, not the latest. **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. 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.19",
|
|
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
|
@@ -55,6 +55,11 @@ function isNativeModule(root, name, dir) {
|
|
|
55
55
|
return false;
|
|
56
56
|
}
|
|
57
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;
|
|
58
63
|
// Without node_modules, ask React Native Directory. Its ios/android flags mean "works on",
|
|
59
64
|
// not "has native code" (pure JS packages set them too), so prefer its hasNativeCode.
|
|
60
65
|
if (dir && dir.github && typeof dir.github.hasNativeCode === 'boolean') return dir.github.hasNativeCode;
|
|
@@ -426,7 +431,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
426
431
|
const v = installedVersion(project.root, name) || locked[name];
|
|
427
432
|
if (v && /^\d+\.\d+\.\d+/.test(v)) exact[name] = v;
|
|
428
433
|
}
|
|
429
|
-
const vulns = await vulnerableDependencies(registry, exact);
|
|
434
|
+
const vulns = await vulnerableDependencies(registry, exact, latest);
|
|
430
435
|
if (vulns === null) net.failed++;
|
|
431
436
|
for (const f of vulns || []) add(f);
|
|
432
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/plan.js
CHANGED
|
@@ -3,6 +3,7 @@ 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';
|
|
6
7
|
import { expoPinnedNames, movesWithExpoSdk } from './expo-pins.js';
|
|
7
8
|
|
|
8
9
|
// Turns a scan result into an ordered upgrade plan. The order matters more than the steps:
|
|
@@ -146,6 +147,29 @@ function safetyNetPhase(tool) {
|
|
|
146
147
|
};
|
|
147
148
|
}
|
|
148
149
|
|
|
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)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return steps;
|
|
171
|
+
}
|
|
172
|
+
|
|
149
173
|
export function buildPlan(result) {
|
|
150
174
|
const isExpo = !!result.project.expo;
|
|
151
175
|
const basePm = commands[packageManager(result.project.root)];
|
|
@@ -153,6 +177,8 @@ export function buildPlan(result) {
|
|
|
153
177
|
const pm = isExpo ? { ...basePm, add: 'npx expo install' } : basePm;
|
|
154
178
|
const pinned = isExpo ? expoPinnedNames(result.project.root) : new Set();
|
|
155
179
|
const movesWithExpo = (name) => isExpo && movesWithExpoSdk(name, pinned);
|
|
180
|
+
// Packages the React Native upgrade steps move (React Native itself and the compatibility tables).
|
|
181
|
+
const movesWithRn = (name) => !isExpo && !!byKind('upgrade-rn').length && (name === 'react-native' || !!COMPAT[name]);
|
|
156
182
|
const installed = new Set(result.deps.map((d) => d.name));
|
|
157
183
|
const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
|
|
158
184
|
const phases = [];
|
|
@@ -260,10 +286,13 @@ export function buildPlan(result) {
|
|
|
260
286
|
} else if (f.fix.kind === 'ats') {
|
|
261
287
|
sec.push({ sev, step: `In \`${f.fix.file}\`, set \`NSAllowsArbitraryLoads\` to false and list only the hosts that need HTTP under \`NSExceptionDomains\`.` });
|
|
262
288
|
} else if (f.fix.kind === 'security') {
|
|
263
|
-
|
|
289
|
+
// Hermes gets its own, more detailed step under legacy tooling.
|
|
290
|
+
const step = byKind('enable-hermes').length ? f.fix.step.replace('Turn on Hermes. ', '') : f.fix.step;
|
|
291
|
+
sec.push({ sev, step });
|
|
264
292
|
} else if (f.fix.kind === 'vuln-dep' && (f.fix.severity === 'critical' || f.fix.severity === 'high')) {
|
|
265
293
|
const v = f.fix;
|
|
266
|
-
sec.push({ sev, step:
|
|
294
|
+
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.` });
|
|
295
|
+
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.' : movesWithRn(v.name) ? 'The React Native steps below move it past this version; if you ship before them, update it now.' : 'Check its changelog for breaking changes.'}` });
|
|
267
296
|
}
|
|
268
297
|
}
|
|
269
298
|
const securitySteps = sec.sort((a, b) => a.sev - b.sev).map((x) => x.step);
|
|
@@ -338,12 +367,30 @@ export function buildPlan(result) {
|
|
|
338
367
|
}
|
|
339
368
|
|
|
340
369
|
const expoMoves = byKind('bump-dep').filter((d) => movesWithExpo(d.name));
|
|
341
|
-
|
|
370
|
+
// Libraries with a compatibility table: only as far as the current React Native supports.
|
|
371
|
+
// The React Native steps move them further.
|
|
372
|
+
const curMinor = result.project.reactNative ? Number(result.project.reactNative.split('.')[1]) : null;
|
|
373
|
+
const archNow = !byKind('enable-new-arch').length;
|
|
374
|
+
const rnTarget = byKind('upgrade-rn')[0];
|
|
375
|
+
const rnBehind = !!(rnTarget && curMinor !== null && Number(rnTarget.to.split('.')[1]) - curMinor >= 3);
|
|
376
|
+
const nativeBumps = byKind('bump-dep')
|
|
377
|
+
.filter((d) => d.native && !movesWithExpo(d.name))
|
|
378
|
+
.map((d) => {
|
|
379
|
+
if (!COMPAT[d.name] || curMinor === null) return d;
|
|
380
|
+
const best = bestRange(d.name, curMinor, archNow);
|
|
381
|
+
if (!best) return null; // the table starts later: the React Native steps move it
|
|
382
|
+
if (compareVersions(d.from, best.from) >= 0 && (!best.to || compareVersions(d.from, best.to) <= 0)) return null;
|
|
383
|
+
if (compareVersions(d.from, best.from) > 0) return null;
|
|
384
|
+
return { ...d, to: best.range, capped: `0.${curMinor}` };
|
|
385
|
+
})
|
|
386
|
+
.filter(Boolean)
|
|
387
|
+
// Far behind: the latest release usually needs a newer React Native than the app has.
|
|
388
|
+
.map((d) => (rnBehind && !d.capped ? { ...d, to: `the newest release that supports React Native 0.${curMinor} (latest is ${d.to})` } : d));
|
|
342
389
|
if (nativeBumps.length) {
|
|
343
390
|
phases.push({
|
|
344
391
|
title: 'Update native modules on the current React Native version',
|
|
345
392
|
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.',
|
|
346
|
-
steps: nativeBumps
|
|
393
|
+
steps: bumpSteps(nativeBumps, (d) => (d.capped ? ` (the newest for React Native ${d.capped}; later versions come with the React Native steps below)` : '')),
|
|
347
394
|
});
|
|
348
395
|
}
|
|
349
396
|
|
|
@@ -515,7 +562,7 @@ export function buildPlan(result) {
|
|
|
515
562
|
phases.push({
|
|
516
563
|
title: 'Update JavaScript-only packages',
|
|
517
564
|
why: 'These do not block the upgrade. Do them last, one major at a time, following each migration guide.',
|
|
518
|
-
steps: jsBumps
|
|
565
|
+
steps: bumpSteps(jsBumps),
|
|
519
566
|
});
|
|
520
567
|
}
|
|
521
568
|
|
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.
|
package/src/security.js
CHANGED
|
@@ -38,7 +38,7 @@ const SEVERITY = { critical: 'critical', high: 'high', moderate: 'medium', 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'}${fixedIn ? ` (fixed in ${fixedIn})` : ''}`,
|
|
69
|
-
detail: `${top.join('; ')}${relevant.length > 3 ? `; and ${relevant.length - 3} more` : ''}. ${fixedIn ? `Fixed in ${fixedIn}${fixedIn.startsWith('a release') ? '' : ' 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;
|