nativekeel 0.1.15 → 0.1.17
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/analyze.js +10 -5
- package/src/expo-pins.js +34 -0
- package/src/native.js +3 -2
- package/src/plan.js +3 -29
- package/src/report-html.js +3 -2
- package/src/report-text.js +8 -1
- package/src/security.js +101 -8
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.17:** every vulnerable package shows the version that fixes it, and Expo apps get one line for the packages the SDK upgrade moves instead of a long list. **0.1.16:** deep-link checks: unverified Android App Links, and the template `myapp://` scheme that other apps can claim (MASVS-PLATFORM). 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
|
|
@@ -38,7 +38,7 @@ Start here
|
|
|
38
38
|
- **Network:** TLS certificate checks turned off in native code, cleartext HTTP, `NSAllowsArbitraryLoads`, plain-HTTP API calls, user-installed CAs in release.
|
|
39
39
|
- **Backend rules:** Firebase Realtime Database, Firestore and Storage rules open to anyone or in test mode.
|
|
40
40
|
- **Cryptography:** hardcoded encryption keys, MD5/SHA-1 on passwords, ECB mode, `Math.random` for nonces and salts.
|
|
41
|
-
- **Platform:** WebViews that read local files or load HTTP into HTTPS, Android components any app can start.
|
|
41
|
+
- **Platform:** WebViews that read local files or load HTTP into HTTPS, Android components any app can start, unverified deep links and the template `myapp://` URL scheme.
|
|
42
42
|
- **Dependencies and reverse engineering:** known vulnerabilities in the exact versions you ship (GitHub Advisory Database), source maps in the app, Hermes or R8 off.
|
|
43
43
|
|
|
44
44
|
These are static checks: they catch the common, detectable mistakes, not everything a penetration test of the running app and backend would.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nativekeel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
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
|
@@ -6,8 +6,9 @@ import { scanSecrets } from './secrets.js';
|
|
|
6
6
|
import { nativeChecks } from './native.js';
|
|
7
7
|
import { stabilityChecks } from './stability.js';
|
|
8
8
|
import { performanceChecks } from './performance.js';
|
|
9
|
-
import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls, cloudRules, insecureTls, weakCrypto, tokenStorage, reverseEngineering, expoConfigSecrets, masvsOf, MASVS_GROUPS } from './security.js';
|
|
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';
|
|
@@ -232,6 +233,8 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
232
233
|
const codegen = forcedOffline ? {} : await registry.codegenSupport(archSuspects.map((n) => ({ name: n, version: versionOf(n) })));
|
|
233
234
|
|
|
234
235
|
const deps = [];
|
|
236
|
+
let pinnedCache = null;
|
|
237
|
+
const expoPinned = () => (pinnedCache ||= expoPinnedNames(project.root));
|
|
235
238
|
for (const name of names) {
|
|
236
239
|
const dir = directory[name];
|
|
237
240
|
const version = versionOf(name);
|
|
@@ -296,13 +299,15 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
296
299
|
});
|
|
297
300
|
}
|
|
298
301
|
if (!noNewArch && !dep.unmaintained && !stale && version && dep.latest && lineOf(version) !== lineOf(dep.latest)) {
|
|
302
|
+
// In Expo apps the SDK decides these versions: the SDK upgrade moves them, no separate work.
|
|
303
|
+
const expoManaged = !!project.expoVersion && movesWithExpoSdk(name, expoPinned());
|
|
299
304
|
add({
|
|
300
305
|
id: `dep-major:${name}`,
|
|
301
|
-
severity: 'low',
|
|
306
|
+
severity: expoManaged ? 'info' : 'low',
|
|
302
307
|
area: 'dependency',
|
|
303
308
|
title: `${name} ${version} → ${dep.latest}`,
|
|
304
|
-
detail: 'Major version behind.',
|
|
305
|
-
fix: { kind: 'bump-dep', name, from: version, to: dep.latest, native },
|
|
309
|
+
detail: expoManaged ? 'Major version behind; the Expo SDK upgrade moves it.' : 'Major version behind.',
|
|
310
|
+
fix: { kind: 'bump-dep', name, from: version, to: dep.latest, native, expoManaged },
|
|
306
311
|
});
|
|
307
312
|
}
|
|
308
313
|
}
|
|
@@ -408,7 +413,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
408
413
|
for (const f of performanceChecks(project)) add(f);
|
|
409
414
|
|
|
410
415
|
// Security beyond leaked keys.
|
|
411
|
-
for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root), ...exportedComponents(project.root), ...plainHttpCalls(project.root), ...cloudRules(project.root, project.deps, now), ...insecureTls(project.root), ...weakCrypto(project.root), ...tokenStorage(project.root), ...reverseEngineering(project.root, project), ...expoConfigSecrets(project.root)]) add(f);
|
|
416
|
+
for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root), ...exportedComponents(project.root), ...plainHttpCalls(project.root), ...cloudRules(project.root, project.deps, now), ...insecureTls(project.root), ...weakCrypto(project.root), ...tokenStorage(project.root), ...reverseEngineering(project.root, project), ...expoConfigSecrets(project.root), ...deepLinks(project.root, project.deps)]) add(f);
|
|
412
417
|
if (!forcedOffline) {
|
|
413
418
|
// Exact versions only (installed, else the lockfile): a range would be a guess.
|
|
414
419
|
const locked = lockedVersions(project.root, project.deps);
|
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/native.js
CHANGED
|
@@ -200,7 +200,8 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
200
200
|
patched.push({ file: f, pkg, version });
|
|
201
201
|
const text = read(path.join(patchDir, f)) || '';
|
|
202
202
|
const buildPaths = (text.match(/^diff --git a\/node_modules\/\S+?\/(?:android|ios)\/(?:build|\.cxx|\.gradle)\//gm) || []).length;
|
|
203
|
-
|
|
203
|
+
// Finder/Explorer junk (.DS_Store, Thumbs.db) caught in a patch is harmless noise.
|
|
204
|
+
const binary = text.split(/^diff --git /m).some((part) => /^GIT binary patch|^Binary files /m.test(part) && !/^\S*?(?:\.DS_Store|Thumbs\.db)\b/.test(part));
|
|
204
205
|
if (buildPaths || binary) {
|
|
205
206
|
add({
|
|
206
207
|
id: `patch-artifacts:${f}`,
|
|
@@ -400,7 +401,7 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
400
401
|
return false;
|
|
401
402
|
}
|
|
402
403
|
};
|
|
403
|
-
const signing = tracked.filter((f) => /\.(jks|keystore)$/i.test(f) && !/debug/i.test(path.basename(f)) && nonEmpty(f));
|
|
404
|
+
const signing = tracked.filter((f) => /\.(jks|keystore)$/i.test(f) && !/debug|fake|dummy/i.test(path.basename(f)) && !/(?:^|\/)(?:__tests__|tests?|e2e|fixtures?)\//.test(f) && nonEmpty(f));
|
|
404
405
|
for (const f of signing) {
|
|
405
406
|
add({
|
|
406
407
|
id: `signing-keystore:${f}`,
|
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 { expoPinnedNames, movesWithExpoSdk } from './expo-pins.js';
|
|
6
7
|
|
|
7
8
|
// Turns a scan result into an ordered upgrade plan. The order matters more than the steps:
|
|
8
9
|
// stop leaks first, then remove blockers, switch architecture on the current version,
|
|
@@ -145,40 +146,13 @@ function safetyNetPhase(tool) {
|
|
|
145
146
|
};
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
// Libraries whose version an Expo SDK decides (from Expo's bundledNativeModules.json). When the
|
|
149
|
-
// app's own copy of that file is installed it is used instead; this list covers scans without
|
|
150
|
-
// node_modules.
|
|
151
|
-
const EXPO_PINNED = new Set([
|
|
152
|
-
'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', 'react-native-screens',
|
|
153
|
-
'react-native-safe-area-context', 'react-native-webview', 'react-native-svg', 'react-native-pager-view',
|
|
154
|
-
'react-native-maps', 'react-native-view-shot', 'react-native-get-random-values', 'react-native-keyboard-controller',
|
|
155
|
-
'@react-native-async-storage/async-storage', '@react-native-community/datetimepicker', '@react-native-community/slider',
|
|
156
|
-
'@react-native-community/netinfo', '@react-native-picker/picker', '@react-native-masked-view/masked-view',
|
|
157
|
-
'lottie-react-native', '@shopify/flash-list', '@shopify/react-native-skia', '@stripe/stripe-react-native',
|
|
158
|
-
]);
|
|
159
|
-
|
|
160
|
-
function expoPinnedNames(root) {
|
|
161
|
-
try {
|
|
162
|
-
let dir = root;
|
|
163
|
-
while (true) {
|
|
164
|
-
const file = path.join(dir, 'node_modules', 'expo', 'bundledNativeModules.json');
|
|
165
|
-
if (fs.existsSync(file)) return new Set(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))));
|
|
166
|
-
const parent = path.dirname(dir);
|
|
167
|
-
if (parent === dir) return EXPO_PINNED;
|
|
168
|
-
dir = parent;
|
|
169
|
-
}
|
|
170
|
-
} catch {
|
|
171
|
-
return EXPO_PINNED;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
149
|
export function buildPlan(result) {
|
|
176
150
|
const isExpo = !!result.project.expo;
|
|
177
151
|
const basePm = commands[packageManager(result.project.root)];
|
|
178
152
|
// In Expo projects `npx expo install` picks the version that matches the SDK.
|
|
179
153
|
const pm = isExpo ? { ...basePm, add: 'npx expo install' } : basePm;
|
|
180
154
|
const pinned = isExpo ? expoPinnedNames(result.project.root) : new Set();
|
|
181
|
-
const movesWithExpo = (name) => isExpo && (
|
|
155
|
+
const movesWithExpo = (name) => isExpo && movesWithExpoSdk(name, pinned);
|
|
182
156
|
const installed = new Set(result.deps.map((d) => d.name));
|
|
183
157
|
const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
|
|
184
158
|
const phases = [];
|
|
@@ -289,7 +263,7 @@ export function buildPlan(result) {
|
|
|
289
263
|
sec.push({ sev, step: f.fix.step });
|
|
290
264
|
} else if (f.fix.kind === 'vuln-dep' && (f.fix.severity === 'critical' || f.fix.severity === 'high')) {
|
|
291
265
|
const v = f.fix;
|
|
292
|
-
sec.push({ sev, step: `Update \`${v.name}\` ${v.from}${v.to ? ` to ${v.to} 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.'}` });
|
|
266
|
+
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
267
|
}
|
|
294
268
|
}
|
|
295
269
|
const securitySteps = sec.sort((a, b) => a.sev - b.sev).map((x) => x.step);
|
package/src/report-html.js
CHANGED
|
@@ -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
|
|
|
@@ -60,6 +60,7 @@ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:32px 0 12px}h2 small{f
|
|
|
60
60
|
.stat.critical b{color:var(--critical)}.stat.high b{color:var(--high)}
|
|
61
61
|
.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}
|
|
62
62
|
ul.list{list-style:none;padding:0;margin:0;display:grid;gap:8px}
|
|
63
|
+
main{overflow-wrap:anywhere}code{overflow-wrap:anywhere;word-break:break-word}.f>div,.sg,.stats>*{min-width:0}.facts{display:flex;flex-wrap:wrap;gap:6px 16px}
|
|
63
64
|
.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}
|
|
64
65
|
.secgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.sg{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column}.sg b{font-size:20px}.sg.ok b{color:var(--accent)}.sg.bad b{color:var(--critical)}.sg small{color:var(--muted)}@media (max-width:560px){.secgrid{grid-template-columns:1fr 1fr}}
|
|
65
66
|
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}
|
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,7 +33,7 @@ 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
|
|
|
@@ -65,8 +65,8 @@ export async function vulnerableDependencies(registry, versions) {
|
|
|
65
65
|
id: `vuln:${name}`,
|
|
66
66
|
severity,
|
|
67
67
|
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).`,
|
|
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
70
|
fix: { kind: 'vuln-dep', name, from: version, to: fixedIn, severity },
|
|
71
71
|
});
|
|
72
72
|
}
|
|
@@ -87,15 +87,17 @@ function applies(version, range) {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// The lowest version outside every advisory's range. "<1.2.3" is fixed in 1.2.3; "<=1.2.2" only
|
|
91
|
+
// says "after 1.2.2", which counts when it is the highest bound.
|
|
90
92
|
function minimumFixed(advisories) {
|
|
91
93
|
let fixed = null;
|
|
92
94
|
for (const a of advisories) {
|
|
93
|
-
const bounds = [...String(a.vulnerable_versions || '').matchAll(
|
|
95
|
+
const bounds = [...String(a.vulnerable_versions || '').matchAll(/<(=?)\s*(\d+\.\d+\.\d+)/g)].map((m) => ({ v: m[2], after: m[1] === '=' }));
|
|
94
96
|
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;
|
|
97
|
+
const hi = bounds.sort((x, y) => compareVersions(x.v, y.v) || (x.after ? 1 : -1)).at(-1);
|
|
98
|
+
if (!fixed || compareVersions(hi.v, fixed.v) > 0 || (compareVersions(hi.v, fixed.v) === 0 && hi.after)) fixed = hi;
|
|
97
99
|
}
|
|
98
|
-
return fixed;
|
|
100
|
+
return fixed && (fixed.after ? `a release after ${fixed.v}` : fixed.v);
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
// A password (or the variables built from it) passed to a log, crash report, analytics event,
|
|
@@ -591,7 +593,7 @@ export function masvsOf(id) {
|
|
|
591
593
|
if (/^(secret:|env-bundled|crypto-|expo-secret)/.test(id)) return 'CRYPTO';
|
|
592
594
|
if (/^(password-leak|token-unencrypted|android-allow-backup)/.test(id)) return 'STORAGE';
|
|
593
595
|
if (/^(insecure-tls|user-certificates|plain-http|ios-ats|android-cleartext)/.test(id)) return 'NETWORK';
|
|
594
|
-
if (/^(webview-|android-exported)/.test(id)) return 'PLATFORM';
|
|
596
|
+
if (/^(webview-|android-exported|deeplink-)/.test(id)) return 'PLATFORM';
|
|
595
597
|
if (/^firebase-/.test(id)) return 'AUTH';
|
|
596
598
|
if (/^(vuln:|signing-|android-debuggable)/.test(id)) return 'CODE';
|
|
597
599
|
if (/^(sourcemap-in-app|easy-reverse-engineering)/.test(id)) return 'RESILIENCE';
|
|
@@ -711,3 +713,94 @@ export function expoConfigSecrets(root) {
|
|
|
711
713
|
}
|
|
712
714
|
return findings;
|
|
713
715
|
}
|
|
716
|
+
|
|
717
|
+
// Deep links (MASVS-PLATFORM). A web link (https://your.domain/...) opens the app only when the
|
|
718
|
+
// domain is verified (autoVerify + assetlinks.json); without it Android 12+ opens the browser,
|
|
719
|
+
// and older Android lets any app that registers the same link receive it. Custom URL schemes can
|
|
720
|
+
// be registered by any app, so the template scheme "myapp" is shared with every other app that
|
|
721
|
+
// kept it, and an OAuth redirect to it can land in the wrong app.
|
|
722
|
+
const TEMPLATE_SCHEMES = new Set(['myapp', 'my-app', 'myscheme', 'your-app-scheme', 'yourappscheme']);
|
|
723
|
+
const SENSITIVE_LINK = /auth|login|signin|sign-in|reset|verify|magic|invite|oauth|callback|token/i;
|
|
724
|
+
const OAUTH_DEPS = ['expo-auth-session', '@clerk/clerk-expo', 'react-native-app-auth', '@react-native-community/oauth', 'react-native-auth0'];
|
|
725
|
+
|
|
726
|
+
export function deepLinks(root, deps = {}) {
|
|
727
|
+
const findings = [];
|
|
728
|
+
const unverified = new Map(); // host -> sensitive?
|
|
729
|
+
const schemes = new Set();
|
|
730
|
+
const realHost = (h) => h && !/[*$@{]/.test(h) && h.includes('.') && !/^(?:localhost|127\.|10\.|192\.168\.)/.test(h);
|
|
731
|
+
|
|
732
|
+
// autoVerify on any one filter makes Android verify every web host in the manifest.
|
|
733
|
+
const manifest = read(path.join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml')) || '';
|
|
734
|
+
const anyVerified = /<intent-filter\b[^>]*android:autoVerify\s*=\s*"true"/.test(manifest);
|
|
735
|
+
for (const m of manifest.matchAll(/<intent-filter\b([^>]*)>([\s\S]*?)<\/intent-filter>/g)) {
|
|
736
|
+
const body = m[2];
|
|
737
|
+
if (!/android\.intent\.category\.BROWSABLE/.test(body)) continue;
|
|
738
|
+
const found = [...body.matchAll(/android:scheme\s*=\s*"([^"]+)"/g)].map((x) => x[1]);
|
|
739
|
+
for (const s of found) if (!/^https?$/.test(s)) schemes.add(s.toLowerCase());
|
|
740
|
+
if (anyVerified || !found.some((s) => /^https?$/.test(s))) continue;
|
|
741
|
+
const paths = [...body.matchAll(/android:path(?:Prefix|Pattern)?\s*=\s*"([^"]+)"/g)].map((x) => x[1]).join(' ');
|
|
742
|
+
for (const h of body.matchAll(/android:host\s*=\s*"([^"]+)"/g)) {
|
|
743
|
+
if (realHost(h[1])) unverified.set(h[1], unverified.get(h[1]) || SENSITIVE_LINK.test(`${h[1]} ${paths}`));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
let app = null;
|
|
748
|
+
try {
|
|
749
|
+
app = JSON.parse(read(path.join(root, 'app.json')) || 'null');
|
|
750
|
+
} catch {
|
|
751
|
+
// not JSON
|
|
752
|
+
}
|
|
753
|
+
const expo = (app && (app.expo || app)) || {};
|
|
754
|
+
for (const s of [].concat(expo.scheme || [])) if (typeof s === 'string') schemes.add(s.toLowerCase());
|
|
755
|
+
for (const f of ['app.config.js', 'app.config.ts']) {
|
|
756
|
+
const text = read(path.join(root, f)) || '';
|
|
757
|
+
for (const m of text.matchAll(/\bscheme\s*:\s*['"`]([\w.+-]+)['"`]/g)) schemes.add(m[1].toLowerCase());
|
|
758
|
+
}
|
|
759
|
+
const expoFilters = (expo.android && expo.android.intentFilters) || [];
|
|
760
|
+
for (const filter of expoFilters.some((f) => f && f.autoVerify) ? [] : expoFilters) {
|
|
761
|
+
if (!filter || ![].concat(filter.category || []).includes('BROWSABLE')) continue;
|
|
762
|
+
for (const d of [].concat(filter.data || [])) {
|
|
763
|
+
if (d && /^https?$/.test(d.scheme || '') && realHost(d.host)) {
|
|
764
|
+
unverified.set(d.host, unverified.get(d.host) || SENSITIVE_LINK.test(`${d.host} ${d.pathPrefix || ''} ${d.path || ''} ${d.pathPattern || ''}`));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
let iosDirs = [];
|
|
769
|
+
try {
|
|
770
|
+
iosDirs = fs.readdirSync(path.join(root, 'ios'), { withFileTypes: true }).filter((e) => e.isDirectory() && !/^(?:Pods|build)$|Tests$|\.xc/.test(e.name));
|
|
771
|
+
} catch {
|
|
772
|
+
// no ios folder
|
|
773
|
+
}
|
|
774
|
+
for (const d of iosDirs) {
|
|
775
|
+
const plist = read(path.join(root, 'ios', d.name, 'Info.plist')) || '';
|
|
776
|
+
for (const block of plist.match(/<key>CFBundleURLSchemes<\/key>\s*<array>[\s\S]*?<\/array>/g) || []) {
|
|
777
|
+
for (const s of block.matchAll(/<string>([^<$]+)<\/string>/g)) schemes.add(s[1].toLowerCase());
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (unverified.size) {
|
|
782
|
+
const hosts = [...unverified.keys()];
|
|
783
|
+
const sensitive = hosts.filter((h) => unverified.get(h));
|
|
784
|
+
findings.push({
|
|
785
|
+
id: 'deeplink-unverified',
|
|
786
|
+
severity: sensitive.length ? 'medium' : 'low',
|
|
787
|
+
area: 'security',
|
|
788
|
+
title: `Web links to ${hosts.slice(0, 2).join(', ')}${hosts.length > 2 ? ` and ${hosts.length - 2} more` : ''} are not verified (no autoVerify)`,
|
|
789
|
+
detail: `On Android 12+ these links open in the browser instead of the app, and on older Android any installed app that registers the same links can receive them${sensitive.length ? ', including the login/reset/invite links that carry tokens' : ''}. For domains you own, add android:autoVerify="true" to the intent filter and publish /.well-known/assetlinks.json with your signing certificate. (OWASP MASVS-PLATFORM)`,
|
|
790
|
+
fix: { kind: 'security', step: `Add \`android:autoVerify="true"\` to the https intent filters for ${hosts.slice(0, 3).map((h) => `\`${h}\``).join(', ')} and serve \`https://${hosts[0]}/.well-known/assetlinks.json\`.` },
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
const template = [...schemes].filter((s) => TEMPLATE_SCHEMES.has(s));
|
|
794
|
+
if (template.length) {
|
|
795
|
+
const oauth = OAUTH_DEPS.filter((d) => deps[d]);
|
|
796
|
+
findings.push({
|
|
797
|
+
id: 'deeplink-template-scheme',
|
|
798
|
+
severity: oauth.length ? 'medium' : 'low',
|
|
799
|
+
area: 'security',
|
|
800
|
+
title: `The app still uses the template URL scheme "${template[0]}://"`,
|
|
801
|
+
detail: `Many other apps kept the same scheme, so ${template[0]}:// links can open a different app${oauth.length ? `, and the sign-in redirect of ${oauth.join(', ')} can be delivered to it` : ''}. Pick a scheme unique to your app (for example your bundle identifier). (OWASP MASVS-PLATFORM)`,
|
|
802
|
+
fix: { kind: 'security', step: `Replace the "${template[0]}" scheme with one unique to the app (app.json \`scheme\`, AndroidManifest and Info.plist), and update redirect URIs registered with your sign-in providers.` },
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
return findings;
|
|
806
|
+
}
|