nativekeel 0.1.9 → 0.1.11
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 +2 -2
- package/package.json +1 -1
- package/src/plan.js +20 -0
- package/src/report-html.js +3 -0
- package/src/report-text.js +7 -1
- package/src/stability.js +62 -0
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.11:** the GitHub Action is fixed and tested on every change. **0.1.10:** every report opens with *Start here*, the three most urgent first steps; missing iOS permission texts (a crash and an App Review rejection) are caught. Recent releases added known vulnerabilities in the exact versions you ship, password leaks into logs and databases, unsafe WebViews, open Android components, plain-HTTP API calls and runtime crash risks. Tuned on 280+ open-source React Native and Expo apps to keep false alarms out. [All releases →](https://nativekeel.com/changelog)
|
|
10
10
|
|
|
11
11
|
It answers the questions that decide whether your app can still ship:
|
|
12
12
|
|
|
@@ -19,7 +19,7 @@ It answers the questions that decide whether your app can still ship:
|
|
|
19
19
|
- **Is transport security off?** Cleartext HTTP or `debuggable` in the release Android manifest, `NSAllowsArbitraryLoads` on iOS.
|
|
20
20
|
- **Do you ship known vulnerabilities?** Advisories from the GitHub Advisory Database for the exact versions in your lockfile or `node_modules`. Advisories that only affect Node.js servers are kept, at low severity.
|
|
21
21
|
- **Can someone read your users' data?** Passwords that flow into crash reports, analytics, remote databases or plain AsyncStorage (followed through intermediate variables), WebViews that let page scripts read local files or load HTTP into HTTPS, Android backups that copy tokens, Android components any app can start, API calls over plain HTTP.
|
|
22
|
-
- **Will it crash at runtime?** Reanimated without its Babel plugin or `react-native-worklets`, mixed `@react-native-firebase/*` or `@react-navigation/*` majors, a second copy of React or React Native inside a dependency, Kotlin modules that cannot load through the New Architecture interop layer, AppDelegate setups that crash in Release.
|
|
22
|
+
- **Will it crash at runtime?** Reanimated without its Babel plugin or `react-native-worklets`, mixed `@react-native-firebase/*` or `@react-navigation/*` majors, a second copy of React or React Native inside a dependency, Kotlin modules that cannot load through the New Architecture interop layer, AppDelegate setups that crash in Release, camera/photos/location access without the Info.plist description iOS requires.
|
|
23
23
|
- **Leftovers from old templates?** Flipper, JavaScriptCore instead of Hermes.
|
|
24
24
|
|
|
25
25
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nativekeel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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/plan.js
CHANGED
|
@@ -542,6 +542,11 @@ export function buildPlan(result) {
|
|
|
542
542
|
});
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
// Store deadlines sit together, right after the target SDK.
|
|
546
|
+
const storeAt = phases.findIndex((p) => p.title === 'Meet App Store and Google Play requirements');
|
|
547
|
+
const targetAt = phases.findIndex((p) => p.title === 'Meet the Google Play target SDK');
|
|
548
|
+
if (storeAt > targetAt + 1 && targetAt >= 0) phases.splice(targetAt + 1, 0, ...phases.splice(storeAt, 1));
|
|
549
|
+
|
|
545
550
|
// Only worth it when the plan changes code: secrets alone do not need UI tests.
|
|
546
551
|
const leaksFirst = phases[0] && phases[0].title === 'Stop the leaks' ? 1 : 0;
|
|
547
552
|
if (phases.length > leaksFirst) {
|
|
@@ -582,3 +587,18 @@ export function planToMarkdown(result, plan) {
|
|
|
582
587
|
lines.push('---', `No time to do it yourself? We do fixed-price upgrades on a branch of your repo: ${SITE_URL}/#services`);
|
|
583
588
|
return `${lines.join('\n')}\n`;
|
|
584
589
|
}
|
|
590
|
+
|
|
591
|
+
// The first concrete step of the first few phases: what to do today. Shown at the top of the
|
|
592
|
+
// HTML report and under the summary in the terminal.
|
|
593
|
+
const URGENCY = ['Stop the leaks', 'Fix crash risks', 'Meet the Google Play target SDK', 'Meet App Store and Google Play requirements', 'Close security gaps', 'Avoid known traps'];
|
|
594
|
+
|
|
595
|
+
export function startHere(result, count = 3) {
|
|
596
|
+
const skip = new Set(['Set up a safety net', 'Verify before you ship']);
|
|
597
|
+
const rank = (t) => (URGENCY.indexOf(t) >= 0 ? URGENCY.indexOf(t) : URGENCY.length);
|
|
598
|
+
return buildPlan(result)
|
|
599
|
+
.phases.filter((p) => !skip.has(p.title) && p.steps.length)
|
|
600
|
+
.map((p, i) => ({ p, i }))
|
|
601
|
+
.sort((a, b) => rank(a.p.title) - rank(b.p.title) || a.i - b.i)
|
|
602
|
+
.slice(0, count)
|
|
603
|
+
.map(({ p }) => ({ phase: p.title, step: p.steps[0].replace(/ The [^.]* phase below gets you there;.*$/, '') }));
|
|
604
|
+
}
|
package/src/report-html.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SEVERITIES, countBySeverity } from './analyze.js';
|
|
2
2
|
import { SITE_URL } from './config.js';
|
|
3
|
+
import { startHere } from './plan.js';
|
|
3
4
|
|
|
4
5
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
5
6
|
// Turns `code` spans from finding text into <code>.
|
|
@@ -58,6 +59,7 @@ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:32px 0 12px}h2 small{f
|
|
|
58
59
|
.stat.critical b{color:var(--critical)}.stat.high b{color:var(--high)}
|
|
59
60
|
.facts{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 16px;margin-top:12px;display:flex;flex-wrap:wrap;gap:6px 24px;font-size:14px}
|
|
60
61
|
ul.list{list-style:none;padding:0;margin:0;display:grid;gap:8px}
|
|
62
|
+
.start{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:8px;padding:6px 18px 10px;margin-top:20px}.start h2{margin:10px 0 6px}.start li{margin:6px 0}
|
|
61
63
|
details.bumps{margin-top:8px;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--low);border-radius:8px;padding:10px 14px}details.bumps summary{cursor:pointer;font-weight:600}details.bumps table{width:100%;border-collapse:collapse;margin-top:10px;font-size:14px}details.bumps th,details.bumps td{text-align:left;padding:4px 8px;border-top:1px solid var(--line)}.muted{color:var(--muted);font-weight:400}
|
|
62
64
|
.f{display:flex;gap:12px;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--low);border-radius:8px;padding:10px 14px}
|
|
63
65
|
.f p{margin:2px 0 0;color:var(--muted);font-size:14px}.f strong{overflow-wrap:anywhere}
|
|
@@ -88,6 +90,7 @@ ${p.expo ? `<span>Expo SDK <b>${esc(p.expo.split('.')[0])}</b></span>` : ''}
|
|
|
88
90
|
${p.android && p.android.targetSdk ? `<span>targetSdk <b>${p.android.targetSdk}</b></span>` : ''}
|
|
89
91
|
<span>${result.deps.length} dependencies (${result.deps.filter((d) => d.native).length} native)</span>
|
|
90
92
|
</div>
|
|
93
|
+
${n.critical + n.high > 0 ? `<section class="start"><h2>Start here</h2><ol>${startHere(result).map((x) => `<li>${rich(x.step)} <span class="muted">(${esc(x.phase)})</span></li>`).join('')}</ol><p class="muted">The full, ordered plan: <code>npx nativekeel plan</code>.</p></section>` : ''}
|
|
91
94
|
<h2>Findings</h2>
|
|
92
95
|
${result.findings.length ? `${rows ? `<ul class="list">${rows}</ul>` : ''}${bumpTable}` : '<p>No issues found.</p>'}
|
|
93
96
|
${planHtml}
|
package/src/report-text.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SEVERITIES, countBySeverity } from './analyze.js';
|
|
2
2
|
import { SITE_URL } from './config.js';
|
|
3
|
+
import { startHere } from './plan.js';
|
|
3
4
|
|
|
4
5
|
export function textReport(result, { color = true, verbose = false } = {}) {
|
|
5
6
|
const paint = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
@@ -64,7 +65,12 @@ export function textReport(result, { color = true, verbose = false } = {}) {
|
|
|
64
65
|
|
|
65
66
|
if (n.critical + n.high > 0) {
|
|
66
67
|
out.push('');
|
|
67
|
-
out.push(
|
|
68
|
+
out.push(c.bold('Start here'));
|
|
69
|
+
const plain = (t) => t.replace(/\*\*/g, '').replace(/`/g, '');
|
|
70
|
+
const cut = (t) => (t.length > 150 ? `${t.slice(0, 147).replace(/\s+\S*$/, '')}…` : t);
|
|
71
|
+
startHere(result).forEach((s, i) => out.push(` ${i + 1}. ${cut(plain(s.step))}`));
|
|
72
|
+
out.push('');
|
|
73
|
+
out.push(`Next: ${c.bold('npx nativekeel plan')} writes the full step-by-step plan.`);
|
|
68
74
|
out.push(c.gray(`No time to do it yourself? Fixed-price upgrades: ${SITE_URL}/#services`));
|
|
69
75
|
} else if (!result.findings.length) {
|
|
70
76
|
out.push(c.green('Nothing to fix. This app is in good shape.'));
|
package/src/stability.js
CHANGED
|
@@ -158,6 +158,7 @@ export function stabilityChecks(project) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
for (const f of iosUsageDescriptions(project)) add(f);
|
|
161
162
|
return findings;
|
|
162
163
|
}
|
|
163
164
|
|
|
@@ -184,3 +185,64 @@ function listPackages(nm) {
|
|
|
184
185
|
}
|
|
185
186
|
return out;
|
|
186
187
|
}
|
|
188
|
+
|
|
189
|
+
// iOS terminates an app that touches a protected resource without the matching usage
|
|
190
|
+
// description in Info.plist, and App Review rejects it. Expo managed apps get these from config
|
|
191
|
+
// plugins, so only projects with their own ios/ folder are checked.
|
|
192
|
+
const USAGE_KEYS = [
|
|
193
|
+
{ key: 'NSCameraUsageDescription', what: 'the camera', pkgs: ['react-native-vision-camera', 'react-native-camera', 'expo-camera', 'react-native-camera-kit', 'react-native-qrcode-scanner'] },
|
|
194
|
+
{ key: 'NSPhotoLibraryUsageDescription', what: 'the photo library', pkgs: ['@react-native-camera-roll/camera-roll', '@react-native-community/cameraroll', 'expo-media-library', 'react-native-image-crop-picker'] },
|
|
195
|
+
{ key: 'NSLocationWhenInUseUsageDescription', what: 'location', pkgs: ['@react-native-community/geolocation', 'react-native-geolocation-service', 'expo-location', 'react-native-background-geolocation', '@mauron85/react-native-background-geolocation'] },
|
|
196
|
+
{ key: 'NSMicrophoneUsageDescription', what: 'the microphone', pkgs: ['@react-native-voice/voice', 'react-native-audio-recorder-player', 'expo-audio', 'react-native-audio-record'] },
|
|
197
|
+
{ key: 'NSContactsUsageDescription', what: 'contacts', pkgs: ['react-native-contacts', 'expo-contacts'] },
|
|
198
|
+
{ key: 'NSCalendarsUsageDescription', what: 'calendars', pkgs: ['react-native-calendar-events', 'expo-calendar'] },
|
|
199
|
+
{ key: 'NSBluetoothAlwaysUsageDescription', what: 'Bluetooth', pkgs: ['react-native-ble-plx', 'react-native-ble-manager'] },
|
|
200
|
+
{ key: 'NSFaceIDUsageDescription', what: 'Face ID', pkgs: ['react-native-biometrics', '@sbaiahmed1/react-native-biometrics', 'expo-local-authentication', 'react-native-touch-id'] },
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
export function iosUsageDescriptions(project) {
|
|
204
|
+
const iosDir = path.join(project.root, 'ios');
|
|
205
|
+
if (!fs.existsSync(iosDir) || project.managed) return [];
|
|
206
|
+
const plists = [];
|
|
207
|
+
const walk = (dir, depth) => {
|
|
208
|
+
let entries = [];
|
|
209
|
+
try {
|
|
210
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
211
|
+
} catch {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const e of entries) {
|
|
215
|
+
if (e.isDirectory() && depth < 2 && !['Pods', 'build', 'DerivedData'].includes(e.name) && !/Tests?$|Extension$|Widget/.test(e.name)) walk(path.join(dir, e.name), depth + 1);
|
|
216
|
+
else if (e.name === 'Info.plist') plists.push(path.join(dir, e.name));
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
walk(iosDir, 0);
|
|
220
|
+
if (!plists.length) return [];
|
|
221
|
+
const text = plists.map(readText).join('\n');
|
|
222
|
+
// Expo projects can add the keys at prebuild from app.json/app.config (infoPlist or a config
|
|
223
|
+
// plugin of the same package): count those too.
|
|
224
|
+
const expoConfig = project.expoVersion ? ['app.json', 'app.config.js', 'app.config.ts'].map((f) => readText(path.join(project.root, f)) || '').join('\n') : '';
|
|
225
|
+
const deps = { ...project.deps };
|
|
226
|
+
const missing = [];
|
|
227
|
+
for (const u of USAGE_KEYS) {
|
|
228
|
+
// In Expo projects, prebuild applies the config plugins of installed expo-* packages on its
|
|
229
|
+
// own, and they add default usage strings.
|
|
230
|
+
const used = u.pkgs.filter((p) => deps[p] && !(project.expoVersion && p.startsWith('expo-')));
|
|
231
|
+
if (!used.length) continue;
|
|
232
|
+
const keys = u.key === 'NSPhotoLibraryUsageDescription' ? [u.key, 'NSPhotoLibraryAddUsageDescription'] : [u.key];
|
|
233
|
+
const inPlist = keys.some((k) => text.includes(`<key>${k}</key>`));
|
|
234
|
+
const inExpo = keys.some((k) => expoConfig.includes(k)) || used.some((p) => expoConfig.includes(`"${p}"`) || expoConfig.includes(`'${p}'`));
|
|
235
|
+
if (!inPlist && !inExpo) missing.push({ ...u, used });
|
|
236
|
+
}
|
|
237
|
+
if (!missing.length) return [];
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
id: 'crash-ios-usage-description',
|
|
241
|
+
severity: 'high',
|
|
242
|
+
area: 'crash',
|
|
243
|
+
title: `Info.plist is missing ${missing.length === 1 ? 'a usage description' : `${missing.length} usage descriptions`} (${missing.map((m) => m.key).join(', ')})`,
|
|
244
|
+
detail: `${missing.map((m) => `${m.used[0]} uses ${m.what}`).join('; ')}. iOS terminates the app the moment it asks for access without the matching key, and App Review rejects the build. Add each key with a sentence that says why the app needs it.`,
|
|
245
|
+
fix: { kind: 'crash', step: `Add ${missing.map((m) => `\`${m.key}\``).join(', ')} to the app's Info.plist, each with a user-facing reason.` },
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
}
|