@necessarysdk/react-native 0.0.0-stage → 0.1.6
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/CHANGELOG.md +40 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/NecessaryReactNative.podspec +23 -0
- package/README.md +429 -2
- package/android/build.gradle +83 -0
- package/android/src/main/AndroidManifest.xml +41 -0
- package/android/src/main/cpp/CMakeLists.txt +7 -0
- package/android/src/main/cpp/code_integrity.cpp +484 -0
- package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
- package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
- package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1157 -0
- package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +338 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +333 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
- package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +767 -0
- package/contract/report-1/android-8-ios-5/RULES.md +149 -0
- package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
- package/doc/integration_guide.md +771 -0
- package/doc/measurements.md +774 -0
- package/ios/InstallationKeyManager.swift +694 -0
- package/ios/IntegrityProbeManager.swift +959 -0
- package/ios/NecessaryModules.h +34 -0
- package/ios/NecessaryModules.mm +208 -0
- package/ios/NecessaryNative.swift +327 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/ios/ProbeReads.swift +370 -0
- package/lib/client.d.ts +105 -0
- package/lib/client.js +176 -0
- package/lib/collector.d.ts +18 -0
- package/lib/collector.js +49 -0
- package/lib/deviceTrust.d.ts +253 -0
- package/lib/deviceTrust.js +699 -0
- package/lib/errors.d.ts +43 -0
- package/lib/errors.js +56 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +34 -0
- package/lib/installationKey.d.ts +62 -0
- package/lib/installationKey.js +185 -0
- package/lib/installationStore.d.ts +49 -0
- package/lib/installationStore.js +127 -0
- package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
- package/lib/native/NativeNecessaryInstallationKey.js +4 -0
- package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
- package/lib/native/NativeNecessaryIntegrity.js +4 -0
- package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
- package/lib/native/NativeNecessarySecureScreen.js +4 -0
- package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
- package/lib/native/NativeNecessaryStepUpKey.js +4 -0
- package/lib/native/NativeNecessaryStorage.d.ts +14 -0
- package/lib/native/NativeNecessaryStorage.js +4 -0
- package/lib/protocol.d.ts +24 -0
- package/lib/protocol.js +50 -0
- package/lib/reinstallHint.d.ts +23 -0
- package/lib/reinstallHint.js +65 -0
- package/lib/report.d.ts +62 -0
- package/lib/report.js +134 -0
- package/lib/requestProof.d.ts +116 -0
- package/lib/requestProof.js +220 -0
- package/lib/secureScreen.d.ts +15 -0
- package/lib/secureScreen.js +32 -0
- package/lib/stepUpKey.d.ts +105 -0
- package/lib/stepUpKey.js +187 -0
- package/lib/transport.d.ts +39 -0
- package/lib/transport.js +117 -0
- package/lib/util/answers.d.ts +6 -0
- package/lib/util/answers.js +16 -0
- package/lib/util/bytes.d.ts +18 -0
- package/lib/util/bytes.js +207 -0
- package/lib/util/native.d.ts +20 -0
- package/lib/util/native.js +49 -0
- package/lib/util/release.d.ts +2 -0
- package/lib/util/release.js +7 -0
- package/lib/util/target.d.ts +18 -0
- package/lib/util/target.js +128 -0
- package/package.json +81 -4
- package/src/client.ts +221 -0
- package/src/collector.ts +64 -0
- package/src/deviceTrust.ts +851 -0
- package/src/errors.ts +71 -0
- package/src/index.ts +19 -0
- package/src/installationKey.ts +223 -0
- package/src/installationStore.ts +143 -0
- package/src/native/NativeNecessaryInstallationKey.ts +12 -0
- package/src/native/NativeNecessaryIntegrity.ts +15 -0
- package/src/native/NativeNecessarySecureScreen.ts +9 -0
- package/src/native/NativeNecessaryStepUpKey.ts +11 -0
- package/src/native/NativeNecessaryStorage.ts +16 -0
- package/src/protocol.ts +52 -0
- package/src/reinstallHint.ts +69 -0
- package/src/report.ts +168 -0
- package/src/requestProof.ts +295 -0
- package/src/secureScreen.ts +29 -0
- package/src/stepUpKey.ts +238 -0
- package/src/transport.ts +145 -0
- package/src/util/answers.ts +15 -0
- package/src/util/bytes.ts +194 -0
- package/src/util/native.ts +56 -0
- package/src/util/release.ts +6 -0
- package/src/util/target.ts +132 -0
- package/vectors/client_server.json +264 -0
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
package com.necessarysdk.reactnative
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.accessibilityservice.AccessibilityServiceInfo
|
|
5
|
+
import android.annotation.SuppressLint
|
|
6
|
+
import android.app.Activity
|
|
7
|
+
import android.app.admin.DevicePolicyManager
|
|
8
|
+
import android.content.ComponentName
|
|
9
|
+
import android.content.Context
|
|
10
|
+
import android.content.pm.ApplicationInfo
|
|
11
|
+
import android.content.pm.PackageManager
|
|
12
|
+
import android.hardware.display.DisplayManager
|
|
13
|
+
import android.location.Location
|
|
14
|
+
import android.location.LocationManager
|
|
15
|
+
import android.net.ConnectivityManager
|
|
16
|
+
import android.net.NetworkCapabilities
|
|
17
|
+
import android.os.Build
|
|
18
|
+
import android.os.Debug
|
|
19
|
+
import android.os.SystemClock
|
|
20
|
+
import android.provider.Settings
|
|
21
|
+
import android.view.Display
|
|
22
|
+
import android.view.WindowManager
|
|
23
|
+
import android.view.accessibility.AccessibilityManager
|
|
24
|
+
import java.io.BufferedReader
|
|
25
|
+
import java.io.File
|
|
26
|
+
import java.io.FileInputStream
|
|
27
|
+
import java.io.InputStreamReader
|
|
28
|
+
import java.net.ConnectException
|
|
29
|
+
import java.net.InetSocketAddress
|
|
30
|
+
import java.net.NetworkInterface
|
|
31
|
+
import java.net.Socket
|
|
32
|
+
import java.security.MessageDigest
|
|
33
|
+
import java.util.Collections
|
|
34
|
+
import java.util.Locale
|
|
35
|
+
import java.util.concurrent.Executor
|
|
36
|
+
import java.util.concurrent.TimeUnit
|
|
37
|
+
import java.util.function.Consumer
|
|
38
|
+
import org.json.JSONObject
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Local integrity measurement collector.
|
|
42
|
+
*
|
|
43
|
+
* IMPORTANT: these measurements are risk signals, not remote attestation. On a
|
|
44
|
+
* fully compromised OS an attacker may be able to lie about local state. The
|
|
45
|
+
* server must therefore score these signals together with server-observed
|
|
46
|
+
* device/account behavior and must never trust a client-provided score.
|
|
47
|
+
*/
|
|
48
|
+
class IntegrityProbeManager(
|
|
49
|
+
private val context: Context,
|
|
50
|
+
// The visible activity, for the one measurement that needs a window:
|
|
51
|
+
// whether it is being recorded (screen_capture, Android 15 and later).
|
|
52
|
+
private val activity: () -> Activity? = { null },
|
|
53
|
+
) {
|
|
54
|
+
companion object {
|
|
55
|
+
// 1 = name-based hook detection only (runtime_maps token scan).
|
|
56
|
+
// 2 = adds the structural probes: instrumentation_threads,
|
|
57
|
+
// exec_mappings, and the native code_integrity buckets. Every
|
|
58
|
+
// report carries this, so a stored report says which probe set
|
|
59
|
+
// produced it.
|
|
60
|
+
// 4 = volunteers device and network on every scan, and answers the
|
|
61
|
+
// optional measurements the server asks for.
|
|
62
|
+
// 5 = adds the optional measurements of what can read or drive the
|
|
63
|
+
// phone, screen capture, location and mock locations.
|
|
64
|
+
// 6 = sizes of writable-executable and unlabelled executable memory,
|
|
65
|
+
// and the process ABI, for runtime baselines published by the
|
|
66
|
+
// release pipeline.
|
|
67
|
+
// 7 = the page size, and the current signers apart from the lineage.
|
|
68
|
+
// 8 = a failed read is never a clean negative: the probe answers an
|
|
69
|
+
// error, or names the parts it could not read in "unread"
|
|
70
|
+
// (ProbeReads.kt). One location read answers both location
|
|
71
|
+
// measurements, which carry its fix_time.
|
|
72
|
+
private const val COLLECTOR_VERSION = 10
|
|
73
|
+
// Sent with every scan whether or not the server asked: the server
|
|
74
|
+
// tolerates probes beyond its plan but refuses a report missing one it
|
|
75
|
+
// asked for, so a probe new to this collector stays out of the plan and
|
|
76
|
+
// every older client keeps working.
|
|
77
|
+
private val volunteeredProbes = listOf("device", "network")
|
|
78
|
+
// The optional measurements this collector knows. Each is collected
|
|
79
|
+
// only when the server asks for it AND the app enabled it.
|
|
80
|
+
private val optionalMeasurements = setOf(
|
|
81
|
+
"local_addresses", "accessibility", "notification_access", "other_access",
|
|
82
|
+
"screen_capture", "location", "mock_location"
|
|
83
|
+
)
|
|
84
|
+
// Apps listed per capability, at most.
|
|
85
|
+
private const val MAX_APPS = 32
|
|
86
|
+
// Remote-control apps scammers have their victims install. Android 11
|
|
87
|
+
// and later show an app only the packages it names in advance, so the
|
|
88
|
+
// same names are in the manifest's <queries>.
|
|
89
|
+
private val remoteAccessPackages = listOf(
|
|
90
|
+
"com.anydesk.anydeskandroid",
|
|
91
|
+
"com.teamviewer.quicksupport.market",
|
|
92
|
+
"com.carriez.flutter_hbb",
|
|
93
|
+
"com.sand.airdroid"
|
|
94
|
+
)
|
|
95
|
+
private const val MAX_TEXT = 8192
|
|
96
|
+
// Every shell command is bounded: one hung
|
|
97
|
+
// child used to block its probe, and the whole scan, indefinitely.
|
|
98
|
+
private const val COMMAND_TIMEOUT_MS = 3000L
|
|
99
|
+
private val suspiciousRuntimeTokens = listOf(
|
|
100
|
+
"frida", "gadget", "objection", "xposed", "lsposed",
|
|
101
|
+
"substrate", "cydia", "zygisk", "riru", "magisk",
|
|
102
|
+
"kernelsu", "apatch"
|
|
103
|
+
)
|
|
104
|
+
private val rootPaths = listOf(
|
|
105
|
+
"/system/bin/su",
|
|
106
|
+
"/system/xbin/su",
|
|
107
|
+
"/sbin/su",
|
|
108
|
+
"/su/bin/su",
|
|
109
|
+
"/data/local/su",
|
|
110
|
+
"/data/local/bin/su",
|
|
111
|
+
"/data/local/xbin/su",
|
|
112
|
+
"/system/app/Superuser.apk",
|
|
113
|
+
"/system/app/SuperSU.apk",
|
|
114
|
+
"/sbin/magisk",
|
|
115
|
+
"/data/adb/magisk",
|
|
116
|
+
"/data/adb/modules",
|
|
117
|
+
"/data/adb/ksu",
|
|
118
|
+
"/data/adb/ap",
|
|
119
|
+
"/metadata/adb/magisk"
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@Volatile private var cachedApkSha256: String? = null
|
|
124
|
+
// The split inventory, once every split was hashed: the installed APKs do
|
|
125
|
+
// not change under a running process (an update restarts it).
|
|
126
|
+
@Volatile private var cachedSplits: List<Map<String, String>>? = null
|
|
127
|
+
|
|
128
|
+
// Native code-integrity component. Loaded best-effort: if it is absent the
|
|
129
|
+
// probe degrades to reporting checked=false rather than crashing.
|
|
130
|
+
private external fun nativeCodeIntegrity(appApks: Array<String>, appDirs: Array<String>): String?
|
|
131
|
+
|
|
132
|
+
private val nativeAvailable: Boolean = try {
|
|
133
|
+
System.loadLibrary("codeintegrity")
|
|
134
|
+
true
|
|
135
|
+
} catch (_: Throwable) {
|
|
136
|
+
false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fun collect(
|
|
140
|
+
requiredProbes: List<String>,
|
|
141
|
+
challengeNonce: String,
|
|
142
|
+
optionalProbes: List<String> = emptyList(),
|
|
143
|
+
enabledMeasurements: Set<String> = emptySet(),
|
|
144
|
+
): Map<String, Any> {
|
|
145
|
+
// One location read per collection, made only if an enabled location
|
|
146
|
+
// measurement asks: both answer from it.
|
|
147
|
+
val location = ProbeReads.LocationCapture { readLocation() }
|
|
148
|
+
val probes = linkedMapOf<String, Any>()
|
|
149
|
+
for (probe in requiredProbes.distinct()) {
|
|
150
|
+
probes[probe] = runProbe(probe, enabledMeasurements, location)
|
|
151
|
+
}
|
|
152
|
+
for (probe in volunteeredProbes) {
|
|
153
|
+
if (!probes.containsKey(probe)) probes[probe] = runProbe(probe, enabledMeasurements, location)
|
|
154
|
+
}
|
|
155
|
+
for (probe in optionalProbes.distinct()) {
|
|
156
|
+
if (!probes.containsKey(probe) && probe in optionalMeasurements) {
|
|
157
|
+
probes[probe] = runProbe(probe, enabledMeasurements, location)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return linkedMapOf(
|
|
162
|
+
"collector_version" to COLLECTOR_VERSION,
|
|
163
|
+
"platform" to "android",
|
|
164
|
+
"challenge_nonce_echo" to challengeNonce,
|
|
165
|
+
"sdk_int" to Build.VERSION.SDK_INT,
|
|
166
|
+
"probes" to probes
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private fun runProbe(
|
|
171
|
+
probe: String,
|
|
172
|
+
enabledMeasurements: Set<String>,
|
|
173
|
+
location: ProbeReads.LocationCapture,
|
|
174
|
+
): Map<String, Any> =
|
|
175
|
+
try {
|
|
176
|
+
when (probe) {
|
|
177
|
+
"app_identity" -> probeAppIdentity()
|
|
178
|
+
"debug_state" -> probeDebugState()
|
|
179
|
+
"root_files" -> probeRootFiles()
|
|
180
|
+
"root_shell" -> probeRootShell()
|
|
181
|
+
"system_properties" -> probeSystemProperties()
|
|
182
|
+
"selinux" -> probeSelinux()
|
|
183
|
+
"mounts" -> probeMounts()
|
|
184
|
+
"runtime_maps" -> probeRuntimeMaps()
|
|
185
|
+
"tracer" -> probeTracer()
|
|
186
|
+
"frida_ports" -> probeFridaPorts()
|
|
187
|
+
"emulator" -> probeEmulator()
|
|
188
|
+
"developer_settings" -> probeDeveloperSettings()
|
|
189
|
+
"instrumentation_threads" -> probeInstrumentationThreads()
|
|
190
|
+
"exec_mappings" -> probeExecMappings()
|
|
191
|
+
"code_integrity" -> probeCodeIntegrity()
|
|
192
|
+
"device" -> probeDevice()
|
|
193
|
+
"network" -> probeNetwork()
|
|
194
|
+
"local_addresses" ->
|
|
195
|
+
if (probe in enabledMeasurements) probeLocalAddresses() else notEnabled()
|
|
196
|
+
"accessibility" ->
|
|
197
|
+
if (probe in enabledMeasurements) probeAccessibility() else notEnabled()
|
|
198
|
+
"notification_access" ->
|
|
199
|
+
if (probe in enabledMeasurements) probeNotificationAccess() else notEnabled()
|
|
200
|
+
"other_access" ->
|
|
201
|
+
if (probe in enabledMeasurements) probeOtherAccess() else notEnabled()
|
|
202
|
+
"screen_capture" ->
|
|
203
|
+
if (probe in enabledMeasurements) probeScreenCapture() else notEnabled()
|
|
204
|
+
"location" ->
|
|
205
|
+
if (probe in enabledMeasurements) ProbeReads.location(location.value) else notEnabled()
|
|
206
|
+
"mock_location" ->
|
|
207
|
+
if (probe in enabledMeasurements) ProbeReads.mockLocation(location.value) else notEnabled()
|
|
208
|
+
else -> resultUnsupported("Unknown probe requested by server")
|
|
209
|
+
}
|
|
210
|
+
} catch (error: Throwable) {
|
|
211
|
+
linkedMapOf(
|
|
212
|
+
"status" to "error",
|
|
213
|
+
"error_type" to error.javaClass.simpleName,
|
|
214
|
+
// Its words, then every cause beneath them: the kind alone
|
|
215
|
+
// (error_type) says little of an Android 9 keystore failure.
|
|
216
|
+
"error" to Causes.withCauses(error.message ?: "probe failed", error.cause).take(240)
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun ok(vararg pairs: Pair<String, Any?>): Map<String, Any> {
|
|
221
|
+
val result = linkedMapOf<String, Any>("status" to "ok")
|
|
222
|
+
for ((key, value) in pairs) {
|
|
223
|
+
if (value != null) result[key] = value
|
|
224
|
+
}
|
|
225
|
+
return result
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A probe that could not make its measurement says so, rather than
|
|
229
|
+
// reporting "ok" with nothing in it: unknown is not clean.
|
|
230
|
+
private fun unavailable(reason: String): Map<String, Any> = linkedMapOf(
|
|
231
|
+
"status" to "error",
|
|
232
|
+
"error" to reason
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
private fun resultUnsupported(reason: String): Map<String, Any> = linkedMapOf(
|
|
236
|
+
"status" to "unsupported",
|
|
237
|
+
"reason" to reason
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
// An optional measurement the server asked for but this app's developers
|
|
241
|
+
// did not enable: the business asked, the app declined.
|
|
242
|
+
private fun notEnabled(): Map<String, Any> = linkedMapOf("status" to "not_enabled")
|
|
243
|
+
|
|
244
|
+
/** The OS and the hardware model, and how current the security patches are. */
|
|
245
|
+
private fun probeDevice(): Map<String, Any> = ok(
|
|
246
|
+
"release" to (Build.VERSION.RELEASE ?: ""),
|
|
247
|
+
"sdk_int" to Build.VERSION.SDK_INT,
|
|
248
|
+
"security_patch" to
|
|
249
|
+
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) Build.VERSION.SECURITY_PATCH else ""),
|
|
250
|
+
"manufacturer" to (Build.MANUFACTURER ?: ""),
|
|
251
|
+
"brand" to (Build.BRAND ?: ""),
|
|
252
|
+
"model" to (Build.MODEL ?: ""),
|
|
253
|
+
"device" to (Build.DEVICE ?: ""),
|
|
254
|
+
"build_id" to (Build.ID ?: ""),
|
|
255
|
+
"fingerprint" to (Build.FINGERPRINT ?: "").take(256),
|
|
256
|
+
// The ABI this process runs as: a 32-bit app on a 64-bit phone maps
|
|
257
|
+
// different code, so a runtime baseline is per ABI.
|
|
258
|
+
"abi" to processAbi(),
|
|
259
|
+
// The memory page size: 4 KiB, or 16 KiB on phones built for it from
|
|
260
|
+
// Android 15 - a runtime baseline is per page size too (collector 7).
|
|
261
|
+
"page_size" to android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE).toInt()
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
private fun processAbi(): String {
|
|
265
|
+
val abis = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
266
|
+
if (android.os.Process.is64Bit()) Build.SUPPORTED_64_BIT_ABIS else Build.SUPPORTED_32_BIT_ABIS
|
|
267
|
+
} else {
|
|
268
|
+
Build.SUPPORTED_ABIS
|
|
269
|
+
}
|
|
270
|
+
return abis.firstOrNull() ?: ""
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Whether a VPN is up and an HTTP proxy is set, as the system's own
|
|
275
|
+
* connectivity service says (ACCESS_NETWORK_STATE, granted at install). A
|
|
276
|
+
* VPN is a network of its own, so every network is asked, not only the
|
|
277
|
+
* default one. Interface names only - never an address.
|
|
278
|
+
*/
|
|
279
|
+
private fun probeNetwork(): Map<String, Any> {
|
|
280
|
+
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
|
281
|
+
?: return unavailable("ConnectivityManager is not available")
|
|
282
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
|
283
|
+
return unavailable("needs Android 6 or newer")
|
|
284
|
+
}
|
|
285
|
+
val transports = linkedSetOf<String>()
|
|
286
|
+
manager.activeNetwork?.let { network ->
|
|
287
|
+
manager.getNetworkCapabilities(network)?.let { caps ->
|
|
288
|
+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) transports.add("wifi")
|
|
289
|
+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) transports.add("cellular")
|
|
290
|
+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) transports.add("ethernet")
|
|
291
|
+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) transports.add("vpn")
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
@Suppress("DEPRECATION")
|
|
295
|
+
val vpn = manager.allNetworks.any { network ->
|
|
296
|
+
manager.getNetworkCapabilities(network)
|
|
297
|
+
?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
|
|
298
|
+
}
|
|
299
|
+
val proxy = manager.defaultProxy != null ||
|
|
300
|
+
!System.getProperty("http.proxyHost").isNullOrBlank()
|
|
301
|
+
// Names are context; the two answers above stand without them. A
|
|
302
|
+
// listing that failed, whole or for one interface, is in unread: an
|
|
303
|
+
// empty or short list would read as "no such interface".
|
|
304
|
+
val interfaces = mutableListOf<String>()
|
|
305
|
+
var interfacesUnread: String? = null
|
|
306
|
+
try {
|
|
307
|
+
NetworkInterface.getNetworkInterfaces()?.let { all ->
|
|
308
|
+
for (item in Collections.list(all)) {
|
|
309
|
+
try {
|
|
310
|
+
if (item.isUp && !item.isLoopback) interfaces.add(item.name.take(32))
|
|
311
|
+
} catch (error: Throwable) {
|
|
312
|
+
if (interfacesUnread == null) {
|
|
313
|
+
interfacesUnread = ProbeReads.why(error)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
} catch (error: Throwable) {
|
|
319
|
+
interfacesUnread = ProbeReads.why(error)
|
|
320
|
+
}
|
|
321
|
+
val result = ok(
|
|
322
|
+
"vpn_active" to vpn,
|
|
323
|
+
"http_proxy_configured" to proxy,
|
|
324
|
+
"transports" to transports.toList(),
|
|
325
|
+
"interfaces" to interfaces.distinct().take(32)
|
|
326
|
+
).toMutableMap()
|
|
327
|
+
interfacesUnread?.let { result["unread"] = mapOf("interfaces" to it) }
|
|
328
|
+
return result
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The phone's own IP addresses, per interface - an optional measurement.
|
|
333
|
+
* Link-local addresses are left out: they can carry the
|
|
334
|
+
* hardware address, which the platforms deliberately hide.
|
|
335
|
+
*/
|
|
336
|
+
private fun probeLocalAddresses(): Map<String, Any> {
|
|
337
|
+
val addresses = mutableListOf<Map<String, Any>>()
|
|
338
|
+
// More than a list holds is said, never cut off unsaid.
|
|
339
|
+
var truncated = false
|
|
340
|
+
val all = NetworkInterface.getNetworkInterfaces()
|
|
341
|
+
?: return unavailable("no network interface could be listed")
|
|
342
|
+
for (item in Collections.list(all)) {
|
|
343
|
+
if (!item.isUp || item.isLoopback) continue
|
|
344
|
+
for (address in Collections.list(item.inetAddresses)) {
|
|
345
|
+
if (address.isLoopbackAddress || address.isLinkLocalAddress) continue
|
|
346
|
+
if (addresses.size >= 32) {
|
|
347
|
+
truncated = true
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
addresses.add(
|
|
351
|
+
linkedMapOf(
|
|
352
|
+
"interface" to item.name.take(32),
|
|
353
|
+
"address" to (address.hostAddress ?: "").substringBefore('%').take(64)
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return ok("addresses" to addresses, "truncated" to truncated)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* One app holding a capability: its package and component
|
|
363
|
+
* class, whether it is part of the system, and which package installed it
|
|
364
|
+
* ("" for none - adb, or an installer that names nobody). system and
|
|
365
|
+
* installer are left out where Android hides the app from this one; the
|
|
366
|
+
* server then calls its source unknown, which is never evidence. The
|
|
367
|
+
* server, not the phone, decides what counts as an app store.
|
|
368
|
+
*/
|
|
369
|
+
private fun appEntry(
|
|
370
|
+
packageName: String,
|
|
371
|
+
component: String? = null,
|
|
372
|
+
knownInfo: ApplicationInfo? = null,
|
|
373
|
+
): Map<String, Any> {
|
|
374
|
+
val entry = linkedMapOf<String, Any>("package" to packageName.take(128))
|
|
375
|
+
if (component != null) entry["service"] = component.take(200)
|
|
376
|
+
val pm = context.packageManager
|
|
377
|
+
val info = knownInfo ?: try {
|
|
378
|
+
pm.getApplicationInfo(packageName, 0)
|
|
379
|
+
} catch (_: Throwable) {
|
|
380
|
+
null
|
|
381
|
+
}
|
|
382
|
+
val installer: String? = try {
|
|
383
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
384
|
+
pm.getInstallSourceInfo(packageName).installingPackageName ?: ""
|
|
385
|
+
} else {
|
|
386
|
+
@Suppress("DEPRECATION")
|
|
387
|
+
pm.getInstallerPackageName(packageName) ?: ""
|
|
388
|
+
}
|
|
389
|
+
} catch (_: Throwable) {
|
|
390
|
+
null
|
|
391
|
+
}
|
|
392
|
+
if (info != null) {
|
|
393
|
+
entry["system"] = (info.flags and ApplicationInfo.FLAG_SYSTEM) != 0
|
|
394
|
+
if (installer != null) entry["installer"] = installer.take(128)
|
|
395
|
+
}
|
|
396
|
+
return entry
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Every Accessibility service that is on, and the keyboard in use - the
|
|
401
|
+
* two ways banking trojans read what is typed. The
|
|
402
|
+
* Accessibility manager's list is compared with the system setting it is
|
|
403
|
+
* built from: a service the setting names that the manager does not report
|
|
404
|
+
* is hiding, as an Android bug allowed until the December 2022 patch.
|
|
405
|
+
*/
|
|
406
|
+
private fun probeAccessibility(): Map<String, Any> {
|
|
407
|
+
val manager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
|
|
408
|
+
?: return unavailable("AccessibilityManager is not available")
|
|
409
|
+
val services = mutableListOf<Map<String, Any>>()
|
|
410
|
+
val reported = mutableSetOf<String>()
|
|
411
|
+
var undescribed = 0
|
|
412
|
+
for (info in manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)) {
|
|
413
|
+
val service = info.resolveInfo?.serviceInfo
|
|
414
|
+
if (service == null) {
|
|
415
|
+
// The manager reports it, but not its app: counted as unread,
|
|
416
|
+
// and never read as a service hiding from the manager.
|
|
417
|
+
info.id?.let { ComponentName.unflattenFromString(it.trim())?.flattenToString() }
|
|
418
|
+
?.let { reported.add(it) }
|
|
419
|
+
undescribed++
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
if (!reported.add(ComponentName(service.packageName, service.name).flattenToString())) continue
|
|
423
|
+
if (services.size < MAX_APPS) {
|
|
424
|
+
services.add(appEntry(service.packageName, service.name, service.applicationInfo))
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
val hidden = Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)
|
|
428
|
+
?.split(':')
|
|
429
|
+
?.mapNotNull { ComponentName.unflattenFromString(it.trim())?.flattenToString() }
|
|
430
|
+
?.filter { it !in reported }
|
|
431
|
+
?.distinct()
|
|
432
|
+
?: emptyList()
|
|
433
|
+
val result = linkedMapOf<String, Any>(
|
|
434
|
+
"status" to "ok",
|
|
435
|
+
"services" to services,
|
|
436
|
+
"settings_only" to hidden.take(MAX_APPS).map { it.take(200) },
|
|
437
|
+
// More than this collector sends: the server says so, never guesses.
|
|
438
|
+
"truncated" to (reported.size > MAX_APPS || hidden.size > MAX_APPS)
|
|
439
|
+
)
|
|
440
|
+
Settings.Secure.getString(context.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD)
|
|
441
|
+
?.let { ComponentName.unflattenFromString(it.trim()) }
|
|
442
|
+
?.let { result["keyboard"] = appEntry(it.packageName, it.className) }
|
|
443
|
+
if (undescribed > 0) {
|
|
444
|
+
result["unread"] = mapOf("services" to "$undescribed enabled services the system did not describe")
|
|
445
|
+
}
|
|
446
|
+
return result
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Every app allowed to read the phone's notifications - one-time codes among them. */
|
|
450
|
+
private fun probeNotificationAccess(): Map<String, Any> {
|
|
451
|
+
// Not a public constant; AndroidX's NotificationManagerCompat reads the
|
|
452
|
+
// same setting. Unreadable, it throws, and the probe answers error.
|
|
453
|
+
val all = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
|
|
454
|
+
?.split(':')
|
|
455
|
+
?.mapNotNull { ComponentName.unflattenFromString(it.trim()) }
|
|
456
|
+
?.distinctBy { it.flattenToString() }
|
|
457
|
+
?: emptyList()
|
|
458
|
+
return ok(
|
|
459
|
+
"listeners" to all.take(MAX_APPS).map { appEntry(it.packageName, it.className) },
|
|
460
|
+
"truncated" to (all.size > MAX_APPS)
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The active device administrators, and the known remote-control apps installed. */
|
|
465
|
+
private fun probeOtherAccess(): Map<String, Any> {
|
|
466
|
+
val policy = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as? DevicePolicyManager
|
|
467
|
+
?: return unavailable("DevicePolicyManager is not available")
|
|
468
|
+
val active = policy.activeAdmins ?: emptyList()
|
|
469
|
+
val admins = active.take(MAX_APPS).map { appEntry(it.packageName, it.className) }
|
|
470
|
+
val pm = context.packageManager
|
|
471
|
+
val remote = remoteAccessPackages.filter { name ->
|
|
472
|
+
try {
|
|
473
|
+
pm.getPackageInfo(name, 0)
|
|
474
|
+
true
|
|
475
|
+
} catch (_: PackageManager.NameNotFoundException) {
|
|
476
|
+
false
|
|
477
|
+
}
|
|
478
|
+
}.map { appEntry(it) }
|
|
479
|
+
return ok(
|
|
480
|
+
"device_admins" to admins,
|
|
481
|
+
"remote_access_apps" to remote,
|
|
482
|
+
"truncated" to (active.size > MAX_APPS)
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Whether this app's window is being recorded (Android 15 and later, with
|
|
488
|
+
* DETECT_SCREEN_RECORDING, a normal permission the app declares), and how
|
|
489
|
+
* many displays besides the phone's own are on and public: casting,
|
|
490
|
+
* mirroring, an external screen - which this cannot tell apart. A display
|
|
491
|
+
* that is off (a foldable's other screen) or private to another app is
|
|
492
|
+
* not counted. When recording cannot be measured it is left out -
|
|
493
|
+
* unknown, not "no" - and recording_status says why (collector 7):
|
|
494
|
+
* unsupported below Android 15, no_permission without
|
|
495
|
+
* DETECT_SCREEN_RECORDING, unavailable with no window to ask.
|
|
496
|
+
*/
|
|
497
|
+
private fun probeScreenCapture(): Map<String, Any> {
|
|
498
|
+
val result = linkedMapOf<String, Any>("status" to "ok")
|
|
499
|
+
val window = activity()?.windowManager
|
|
500
|
+
val granted = context.checkSelfPermission("android.permission.DETECT_SCREEN_RECORDING") ==
|
|
501
|
+
PackageManager.PERMISSION_GRANTED
|
|
502
|
+
if (Build.VERSION.SDK_INT >= 35 && window != null && granted) {
|
|
503
|
+
val callback = Consumer<Int> { }
|
|
504
|
+
try {
|
|
505
|
+
val state = window.addScreenRecordingCallback(Executor { it.run() }, callback)
|
|
506
|
+
result["recording"] = state == WindowManager.SCREEN_RECORDING_STATE_VISIBLE
|
|
507
|
+
} finally {
|
|
508
|
+
window.removeScreenRecordingCallback(callback)
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
result["recording_status"] = when {
|
|
512
|
+
Build.VERSION.SDK_INT < 35 -> "unsupported"
|
|
513
|
+
!granted -> "no_permission"
|
|
514
|
+
else -> "unavailable"
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
val displays = (context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.displays
|
|
518
|
+
?: return unavailable("DisplayManager is not available")
|
|
519
|
+
result["external_displays"] = displays.count {
|
|
520
|
+
it.displayId != Display.DEFAULT_DISPLAY && it.state == Display.STATE_ON &&
|
|
521
|
+
(it.flags and Display.FLAG_PRIVATE) == 0
|
|
522
|
+
}
|
|
523
|
+
return result
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private fun locationPermission(): String = when {
|
|
527
|
+
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
528
|
+
PackageManager.PERMISSION_GRANTED -> "precise"
|
|
529
|
+
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
|
530
|
+
PackageManager.PERMISSION_GRANTED -> "approximate"
|
|
531
|
+
else -> "none"
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The collection's one location read: the permission, then the newest fix any enabled provider
|
|
536
|
+
* already holds - never a fresh fix (no GPS power, no waiting) and never
|
|
537
|
+
* a prompt; only the app asks for the permission, on its own screen.
|
|
538
|
+
*/
|
|
539
|
+
@SuppressLint("MissingPermission")
|
|
540
|
+
private fun readLocation(): ProbeReads.FixRead {
|
|
541
|
+
val permission = locationPermission()
|
|
542
|
+
if (permission == "none") return ProbeReads.FixRead(permission)
|
|
543
|
+
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
|
|
544
|
+
?: return ProbeReads.FixRead(permission, failure = "the location service is unavailable")
|
|
545
|
+
val now = SystemClock.elapsedRealtimeNanos()
|
|
546
|
+
val providers = linkedMapOf<String, Result<ProbeReads.Fix?>>()
|
|
547
|
+
for (provider in manager.getProviders(true)) {
|
|
548
|
+
providers[provider] = runCatching { manager.getLastKnownLocation(provider)?.let { fixOf(it, now) } }
|
|
549
|
+
}
|
|
550
|
+
return ProbeReads.newestFix(permission, providers)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private fun fixOf(fix: Location, now: Long) = ProbeReads.Fix(
|
|
554
|
+
latitude = fix.latitude,
|
|
555
|
+
longitude = fix.longitude,
|
|
556
|
+
accuracyM = if (fix.hasAccuracy()) fix.accuracy.toDouble() else null,
|
|
557
|
+
ageSeconds = maxOf(0L, (now - fix.elapsedRealtimeNanos) / 1_000_000_000L),
|
|
558
|
+
elapsedNanos = fix.elapsedRealtimeNanos,
|
|
559
|
+
provider = fix.provider ?: "",
|
|
560
|
+
timeMillis = fix.time,
|
|
561
|
+
mock = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
562
|
+
fix.isMock
|
|
563
|
+
} else {
|
|
564
|
+
@Suppress("DEPRECATION")
|
|
565
|
+
fix.isFromMockProvider
|
|
566
|
+
},
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
@Suppress("DEPRECATION")
|
|
570
|
+
private fun probeAppIdentity(): Map<String, Any> {
|
|
571
|
+
val pm = context.packageManager
|
|
572
|
+
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
573
|
+
PackageManager.GET_SIGNING_CERTIFICATES
|
|
574
|
+
} else {
|
|
575
|
+
PackageManager.GET_SIGNATURES
|
|
576
|
+
}
|
|
577
|
+
val packageInfo = pm.getPackageInfo(context.packageName, flags)
|
|
578
|
+
val appInfo = packageInfo.applicationInfo ?: context.applicationInfo
|
|
579
|
+
val certDigests = mutableListOf<String>()
|
|
580
|
+
// Apart from cert_sha256 (collector 7): the
|
|
581
|
+
// certificate(s) signing the APK now, and - after a key rotation - the
|
|
582
|
+
// signing lineage, oldest first. The server takes a rotation as no
|
|
583
|
+
// signer change only where the release process approved it.
|
|
584
|
+
val currentSigners = mutableListOf<String>()
|
|
585
|
+
val lineage = mutableListOf<String>()
|
|
586
|
+
|
|
587
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
588
|
+
val signingInfo = packageInfo.signingInfo
|
|
589
|
+
val signatures = signingInfo?.let {
|
|
590
|
+
if (it.hasMultipleSigners()) it.apkContentsSigners else it.signingCertificateHistory
|
|
591
|
+
}.orEmpty()
|
|
592
|
+
|
|
593
|
+
signatures.forEach { signature ->
|
|
594
|
+
certDigests.add(sha256Hex(signature.toByteArray()))
|
|
595
|
+
}
|
|
596
|
+
signingInfo?.apkContentsSigners.orEmpty().forEach { signature ->
|
|
597
|
+
currentSigners.add(sha256Hex(signature.toByteArray()))
|
|
598
|
+
}
|
|
599
|
+
if (signingInfo != null && !signingInfo.hasMultipleSigners()) {
|
|
600
|
+
signingInfo.signingCertificateHistory.orEmpty().forEach { signature ->
|
|
601
|
+
lineage.add(sha256Hex(signature.toByteArray()))
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
packageInfo.signatures?.forEach { signature ->
|
|
606
|
+
certDigests.add(sha256Hex(signature.toByteArray()))
|
|
607
|
+
}
|
|
608
|
+
currentSigners.addAll(certDigests)
|
|
609
|
+
}
|
|
610
|
+
certDigests.sort()
|
|
611
|
+
currentSigners.sort()
|
|
612
|
+
|
|
613
|
+
// A lookup that threw is unread; null from a lookup that worked means
|
|
614
|
+
// no installer named one (adb, or an installer that names nobody).
|
|
615
|
+
var installerUnread: String? = null
|
|
616
|
+
val installer = try {
|
|
617
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
618
|
+
pm.getInstallSourceInfo(context.packageName).installingPackageName
|
|
619
|
+
} else {
|
|
620
|
+
@Suppress("DEPRECATION")
|
|
621
|
+
pm.getInstallerPackageName(context.packageName)
|
|
622
|
+
}
|
|
623
|
+
} catch (error: Throwable) {
|
|
624
|
+
installerUnread = ProbeReads.why(error)
|
|
625
|
+
null
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// The base APK alone (ApplicationInfo.sourceDir): apk_sha256 never
|
|
629
|
+
// covers the splits, which are listed apart.
|
|
630
|
+
val apkHash = cachedApkSha256 ?: sha256File(File(appInfo.sourceDir)).also {
|
|
631
|
+
cachedApkSha256 = it
|
|
632
|
+
}
|
|
633
|
+
var splitsUnread = emptyMap<String, String>()
|
|
634
|
+
val splits = cachedSplits ?: ProbeReads.splitApks(
|
|
635
|
+
appInfo.splitSourceDirs.orEmpty().associate { path ->
|
|
636
|
+
File(path).name to runCatching { sha256File(File(path)) }
|
|
637
|
+
}
|
|
638
|
+
).let { (listed, unread) ->
|
|
639
|
+
splitsUnread = unread
|
|
640
|
+
if (unread.isEmpty()) cachedSplits = listed
|
|
641
|
+
listed
|
|
642
|
+
}
|
|
643
|
+
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
644
|
+
packageInfo.longVersionCode
|
|
645
|
+
} else {
|
|
646
|
+
@Suppress("DEPRECATION")
|
|
647
|
+
packageInfo.versionCode.toLong()
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return ok(
|
|
651
|
+
"package_name" to context.packageName,
|
|
652
|
+
"version_name" to (packageInfo.versionName ?: ""),
|
|
653
|
+
"version_code" to versionCode,
|
|
654
|
+
"debuggable" to ((appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0),
|
|
655
|
+
"allow_backup" to ((appInfo.flags and ApplicationInfo.FLAG_ALLOW_BACKUP) != 0),
|
|
656
|
+
"cert_sha256" to certDigests,
|
|
657
|
+
"current_signers" to currentSigners.distinct().take(8),
|
|
658
|
+
"signing_lineage" to lineage.take(8),
|
|
659
|
+
"apk_sha256" to apkHash,
|
|
660
|
+
"split_apks" to splits,
|
|
661
|
+
"installer_package" to installer,
|
|
662
|
+
"source_dir" to appInfo.sourceDir
|
|
663
|
+
).let { result ->
|
|
664
|
+
val unread = linkedMapOf<String, String>()
|
|
665
|
+
installerUnread?.let { unread["installer_package"] = it }
|
|
666
|
+
unread.putAll(splitsUnread)
|
|
667
|
+
if (unread.isEmpty()) result else result + ("unread" to unread)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private fun probeDebugState(): Map<String, Any> = ok(
|
|
672
|
+
"debugger_connected" to Debug.isDebuggerConnected(),
|
|
673
|
+
"waiting_for_debugger" to Debug.waitingForDebugger()
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
private fun probeRootFiles(): Map<String, Any> = ProbeReads.rootFiles(
|
|
677
|
+
rootPaths.associateWith { path -> runCatching { File(path).exists() } },
|
|
678
|
+
Build.TAGS ?: ""
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
// A lookup that could not start, or never finished, is not "no su": the
|
|
682
|
+
// probe fails, and the report says it measured nothing. What the shell
|
|
683
|
+
// says on its error stream is kept out: runCommand merges it, and a
|
|
684
|
+
// complaint that `which` is missing would read as a path to su.
|
|
685
|
+
private fun probeRootShell(): Map<String, Any> = ProbeReads.rootShell(
|
|
686
|
+
runCommand(listOf("/system/bin/sh", "-c", "command -v su 2>/dev/null || which su 2>/dev/null || true"))
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
private fun probeSystemProperties(): Map<String, Any> {
|
|
690
|
+
val names = listOf(
|
|
691
|
+
"ro.secure",
|
|
692
|
+
"ro.debuggable",
|
|
693
|
+
"ro.build.type",
|
|
694
|
+
"ro.build.tags",
|
|
695
|
+
"ro.boot.verifiedbootstate",
|
|
696
|
+
"ro.boot.flash.locked",
|
|
697
|
+
"ro.boot.vbmeta.device_state",
|
|
698
|
+
"ro.boot.veritymode"
|
|
699
|
+
)
|
|
700
|
+
return ProbeReads.systemProperties(
|
|
701
|
+
names.associateWith { name -> runCommand(listOf("/system/bin/getprop", name)) }
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private fun probeSelinux(): Map<String, Any> {
|
|
706
|
+
// Collect SELinux state using two independent local views. Some OEMs do
|
|
707
|
+
// not expose /system/bin/getenforce consistently to an application
|
|
708
|
+
// process even though SELinux itself is enforcing. /sys/fs/selinux/enforce
|
|
709
|
+
// is the authoritative kernel boolean when readable: 1=enforcing,
|
|
710
|
+
// 0=permissive. Never turn command errors into a security verdict here;
|
|
711
|
+
// the server normalizes and scores the evidence.
|
|
712
|
+
// A timed-out getenforce is unknown, like an unreadable one: unknown
|
|
713
|
+
// SELinux state is telemetry, never evidence (server scores it +0).
|
|
714
|
+
val mode = runCommand(listOf("/system/bin/sh", "-c", "getenforce 2>/dev/null || true"))
|
|
715
|
+
.let { if (it.problem == null) it.output else "" }
|
|
716
|
+
.trim().lineSequence().firstOrNull().orEmpty().take(64)
|
|
717
|
+
|
|
718
|
+
val enforceValue = try {
|
|
719
|
+
val file = File("/sys/fs/selinux/enforce")
|
|
720
|
+
if (file.canRead()) file.readText().trim().take(8) else ""
|
|
721
|
+
} catch (_: Throwable) {
|
|
722
|
+
""
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
val normalizedMode = when {
|
|
726
|
+
mode.equals("Enforcing", ignoreCase = true) -> "enforcing"
|
|
727
|
+
mode.equals("Permissive", ignoreCase = true) -> "permissive"
|
|
728
|
+
mode.equals("Disabled", ignoreCase = true) -> "disabled"
|
|
729
|
+
enforceValue == "1" -> "enforcing"
|
|
730
|
+
enforceValue == "0" -> "permissive"
|
|
731
|
+
else -> "unknown"
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return ok(
|
|
735
|
+
"mode" to normalizedMode,
|
|
736
|
+
"getenforce" to mode,
|
|
737
|
+
"enforce_value" to enforceValue
|
|
738
|
+
)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// An unreadable /proc/mounts is no measurement, never "no writable mount".
|
|
742
|
+
private fun probeMounts(): Map<String, Any> = ProbeReads.mounts(readLines(File("/proc/mounts")))
|
|
743
|
+
|
|
744
|
+
private fun probeRuntimeMaps(): Map<String, Any> {
|
|
745
|
+
val found = linkedSetOf<String>()
|
|
746
|
+
var suspiciousLines = 0
|
|
747
|
+
var mapsLines = 0
|
|
748
|
+
val file = File("/proc/self/maps")
|
|
749
|
+
if (!file.canRead()) return unavailable("/proc/self/maps is not readable")
|
|
750
|
+
val folders = ownFolders()
|
|
751
|
+
run {
|
|
752
|
+
file.forEachLine { line ->
|
|
753
|
+
mapsLines++
|
|
754
|
+
val named = ProbeReads.namesIn(line, folders, context.packageName, suspiciousRuntimeTokens)
|
|
755
|
+
found.addAll(named)
|
|
756
|
+
if (named.isNotEmpty()) suspiciousLines++
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (mapsLines == 0) return unavailable("/proc/self/maps returned no lines")
|
|
760
|
+
return ok(
|
|
761
|
+
"suspicious_tokens" to found.toList(),
|
|
762
|
+
"suspicious_line_count" to suspiciousLines,
|
|
763
|
+
"maps_lines" to mapsLines
|
|
764
|
+
)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// An unreadable status file, or a TracerPid missing or not a number, is no
|
|
768
|
+
// measurement - never a tracer pid of 0.
|
|
769
|
+
private fun probeTracer(): Map<String, Any> = ProbeReads.tracer(readLines(File("/proc/self/status")))
|
|
770
|
+
|
|
771
|
+
// A refused connection is a measured "closed"; any other failure means the
|
|
772
|
+
// port could not be tested, and says so.
|
|
773
|
+
private fun probeFridaPorts(): Map<String, Any> = ProbeReads.fridaPorts(
|
|
774
|
+
listOf(27042, 27043).associateWith { port ->
|
|
775
|
+
try {
|
|
776
|
+
Socket().use { socket ->
|
|
777
|
+
socket.connect(InetSocketAddress("127.0.0.1", port), 80)
|
|
778
|
+
}
|
|
779
|
+
ProbeReads.PortResult.Open
|
|
780
|
+
} catch (_: ConnectException) {
|
|
781
|
+
ProbeReads.PortResult.Refused
|
|
782
|
+
} catch (error: Throwable) {
|
|
783
|
+
ProbeReads.PortResult.Failed(ProbeReads.why(error))
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
private fun probeEmulator(): Map<String, Any> {
|
|
789
|
+
val reasons = mutableListOf<String>()
|
|
790
|
+
fun contains(value: String?, token: String) =
|
|
791
|
+
value?.lowercase(Locale.US)?.contains(token) == true
|
|
792
|
+
|
|
793
|
+
if (Build.FINGERPRINT.startsWith("generic") || contains(Build.FINGERPRINT, "emulator")) reasons.add("fingerprint")
|
|
794
|
+
if (contains(Build.MODEL, "google_sdk") || contains(Build.MODEL, "emulator") || contains(Build.MODEL, "android sdk built for")) reasons.add("model")
|
|
795
|
+
if (contains(Build.MANUFACTURER, "genymotion")) reasons.add("manufacturer")
|
|
796
|
+
if (contains(Build.PRODUCT, "sdk") || contains(Build.PRODUCT, "emulator") || contains(Build.PRODUCT, "simulator")) reasons.add("product")
|
|
797
|
+
if (contains(Build.HARDWARE, "goldfish") || contains(Build.HARDWARE, "ranchu")) reasons.add("hardware")
|
|
798
|
+
if (contains(Build.BOARD, "goldfish")) reasons.add("board")
|
|
799
|
+
|
|
800
|
+
return ok(
|
|
801
|
+
"suspected" to reasons.isNotEmpty(),
|
|
802
|
+
"reasons" to reasons,
|
|
803
|
+
"model" to Build.MODEL,
|
|
804
|
+
"manufacturer" to Build.MANUFACTURER,
|
|
805
|
+
"product" to Build.PRODUCT,
|
|
806
|
+
"hardware" to Build.HARDWARE
|
|
807
|
+
)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// A setting that could not be read is unread, never "off".
|
|
811
|
+
private fun probeDeveloperSettings(): Map<String, Any> {
|
|
812
|
+
val resolver = context.contentResolver
|
|
813
|
+
return ProbeReads.developerSettings(
|
|
814
|
+
runCatching { Settings.Global.getInt(resolver, Settings.Global.ADB_ENABLED, 0) != 0 },
|
|
815
|
+
runCatching { Settings.Global.getInt(resolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) != 0 }
|
|
816
|
+
)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Instrumentation-runtime thread names.
|
|
821
|
+
*
|
|
822
|
+
* Structural, not name-of-file based: Frida's Gum runtime and its GLib
|
|
823
|
+
* dependency spawn threads with names compiled into the framework
|
|
824
|
+
* (gum-js-loop, pool-frida, gmain, gdbus). Renaming the injected .so - which
|
|
825
|
+
* defeats the /proc/self/maps pathname scan - does not rename these threads,
|
|
826
|
+
* so an idle, renamed Frida Gadget is still visible here. Reads
|
|
827
|
+
* /proc/self/task/<tid>/comm, which an app can always read for its own
|
|
828
|
+
* threads regardless of SELinux.
|
|
829
|
+
*/
|
|
830
|
+
private fun probeInstrumentationThreads(): Map<String, Any> {
|
|
831
|
+
// Unmistakably Frida/Gum: these are compiled into the framework, so a
|
|
832
|
+
// renamed injected .so keeps them. gum-js-loop is the Gum JS event loop.
|
|
833
|
+
val fridaThreads = listOf("gum-js-loop", "gum-js", "pool-frida")
|
|
834
|
+
// GLib runtime threads: Frida pulls GLib in, and Android apps almost
|
|
835
|
+
// never link GLib themselves, but reported separately so the server can
|
|
836
|
+
// weight them as corroborating rather than conclusive.
|
|
837
|
+
val glibThreads = listOf("gmain", "gdbus", "pool-spawner")
|
|
838
|
+
// A running process always has threads; none enumerated means the
|
|
839
|
+
// probe saw nothing, not that nothing is there. A thread that ended
|
|
840
|
+
// between listing and reading is gone; one whose name could not be
|
|
841
|
+
// read is unread - a renamed instrumentation thread could be that one.
|
|
842
|
+
val names = mutableListOf<String>()
|
|
843
|
+
var unreadable = 0
|
|
844
|
+
for (task in File("/proc/self/task").listFiles().orEmpty()) {
|
|
845
|
+
val name = try {
|
|
846
|
+
File(task, "comm").readText().trim()
|
|
847
|
+
} catch (_: Throwable) {
|
|
848
|
+
if (task.exists()) unreadable++
|
|
849
|
+
continue
|
|
850
|
+
}
|
|
851
|
+
names.add(name)
|
|
852
|
+
}
|
|
853
|
+
return ProbeReads.threads(
|
|
854
|
+
names, unreadable, fridaThreads, glibThreads, suspiciousRuntimeTokens, context.packageName
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Structurally suspicious executable memory.
|
|
860
|
+
*
|
|
861
|
+
* Behaviour-based: inline-hooking and injection frameworks allocate
|
|
862
|
+
* executable trampoline/agent memory. Two shapes are rare in a clean,
|
|
863
|
+
* W^X-compliant app and are reported here: mappings that are simultaneously
|
|
864
|
+
* writable and executable, and executable mappings backed by a file marked
|
|
865
|
+
* "(deleted)". Anonymous executable regions carrying a recognized
|
|
866
|
+
* "[anon:...]" label (for example the ART JIT code cache) are counted
|
|
867
|
+
* separately as telemetry and are NOT treated as suspicious, to avoid
|
|
868
|
+
* flagging the legitimate runtime.
|
|
869
|
+
*/
|
|
870
|
+
private fun probeExecMappings(): Map<String, Any> {
|
|
871
|
+
var wx = 0
|
|
872
|
+
var deletedExec = 0
|
|
873
|
+
var deletedExecJit = 0
|
|
874
|
+
var deletedExecOwnCode = 0
|
|
875
|
+
// The app's own compiled code: Android recompiles an app in the
|
|
876
|
+
// background after an install or update and replaces these files
|
|
877
|
+
// under the running process (seen about a minute after an
|
|
878
|
+
// install). Writing here takes installd, system or root --
|
|
879
|
+
// an attacker who can plant code here already owns the device -- so
|
|
880
|
+
// they are counted apart and the server treats them as advisory.
|
|
881
|
+
// Nothing outside oat/ is exempt, not even base.apk.
|
|
882
|
+
val ownCodeDir = File(context.applicationInfo.sourceDir).parentFile?.path?.let { "$it/oat/" }
|
|
883
|
+
var anonExecLabeled = 0
|
|
884
|
+
var anonExecUnlabeled = 0
|
|
885
|
+
var anonExecUnlabeledBytes = 0L
|
|
886
|
+
// The size of every writable-executable mapping, so the server can
|
|
887
|
+
// hold them to the build's published envelope.
|
|
888
|
+
val wxSizes = java.util.TreeMap<Long, Int>(Collections.reverseOrder<Long>())
|
|
889
|
+
val samples = mutableListOf<String>()
|
|
890
|
+
var mapsLines = 0
|
|
891
|
+
val file = File("/proc/self/maps")
|
|
892
|
+
if (!file.canRead()) return unavailable("/proc/self/maps is not readable")
|
|
893
|
+
run {
|
|
894
|
+
file.forEachLine { line ->
|
|
895
|
+
mapsLines++
|
|
896
|
+
val parts = line.trim().split(Regex("\\s+"), limit = 6)
|
|
897
|
+
if (parts.size < 5) return@forEachLine
|
|
898
|
+
val perms = parts[1]
|
|
899
|
+
if (perms.length < 4) return@forEachLine
|
|
900
|
+
val writable = perms[1] == 'w'
|
|
901
|
+
val executable = perms[2] == 'x'
|
|
902
|
+
if (!executable) return@forEachLine
|
|
903
|
+
val path = if (parts.size >= 6) parts[5] else ""
|
|
904
|
+
val size = mappingSize(parts[0])
|
|
905
|
+
if (writable) {
|
|
906
|
+
wx++
|
|
907
|
+
if (size > 0) wxSizes[size] = (wxSizes[size] ?: 0) + 1
|
|
908
|
+
if (samples.size < 8) samples.add(line.trim().take(160))
|
|
909
|
+
}
|
|
910
|
+
if (path.contains("(deleted)")) {
|
|
911
|
+
// The ART JIT code cache is a memfd/ashmem region that
|
|
912
|
+
// always shows as executable and "(deleted)" on a clean
|
|
913
|
+
// device. Exclude it, or every device is a false positive.
|
|
914
|
+
val lower = path.lowercase(Locale.US)
|
|
915
|
+
val isJit = lower.contains("jit-cache") ||
|
|
916
|
+
lower.contains("dalvik-jit-code-cache") ||
|
|
917
|
+
lower.contains("dalvik-") ||
|
|
918
|
+
lower.contains("/art") ||
|
|
919
|
+
lower.contains("jit-zygote")
|
|
920
|
+
val mapped = path.removeSuffix("(deleted)").trim()
|
|
921
|
+
val isOwnCode = ownCodeDir != null && mapped.startsWith(ownCodeDir) &&
|
|
922
|
+
(mapped.endsWith(".odex") || mapped.endsWith(".vdex") ||
|
|
923
|
+
mapped.endsWith(".art") || mapped.endsWith(".oat"))
|
|
924
|
+
if (isJit) {
|
|
925
|
+
deletedExecJit++
|
|
926
|
+
} else if (isOwnCode) {
|
|
927
|
+
deletedExecOwnCode++
|
|
928
|
+
if (samples.size < 8) samples.add(line.trim().take(160))
|
|
929
|
+
} else {
|
|
930
|
+
deletedExec++
|
|
931
|
+
if (samples.size < 8) samples.add(line.trim().take(160))
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (path.isEmpty()) {
|
|
935
|
+
anonExecUnlabeled++
|
|
936
|
+
anonExecUnlabeledBytes += maxOf(0L, size)
|
|
937
|
+
} else if (path.startsWith("[anon:")) {
|
|
938
|
+
anonExecLabeled++
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (mapsLines == 0) return unavailable("/proc/self/maps returned no lines")
|
|
943
|
+
val wxBytes = wxSizes.entries.sumOf { it.key * it.value }
|
|
944
|
+
return ok(
|
|
945
|
+
"wx_mappings" to wx,
|
|
946
|
+
// Every size class, largest first, as "<bytes>:<count>": a list cut
|
|
947
|
+
// short earns no baseline allowance.
|
|
948
|
+
"wx_bytes" to wxBytes,
|
|
949
|
+
"wx_size_classes" to wxSizes.entries.joinToString(",") { "${it.key}:${it.value}" },
|
|
950
|
+
"wx_smallest_bytes" to (wxSizes.keys.lastOrNull() ?: 0L),
|
|
951
|
+
"wx_largest_bytes" to (wxSizes.keys.firstOrNull() ?: 0L),
|
|
952
|
+
"anon_exec_unlabeled_bytes" to anonExecUnlabeledBytes,
|
|
953
|
+
"maps_lines" to mapsLines,
|
|
954
|
+
"deleted_exec_mappings" to deletedExec,
|
|
955
|
+
"deleted_exec_jit" to deletedExecJit,
|
|
956
|
+
"deleted_exec_own_code" to deletedExecOwnCode,
|
|
957
|
+
"anon_exec_labeled" to anonExecLabeled,
|
|
958
|
+
"anon_exec_unlabeled" to anonExecUnlabeled,
|
|
959
|
+
"samples" to samples
|
|
960
|
+
)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** The folders the app's own files live in: its install folder and its data folders. */
|
|
964
|
+
private fun ownFolders(): List<String> {
|
|
965
|
+
val info = context.applicationInfo
|
|
966
|
+
return listOfNotNull(
|
|
967
|
+
info.sourceDir?.let { File(it).parent }?.let { "$it/" },
|
|
968
|
+
info.dataDir?.let { "$it/" },
|
|
969
|
+
"/data/data/${context.packageName}/",
|
|
970
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) info.deviceProtectedDataDir?.let { "$it/" } else null,
|
|
971
|
+
).distinct()
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** The size of one /proc/self/maps range, "start-end" in hex; 0 if unreadable. */
|
|
975
|
+
private fun mappingSize(range: String): Long {
|
|
976
|
+
val dash = range.indexOf('-')
|
|
977
|
+
if (dash <= 0) return 0L
|
|
978
|
+
val start = range.substring(0, dash).toLongOrNull(16) ?: return 0L
|
|
979
|
+
val end = range.substring(dash + 1).toLongOrNull(16) ?: return 0L
|
|
980
|
+
return if (end > start) end - start else 0L
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Code integrity of libc: in-memory .text versus the same bytes on disk.
|
|
985
|
+
*
|
|
986
|
+
* The purest structural check. An inline hook overwrites the prologue of a
|
|
987
|
+
* hooked function with a trampoline, so the executable segment loaded in
|
|
988
|
+
* memory diverges from the file on disk. This is name-independent and
|
|
989
|
+
* behaviour-based - it catches any inline-hooking library, whatever it is
|
|
990
|
+
* called. The executable segment is not modified at runtime by the dynamic
|
|
991
|
+
* linker (relocations land in the GOT/data segments, not in .text), so on a
|
|
992
|
+
* clean device the two are byte-identical. Reads a bounded window through
|
|
993
|
+
* /proc/self/mem, which an app may read for its own address space.
|
|
994
|
+
*/
|
|
995
|
+
private fun probeCodeIntegrity(): Map<String, Any> {
|
|
996
|
+
// Native reads our own mapped r-x pages by pointer, so unlike the
|
|
997
|
+
// /proc/self/mem path it is not blocked by SELinux. It compares three
|
|
998
|
+
// buckets of libraries in memory against disk: core (libc/libart),
|
|
999
|
+
// ext (other system hook targets incl. TLS), and app (the app's own
|
|
1000
|
+
// native code). Any inline hook overwrites a prologue and shows up as
|
|
1001
|
+
// a byte difference, whatever the hooking framework is called.
|
|
1002
|
+
if (!nativeAvailable) {
|
|
1003
|
+
return ok("checked" to false, "reason" to "native_unavailable")
|
|
1004
|
+
}
|
|
1005
|
+
// The app's own: the APKs it was installed from (its region, by exact
|
|
1006
|
+
// path) and the directories its files live in (what is outside the
|
|
1007
|
+
// region but the app's, collector 10).
|
|
1008
|
+
val info = context.applicationInfo
|
|
1009
|
+
val apks = (listOf(info.sourceDir) + info.splitSourceDirs.orEmpty().toList())
|
|
1010
|
+
.filterNotNull().distinct().toTypedArray()
|
|
1011
|
+
val dirs = ownFolders().toTypedArray()
|
|
1012
|
+
val started = System.nanoTime()
|
|
1013
|
+
val json = try {
|
|
1014
|
+
nativeCodeIntegrity(apks, dirs)
|
|
1015
|
+
} catch (error: Throwable) {
|
|
1016
|
+
return ok("checked" to false, "reason" to ("native_error:" + error.javaClass.simpleName))
|
|
1017
|
+
}
|
|
1018
|
+
val elapsedMs = (System.nanoTime() - started) / 1_000_000
|
|
1019
|
+
if (json.isNullOrEmpty()) {
|
|
1020
|
+
return ok("checked" to false, "reason" to "native_no_result")
|
|
1021
|
+
}
|
|
1022
|
+
return try {
|
|
1023
|
+
val o = JSONObject(json)
|
|
1024
|
+
val checked = o.optBoolean("checked", false)
|
|
1025
|
+
val result = linkedMapOf<String, Any>(
|
|
1026
|
+
"status" to "ok",
|
|
1027
|
+
"checked" to checked,
|
|
1028
|
+
"diff_bytes" to o.optLong("diff_bytes", 0),
|
|
1029
|
+
"core_compared_bytes" to o.optLong("core_compared_bytes", 0),
|
|
1030
|
+
"core_diff_bytes" to o.optLong("core_diff_bytes", 0),
|
|
1031
|
+
"ext_compared_bytes" to o.optLong("ext_compared_bytes", 0),
|
|
1032
|
+
"ext_diff_bytes" to o.optLong("ext_diff_bytes", 0),
|
|
1033
|
+
"ext_libs_diff" to o.optInt("ext_libs_diff", 0),
|
|
1034
|
+
"app_compared_bytes" to o.optLong("app_compared_bytes", 0),
|
|
1035
|
+
"app_diff_bytes" to o.optLong("app_diff_bytes", 0),
|
|
1036
|
+
"app_libs_diff" to o.optInt("app_libs_diff", 0),
|
|
1037
|
+
// Visibility into execute-only mappings: a bucket that skipped
|
|
1038
|
+
// everything reads as clean unless these are reported.
|
|
1039
|
+
"xom_regions_unlocked" to o.optInt("xom_regions_unlocked", 0),
|
|
1040
|
+
"xom_regions_unreadable" to o.optInt("xom_regions_unreadable", 0),
|
|
1041
|
+
"diffed_libs" to o.optString("diffed_libs", ""),
|
|
1042
|
+
// Wall time of the native comparison: what a scan costs.
|
|
1043
|
+
"elapsed_ms" to elapsedMs
|
|
1044
|
+
)
|
|
1045
|
+
// Coverage per bucket: expected = compared + skipped +
|
|
1046
|
+
// unreadable, so partial measurement is visible, never read as
|
|
1047
|
+
// clean. Copied only when the native side reported them.
|
|
1048
|
+
for (bucket in listOf("core", "ext", "app")) {
|
|
1049
|
+
for (field in listOf("expected_bytes", "skipped_bytes", "unreadable_bytes")) {
|
|
1050
|
+
val key = "${bucket}_$field"
|
|
1051
|
+
if (o.has(key)) result[key] = o.optLong(key, 0)
|
|
1052
|
+
}
|
|
1053
|
+
val complete = "${bucket}_complete"
|
|
1054
|
+
if (o.has(complete)) result[complete] = o.optBoolean(complete, false)
|
|
1055
|
+
}
|
|
1056
|
+
if (o.has("protect_restore_failures")) {
|
|
1057
|
+
result["protect_restore_failures"] = o.optInt("protect_restore_failures", 0)
|
|
1058
|
+
}
|
|
1059
|
+
// The app's own executable files mapped outside the app region
|
|
1060
|
+
// (collector 10): counted, and named, at most 32.
|
|
1061
|
+
if (o.has("app_outside_count")) {
|
|
1062
|
+
result["app_outside_count"] = o.optInt("app_outside_count", 0)
|
|
1063
|
+
val files = o.optJSONArray("app_outside_files")
|
|
1064
|
+
result["app_outside_files"] = (0 until (files?.length() ?: 0)).map { files!!.optString(it) }
|
|
1065
|
+
}
|
|
1066
|
+
// Why nothing was compared: the memory map unreadable or empty, or
|
|
1067
|
+
// no target mapped in it -- never a bucket read as complete.
|
|
1068
|
+
val reason = o.optString("reason", "")
|
|
1069
|
+
if (!checked && reason.isNotEmpty()) result["reason"] = reason
|
|
1070
|
+
result
|
|
1071
|
+
} catch (error: Throwable) {
|
|
1072
|
+
ok("checked" to false, "reason" to ("parse_error:" + error.javaClass.simpleName))
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Runs one command with a deadline, and says how it went: started or not,
|
|
1078
|
+
* finished within COMMAND_TIMEOUT_MS or not (the child is then killed),
|
|
1079
|
+
* its exit code, and its output (at most MAX_TEXT) when it was read to the
|
|
1080
|
+
* end. The caller decides what an unanswered command means for its probe;
|
|
1081
|
+
* it is never an empty answer. The output is read on a separate thread
|
|
1082
|
+
* because a blocking read is exactly what used to hang.
|
|
1083
|
+
*/
|
|
1084
|
+
private fun runCommand(command: List<String>): ProbeReads.CommandResult {
|
|
1085
|
+
val process = try {
|
|
1086
|
+
ProcessBuilder(command).redirectErrorStream(true).start()
|
|
1087
|
+
} catch (error: Throwable) {
|
|
1088
|
+
// Never "" -- a command that did not start answered nothing.
|
|
1089
|
+
return ProbeReads.CommandResult(false, false, null, "", ProbeReads.why(error))
|
|
1090
|
+
}
|
|
1091
|
+
val output = StringBuilder()
|
|
1092
|
+
var readError: String? = null
|
|
1093
|
+
val reader = Thread {
|
|
1094
|
+
try {
|
|
1095
|
+
BufferedReader(InputStreamReader(process.inputStream)).use { input ->
|
|
1096
|
+
var line: String?
|
|
1097
|
+
while (input.readLine().also { line = it } != null) {
|
|
1098
|
+
synchronized(output) {
|
|
1099
|
+
if (output.length < MAX_TEXT) output.append(line).append('\n')
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
} catch (error: Throwable) {
|
|
1104
|
+
readError = ProbeReads.why(error)
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
reader.isDaemon = true
|
|
1108
|
+
reader.start()
|
|
1109
|
+
return try {
|
|
1110
|
+
if (!process.waitFor(COMMAND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
|
1111
|
+
process.destroyForcibly()
|
|
1112
|
+
ProbeReads.CommandResult(true, false, null, "")
|
|
1113
|
+
} else {
|
|
1114
|
+
reader.join(500)
|
|
1115
|
+
val failed = readError
|
|
1116
|
+
if (failed != null) {
|
|
1117
|
+
// Its output could not be read to the end: not an answer.
|
|
1118
|
+
ProbeReads.CommandResult(true, false, process.exitValue(), "", "output: $failed")
|
|
1119
|
+
} else {
|
|
1120
|
+
ProbeReads.CommandResult(
|
|
1121
|
+
true, true, process.exitValue(),
|
|
1122
|
+
synchronized(output) { output.toString().take(MAX_TEXT) }
|
|
1123
|
+
)
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
} catch (_: InterruptedException) {
|
|
1127
|
+
process.destroyForcibly()
|
|
1128
|
+
Thread.currentThread().interrupt()
|
|
1129
|
+
ProbeReads.CommandResult(true, false, null, "")
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** A file's lines, or null when it could not be read: never an empty "nothing found". */
|
|
1134
|
+
private fun readLines(file: File): List<String>? = try {
|
|
1135
|
+
if (file.canRead()) file.readLines() else null
|
|
1136
|
+
} catch (_: Throwable) {
|
|
1137
|
+
null
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
private fun sha256Hex(bytes: ByteArray): String =
|
|
1141
|
+
MessageDigest.getInstance("SHA-256")
|
|
1142
|
+
.digest(bytes)
|
|
1143
|
+
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
|
1144
|
+
|
|
1145
|
+
private fun sha256File(file: File): String {
|
|
1146
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
1147
|
+
FileInputStream(file).use { input ->
|
|
1148
|
+
val buffer = ByteArray(1024 * 1024)
|
|
1149
|
+
while (true) {
|
|
1150
|
+
val read = input.read(buffer)
|
|
1151
|
+
if (read <= 0) break
|
|
1152
|
+
digest.update(buffer, 0, read)
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return digest.digest().joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
|
1156
|
+
}
|
|
1157
|
+
}
|