@necessarysdk/react-native 0.0.0-stage → 0.1.5

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.
Files changed (114) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +2 -0
  4. package/NecessaryReactNative.podspec +23 -0
  5. package/README.md +429 -2
  6. package/android/build.gradle +83 -0
  7. package/android/src/main/AndroidManifest.xml +41 -0
  8. package/android/src/main/cpp/CMakeLists.txt +7 -0
  9. package/android/src/main/cpp/code_integrity.cpp +484 -0
  10. package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
  11. package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
  12. package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1152 -0
  13. package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
  14. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
  15. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +323 -0
  16. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
  17. package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +314 -0
  18. package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
  19. package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
  20. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
  21. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +762 -0
  22. package/contract/report-1/android-8-ios-5/RULES.md +149 -0
  23. package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
  24. package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
  25. package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
  26. package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
  27. package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
  28. package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
  29. package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
  30. package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
  31. package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
  32. package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
  33. package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
  34. package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
  35. package/doc/integration_guide.md +771 -0
  36. package/doc/measurements.md +774 -0
  37. package/ios/InstallationKeyManager.swift +694 -0
  38. package/ios/IntegrityProbeManager.swift +960 -0
  39. package/ios/NecessaryModules.h +34 -0
  40. package/ios/NecessaryModules.mm +208 -0
  41. package/ios/NecessaryNative.swift +327 -0
  42. package/ios/PrivacyInfo.xcprivacy +14 -0
  43. package/ios/ProbeReads.swift +353 -0
  44. package/lib/client.d.ts +105 -0
  45. package/lib/client.js +176 -0
  46. package/lib/collector.d.ts +18 -0
  47. package/lib/collector.js +49 -0
  48. package/lib/deviceTrust.d.ts +253 -0
  49. package/lib/deviceTrust.js +690 -0
  50. package/lib/errors.d.ts +43 -0
  51. package/lib/errors.js +56 -0
  52. package/lib/index.d.ts +18 -0
  53. package/lib/index.js +34 -0
  54. package/lib/installationKey.d.ts +62 -0
  55. package/lib/installationKey.js +185 -0
  56. package/lib/installationStore.d.ts +49 -0
  57. package/lib/installationStore.js +127 -0
  58. package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
  59. package/lib/native/NativeNecessaryInstallationKey.js +4 -0
  60. package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
  61. package/lib/native/NativeNecessaryIntegrity.js +4 -0
  62. package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
  63. package/lib/native/NativeNecessarySecureScreen.js +4 -0
  64. package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
  65. package/lib/native/NativeNecessaryStepUpKey.js +4 -0
  66. package/lib/native/NativeNecessaryStorage.d.ts +14 -0
  67. package/lib/native/NativeNecessaryStorage.js +4 -0
  68. package/lib/protocol.d.ts +24 -0
  69. package/lib/protocol.js +50 -0
  70. package/lib/reinstallHint.d.ts +23 -0
  71. package/lib/reinstallHint.js +65 -0
  72. package/lib/report.d.ts +62 -0
  73. package/lib/report.js +140 -0
  74. package/lib/requestProof.d.ts +116 -0
  75. package/lib/requestProof.js +220 -0
  76. package/lib/secureScreen.d.ts +15 -0
  77. package/lib/secureScreen.js +32 -0
  78. package/lib/stepUpKey.d.ts +105 -0
  79. package/lib/stepUpKey.js +187 -0
  80. package/lib/transport.d.ts +39 -0
  81. package/lib/transport.js +117 -0
  82. package/lib/util/bytes.d.ts +18 -0
  83. package/lib/util/bytes.js +207 -0
  84. package/lib/util/native.d.ts +20 -0
  85. package/lib/util/native.js +49 -0
  86. package/lib/util/release.d.ts +2 -0
  87. package/lib/util/release.js +7 -0
  88. package/lib/util/target.d.ts +18 -0
  89. package/lib/util/target.js +128 -0
  90. package/package.json +81 -4
  91. package/src/client.ts +221 -0
  92. package/src/collector.ts +64 -0
  93. package/src/deviceTrust.ts +842 -0
  94. package/src/errors.ts +71 -0
  95. package/src/index.ts +19 -0
  96. package/src/installationKey.ts +223 -0
  97. package/src/installationStore.ts +143 -0
  98. package/src/native/NativeNecessaryInstallationKey.ts +12 -0
  99. package/src/native/NativeNecessaryIntegrity.ts +15 -0
  100. package/src/native/NativeNecessarySecureScreen.ts +9 -0
  101. package/src/native/NativeNecessaryStepUpKey.ts +11 -0
  102. package/src/native/NativeNecessaryStorage.ts +16 -0
  103. package/src/protocol.ts +52 -0
  104. package/src/reinstallHint.ts +69 -0
  105. package/src/report.ts +174 -0
  106. package/src/requestProof.ts +295 -0
  107. package/src/secureScreen.ts +29 -0
  108. package/src/stepUpKey.ts +238 -0
  109. package/src/transport.ts +145 -0
  110. package/src/util/bytes.ts +194 -0
  111. package/src/util/native.ts +56 -0
  112. package/src/util/release.ts +6 -0
  113. package/src/util/target.ts +132 -0
  114. package/vectors/client_server.json +264 -0
