nativekeel 0.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/LICENSE +21 -0
- package/README.md +101 -0
- package/bin/nativekeel.js +162 -0
- package/package.json +38 -0
- package/src/analyze.js +360 -0
- package/src/baseline.js +23 -0
- package/src/compare.js +55 -0
- package/src/compat.js +125 -0
- package/src/config.js +3 -0
- package/src/elf-align.js +136 -0
- package/src/interop.js +53 -0
- package/src/known-issues.js +61 -0
- package/src/native.js +454 -0
- package/src/plan.js +357 -0
- package/src/project.js +112 -0
- package/src/registry.js +89 -0
- package/src/report-html.js +87 -0
- package/src/report-md.js +42 -0
- package/src/report-sarif.js +61 -0
- package/src/report-text.js +72 -0
- package/src/rules.js +59 -0
- package/src/secrets.js +98 -0
- package/src/usage.js +127 -0
package/src/native.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { checkArchive, findBuiltArtifact } from './elf-align.js';
|
|
5
|
+
import { PAGE_SIZE_16K, PLATFORM_MINIMUMS } from './rules.js';
|
|
6
|
+
import { installedVersion as installedVersionOf } from './project.js';
|
|
7
|
+
|
|
8
|
+
// Checks on native project files: store requirements and security settings that live
|
|
9
|
+
// outside package.json. Every check returns findings in the same shape as analyze.js.
|
|
10
|
+
|
|
11
|
+
const read = (file) => {
|
|
12
|
+
try {
|
|
13
|
+
return fs.readFileSync(file, 'utf8');
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function findFiles(dir, test, depth = 0, out = []) {
|
|
20
|
+
if (depth > 4) return out;
|
|
21
|
+
let list;
|
|
22
|
+
try {
|
|
23
|
+
list = fs.readdirSync(dir, { withFileTypes: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
for (const e of list) {
|
|
28
|
+
if (['Pods', 'build', 'node_modules', '.git', 'DerivedData'].includes(e.name)) continue;
|
|
29
|
+
const full = path.join(dir, e.name);
|
|
30
|
+
if (e.isDirectory()) findFiles(full, test, depth + 1, out);
|
|
31
|
+
else if (test(e.name, full)) out.push(full);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const minorOf = (v) => (v ? Number(v.split('.')[1]) : null);
|
|
37
|
+
|
|
38
|
+
function gitTracked(root) {
|
|
39
|
+
const res = spawnSync('git', ['ls-files'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
40
|
+
if (res.status !== 0) return null;
|
|
41
|
+
// ls-files paths are relative to the current directory, which is the app root here.
|
|
42
|
+
return res.stdout.split('\n').filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Debug-only native libraries that never ship in a release build.
|
|
46
|
+
const DEBUG_ONLY_LIBS = /\/lib(flipper|event(_core|_extra)?-[\d.]+|fbjni_debug)\.so$/;
|
|
47
|
+
|
|
48
|
+
export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}) {
|
|
49
|
+
const today = now.toISOString().slice(0, 10);
|
|
50
|
+
const findings = [];
|
|
51
|
+
const add = (f) => findings.push(f);
|
|
52
|
+
const root = project.root;
|
|
53
|
+
const rn = minorOf(project.rnVersion);
|
|
54
|
+
const hasIos = fs.existsSync(path.join(root, 'ios'));
|
|
55
|
+
const hasAndroid = fs.existsSync(path.join(root, 'android'));
|
|
56
|
+
|
|
57
|
+
// 16 KB page size: React Native itself before 0.77, then the actual built binaries.
|
|
58
|
+
if (hasAndroid || project.managed) {
|
|
59
|
+
if (rn !== null && rn < PAGE_SIZE_16K.firstRnMinor && today >= PAGE_SIZE_16K.since) {
|
|
60
|
+
add({
|
|
61
|
+
id: 'android-16kb-rn',
|
|
62
|
+
severity: 'critical',
|
|
63
|
+
area: 'store',
|
|
64
|
+
title: `React Native ${project.rnVersion} does not support 16 KB memory pages`,
|
|
65
|
+
detail: `Google Play rejects updates without 16 KB page support since ${PAGE_SIZE_16K.since} (extensions ended ${PAGE_SIZE_16K.extensionUntil}). React Native supports it from 0.${PAGE_SIZE_16K.firstRnMinor}.`,
|
|
66
|
+
fix: { kind: 'upgrade-rn-min', to: `0.${PAGE_SIZE_16K.firstRnMinor}.0` },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const artifact = findBuiltArtifact(root);
|
|
70
|
+
if (artifact) {
|
|
71
|
+
let result = null;
|
|
72
|
+
try {
|
|
73
|
+
result = checkArchive(artifact);
|
|
74
|
+
} catch {
|
|
75
|
+
result = null;
|
|
76
|
+
}
|
|
77
|
+
if (result) {
|
|
78
|
+
const isRelease = /release/i.test(artifact);
|
|
79
|
+
const relevant = result.misaligned.filter((l) => isRelease || !DEBUG_ONLY_LIBS.test(l.path));
|
|
80
|
+
const rel = path.relative(root, artifact);
|
|
81
|
+
const libs = [...new Set(relevant.map((l) => path.basename(l.path)))];
|
|
82
|
+
if (relevant.length) {
|
|
83
|
+
add({
|
|
84
|
+
id: 'android-16kb-libs',
|
|
85
|
+
severity: isRelease ? 'critical' : 'high',
|
|
86
|
+
area: 'store',
|
|
87
|
+
title: `${libs.length} native librar${libs.length === 1 ? 'y is' : 'ies are'} not 16 KB aligned (${rel})`,
|
|
88
|
+
detail: `${libs.slice(0, 6).join(', ')}${libs.length > 6 ? ', …' : ''}. Google Play rejects updates containing them. Update the package that ships each library, or rebuild it with NDK r28+.`,
|
|
89
|
+
fix: { kind: 'align-16kb', libs },
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
const ignored = result.misaligned.length;
|
|
93
|
+
add({
|
|
94
|
+
id: 'android-16kb-ok',
|
|
95
|
+
severity: 'info',
|
|
96
|
+
area: 'store',
|
|
97
|
+
title: ignored
|
|
98
|
+
? `${result.libs.length - ignored} of ${result.libs.length} 64-bit native libraries are 16 KB aligned; the other ${ignored} are debug-only`
|
|
99
|
+
: `All ${result.libs.length} 64-bit native libraries are 16 KB aligned`,
|
|
100
|
+
detail: `Checked ${rel}.${ignored ? ' Debug-only libraries (e.g. Flipper) never ship, but build a release bundle and re-run to be certain.' : ''}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// RCTAppDependencyProvider: required in AppDelegate since React Native 0.77. Without it,
|
|
108
|
+
// third-party Fabric components fall back to the legacy interop layer and send
|
|
109
|
+
// RCTEventEmitter events, which do not exist in bridgeless mode: an error in Debug and a
|
|
110
|
+
// launch crash in Release (seen as an App Review rejection).
|
|
111
|
+
if (hasIos && rn !== null && rn >= 77) {
|
|
112
|
+
const delegates = findFiles(path.join(root, 'ios'), (name) => /^AppDelegate\.(mm|m|swift)$/.test(name));
|
|
113
|
+
const sources = delegates.map((f) => ({ f, text: read(f) || '' }));
|
|
114
|
+
const usesRnDelegate = sources.some(({ text }) => /RCTAppDelegate|RCTReactNativeFactory|RCTDefaultReactNativeFactoryDelegate/.test(text)) ||
|
|
115
|
+
findFiles(path.join(root, 'ios'), (name) => name === 'AppDelegate.h').some((f) => /RCTAppDelegate/.test(read(f) || ''));
|
|
116
|
+
const hasProvider = sources.some(({ text }) => /RCTAppDependencyProvider/.test(text));
|
|
117
|
+
if (delegates.length && usesRnDelegate && !hasProvider) {
|
|
118
|
+
const archOn = project.newArch.ios !== false;
|
|
119
|
+
add({
|
|
120
|
+
id: 'ios-app-dependency-provider',
|
|
121
|
+
severity: archOn ? 'critical' : 'high',
|
|
122
|
+
area: 'native',
|
|
123
|
+
title: `iOS AppDelegate is missing RCTAppDependencyProvider (${path.relative(root, delegates[0])})`,
|
|
124
|
+
detail: `Required since React Native 0.77. Without it, library Fabric components fall back to the legacy interop layer and send RCTEventEmitter events, which are not registered with the New Architecture: "RCTEventEmitter.receiveEvent() ... has not been registered as callable", logged in Debug, a launch crash in Release.${archOn ? '' : ' It will surface as soon as the New Architecture is turned on.'} Add the import and \`self.dependencyProvider = [RCTAppDependencyProvider new];\` before calling super.`,
|
|
125
|
+
fix: { kind: 'app-dependency-provider', file: path.relative(root, delegates[0]) },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Flipper was removed from the React Native template in 0.74.
|
|
131
|
+
const appGradle = read(path.join(root, 'android', 'app', 'build.gradle')) || read(path.join(root, 'android', 'app', 'build.gradle.kts')) || '';
|
|
132
|
+
const gradleProps = read(path.join(root, 'android', 'gradle.properties')) || '';
|
|
133
|
+
const podfile = read(path.join(root, 'ios', 'Podfile')) || '';
|
|
134
|
+
if (/com\.facebook\.flipper|FLIPPER_VERSION/.test(appGradle + gradleProps) || /use_flipper!|FlipperConfiguration\.enabled/.test(podfile)) {
|
|
135
|
+
add({
|
|
136
|
+
id: 'flipper',
|
|
137
|
+
severity: 'medium',
|
|
138
|
+
area: 'native',
|
|
139
|
+
title: 'Flipper is still integrated',
|
|
140
|
+
detail: 'Flipper was removed from React Native in 0.74 and its native libraries are not 16 KB aligned. Remove the Flipper dependencies and initialization code before upgrading; use React Native DevTools instead.',
|
|
141
|
+
fix: { kind: 'remove-flipper' },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Podfile still loading the CLI's native_modules.rb: React Native provides use_native_modules!
|
|
146
|
+
// itself, and the CLI that ships with React Native 0.80 no longer has this file.
|
|
147
|
+
if (/require_relative\s+['"][^'"]*@react-native-community\/cli-platform-ios\/native_modules['"]/.test(podfile)) {
|
|
148
|
+
add({
|
|
149
|
+
id: 'podfile-cli-native-modules',
|
|
150
|
+
severity: rn !== null && rn >= 80 ? 'high' : 'medium',
|
|
151
|
+
area: 'native',
|
|
152
|
+
title: 'Podfile loads @react-native-community/cli-platform-ios/native_modules',
|
|
153
|
+
detail: "React Native provides use_native_modules! itself. The CLI used from React Native 0.80 no longer ships this file, so pod install fails with \"cannot load such file\". Delete the require_relative line.",
|
|
154
|
+
fix: { kind: 'podfile-cli-require' },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// RCTAppDelegate is deprecated in favour of RCTReactNativeFactory.
|
|
159
|
+
if (hasIos && rn !== null && rn >= 80) {
|
|
160
|
+
const delegates = findFiles(path.join(root, 'ios'), (name) => /^AppDelegate\.(h|mm|m|swift)$/.test(name));
|
|
161
|
+
if (delegates.some((f) => /:\s*RCTAppDelegate\b|RCTAppDelegate\s*\{|class\s+\w+\s*:\s*RCTAppDelegate/.test(read(f) || ''))) {
|
|
162
|
+
add({
|
|
163
|
+
id: 'ios-rctappdelegate-deprecated',
|
|
164
|
+
severity: 'low',
|
|
165
|
+
area: 'native',
|
|
166
|
+
title: 'AppDelegate subclasses RCTAppDelegate, which is deprecated',
|
|
167
|
+
detail: 'React Native marks RCTAppDelegate as deprecated and to be removed; the template uses RCTReactNativeFactory with a RCTDefaultReactNativeFactoryDelegate. Migrate before the version that removes it.',
|
|
168
|
+
fix: { kind: 'rctappdelegate' },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// patch-package patches: they silently stop applying, and they are written against one
|
|
174
|
+
// version of a library and one version of React Native.
|
|
175
|
+
const patchDir = path.join(root, 'patches');
|
|
176
|
+
let patchFiles = [];
|
|
177
|
+
try {
|
|
178
|
+
patchFiles = fs.readdirSync(patchDir).filter((f) => f.endsWith('.patch'));
|
|
179
|
+
} catch {
|
|
180
|
+
patchFiles = [];
|
|
181
|
+
}
|
|
182
|
+
const patched = [];
|
|
183
|
+
for (const f of patchFiles) {
|
|
184
|
+
const m = f.match(/^(.+?)\+(\d+\.\d+\.\d+[^.]*)(?:\+\d+)?\.patch$/);
|
|
185
|
+
if (!m) continue;
|
|
186
|
+
const pkg = m[1].replace(/\+/g, '/');
|
|
187
|
+
const version = m[2];
|
|
188
|
+
patched.push({ file: f, pkg, version });
|
|
189
|
+
const text = read(path.join(patchDir, f)) || '';
|
|
190
|
+
const buildPaths = (text.match(/^diff --git a\/node_modules\/\S+?\/(?:android|ios)\/(?:build|\.cxx|\.gradle)\//gm) || []).length;
|
|
191
|
+
const binary = /^GIT binary patch|^Binary files /m.test(text);
|
|
192
|
+
if (buildPaths || binary) {
|
|
193
|
+
add({
|
|
194
|
+
id: `patch-artifacts:${f}`,
|
|
195
|
+
severity: 'high',
|
|
196
|
+
area: 'native',
|
|
197
|
+
title: `Patch contains build output and will not apply on a clean install (patches/${f})`,
|
|
198
|
+
detail: `${buildPaths ? `${buildPaths} file(s) under android/ios build folders` : 'binary content'} were captured when the patch was created. On CI or a new machine patch-package fails and the fix is silently missing. Recreate it with \`npx patch-package ${pkg} --exclude '(^package\\.json$|/build/)'\` from a clean copy.`,
|
|
199
|
+
fix: { kind: 'patch-artifacts', file: f, pkg },
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const installed = project.deps[pkg] || (project.devDeps && project.devDeps[pkg]) ? installedVersionOf(root, pkg) : null;
|
|
203
|
+
if (installed && installed !== version) {
|
|
204
|
+
add({
|
|
205
|
+
id: `patch-version:${f}`,
|
|
206
|
+
severity: 'high',
|
|
207
|
+
area: 'native',
|
|
208
|
+
title: `Patch for ${pkg} ${version} does not match the installed ${installed} (patches/${f})`,
|
|
209
|
+
detail: 'patch-package refuses or misapplies patches written for another version. Re-check whether the fix is still needed on this version and recreate the patch, or delete it.',
|
|
210
|
+
fix: { kind: 'patch-version', file: f, pkg, version, installed },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (patched.length) {
|
|
215
|
+
add({
|
|
216
|
+
id: 'patches-present',
|
|
217
|
+
severity: 'info',
|
|
218
|
+
area: 'native',
|
|
219
|
+
title: `${patched.length} patch-package patch${patched.length === 1 ? '' : 'es'}: ${patched.map((p) => p.pkg).join(', ')}`,
|
|
220
|
+
detail: 'Each was written against one library version and one React Native version. After every upgrade step check that each still applies and is still needed; a patch that adapted a library to an older React Native can break it on a newer one.',
|
|
221
|
+
fix: { kind: 'patches-review', patches: patched },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Old projects keep the C++ flags of the template they were created from. With
|
|
226
|
+
// -DFOLLY_NO_CONFIG but no -DFOLLY_CFG_NO_COROUTINES, an Objective-C++ AppDelegate that
|
|
227
|
+
// includes React headers fails from React Native 0.80: 'folly/coro/Coroutine.h' file not found.
|
|
228
|
+
if (hasIos) {
|
|
229
|
+
const pbx = findFiles(path.join(root, 'ios'), (name, full) => name === 'project.pbxproj' && !full.includes('Pods.xcodeproj'));
|
|
230
|
+
const objcppDelegate = findFiles(path.join(root, 'ios'), (name) => name === 'AppDelegate.mm').length > 0;
|
|
231
|
+
const stale = pbx.find((f) => {
|
|
232
|
+
const text = read(f) || '';
|
|
233
|
+
return /-DFOLLY_NO_CONFIG/.test(text) && !/FOLLY_CFG_NO_COROUTINES/.test(text);
|
|
234
|
+
});
|
|
235
|
+
const target = minorOf(rnLatest);
|
|
236
|
+
if (stale && objcppDelegate && rn !== null && (rn >= 80 || (target !== null && target >= 80))) {
|
|
237
|
+
add({
|
|
238
|
+
id: 'ios-folly-flags',
|
|
239
|
+
severity: rn >= 80 ? 'high' : 'medium',
|
|
240
|
+
area: 'native',
|
|
241
|
+
title: `Xcode project lacks -DFOLLY_CFG_NO_COROUTINES (${path.relative(root, stale)})`,
|
|
242
|
+
detail: `OTHER_CPLUSPLUSFLAGS comes from an older template. From React Native 0.80 an Objective-C++ AppDelegate fails to compile with "'folly/coro/Coroutine.h' file not found". Add "-DFOLLY_CFG_NO_COROUTINES=1" and "-DFOLLY_HAVE_CLOCK_GETTIME=1" next to "-DFOLLY_USE_LIBCPP=1" in both build configurations, as in the current template.`,
|
|
243
|
+
fix: { kind: 'folly-flags', file: path.relative(root, stale) },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Java MainApplication calling getDefaultReactHost(context, host): from React Native 0.80 the
|
|
249
|
+
// Kotlin function has a third JSRuntimeFactory parameter whose default Java cannot see.
|
|
250
|
+
if (hasAndroid && rn !== null) {
|
|
251
|
+
const target = minorOf(rnLatest);
|
|
252
|
+
const javaApps = findFiles(path.join(root, 'android', 'app', 'src', 'main'), (name) => name === 'MainApplication.java');
|
|
253
|
+
const call = javaApps.find((f) => /getDefaultReactHost\(\s*[^,()]+(?:\([^)]*\))?\s*,\s*[^,()]+\)/.test(read(f) || ''));
|
|
254
|
+
if (call && (rn >= 80 || (target !== null && target >= 80))) {
|
|
255
|
+
add({
|
|
256
|
+
id: 'android-default-react-host',
|
|
257
|
+
severity: rn >= 80 ? 'high' : 'low',
|
|
258
|
+
area: 'native',
|
|
259
|
+
title: `MainApplication.java calls getDefaultReactHost with two arguments (${path.relative(root, call)})`,
|
|
260
|
+
detail: 'From React Native 0.80 this fails to compile from Java ("no suitable method found for getDefaultReactHost(Context,ReactNativeHost)"). Pass null as the third JSRuntimeFactory argument, or convert MainApplication to Kotlin like the template.',
|
|
261
|
+
fix: { kind: 'default-react-host', file: path.relative(root, call) },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Hermes: JavaScriptCore is no longer bundled with React Native.
|
|
267
|
+
const hermesOff = /^\s*hermesEnabled\s*=\s*false/m.test(gradleProps) || /:hermes_enabled\s*=>\s*false/.test(podfile);
|
|
268
|
+
if (hermesOff) {
|
|
269
|
+
add({
|
|
270
|
+
id: 'hermes-disabled',
|
|
271
|
+
severity: 'medium',
|
|
272
|
+
area: 'native',
|
|
273
|
+
title: 'Hermes is disabled (JavaScriptCore)',
|
|
274
|
+
detail: 'JavaScriptCore is no longer part of React Native; newer versions need the community JSC package. Switching to Hermes is the supported path and usually faster.',
|
|
275
|
+
fix: { kind: 'enable-hermes' },
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// iOS privacy manifest: required by App Store Connect since 1 May 2024. React Native itself
|
|
280
|
+
// uses "required reason" APIs, so every React Native app needs one.
|
|
281
|
+
if (hasIos) {
|
|
282
|
+
const manifests = findFiles(path.join(root, 'ios'), (name) => name.endsWith('.xcprivacy'));
|
|
283
|
+
if (!manifests.length) {
|
|
284
|
+
add({
|
|
285
|
+
id: 'ios-privacy-manifest',
|
|
286
|
+
severity: 'high',
|
|
287
|
+
area: 'store',
|
|
288
|
+
title: 'iOS privacy manifest (PrivacyInfo.xcprivacy) is missing',
|
|
289
|
+
detail: 'App Store Connect rejects uploads that use required-reason APIs without declaring them. React Native uses several (UserDefaults, file timestamps, system boot time). Add PrivacyInfo.xcprivacy to the app target.',
|
|
290
|
+
fix: { kind: 'privacy-manifest' },
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Platform minimums of the latest React Native: upgrading drops older OS versions.
|
|
296
|
+
const latestMinor = minorOf(rnLatest);
|
|
297
|
+
if (latestMinor !== null && rn !== null && latestMinor > rn) {
|
|
298
|
+
const minSdk = project.android && project.android.minSdk;
|
|
299
|
+
if (minSdk && minSdk < PLATFORM_MINIMUMS.androidMinSdk) {
|
|
300
|
+
add({
|
|
301
|
+
id: 'android-min-sdk',
|
|
302
|
+
severity: 'info',
|
|
303
|
+
area: 'native',
|
|
304
|
+
title: `minSdk ${minSdk} is below what current React Native supports (${PLATFORM_MINIMUMS.androidMinSdk})`,
|
|
305
|
+
detail: `Upgrading raises the minimum to Android API ${PLATFORM_MINIMUMS.androidMinSdk}. Check how many of your users are on older Android versions first.`,
|
|
306
|
+
fix: { kind: 'min-sdk', to: PLATFORM_MINIMUMS.androidMinSdk },
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const iosMin = podfile.match(/platform\s+:ios\s*,\s*['"](\d+(?:\.\d+)?)['"]/);
|
|
310
|
+
if (iosMin && Number(iosMin[1]) < PLATFORM_MINIMUMS.iosMin) {
|
|
311
|
+
add({
|
|
312
|
+
id: 'ios-min-version',
|
|
313
|
+
severity: 'info',
|
|
314
|
+
area: 'native',
|
|
315
|
+
title: `iOS deployment target ${iosMin[1]} is below what current React Native supports (${PLATFORM_MINIMUMS.iosMin})`,
|
|
316
|
+
detail: `Upgrading raises the minimum to iOS ${PLATFORM_MINIMUMS.iosMin}. Check how many of your users are on older iOS versions first.`,
|
|
317
|
+
fix: { kind: 'ios-min', to: PLATFORM_MINIMUMS.iosMin },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Android security settings in the main (shipped) manifest.
|
|
323
|
+
const manifest = read(path.join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml'));
|
|
324
|
+
if (manifest) {
|
|
325
|
+
if (/android:debuggable\s*=\s*"true"/.test(manifest)) {
|
|
326
|
+
add({
|
|
327
|
+
id: 'android-debuggable',
|
|
328
|
+
severity: 'high',
|
|
329
|
+
area: 'security',
|
|
330
|
+
title: 'android:debuggable="true" in the release manifest',
|
|
331
|
+
detail: 'Anyone can attach a debugger to the installed app and read its memory. Remove it; Gradle sets it for debug builds automatically.',
|
|
332
|
+
fix: { kind: 'manifest', attr: 'android:debuggable' },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (/android:usesCleartextTraffic\s*=\s*"true"/.test(manifest)) {
|
|
336
|
+
add({
|
|
337
|
+
id: 'android-cleartext',
|
|
338
|
+
severity: 'medium',
|
|
339
|
+
area: 'security',
|
|
340
|
+
title: 'Cleartext HTTP is allowed in the release manifest',
|
|
341
|
+
detail: 'android:usesCleartextTraffic="true" lets the app send data unencrypted. Keep it only in src/debug/AndroidManifest.xml (Metro needs it in development), or allow specific domains with a network security config.',
|
|
342
|
+
fix: { kind: 'manifest', attr: 'android:usesCleartextTraffic' },
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// iOS App Transport Security disabled for all domains.
|
|
348
|
+
if (hasIos) {
|
|
349
|
+
for (const plist of findFiles(path.join(root, 'ios'), (name) => name === 'Info.plist')) {
|
|
350
|
+
const text = read(plist) || '';
|
|
351
|
+
if (/<key>NSAllowsArbitraryLoads<\/key>\s*<true\s*\/>/.test(text)) {
|
|
352
|
+
add({
|
|
353
|
+
id: `ios-ats:${path.relative(root, plist)}`,
|
|
354
|
+
severity: 'medium',
|
|
355
|
+
area: 'security',
|
|
356
|
+
title: `App Transport Security is disabled (${path.relative(root, plist)})`,
|
|
357
|
+
detail: 'NSAllowsArbitraryLoads = true allows unencrypted HTTP to every domain, and App Review may ask you to justify it. Use NSExceptionDomains for the few hosts that need it.',
|
|
358
|
+
fix: { kind: 'ats', file: path.relative(root, plist) },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Signing material in the repository. Release keystores and App Store Connect API keys let
|
|
365
|
+
// someone publish as you. The debug keystore and its public 'android' password are fine.
|
|
366
|
+
const tracked = gitTracked(root);
|
|
367
|
+
if (tracked) {
|
|
368
|
+
const signing = tracked.filter((f) => /\.(jks|keystore)$/i.test(f) && !/debug/i.test(path.basename(f)));
|
|
369
|
+
for (const f of signing) {
|
|
370
|
+
add({
|
|
371
|
+
id: `signing-keystore:${f}`,
|
|
372
|
+
severity: 'high',
|
|
373
|
+
area: 'secret',
|
|
374
|
+
title: `Release signing keystore committed (${f})`,
|
|
375
|
+
detail: 'With the keystore and its passwords anyone can sign builds as you. Move it out of the repository and into your CI secret store; Play App Signing lets you reset the upload key if it leaked.',
|
|
376
|
+
fix: { kind: 'secret', label: 'Release keystore', file: f, line: 1, bundled: false },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
for (const f of tracked.filter((x) => /(^|\/)AuthKey_[A-Z0-9]+\.p8$/.test(x))) {
|
|
380
|
+
add({
|
|
381
|
+
id: `signing-asc-key:${f}`,
|
|
382
|
+
severity: 'critical',
|
|
383
|
+
area: 'secret',
|
|
384
|
+
title: `App Store Connect API key committed (${f})`,
|
|
385
|
+
detail: 'This key can upload and release builds and change your App Store listing. Revoke it in App Store Connect → Users and Access → Integrations, then keep the new one in CI secrets.',
|
|
386
|
+
fix: { kind: 'secret', label: 'App Store Connect API key', file: f, line: 1, bundled: false },
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
for (const f of tracked.filter((x) => /\.p12$/i.test(x))) {
|
|
390
|
+
add({
|
|
391
|
+
id: `signing-p12:${f}`,
|
|
392
|
+
severity: 'high',
|
|
393
|
+
area: 'secret',
|
|
394
|
+
title: `Signing certificate with private key committed (${f})`,
|
|
395
|
+
detail: 'A .p12 contains a certificate and its private key. Revoke the certificate in the Apple Developer portal and keep signing identities in CI secrets or a match repository.',
|
|
396
|
+
fix: { kind: 'secret', label: 'Signing certificate', file: f, line: 1, bundled: false },
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
const passwordFiles = ['android/gradle.properties', 'android/app/build.gradle', 'android/app/build.gradle.kts'].filter((f) => tracked.includes(f));
|
|
400
|
+
for (const f of passwordFiles) {
|
|
401
|
+
const text = read(path.join(root, f)) || '';
|
|
402
|
+
const leaked = [
|
|
403
|
+
...[...text.matchAll(/^\s*([A-Z0-9_]*(?:STORE|KEY)_PASSWORD)\s*=\s*(\S+)\s*$/gm)].map((m) => ({ name: m[1], value: m[2] })),
|
|
404
|
+
...[...text.matchAll(/\b(storePassword|keyPassword)\s*=?\s*["']([^"']+)["']/g)].map((m) => ({ name: m[1], value: m[2] })),
|
|
405
|
+
].filter((l) => l.value !== 'android' && !/^System\.getenv|^\$\{?[A-Z_]+\}?$/.test(l.value));
|
|
406
|
+
if (leaked.length) {
|
|
407
|
+
add({
|
|
408
|
+
id: `signing-password:${f}`,
|
|
409
|
+
severity: 'high',
|
|
410
|
+
area: 'secret',
|
|
411
|
+
title: `Release signing password${leaked.length > 1 ? 's' : ''} committed (${f})`,
|
|
412
|
+
detail: `${[...new Set(leaked.map((l) => l.name))].join(', ')}. Anyone with repository access has them. Read them from ~/.gradle/gradle.properties or environment variables instead, and change them if the keystore was ever shared.`,
|
|
413
|
+
fix: { kind: 'secret', label: 'Signing passwords', file: f, line: 1, bundled: false },
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Environment files compiled into the bundle.
|
|
420
|
+
const bundlesEnv = ['react-native-config', 'react-native-dotenv', 'babel-plugin-transform-inline-environment-variables'].filter(
|
|
421
|
+
(n) => project.deps[n] || (project.devDeps && project.devDeps[n]),
|
|
422
|
+
);
|
|
423
|
+
const isExpo = !!project.expoVersion;
|
|
424
|
+
if (bundlesEnv.length || isExpo) {
|
|
425
|
+
let envFiles = [];
|
|
426
|
+
try {
|
|
427
|
+
envFiles = fs.readdirSync(root).filter((f) => /^\.env(\.|$)/.test(f) && !/example|sample|template/i.test(f));
|
|
428
|
+
} catch {
|
|
429
|
+
envFiles = [];
|
|
430
|
+
}
|
|
431
|
+
for (const f of envFiles) {
|
|
432
|
+
const text = read(path.join(root, f)) || '';
|
|
433
|
+
const risky = text
|
|
434
|
+
.split('\n')
|
|
435
|
+
.map((l) => l.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(\S+)/))
|
|
436
|
+
.filter(Boolean)
|
|
437
|
+
.filter(([, name, value]) => /SECRET|PRIVATE|PASSWORD|PASSWD|SERVICE_ROLE|ADMIN_KEY/.test(name) && value.replace(/["']/g, '').length > 0)
|
|
438
|
+
.filter(([, name]) => bundlesEnv.length || name.startsWith('EXPO_PUBLIC_'))
|
|
439
|
+
.map(([, name]) => name);
|
|
440
|
+
if (risky.length) {
|
|
441
|
+
add({
|
|
442
|
+
id: `env-bundled:${f}`,
|
|
443
|
+
severity: 'high',
|
|
444
|
+
area: 'secret',
|
|
445
|
+
title: `${risky.length} secret-looking variable${risky.length === 1 ? '' : 's'} in ${f} end up inside the app`,
|
|
446
|
+
detail: `${risky.slice(0, 5).join(', ')}. ${bundlesEnv.length ? `${bundlesEnv[0]} compiles` : 'Expo inlines EXPO_PUBLIC_ variables, so'} these values into the JavaScript bundle, where anyone can read them. Keep secrets on a server.`,
|
|
447
|
+
fix: { kind: 'secret', label: 'Environment secrets', file: f, line: 1, bundled: true },
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return findings;
|
|
454
|
+
}
|