@@ -0,0 +1,960 @@
1
+ import Foundation
2
+ import CFNetwork
3
+ import CoreLocation
4
+ import CryptoKit
5
+ import MachO
6
+ import Security
7
+ import UIKit
8
+
9
+ /// Local integrity measurement collector for iOS.
10
+ ///
11
+ /// IMPORTANT: these are risk signals, not remote attestation. On a jailbroken
12
+ /// device an attacker may be able to lie about local state. The server scores
13
+ /// these raw measurements together with server-observed behaviour and never
14
+ /// trusts a client-provided score - the collector deliberately computes no
15
+ /// score of its own, exactly like the Android host.
16
+ ///
17
+ /// The probe names and every field name below are a hard contract with the
18
+ /// published measurement contract and every backend reading it. Renaming a
19
+ /// field silently disables the rule that reads it.
20
+ final class IntegrityProbeManager {
21
+ /// Per-platform numbering. 1 was the baseline probe set; 2 adds
22
+ /// `code_integrity`, the dyld analogue of Android's native probe. 3
23
+ /// volunteers `device` and `network` on every scan and answers the
24
+ /// optional measurements the server asks for. 4 adds screen capture,
25
+ /// location and mock locations, and the OS build. 5 reports the app's code
26
+ /// coverage image by image - compared, unreadable or skipped, each one not
27
+ /// compared named with why - and whether the main executable was compared;
28
+ /// and one location read answers both location measurements, which carry
29
+ /// its fix_time. 6 sends the memory page size with `device`, and asks
30
+ /// screen capture of the screen showing the app: with no window scene in
31
+ /// the foreground, recording is not measured and `recording_status` says
32
+ /// `unavailable`, as on Android.
33
+ /// What the reads mean - the coverage tally, the text span's checks, the
34
+ /// one location read - is in ProbeReads, which needs no Apple-only
35
+ /// framework and so can be tested off the phone, on any machine.
36
+ private static let collectorVersion = 8
37
+ /// Sent with every scan whether or not the server asked, like
38
+ /// code_integrity below, so no older client breaks.
39
+ private static let volunteeredProbes = ["device", "network"]
40
+ /// Collected only when the server asks for one AND the app enabled it.
41
+ private static let optionalMeasurements: Set<String> = [
42
+ "local_addresses", "accessibility", "notification_access", "other_access",
43
+ "screen_capture", "location", "mock_location",
44
+ ]
45
+ private static let maxTextLength = 8192
46
+
47
+ /// Loaded-image names that indicate instrumentation or a hooking runtime.
48
+ /// This is the iOS counterpart of the /proc/self/maps token scan - and it
49
+ /// carries the same known weakness: it matches on NAME, so a renamed
50
+ /// library evades it; `code_integrity` is the structural answer.
51
+ private static let suspiciousImageTokens = [
52
+ "frida", "gadget", "objection", "cynject", "cycript",
53
+ "substrate", "mobilesubstrate", "substitute", "libhooker",
54
+ "ellekit", "cydia", "shadow", "flex", "libsparkapplist",
55
+ ]
56
+
57
+ /// Classic jailbreak filesystem artifacts, including rootless (/var/jb)
58
+ /// layouts used by modern jailbreaks such as palera1n and Dopamine.
59
+ private static let jailbreakPaths = [
60
+ "/Applications/Cydia.app",
61
+ "/Applications/Sileo.app",
62
+ "/Applications/Zebra.app",
63
+ "/Library/MobileSubstrate/MobileSubstrate.dylib",
64
+ "/Library/MobileSubstrate/DynamicLibraries",
65
+ "/usr/sbin/sshd",
66
+ "/usr/bin/ssh",
67
+ "/usr/libexec/ssh-keysign",
68
+ "/bin/bash",
69
+ "/bin/sh",
70
+ "/etc/apt",
71
+ "/private/var/lib/apt",
72
+ "/private/var/lib/cydia",
73
+ "/private/var/stash",
74
+ "/usr/libexec/ellekit",
75
+ "/var/jb",
76
+ "/var/jb/usr/lib/TweakInject",
77
+ "/.bootstrapped_electra",
78
+ "/taurine",
79
+ "/.installed_unc0ver",
80
+ ]
81
+
82
+ // MARK: - Entry point
83
+
84
+ func collect(
85
+ requiredProbes: [String],
86
+ challengeNonce: String,
87
+ optionalProbes: [String] = [],
88
+ enabledMeasurements: Set<String> = []
89
+ ) -> [String: Any] {
90
+ var probes: [String: Any] = [:]
91
+ // One location read per collection, made only if an enabled location
92
+ // measurement asks: both answer from it.
93
+ let location = ProbeReads.LocationCapture { self.lastLocation() }
94
+
95
+ for probe in NSOrderedSet(array: requiredProbes).array as? [String] ?? requiredProbes {
96
+ probes[probe] = runProbe(probe, enabledMeasurements: enabledMeasurements, location: location)
97
+ }
98
+
99
+ // code_integrity is reported whether or not the server asked for it.
100
+ //
101
+ // The server tolerates probes beyond required_probes but rejects a
102
+ // report that omits a requested one, so a collector cannot be given a
103
+ // new mandatory probe without breaking every client that predates it -
104
+ // here that would mean an older iOS collector, which has the same eight
105
+ // probes this one had. Volunteering the measurement instead lets the
106
+ // server score it when present and ignore it when absent, so the two
107
+ // clients can adopt it independently. Promote it to the mandatory list
108
+ // once both report it.
109
+ if probes["code_integrity"] == nil {
110
+ probes["code_integrity"] = runProbe("code_integrity", location: location)
111
+ }
112
+ for probe in IntegrityProbeManager.volunteeredProbes where probes[probe] == nil {
113
+ probes[probe] = runProbe(probe, enabledMeasurements: enabledMeasurements, location: location)
114
+ }
115
+ for probe in optionalProbes
116
+ where probes[probe] == nil && IntegrityProbeManager.optionalMeasurements.contains(probe) {
117
+ probes[probe] = runProbe(probe, enabledMeasurements: enabledMeasurements, location: location)
118
+ }
119
+
120
+ return [
121
+ "collector_version": IntegrityProbeManager.collectorVersion,
122
+ "platform": "ios",
123
+ "challenge_nonce_echo": challengeNonce,
124
+ "os_version": UIDeviceOSVersion(),
125
+ "probes": probes,
126
+ ]
127
+ }
128
+
129
+ private func runProbe(
130
+ _ name: String, enabledMeasurements: Set<String> = [], location: ProbeReads.LocationCapture
131
+ ) -> [String: Any] {
132
+ switch name {
133
+ case "app_identity": return probeAppIdentity()
134
+ case "code_signing": return probeCodeSigning()
135
+ case "debugger": return probeDebugger()
136
+ case "jailbreak_files": return probeJailbreakFiles()
137
+ case "sandbox": return probeSandbox()
138
+ case "dyld_images": return probeDyldImages()
139
+ case "environment": return probeEnvironment()
140
+ case "simulator": return probeSimulator()
141
+ case "code_integrity": return probeCodeIntegrity()
142
+ case "device": return probeDevice()
143
+ case "network": return probeNetwork()
144
+ case "local_addresses":
145
+ // Asked for by the server, declined by this app.
146
+ return enabledMeasurements.contains(name) ? probeLocalAddresses() : ["status": "not_enabled"]
147
+ case "accessibility", "notification_access", "other_access":
148
+ // iOS shows an app no other app: nothing to list.
149
+ return enabledMeasurements.contains(name)
150
+ ? ["status": "unsupported", "reason": "iOS shows an app no other app."]
151
+ : ["status": "not_enabled"]
152
+ case "screen_capture":
153
+ return enabledMeasurements.contains(name) ? probeScreenCapture() : ["status": "not_enabled"]
154
+ case "location":
155
+ return enabledMeasurements.contains(name)
156
+ ? ProbeReads.location(location.value) : ["status": "not_enabled"]
157
+ case "mock_location":
158
+ return enabledMeasurements.contains(name)
159
+ ? ProbeReads.mockLocation(location.value) : ["status": "not_enabled"]
160
+ default:
161
+ return [
162
+ "status": "unsupported",
163
+ "reason": "Unknown probe requested by server",
164
+ ]
165
+ }
166
+ }
167
+
168
+ // MARK: - Probes
169
+
170
+ /// The OS version and build, the hardware model, and the memory page size.
171
+ /// iOS has no separate security patch level: its fixes ship as OS
172
+ /// versions, and the build tells a Rapid Security Response apart. The page
173
+ /// size (collector 6) is 16 KiB on Apple's 64-bit phones; a backend that
174
+ /// keeps a record of a release's runtime keeps one per page size.
175
+ private func probeDevice() -> [String: Any] {
176
+ return [
177
+ "status": "ok",
178
+ "os_version": UIDeviceOSVersion(),
179
+ "os_build": osBuild(),
180
+ "model_identifier": deviceModelIdentifier(),
181
+ "page_size": Int(getpagesize()),
182
+ ]
183
+ }
184
+
185
+ /// Runs on the main thread: UIKit and Core Location state is read there.
186
+ /// The collector runs on a worker queue, so this never waits on itself.
187
+ private func onMain<T>(_ read: () -> T) -> T {
188
+ return Thread.isMainThread ? read() : DispatchQueue.main.sync(execute: read)
189
+ }
190
+
191
+ /// Whether the screen showing the app is being recorded, mirrored or sent
192
+ /// to AirPlay (`UIScreen.isCaptured`, asked of the screen of each window
193
+ /// scene in the foreground), and how many other screens are attached.
194
+ /// With no window scene in the foreground - the app in the background, or
195
+ /// before its first window - there is no screen of the app's to ask:
196
+ /// ProbeReads.screenCapture leaves recording out and says why
197
+ /// (collector 6).
198
+ private func probeScreenCapture() -> [String: Any] {
199
+ return onMain {
200
+ let shown = UIApplication.shared.connectedScenes
201
+ .compactMap { $0 as? UIWindowScene }
202
+ .filter {
203
+ $0.activationState == .foregroundActive
204
+ || $0.activationState == .foregroundInactive
205
+ }
206
+ return ProbeReads.screenCapture(
207
+ captured: shown.isEmpty ? nil : shown.contains { $0.screen.isCaptured },
208
+ otherScreens: UIScreen.screens.count - 1)
209
+ }
210
+ }
211
+
212
+ private func locationPermission(_ manager: CLLocationManager) -> String {
213
+ let status: CLAuthorizationStatus
214
+ if #available(iOS 14.0, *) {
215
+ status = manager.authorizationStatus
216
+ } else {
217
+ status = CLLocationManager.authorizationStatus()
218
+ }
219
+ guard status == .authorizedWhenInUse || status == .authorizedAlways else { return "none" }
220
+ if #available(iOS 14.0, *), manager.accuracyAuthorization == .reducedAccuracy {
221
+ return "approximate"
222
+ }
223
+ return "precise"
224
+ }
225
+
226
+ /// The collection's one location read: the last location the phone already holds - never a fresh
227
+ /// fix, never a prompt: only the app asks for the permission.
228
+ private func lastLocation() -> ProbeReads.LocationRead {
229
+ return onMain {
230
+ let manager = CLLocationManager()
231
+ let permission = locationPermission(manager)
232
+ let held: CLLocation? = permission == "none" ? nil : manager.location
233
+ return ProbeReads.read(permission: permission, fix: held.map { self.fixOf($0) }, now: Date())
234
+ }
235
+ }
236
+
237
+ /// iOS 15 and later say whether a location was simulated by software or
238
+ /// produced by an accessory; before that the fix cannot tell.
239
+ private func fixOf(_ location: CLLocation) -> ProbeReads.Fix {
240
+ var simulated: Bool?
241
+ var accessory: Bool?
242
+ if #available(iOS 15.0, *), let source = location.sourceInformation {
243
+ simulated = source.isSimulatedBySoftware
244
+ accessory = source.isProducedByAccessory
245
+ }
246
+ return ProbeReads.Fix(
247
+ latitude: location.coordinate.latitude,
248
+ longitude: location.coordinate.longitude,
249
+ horizontalAccuracy: location.horizontalAccuracy,
250
+ timestamp: location.timestamp,
251
+ simulatedBySoftware: simulated,
252
+ producedByAccessory: accessory
253
+ )
254
+ }
255
+
256
+ /// Whether a VPN or an HTTP proxy is in use. iOS gives other apps no VPN
257
+ /// API, so a VPN is inferred from the interfaces the system proxy settings
258
+ /// are scoped to - a heuristic, so their names are sent with it. Names
259
+ /// only, never an address.
260
+ private func probeNetwork() -> [String: Any] {
261
+ guard let unmanaged = CFNetworkCopySystemProxySettings() else {
262
+ return ["status": "error", "error": "The system proxy settings could not be read."]
263
+ }
264
+ let settings = unmanaged.takeRetainedValue() as NSDictionary
265
+ var scoped: [String] = []
266
+ if let scopes = settings["__SCOPED__"] as? NSDictionary {
267
+ scoped = scopes.allKeys.compactMap { $0 as? String }.sorted()
268
+ }
269
+ let tunnels = ["utun", "ipsec", "ppp", "tun", "tap"]
270
+ let vpn = scoped.contains { name in tunnels.contains { name.hasPrefix($0) } }
271
+ let proxy = ["HTTPEnable", "HTTPSEnable", "ProxyAutoConfigEnable"].contains { key in
272
+ (settings[key] as? NSNumber)?.intValue == 1
273
+ }
274
+ return [
275
+ "status": "ok",
276
+ "vpn_active": vpn,
277
+ "http_proxy_configured": proxy,
278
+ "scoped_interfaces": Array(scoped.prefix(32)),
279
+ ]
280
+ }
281
+
282
+ /// The phone's own IP addresses, per interface - an optional measurement.
283
+ /// Link-local addresses are left out: they can carry the
284
+ /// hardware address the platform deliberately hides.
285
+ private func probeLocalAddresses() -> [String: Any] {
286
+ var head: UnsafeMutablePointer<ifaddrs>?
287
+ guard getifaddrs(&head) == 0, let first = head else {
288
+ return ["status": "error", "error": "The network interfaces could not be listed."]
289
+ }
290
+ defer { freeifaddrs(head) }
291
+ var addresses: [[String: Any]] = []
292
+ // More than a list holds is said, never cut off unsaid (collector 8).
293
+ var truncated = false
294
+ var cursor: UnsafeMutablePointer<ifaddrs>? = first
295
+ while let entry = cursor {
296
+ cursor = entry.pointee.ifa_next
297
+ let flags = entry.pointee.ifa_flags
298
+ guard (flags & UInt32(IFF_UP)) != 0, (flags & UInt32(IFF_LOOPBACK)) == 0,
299
+ let address = entry.pointee.ifa_addr else { continue }
300
+ let family = Int32(address.pointee.sa_family)
301
+ guard family == AF_INET || family == AF_INET6 else { continue }
302
+ var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
303
+ guard getnameinfo(address, socklen_t(address.pointee.sa_len), &host,
304
+ socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue }
305
+ let numeric = host.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
306
+ let text = numeric.split(separator: "%").first.map(String.init) ?? ""
307
+ if text.isEmpty || text.lowercased().hasPrefix("fe80") || text.hasPrefix("169.254") {
308
+ continue
309
+ }
310
+ if addresses.count >= 32 {
311
+ truncated = true
312
+ break
313
+ }
314
+ addresses.append([
315
+ "interface": String(cString: entry.pointee.ifa_name),
316
+ "address": text,
317
+ ])
318
+ }
319
+ return ["status": "ok", "addresses": addresses, "truncated": truncated]
320
+ }
321
+
322
+ private func probeAppIdentity() -> [String: Any] {
323
+ let bundle = Bundle.main
324
+ var result: [String: Any] = [
325
+ "status": "ok",
326
+ "bundle_id": bundle.bundleIdentifier ?? "",
327
+ "version_name": bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
328
+ "version_code": bundle.infoDictionary?["CFBundleVersion"] as? String ?? "",
329
+ ]
330
+ // Hash the main executable. The server compares this against a
331
+ // record of the release when it holds one.
332
+ if let url = bundle.executableURL,
333
+ let data = try? Data(contentsOf: url, options: .mappedIfSafe) {
334
+ result["executable_sha256"] = SHA256.hash(data: data)
335
+ .map { String(format: "%02x", $0) }.joined()
336
+ result["executable_bytes"] = data.count
337
+ } else {
338
+ result["executable_sha256"] = ""
339
+ }
340
+ return result
341
+ }
342
+
343
+ /// Reports this app's own code-signing identity by parsing the Mach-O
344
+ /// signature embedded in its executable on disk.
345
+ ///
346
+ /// The obvious implementation - SecTaskCreateFromSelf plus
347
+ /// SecTaskCopyValueForEntitlement - does not compile for iOS. Those live in
348
+ /// Security/SecTask.h, which Apple ships as public API on macOS only; on
349
+ /// iOS the symbols are private and the build fails with "Cannot find
350
+ /// 'SecTaskCreateFromSelf' in scope".
351
+ ///
352
+ /// Reading our own binary is the better measurement regardless. It reports
353
+ /// what is actually embedded in the file rather than what the kernel was
354
+ /// told at launch, and it degrades honestly: a TrollStore-installed
355
+ /// unsigned build carries no LC_CODE_SIGNATURE at all, which is reported as
356
+ /// signed=false instead of being mistaken for a clean signed app. That is
357
+ /// the same class of trap as an Android integrity bucket reporting
358
+ /// compared_bytes=0 and reading as clean.
359
+ ///
360
+ /// signing_identifier / team_identifier / get_task_allow are fixed by the
361
+ /// measurement contract. The remaining fields are additive.
362
+ private func probeCodeSigning() -> [String: Any] {
363
+ var result: [String: Any] = [
364
+ "status": "ok",
365
+ "signing_identifier": "",
366
+ "team_identifier": "",
367
+ "get_task_allow": false,
368
+ "signed": false,
369
+ ]
370
+
371
+ guard let url = Bundle.main.executableURL,
372
+ let data = try? Data(contentsOf: url, options: .mappedIfSafe) else {
373
+ result["status"] = "error"
374
+ result["error"] = "The main executable could not be read."
375
+ return result
376
+ }
377
+
378
+ guard let slice = machOSliceOffset(data) else {
379
+ result["status"] = "error"
380
+ result["error"] = "The main executable is not a recognised 64-bit Mach-O image."
381
+ return result
382
+ }
383
+
384
+ guard let signature = codeSignatureRange(data, sliceOffset: slice) else {
385
+ // Unsigned - legitimate for the jailbroken-device build. The server
386
+ // only compares these fields when a baseline is configured.
387
+ result["signature_absent"] = true
388
+ return result
389
+ }
390
+
391
+ result["signed"] = true
392
+ parseEmbeddedSignature(
393
+ data, offset: signature.offset, size: signature.size, into: &result
394
+ )
395
+ return result
396
+ }
397
+
398
+ // MARK: - Code integrity
399
+
400
+ /// Compares each loaded image's `__TEXT,__text` in memory against the same
401
+ /// bytes on disk. This is the iOS analogue of Android's native
402
+ /// `code_integrity`, and its field names, the same on both platforms, are
403
+ /// fixed by the measurement contract.
404
+ ///
405
+ /// **Only the app bucket can be measured on iOS, and that is a platform
406
+ /// fact rather than an omission.** Android compares libc and libart
407
+ /// against `/apex/.../libc.so`, real files with real bytes. iOS system
408
+ /// libraries do not exist as individual files: dyld combines them into the
409
+ /// shared cache, so there is nothing to `open()` for UIKit or libobjc.
410
+ /// Those images are therefore counted as *unreadable*, never as clean.
411
+ ///
412
+ /// That distinction is the whole point. An implementation that skipped
413
+ /// them silently would report `ext_compared_bytes: 0, ext_diff_bytes: 0`,
414
+ /// which scores exactly like a pristine device while having measured
415
+ /// nothing. `checked` stays true because the app bucket really was
416
+ /// compared; `system_images_unreadable` and `system_bucket_reason` say why
417
+ /// the rest is empty.
418
+ ///
419
+ /// The app bucket says how much of itself it measured (collector 5): every
420
+ /// bundle image dyld has loaded
421
+ /// is expected, and each is compared, unreadable (its file could not be
422
+ /// read in full) or skipped (no usable `__text`, a `__TEXT` over the
423
+ /// budget, no live address), with the `__text` bytes of each, and each
424
+ /// one not compared named with why. One readable framework no longer makes
425
+ /// the bucket look complete; `app_complete` says whether it is, and
426
+ /// `main_executable_compared` whether the main executable was among them.
427
+ private func probeCodeIntegrity() -> [String: Any] {
428
+ var result: [String: Any] = [
429
+ "status": "ok",
430
+ "checked": false,
431
+ // Core and ext are the system buckets. On iOS they are structurally
432
+ // unmeasurable; reported as zero-compared with a reason, so the
433
+ // server can tell "nothing to compare" from "compared and clean".
434
+ "core_compared_bytes": 0,
435
+ "core_diff_bytes": 0,
436
+ "diff_bytes": 0,
437
+ "ext_compared_bytes": 0,
438
+ "ext_diff_bytes": 0,
439
+ "ext_libs_diff": 0,
440
+ "app_compared_bytes": 0,
441
+ "app_diff_bytes": 0,
442
+ "app_libs_diff": 0,
443
+ "diffed_libs": "",
444
+ "system_images_unreadable": 0,
445
+ "system_bucket_reason": "dyld_shared_cache_has_no_backing_files",
446
+ ]
447
+
448
+ let bundlePath = Bundle.main.bundlePath
449
+ let mainName = Bundle.main.executableURL?.lastPathComponent
450
+ var images: [ProbeReads.AppImage] = []
451
+ var systemUnreadable = 0
452
+
453
+ for index in 0..<_dyld_image_count() {
454
+ guard let namePointer = _dyld_get_image_name(index),
455
+ let header = _dyld_get_image_header(index) else {
456
+ // Counted, never dropped: it may be the app's (collector 8).
457
+ images.append(ProbeReads.unlistedImage(index: Int(index)))
458
+ continue
459
+ }
460
+ let path = String(cString: namePointer)
461
+
462
+ // Anything outside our own bundle is in the shared cache. Counted,
463
+ // not compared, and never treated as clean.
464
+ guard path.hasPrefix(bundlePath) else {
465
+ systemUnreadable += 1
466
+ continue
467
+ }
468
+
469
+ // Every bundle image is expected; one not compared is unreadable or
470
+ // skipped, never dropped, and named by its path in the bundle.
471
+ let label = ProbeReads.bundleLabel(imagePath: path, bundlePath: bundlePath)
472
+ images.append(ProbeReads.AppImage(
473
+ path: label,
474
+ isMainExecutable: label == mainName,
475
+ outcome: compareImage(path: path, header: header,
476
+ slide: _dyld_get_image_vmaddr_slide(index))
477
+ ))
478
+ }
479
+
480
+ // checked means "the app bucket was genuinely compared". If not one
481
+ // bundle image could be read, nothing was measured and saying
482
+ // otherwise would be the exact lie this probe exists to avoid.
483
+ result.merge(ProbeReads.appCoverage(images, systemImagesUnreadable: systemUnreadable)) { _, figure in
484
+ figure
485
+ }
486
+ return result
487
+ }
488
+
489
+ /// One bundle image's __TEXT in memory against its file. The reads are
490
+ /// here; what they mean is ProbeReads'.
491
+ private func compareImage(
492
+ path: String, header: UnsafePointer<mach_header>, slide: Int
493
+ ) -> ProbeReads.ImageOutcome {
494
+ let span: ProbeReads.TextSpan
495
+ let read = textSpan(of: header)
496
+ switch read.span {
497
+ case .success(let found):
498
+ span = found
499
+ case .failure(let problem):
500
+ return .skipped(textBytes: problem.textSize, why: problem.reason)
501
+ }
502
+
503
+ // Where the image is in its file (collector 8): the file itself, or its
504
+ // arm64 slice in a universal file -- frameworks nothing thinned keep
505
+ // that form, and every offset of the image counts from the slice.
506
+ let slice: Int
507
+ switch readFileRange(path: path, offset: 0, length: 4096) {
508
+ case .success(let head):
509
+ guard let found = ProbeReads.arm64Slice([UInt8](head)) else {
510
+ return .unreadable(textBytes: span.textSize,
511
+ why: "neither a 64-bit Mach-O file nor a universal one with an arm64 image")
512
+ }
513
+ slice = found
514
+ case .failure(let problem):
515
+ return .unreadable(textBytes: span.textSize, why: problem.reason)
516
+ }
517
+
518
+ // The file must be the build that is running (collector 8): an app
519
+ // updated while it ran has the old code in memory and the new file on
520
+ // disk. Its header and load commands are read from the file and its
521
+ // LC_UUID compared with the loaded one's.
522
+ let commandsSize = Int(UnsafeRawPointer(header)
523
+ .assumingMemoryBound(to: mach_header_64.self).pointee.sizeofcmds)
524
+ if let loaded = read.uuid,
525
+ case .success(let fileHeader) = readFileRange(
526
+ path: path, offset: slice, length: MemoryLayout<mach_header_64>.size + commandsSize),
527
+ let problem = ProbeReads.buildProblem(
528
+ loaded: loaded, onDisk: ProbeReads.machOUUID([UInt8](fileHeader)), textSize: span.textSize) {
529
+ return .skipped(textBytes: problem.textSize, why: problem.reason)
530
+ }
531
+
532
+ let onDisk: Data
533
+ switch readFileRange(path: path, offset: slice + span.segmentFileOffset, length: span.segmentSize) {
534
+ case .success(let data):
535
+ onDisk = data
536
+ case .failure(let problem):
537
+ // A bundle image we cannot read is not clean - and it is the app's
538
+ // own, not one of the system's (collector 4 counted it there).
539
+ return .unreadable(textBytes: span.textSize, why: problem.reason)
540
+ }
541
+
542
+ guard let liveBase = UnsafeRawPointer(
543
+ bitPattern: UInt(span.segmentVMAddress) &+ UInt(bitPattern: slide)
544
+ ) else {
545
+ return .skipped(textBytes: span.textSize, why: "no live address for __TEXT")
546
+ }
547
+
548
+ // One pass over the whole __TEXT segment, attributing each differing
549
+ // byte to __text or to the rest. __text is what gets scored; the
550
+ // remainder - the Mach-O header, __stubs, __cstring, __unwind_info -
551
+ // is reported separately because it has no clean baseline yet. If it
552
+ // measures zero on hardware, the header's unused mach_header_64
553
+ // .reserved field becomes a safe flip target, the direct analogue of
554
+ // the ELF EI_PAD bytes used to close the app bucket on Android.
555
+ // Until then it must not influence a verdict.
556
+ var textDiffering = 0
557
+ var segmentDiffering = 0
558
+ onDisk.withUnsafeBytes { (diskBytes: UnsafeRawBufferPointer) in
559
+ let liveBytes = liveBase.assumingMemoryBound(to: UInt8.self)
560
+ let textStart = span.textOffsetInSegment
561
+ let textEnd = textStart + span.textSize
562
+ for offset in 0..<onDisk.count where liveBytes[offset] != diskBytes[offset] {
563
+ segmentDiffering += 1
564
+ if offset >= textStart && offset < textEnd {
565
+ textDiffering += 1
566
+ }
567
+ }
568
+ }
569
+ return .compared(textBytes: span.textSize, textDiffering: textDiffering,
570
+ segmentBytes: onDisk.count, segmentDiffering: segmentDiffering)
571
+ }
572
+
573
+ /// Finds the __TEXT segment and its __text section in an image's load
574
+ /// commands, and its encryption; ProbeReads decides whether they can be
575
+ /// compared. Every command is read: LC_ENCRYPTION_INFO_64 follows the
576
+ /// segments, so stopping at __TEXT would never see it.
577
+ /// Where the image's __text lies, and its LC_UUID as loaded (for the build
578
+ /// check, collector 8).
579
+ private func textSpan(of header: UnsafePointer<mach_header>)
580
+ -> (span: Result<ProbeReads.TextSpan, ProbeReads.CoverageProblem>, uuid: [UInt8]?) {
581
+ let raw = UnsafeRawPointer(header)
582
+ let header64 = raw.assumingMemoryBound(to: mach_header_64.self)
583
+ guard header64.pointee.magic == 0xfeed_facf else {
584
+ return (.failure(ProbeReads.CoverageProblem(reason: "not a 64-bit Mach-O image", textSize: 0)), nil)
585
+ }
586
+ var cursor = raw.advanced(by: MemoryLayout<mach_header_64>.size)
587
+ var found: Result<ProbeReads.TextSpan, ProbeReads.CoverageProblem>?
588
+ var encryption: (offset: UInt32, size: UInt32, id: UInt32)?
589
+ var uuid: [UInt8]?
590
+
591
+ for _ in 0..<header64.pointee.ncmds {
592
+ let command = cursor.assumingMemoryBound(to: load_command.self)
593
+ if command.pointee.cmd == UInt32(LC_SEGMENT_64), found == nil {
594
+ let segment = cursor.assumingMemoryBound(to: segment_command_64.self)
595
+ if name(of: segment.pointee.segname) == "__TEXT" {
596
+ var text: (offset: UInt64, size: UInt64)?
597
+ var section = cursor.advanced(by: MemoryLayout<segment_command_64>.size)
598
+ for _ in 0..<segment.pointee.nsects {
599
+ let entry = section.assumingMemoryBound(to: section_64.self)
600
+ if name(of: entry.pointee.sectname) == "__text" {
601
+ text = (offset: UInt64(entry.pointee.offset), size: entry.pointee.size)
602
+ break
603
+ }
604
+ section = section.advanced(by: MemoryLayout<section_64>.size)
605
+ }
606
+ found = ProbeReads.textSpan(
607
+ segmentFileOffset: segment.pointee.fileoff,
608
+ segmentFileSize: segment.pointee.filesize,
609
+ segmentVMAddress: segment.pointee.vmaddr,
610
+ section: text
611
+ )
612
+ }
613
+ } else if command.pointee.cmd == UInt32(LC_ENCRYPTION_INFO_64) {
614
+ let info = cursor.assumingMemoryBound(to: encryption_info_command_64.self)
615
+ encryption = (offset: info.pointee.cryptoff, size: info.pointee.cryptsize,
616
+ id: info.pointee.cryptid)
617
+ } else if command.pointee.cmd == UInt32(LC_UUID) {
618
+ let command = cursor.assumingMemoryBound(to: uuid_command.self)
619
+ uuid = withUnsafeBytes(of: command.pointee.uuid) { Array($0) }
620
+ }
621
+ cursor = cursor.advanced(by: Int(command.pointee.cmdsize))
622
+ }
623
+ guard let result = found else {
624
+ return (.failure(ProbeReads.CoverageProblem(reason: "no __TEXT segment", textSize: 0)), uuid)
625
+ }
626
+ if case .success(let span) = result, let crypt = encryption,
627
+ let problem = ProbeReads.encryptionProblem(
628
+ cryptOffset: crypt.offset, cryptSize: crypt.size, cryptID: crypt.id, span: span) {
629
+ return (.failure(problem), uuid)
630
+ }
631
+ return (result, uuid)
632
+ }
633
+
634
+ /// Mach-O stores segment and section names as a fixed 16-byte field that is
635
+ /// NOT necessarily NUL-terminated, so it cannot be read with String(cString:).
636
+ private func name(of field: Any) -> String {
637
+ var bytes: [UInt8] = []
638
+ withUnsafeBytes(of: field) { raw in
639
+ for byte in raw {
640
+ if byte == 0 { break }
641
+ bytes.append(byte)
642
+ }
643
+ }
644
+ return String(decoding: bytes, as: UTF8.self)
645
+ }
646
+
647
+ /// Exactly `length` bytes of the file from `offset`, or why not: a file
648
+ /// that cannot be opened, a seek or read that fails, or a short read.
649
+ private func readFileRange(
650
+ path: String, offset: Int, length: Int
651
+ ) -> Result<Data, ProbeReads.CoverageProblem> {
652
+ guard let handle = FileHandle(forReadingAtPath: path) else {
653
+ return .failure(ProbeReads.CoverageProblem(
654
+ reason: "unreadable: the file could not be opened", textSize: 0))
655
+ }
656
+ defer { try? handle.close() }
657
+ do {
658
+ try handle.seek(toOffset: UInt64(offset))
659
+ let data = try handle.read(upToCount: length) ?? Data()
660
+ if let problem = ProbeReads.readProblem(got: data.count, wanted: length) {
661
+ return .failure(ProbeReads.CoverageProblem(reason: problem, textSize: 0))
662
+ }
663
+ return .success(data)
664
+ } catch {
665
+ return .failure(ProbeReads.CoverageProblem(
666
+ reason: "unreadable: \(String(String(describing: error).prefix(120)))", textSize: 0))
667
+ }
668
+ }
669
+
670
+ // MARK: - Mach-O and code-signature parsing
671
+
672
+ // Integers are assembled byte by byte rather than loaded through a bound
673
+ // pointer: the offsets below are not guaranteed to be naturally aligned,
674
+ // and loadUnaligned(fromByteOffset:as:) is unavailable at our iOS 15.0
675
+ // deployment target.
676
+
677
+ private func u32le(_ d: Data, _ o: Int) -> UInt32? {
678
+ guard o >= 0, o + 4 <= d.count else { return nil }
679
+ return UInt32(d[o]) | (UInt32(d[o + 1]) << 8)
680
+ | (UInt32(d[o + 2]) << 16) | (UInt32(d[o + 3]) << 24)
681
+ }
682
+
683
+ private func u32be(_ d: Data, _ o: Int) -> UInt32? {
684
+ guard o >= 0, o + 4 <= d.count else { return nil }
685
+ return (UInt32(d[o]) << 24) | (UInt32(d[o + 1]) << 16)
686
+ | (UInt32(d[o + 2]) << 8) | UInt32(d[o + 3])
687
+ }
688
+
689
+ /// File offset of the 64-bit Mach-O image to inspect, resolving a fat
690
+ /// binary to its arm64 slice. Installed device binaries are thin; fat is
691
+ /// handled so the same code works on a locally built bundle.
692
+ ///
693
+ /// Only MH_MAGIC_64 is accepted. A byte-swapped image would need every
694
+ /// subsequent field swapped too, and silently misparsing one is worse than
695
+ /// reporting that the image was not recognised.
696
+ private func machOSliceOffset(_ d: Data) -> Int? {
697
+ if let magic = u32le(d, 0), magic == 0xfeed_facf { return 0 }
698
+
699
+ guard let fat = u32be(d, 0), fat == 0xcafe_babe,
700
+ let count = u32be(d, 4), count < 64 else { return nil }
701
+ let cpuTypeArm64: UInt32 = 0x0100_000c
702
+ for index in 0..<Int(count) {
703
+ let base = 8 + index * 20
704
+ guard let cpu = u32be(d, base), let offset = u32be(d, base + 8) else {
705
+ return nil
706
+ }
707
+ if cpu == cpuTypeArm64,
708
+ let magic = u32le(d, Int(offset)), magic == 0xfeed_facf {
709
+ return Int(offset)
710
+ }
711
+ }
712
+ return nil
713
+ }
714
+
715
+ /// Walks the load commands for LC_CODE_SIGNATURE and returns the signature
716
+ /// blob's range. Its dataoff is relative to the start of the slice, so the
717
+ /// slice offset is added back for a fat image.
718
+ private func codeSignatureRange(
719
+ _ d: Data, sliceOffset: Int
720
+ ) -> (offset: Int, size: Int)? {
721
+ guard let commandCount = u32le(d, sliceOffset + 16) else { return nil }
722
+ var cursor = sliceOffset + 32 // sizeof(struct mach_header_64)
723
+
724
+ for _ in 0..<Int(commandCount) {
725
+ guard let command = u32le(d, cursor),
726
+ let commandSize = u32le(d, cursor + 4),
727
+ commandSize >= 8 else { return nil }
728
+
729
+ if command == 0x1d { // LC_CODE_SIGNATURE
730
+ guard let dataOffset = u32le(d, cursor + 8),
731
+ let dataSize = u32le(d, cursor + 12) else { return nil }
732
+ let absolute = sliceOffset + Int(dataOffset)
733
+ guard absolute >= 0, absolute + Int(dataSize) <= d.count else {
734
+ return nil
735
+ }
736
+ return (absolute, Int(dataSize))
737
+ }
738
+
739
+ cursor += Int(commandSize)
740
+ if cursor >= d.count { return nil }
741
+ }
742
+ return nil
743
+ }
744
+
745
+ /// Parses the embedded signature SuperBlob. The CodeDirectory carries the
746
+ /// signing identifier; the entitlements slot carries an XML plist holding
747
+ /// the team identifier and get-task-allow. Every field in these structures
748
+ /// is big-endian irrespective of the host byte order.
749
+ private func parseEmbeddedSignature(
750
+ _ d: Data, offset: Int, size: Int, into result: inout [String: Any]
751
+ ) {
752
+ guard let magic = u32be(d, offset), magic == 0xfade_0cc0,
753
+ let blobCount = u32be(d, offset + 8), blobCount < 128 else {
754
+ result["signature_parse_error"] = "Not an embedded signature SuperBlob."
755
+ return
756
+ }
757
+
758
+ for index in 0..<Int(blobCount) {
759
+ let entry = offset + 12 + index * 8
760
+ guard let slot = u32be(d, entry),
761
+ let relative = u32be(d, entry + 4) else { return }
762
+ let blob = offset + Int(relative)
763
+ guard blob + 8 <= offset + size else { continue }
764
+
765
+ switch slot {
766
+ case 0: // CSSLOT_CODEDIRECTORY
767
+ guard let cdMagic = u32be(d, blob), cdMagic == 0xfade_0c02,
768
+ let flags = u32be(d, blob + 12),
769
+ let identOffset = u32be(d, blob + 20) else { continue }
770
+ result["code_directory_flags"] = Int(flags)
771
+
772
+ let start = blob + Int(identOffset)
773
+ var end = start
774
+ while end < d.count, d[end] != 0 { end += 1 }
775
+ if end > start,
776
+ let text = String(data: d.subdata(in: start..<end), encoding: .utf8) {
777
+ result["signing_identifier"] = text
778
+ }
779
+
780
+ case 5: // CSSLOT_ENTITLEMENTS
781
+ guard let entitlementMagic = u32be(d, blob),
782
+ entitlementMagic == 0xfade_7171,
783
+ let length = u32be(d, blob + 4), length > 8 else { continue }
784
+ let start = blob + 8
785
+ let stop = blob + Int(length)
786
+ guard stop <= d.count, start < stop else { continue }
787
+
788
+ let object = try? PropertyListSerialization.propertyList(
789
+ from: d.subdata(in: start..<stop), options: [], format: nil
790
+ )
791
+ if let parsed = object as? [String: Any] {
792
+ result["team_identifier"] =
793
+ parsed["com.apple.developer.team-identifier"] as? String ?? ""
794
+ result["get_task_allow"] =
795
+ (parsed["get-task-allow"] as? Bool) ?? false
796
+ result["entitlement_count"] = parsed.count
797
+ }
798
+
799
+ default:
800
+ continue
801
+ }
802
+ }
803
+ }
804
+
805
+ /// P_TRACED via sysctl is the standard, non-private debugger check.
806
+ private func probeDebugger() -> [String: Any] {
807
+ var info = kinfo_proc()
808
+ var size = MemoryLayout<kinfo_proc>.stride
809
+ var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
810
+
811
+ let status = sysctl(&mib, u_int(mib.count), &info, &size, nil, 0)
812
+ if status != 0 {
813
+ return [
814
+ "status": "error",
815
+ "error": "sysctl failed with errno \(errno)",
816
+ ]
817
+ }
818
+ return [
819
+ "status": "ok",
820
+ "traced": (info.kp_proc.p_flag & P_TRACED) != 0,
821
+ ]
822
+ }
823
+
824
+ private func probeJailbreakFiles() -> [String: Any] {
825
+ // The Simulator's filesystem is the MAC's filesystem, and macOS really
826
+ // does ship /bin/bash, /bin/sh, /usr/bin/ssh and /usr/sbin/sshd - four
827
+ // entries in the list below. Running this check there produces a
828
+ // confident false positive on a perfectly clean machine, so it is
829
+ // skipped with an explicit reason rather than silently returning empty.
830
+ #if targetEnvironment(simulator)
831
+ return [
832
+ "status": "unsupported",
833
+ "reason": "simulator_filesystem_is_the_host",
834
+ "found_paths": [String](),
835
+ ]
836
+ #else
837
+ var found: [String] = []
838
+ let manager = FileManager.default
839
+ for path in IntegrityProbeManager.jailbreakPaths {
840
+ // On a clean device the sandbox makes most of these unreadable,
841
+ // which is indistinguishable from absent - and that is fine. A
842
+ // false negative here is safe; the dyld and environment probes
843
+ // catch in-process instrumentation regardless.
844
+ if manager.fileExists(atPath: path) {
845
+ found.append(path)
846
+ }
847
+ }
848
+ return [
849
+ "status": "ok",
850
+ "found_paths": found,
851
+ "checked_count": IntegrityProbeManager.jailbreakPaths.count,
852
+ ]
853
+ #endif
854
+ }
855
+
856
+ /// Attempt a write outside the app container. On a sandboxed device this
857
+ /// must fail; success means the sandbox is not being enforced, which the
858
+ /// server treats as a hard block.
859
+ private func probeSandbox() -> [String: Any] {
860
+ let path = "/private/devicetrust_sandbox_probe.txt"
861
+ var succeeded = false
862
+ do {
863
+ try "probe".write(toFile: path, atomically: true, encoding: .utf8)
864
+ succeeded = true
865
+ // Leave nothing behind on a device where this unexpectedly works.
866
+ try? FileManager.default.removeItem(atPath: path)
867
+ } catch {
868
+ succeeded = false
869
+ }
870
+ return [
871
+ "status": "ok",
872
+ "write_outside_sandbox_succeeded": succeeded,
873
+ "probe_path": path,
874
+ ]
875
+ }
876
+
877
+ /// The iOS analogue of scanning /proc/self/maps: walk the images actually
878
+ /// loaded into this process.
879
+ private func probeDyldImages() -> [String: Any] {
880
+ var tokens = Set<String>()
881
+ var suspiciousCount = 0
882
+ var unnamed = 0
883
+ let imageCount = _dyld_image_count()
884
+
885
+ for index in 0..<imageCount {
886
+ guard let raw = _dyld_get_image_name(index) else {
887
+ unnamed += 1
888
+ continue
889
+ }
890
+ let name = String(cString: raw).lowercased()
891
+ var matched = false
892
+ for token in IntegrityProbeManager.suspiciousImageTokens
893
+ where name.contains(token) {
894
+ tokens.insert(token)
895
+ matched = true
896
+ }
897
+ if matched { suspiciousCount += 1 }
898
+ }
899
+
900
+ var result: [String: Any] = [
901
+ "status": "ok",
902
+ "suspicious_tokens": Array(tokens).sorted(),
903
+ "suspicious_image_count": suspiciousCount,
904
+ "image_count": Int(imageCount),
905
+ ]
906
+ // A name dyld did not give could be the one listed (collector 8).
907
+ let unread = ProbeReads.unnamedImages(unnamed, of: Int(imageCount))
908
+ if !unread.isEmpty { result["unread"] = unread }
909
+ return result
910
+ }
911
+
912
+ private func probeEnvironment() -> [String: Any] {
913
+ let inserted = ProcessInfo.processInfo
914
+ .environment["DYLD_INSERT_LIBRARIES"] ?? ""
915
+ return [
916
+ "status": "ok",
917
+ "dyld_insert_libraries": String(inserted.prefix(IntegrityProbeManager.maxTextLength)),
918
+ ]
919
+ }
920
+
921
+ private func probeSimulator() -> [String: Any] {
922
+ #if targetEnvironment(simulator)
923
+ let isSimulator = true
924
+ #else
925
+ let isSimulator = ProcessInfo.processInfo
926
+ .environment["SIMULATOR_DEVICE_NAME"] != nil
927
+ #endif
928
+ return [
929
+ "status": "ok",
930
+ "is_simulator": isSimulator,
931
+ "model": deviceModelIdentifier(),
932
+ ]
933
+ }
934
+
935
+ // MARK: - Helpers
936
+
937
+ private func deviceModelIdentifier() -> String {
938
+ var systemInfo = utsname()
939
+ uname(&systemInfo)
940
+ let mirror = Mirror(reflecting: systemInfo.machine)
941
+ return mirror.children.reduce(into: "") { identifier, element in
942
+ guard let value = element.value as? Int8, value != 0 else { return }
943
+ identifier += String(UnicodeScalar(UInt8(bitPattern: value)))
944
+ }
945
+ }
946
+
947
+ /// The OS build, for example "21F90" (kern.osversion).
948
+ private func osBuild() -> String {
949
+ var size = 0
950
+ guard sysctlbyname("kern.osversion", nil, &size, nil, 0) == 0, size > 0 else { return "" }
951
+ var buffer = [CChar](repeating: 0, count: size)
952
+ guard sysctlbyname("kern.osversion", &buffer, &size, nil, 0) == 0 else { return "" }
953
+ return String(cString: buffer)
954
+ }
955
+
956
+ private func UIDeviceOSVersion() -> String {
957
+ let version = ProcessInfo.processInfo.operatingSystemVersion
958
+ return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
959
+ }
960
+ }