diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f891bc..e36f2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.0.0] - 2026-11-11 + +- Android SDK version: 20.0.1 +- iOS SDK version: 8.0.1 + +### Breaking + +- `allChecksFinished` RASP execution state action renamed to `fastChecksFinished`. On Android, it is triggered when the fast checks are completed, while the postponed checks may still run; on iOS, when all checks are completed +- `TalsecIosConfig.appBundleId` (string) replaced by `appBundleIds` (string[]), matching the iOS SDK +- Methods that resolved with a status string or a constant now resolve to `void`: `talsecStart`, `addToWhitelist`, `blockScreenCapture`, `blockScreenControl`, `startOngoingCallDetection`, `stopOngoingCallDetection`, `storeExternalId`, `removeExternalId` +- All methods now reject with `TalsecError` (`code`: `talsec-failure`, `unsupported-platform`, `configuration-exception`, `external-id-failure`, `malware-failure`) instead of native errors or plain strings +- `onInvalidCallback` and `removeListenerForEvent` are no longer exported; use `abortApp` to terminate the app + +### React Native + +#### Added + +- `useFreeRasp` returns `{ started, error }`; `error` is the `TalsecError` that prevented the listeners from being registered or freeRASP from starting, which was previously only logged to the console +- `ongoingCall` threat action with the new `CallState` type (`'STARTED' | 'ONGOING' | 'STOPPED'`), driven by `startOngoingCallDetection()` and `stopOngoingCallDetection()` (Android only) +- `blockScreenControl()` and `isScreenControlBlocked()` to prevent accessibility services from reading or controlling the app screen (Android only) +- `blockOverlayWindows()` and `isOverlayWindowsBlocked()` to hide overlay windows of other apps (Android 12+ only, requires the `HIDE_OVERLAY_WINDOWS` permission) +- `criticalChecksFinished` and `postponedChecksFinished` RASP execution state actions (Android only) + +#### Changed + +- Android package (namespace) of the plugin renamed from `com.freeraspreactnative` to `app.talsec.rasp.reactnative`, following the Android SDK's `app.talsec.rasp` (update the import if you register `FreeraspReactNativePackage` manually) +- On Android, `isScreenCaptureBlocked()` reports the protection state of the current activity +- Threat and RASP execution state actions are typed as returning `void` instead of `any` + +### Android + +#### Fixed + +- Improved hook detection to prevent collisions with running VPN services and to better work with the device resources (threads/RAM) +- Fixed a release crash during root detection when a checked package is not installed +- Fixed a crash in `ScreenProtector.unregisterScreenCallbacks` on Android 15+ when several activities registered screen callbacks +- Fixed intermittent build failures of apps using the SDK (`NullPointerException: Cannot read field "d"`, shown by Gradle as `[CIRCULAR REFERENCE]`) caused by invalid bytecode in some SDK builds +- Fixed `killOnBypass` closing the app when the same threat was reported twice in quick succession + +#### Changed + +- Deprecated the freeRASP Maven coordinates (`com.aheaditec.talsec.security:TalsecSecurity-{flavor}`) +- Improved Bootloader detection +- Extended emulator detection with additional device-property and ABI checks +- Improved logging structure and data enrichment for better Portal insights/reports +- Refactoring of the time spoofing detection which improves the performance of the freeRASP check execution +- Improved the performance of the App integrity check +- Updated CA bundle + ## [5.3.0] - 2026-11-09 - Android SDK version: 19.3.1 diff --git a/README.md b/README.md index 1d2db69..15296b8 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Key features are the detection and prevention of - Unlocked or compromised bootloader - Hooking framework (e.g., Frida, LSPosed, Shadow) - UI automation and driven-input attacks (e.g., Appium) +- Ongoing cellular and VoIP calls during sensitive flows (Android) +- Screen control by accessibility services and overlay windows of other apps (Android) - Untrusted installation method - App Data Migration protection diff --git a/android/build.gradle b/android/build.gradle index 0c1c820..47ac081 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -57,7 +57,7 @@ android { compileSdk getMaxVersion("compileSdk") talsec { - sdkVersion = "19.3.1" + sdkVersion = "20.0.1" platform = "ReactNative" } @@ -87,7 +87,7 @@ android { } if (project.android.hasProperty("namespace")) { - namespace("com.freeraspreactnative") + namespace("app.talsec.rasp.reactnative") } } @@ -119,6 +119,6 @@ if (isNewArchitectureEnabled()) { react { jsRootDir = file("../src/") libraryName = "FreeraspReactNative" - codegenJavaPackageName = "com.freeraspreactnative" + codegenJavaPackageName = "app.talsec.rasp.reactnative" } } diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro index 858660d..723be2a 100644 --- a/android/proguard-rules.pro +++ b/android/proguard-rules.pro @@ -1,12 +1,12 @@ -dontwarn java.lang.invoke.StringConcatFactory --keep class com.freeraspreactnative.FreeraspReactNativePackage { public *; } +-keep class app.talsec.rasp.reactnative.FreeraspReactNativePackage { public *; } -if @kotlinx.serialization.Serializable class ** -keep class <1> { *; } --keep class com.freeraspreactnative.models.RNSuspiciousAppInfo$Companion --keep class com.freeraspreactnative.models.RNPackageInfo$Companion +-keep class app.talsec.rasp.reactnative.models.RNSuspiciousAppInfo$Companion +-keep class app.talsec.rasp.reactnative.models.RNPackageInfo$Companion -repackageclasses 'ts' \ No newline at end of file diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 7db0be4..9e3ed50 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ + package="app.talsec.rasp.reactnative"> diff --git a/android/src/main/java/com/freeraspreactnative/FreeraspReactNativeModule.kt b/android/src/main/java/app/talsec/rasp/reactnative/FreeraspReactNativeModule.kt similarity index 64% rename from android/src/main/java/com/freeraspreactnative/FreeraspReactNativeModule.kt rename to android/src/main/java/app/talsec/rasp/reactnative/FreeraspReactNativeModule.kt index dc71092..2ff64f6 100644 --- a/android/src/main/java/com/freeraspreactnative/FreeraspReactNativeModule.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/FreeraspReactNativeModule.kt @@ -1,5 +1,7 @@ -package com.freeraspreactnative +package app.talsec.rasp.reactnative +import android.annotation.SuppressLint +import android.app.Activity import android.os.Build import android.os.Handler import android.os.HandlerThread @@ -9,6 +11,7 @@ import app.talsec.rasp.security.api.SuspiciousAppInfo import app.talsec.rasp.security.api.Talsec import app.talsec.rasp.security.api.TalsecConfig import app.talsec.rasp.security.api.TalsecMode +import app.talsec.rasp.security.api.ThreatListener.CallState import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.Promise @@ -20,20 +23,21 @@ import com.facebook.react.bridge.UiThreadUtil.runOnUiThread import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule -import com.freeraspreactnative.dispatchers.ExecutionStateDispatcher -import com.freeraspreactnative.dispatchers.ThreatDispatcher -import com.freeraspreactnative.events.RaspExecutionStateEvent -import com.freeraspreactnative.events.ThreatEvent -import com.freeraspreactnative.interfaces.PluginExecutionStateListener -import com.freeraspreactnative.interfaces.PluginThreatListener -import com.freeraspreactnative.utils.Utils -import com.freeraspreactnative.utils.getArraySafe -import com.freeraspreactnative.utils.getBooleanSafe -import com.freeraspreactnative.utils.getMapThrowing -import com.freeraspreactnative.utils.getNestedArraySafe -import com.freeraspreactnative.utils.getStringThrowing -import com.freeraspreactnative.utils.toEncodedWritableArray -import com.freeraspreactnative.utils.toSuspiciousAppDetectionConfig +import app.talsec.rasp.reactnative.dispatchers.ExecutionStateDispatcher +import app.talsec.rasp.reactnative.dispatchers.ThreatDispatcher +import app.talsec.rasp.reactnative.events.RaspExecutionStateEvent +import app.talsec.rasp.reactnative.events.ThreatEvent +import app.talsec.rasp.reactnative.interfaces.PluginExecutionStateListener +import app.talsec.rasp.reactnative.interfaces.PluginThreatListener +import app.talsec.rasp.reactnative.utils.Utils +import app.talsec.rasp.reactnative.utils.getArraySafe +import app.talsec.rasp.reactnative.utils.getBooleanSafe +import app.talsec.rasp.reactnative.utils.getMapThrowing +import app.talsec.rasp.reactnative.utils.getNestedArraySafe +import app.talsec.rasp.reactnative.utils.getStringThrowing +import app.talsec.rasp.reactnative.utils.toEncodedWritableArray +import app.talsec.rasp.reactnative.utils.toSuspiciousAppDetectionConfig +import java.lang.ref.WeakReference class FreeraspReactNativeModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { @@ -54,6 +58,7 @@ class FreeraspReactNativeModule(private val reactContext: ReactApplicationContex reactContext.currentActivity?.let { ScreenProtector.unregister(it) } } if (reactContext.currentActivity?.isFinishing == true) { + stopOngoingCallDetectionInternal() ThreatDispatcher.unregisterListener() ExecutionStateDispatcher.unregisterListener() PluginThreatHandler.unregisterSDKListener(reactContext) @@ -134,7 +139,7 @@ class FreeraspReactNativeModule(private val reactContext: ReactApplicationContex /** * Method to setup the threat message passing between native and React Native - * @return list of [THREAT_CHANNEL_NAME, THREAT_CHANNEL_KEY] + * @return list of [THREAT_CHANNEL_NAME, THREAT_CHANNEL_KEY, MALWARE_CHANNEL_KEY, CALL_STATE_CHANNEL_KEY] */ @ReactMethod fun getThreatChannelData(promise: Promise) { @@ -142,6 +147,7 @@ class FreeraspReactNativeModule(private val reactContext: ReactApplicationContex channelData.pushString(ThreatEvent.CHANNEL_NAME) channelData.pushString(ThreatEvent.CHANNEL_KEY) channelData.pushString(ThreatEvent.MALWARE_CHANNEL_KEY) + channelData.pushString(ThreatEvent.CALL_STATE_CHANNEL_KEY) promise.resolve(channelData) } @@ -245,14 +251,158 @@ class FreeraspReactNativeModule(private val reactContext: ReactApplicationContex */ @ReactMethod fun isScreenCaptureBlocked(promise: Promise) { + val activity = reactContext.currentActivity ?: run { + promise.reject( + "NativePluginError", "Cannot check screen capture state, activity is null." + ) + return + } + try { - val isBlocked = Talsec.isScreenCaptureBlocked() + val isBlocked = Talsec.isScreenCaptureBlocked(activity) promise.resolve(isBlocked) } catch (e: Exception) { promise.reject("NativePluginError", "Error in isScreenCaptureBlocked: ${e.message}") } } + /** + * Method Block/Unblock accessibility services from reading or controlling the screen + * @param enable boolean for whether want to block or unblock the screen control + */ + @ReactMethod + fun blockScreenControl(enable: Boolean, promise: Promise) { + val activity = reactContext.currentActivity ?: run { + promise.reject( + "NativePluginError", "Cannot block screen control, activity is null." + ) + return + } + + runOnUiThread { + try { + Talsec.blockScreenControl(activity, enable) + promise.resolve("Screen control is now ${if (enable) "Blocked" else "Enabled"}.") + } catch (e: Exception) { + promise.reject("NativePluginError", "Error in blockScreenControl: ${e.message}") + } + } + } + + /** + * Method Returns whether screen control is blocked or not + * @return boolean for is screen control blocked or not + */ + @ReactMethod + fun isScreenControlBlocked(promise: Promise) { + val activity = reactContext.currentActivity ?: run { + promise.reject( + "NativePluginError", "Cannot check screen control state, activity is null." + ) + return + } + + try { + promise.resolve(Talsec.isScreenControlBlocked(activity)) + } catch (e: Exception) { + promise.reject("NativePluginError", "Error in isScreenControlBlocked: ${e.message}") + } + } + + /** + * Method Hide/Show overlay windows of other apps over the activity. + * Requires Android 12+ and the HIDE_OVERLAY_WINDOWS permission. + * @param enable boolean for whether want to hide or show overlay windows + * @return boolean whether the request was applied + */ + // Missing permission is suppressed because the decision to use the overlay API is made + // by developer, and not enforced by the library. + @SuppressLint("MissingPermission") + @ReactMethod + fun blockOverlayWindows(enable: Boolean, promise: Promise) { + val activity = reactContext.currentActivity ?: run { + promise.reject( + "NativePluginError", "Cannot block overlay windows, activity is null." + ) + return + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + promise.resolve(false) + return + } + + runOnUiThread { + try { + promise.resolve(Talsec.blockOverlayWindows(activity, enable)) + } catch (e: Exception) { + promise.reject("NativePluginError", "Error in blockOverlayWindows: ${e.message}") + } + } + } + + /** + * Method Returns whether overlay windows of other apps are hidden or not + * @return boolean for are overlay windows hidden or not + */ + @ReactMethod + fun isOverlayWindowsBlocked(promise: Promise) { + val activity = reactContext.currentActivity ?: run { + promise.reject( + "NativePluginError", "Cannot check overlay windows state, activity is null." + ) + return + } + + try { + promise.resolve(Talsec.isOverlayWindowsBlocked(activity)) + } catch (e: Exception) { + promise.reject("NativePluginError", "Error in isOverlayWindowsBlocked: ${e.message}") + } + } + + /** + * Method starts the ongoing call (cellular and VoIP) detection for the current activity + */ + @ReactMethod + fun startOngoingCallDetection(promise: Promise) { + val activity = reactContext.currentActivity ?: run { + promise.reject( + "NativePluginError", "Cannot start ongoing call detection, activity is null." + ) + return + } + + try { + if (ongoingCallActivity?.get() !== activity) { + stopOngoingCallDetectionInternal() + Talsec.startOngoingCallDetection(activity) + ongoingCallActivity = WeakReference(activity) + } + promise.resolve("Ongoing call detection started.") + } catch (e: Exception) { + promise.reject("NativePluginError", "Error in startOngoingCallDetection: ${e.message}") + } + } + + /** + * Method stops the ongoing call detection + */ + @ReactMethod + fun stopOngoingCallDetection(promise: Promise) { + try { + stopOngoingCallDetectionInternal() + promise.resolve("Ongoing call detection stopped.") + } catch (e: Exception) { + promise.reject("NativePluginError", "Error in stopOngoingCallDetection: ${e.message}") + } + } + + private fun stopOngoingCallDetectionInternal() { + ongoingCallActivity?.get()?.let { Talsec.stopOngoingCallDetection(it) } + ongoingCallActivity = null + } + @ReactMethod fun storeExternalId( externalId: String, promise: Promise @@ -319,6 +469,9 @@ class FreeraspReactNativeModule(private val reactContext: ReactApplicationContex private val mainHandler = Handler(Looper.getMainLooper()) internal var talsecStarted = false + + // Ongoing call detection is registered per activity + private var ongoingCallActivity: WeakReference? = null } internal class PluginListener(private val reactContext: ReactApplicationContext) : @@ -344,6 +497,13 @@ class FreeraspReactNativeModule(private val reactContext: ReactApplicationContex } } + override fun ongoingCallDetected(state: CallState) { + val params = Arguments.createMap() + params.putInt(ThreatEvent.CHANNEL_KEY, ThreatEvent.OngoingCall.value) + params.putString(ThreatEvent.CALL_STATE_CHANNEL_KEY, state.name) + notifyEvent(ThreatEvent.CHANNEL_NAME, params) + } + override fun raspExecutionStateChanged(event: RaspExecutionStateEvent) { val params = Arguments.createMap() params.putInt(event.channelKey, event.value) diff --git a/android/src/main/java/com/freeraspreactnative/FreeraspReactNativePackage.kt b/android/src/main/java/app/talsec/rasp/reactnative/FreeraspReactNativePackage.kt similarity index 93% rename from android/src/main/java/com/freeraspreactnative/FreeraspReactNativePackage.kt rename to android/src/main/java/app/talsec/rasp/reactnative/FreeraspReactNativePackage.kt index 81b8f32..75b4ee5 100644 --- a/android/src/main/java/com/freeraspreactnative/FreeraspReactNativePackage.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/FreeraspReactNativePackage.kt @@ -1,4 +1,4 @@ -package com.freeraspreactnative +package app.talsec.rasp.reactnative import com.facebook.react.ReactPackage import com.facebook.react.bridge.NativeModule diff --git a/android/src/main/java/com/freeraspreactnative/PluginThreatHandler.kt b/android/src/main/java/app/talsec/rasp/reactnative/PluginThreatHandler.kt similarity index 79% rename from android/src/main/java/com/freeraspreactnative/PluginThreatHandler.kt rename to android/src/main/java/app/talsec/rasp/reactnative/PluginThreatHandler.kt index 3d7c29c..cd96e29 100644 --- a/android/src/main/java/com/freeraspreactnative/PluginThreatHandler.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/PluginThreatHandler.kt @@ -1,12 +1,13 @@ -package com.freeraspreactnative +package app.talsec.rasp.reactnative import android.content.Context import app.talsec.rasp.security.api.SuspiciousAppInfo import app.talsec.rasp.security.api.ThreatListener -import com.freeraspreactnative.dispatchers.ExecutionStateDispatcher -import com.freeraspreactnative.dispatchers.ThreatDispatcher -import com.freeraspreactnative.events.RaspExecutionStateEvent -import com.freeraspreactnative.events.ThreatEvent +import app.talsec.rasp.security.api.ThreatListener.CallState +import app.talsec.rasp.reactnative.dispatchers.ExecutionStateDispatcher +import app.talsec.rasp.reactnative.dispatchers.ThreatDispatcher +import app.talsec.rasp.reactnative.events.RaspExecutionStateEvent +import app.talsec.rasp.reactnative.events.ThreatEvent internal object PluginThreatHandler { @@ -79,6 +80,10 @@ internal object PluginThreatHandler { override fun onBootloader() { ThreatDispatcher.dispatchThreat(ThreatEvent.Bootloader) } + + override fun onOngoingCall(state: CallState) { + ThreatDispatcher.dispatchOngoingCall(state) + } } private val deviceState = object : ThreatListener.DeviceState() { @@ -105,8 +110,16 @@ internal object PluginThreatHandler { } private val raspExecutionState = object : ThreatListener.RaspExecutionState() { - override fun onAllChecksFinished() { - ExecutionStateDispatcher.dispatch(RaspExecutionStateEvent.AllChecksFinished) + override fun onCriticalChecksFinished() { + ExecutionStateDispatcher.dispatch(RaspExecutionStateEvent.CriticalChecksFinished) + } + + override fun onFastChecksFinished() { + ExecutionStateDispatcher.dispatch(RaspExecutionStateEvent.FastChecksFinished) + } + + override fun onPostponedChecksFinished() { + ExecutionStateDispatcher.dispatch(RaspExecutionStateEvent.PostponedChecksFinished) } } diff --git a/android/src/main/java/com/freeraspreactnative/ScreenProtector.kt b/android/src/main/java/app/talsec/rasp/reactnative/ScreenProtector.kt similarity index 99% rename from android/src/main/java/com/freeraspreactnative/ScreenProtector.kt rename to android/src/main/java/app/talsec/rasp/reactnative/ScreenProtector.kt index 489660c..cbe6387 100644 --- a/android/src/main/java/com/freeraspreactnative/ScreenProtector.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/ScreenProtector.kt @@ -1,4 +1,4 @@ -package com.freeraspreactnative +package app.talsec.rasp.reactnative import android.annotation.SuppressLint import android.annotation.TargetApi diff --git a/android/src/main/java/com/freeraspreactnative/dispatchers/ExecutionStateDispatcher.kt b/android/src/main/java/app/talsec/rasp/reactnative/dispatchers/ExecutionStateDispatcher.kt similarity index 85% rename from android/src/main/java/com/freeraspreactnative/dispatchers/ExecutionStateDispatcher.kt rename to android/src/main/java/app/talsec/rasp/reactnative/dispatchers/ExecutionStateDispatcher.kt index 1c4d8c7..14bab19 100644 --- a/android/src/main/java/com/freeraspreactnative/dispatchers/ExecutionStateDispatcher.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/dispatchers/ExecutionStateDispatcher.kt @@ -1,7 +1,7 @@ -package com.freeraspreactnative.dispatchers +package app.talsec.rasp.reactnative.dispatchers -import com.freeraspreactnative.events.RaspExecutionStateEvent -import com.freeraspreactnative.interfaces.PluginExecutionStateListener +import app.talsec.rasp.reactnative.events.RaspExecutionStateEvent +import app.talsec.rasp.reactnative.interfaces.PluginExecutionStateListener internal object ExecutionStateDispatcher { private lateinit var listener: PluginExecutionStateListener diff --git a/android/src/main/java/com/freeraspreactnative/dispatchers/ThreatDispatcher.kt b/android/src/main/java/app/talsec/rasp/reactnative/dispatchers/ThreatDispatcher.kt similarity index 67% rename from android/src/main/java/com/freeraspreactnative/dispatchers/ThreatDispatcher.kt rename to android/src/main/java/app/talsec/rasp/reactnative/dispatchers/ThreatDispatcher.kt index d5f8bea..8c0c6b7 100644 --- a/android/src/main/java/com/freeraspreactnative/dispatchers/ThreatDispatcher.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/dispatchers/ThreatDispatcher.kt @@ -1,13 +1,15 @@ -package com.freeraspreactnative.dispatchers +package app.talsec.rasp.reactnative.dispatchers import app.talsec.rasp.security.api.SuspiciousAppInfo -import com.freeraspreactnative.events.ThreatEvent -import com.freeraspreactnative.interfaces.PluginThreatListener +import app.talsec.rasp.security.api.ThreatListener.CallState +import app.talsec.rasp.reactnative.events.ThreatEvent +import app.talsec.rasp.reactnative.interfaces.PluginThreatListener internal object ThreatDispatcher { private lateinit var listener: PluginThreatListener private val threatCache = mutableSetOf() private val malwareCache = mutableSetOf() + private val callStateCache = mutableListOf() private var isAppInForeground = false private var isListenerRegistered = false @@ -56,6 +58,17 @@ internal object ThreatDispatcher { } } + fun dispatchOngoingCall(state: CallState) { + if (isAppInForeground && isListenerRegistered) { + listener.ongoingCallDetected(state) + } else { + // Keep the order: a call that started and stopped in background is reported as both. + synchronized(callStateCache) { + callStateCache.add(state) + } + } + } + private fun flushCache() { val threats = synchronized(threatCache) { val snapshot = threatCache.toSet() @@ -72,6 +85,13 @@ internal object ThreatDispatcher { if (malware.isNotEmpty()) { listener.malwareDetected(malware) } + + val callStates = synchronized(callStateCache) { + val snapshot = callStateCache.toList() + callStateCache.clear() + snapshot + } + callStates.forEach { listener.ongoingCallDetected(it) } } } diff --git a/android/src/main/java/com/freeraspreactnative/events/BaseRaspEvent.kt b/android/src/main/java/app/talsec/rasp/reactnative/events/BaseRaspEvent.kt similarity index 71% rename from android/src/main/java/com/freeraspreactnative/events/BaseRaspEvent.kt rename to android/src/main/java/app/talsec/rasp/reactnative/events/BaseRaspEvent.kt index dc42359..d4a1db4 100644 --- a/android/src/main/java/com/freeraspreactnative/events/BaseRaspEvent.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/events/BaseRaspEvent.kt @@ -1,4 +1,4 @@ -package com.freeraspreactnative.events +package app.talsec.rasp.reactnative.events internal interface BaseRaspEvent { val value: Int diff --git a/android/src/main/java/app/talsec/rasp/reactnative/events/RaspExecutionStateEvent.kt b/android/src/main/java/app/talsec/rasp/reactnative/events/RaspExecutionStateEvent.kt new file mode 100644 index 0000000..5a9dadf --- /dev/null +++ b/android/src/main/java/app/talsec/rasp/reactnative/events/RaspExecutionStateEvent.kt @@ -0,0 +1,22 @@ +package app.talsec.rasp.reactnative.events + +import app.talsec.rasp.reactnative.utils.RandomGenerator + +internal sealed class RaspExecutionStateEvent(override val value: Int) : BaseRaspEvent { + override val channelName: String get() = CHANNEL_NAME + override val channelKey: String get() = CHANNEL_KEY + + data object FastChecksFinished : RaspExecutionStateEvent(RandomGenerator.next()) + data object CriticalChecksFinished : RaspExecutionStateEvent(RandomGenerator.next()) + data object PostponedChecksFinished : RaspExecutionStateEvent(RandomGenerator.next()) + + companion object Companion { + internal val CHANNEL_NAME = RandomGenerator.next().toString() + internal val CHANNEL_KEY = RandomGenerator.next().toString() + internal val ALL_EVENT_IDENTIFIERS = listOf( + FastChecksFinished, + CriticalChecksFinished, + PostponedChecksFinished + ).map { it.value } + } +} diff --git a/android/src/main/java/com/freeraspreactnative/events/ThreatEvent.kt b/android/src/main/java/app/talsec/rasp/reactnative/events/ThreatEvent.kt similarity index 90% rename from android/src/main/java/com/freeraspreactnative/events/ThreatEvent.kt rename to android/src/main/java/app/talsec/rasp/reactnative/events/ThreatEvent.kt index 7e9fe5e..e43725a 100644 --- a/android/src/main/java/com/freeraspreactnative/events/ThreatEvent.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/events/ThreatEvent.kt @@ -1,6 +1,6 @@ -package com.freeraspreactnative.events +package app.talsec.rasp.reactnative.events -import com.freeraspreactnative.utils.RandomGenerator +import app.talsec.rasp.reactnative.utils.RandomGenerator /** * Sealed class to represent the error codes. @@ -35,11 +35,13 @@ internal sealed class ThreatEvent(override val value: Int) : BaseRaspEvent { data object UnsecureWifi : ThreatEvent(RandomGenerator.next()) data object Automation : ThreatEvent(RandomGenerator.next()) data object Bootloader : ThreatEvent(RandomGenerator.next()) + data object OngoingCall : ThreatEvent(RandomGenerator.next()) companion object { internal val CHANNEL_NAME = RandomGenerator.next().toString() internal val CHANNEL_KEY = RandomGenerator.next().toString() internal val MALWARE_CHANNEL_KEY = RandomGenerator.next().toString() + internal val CALL_STATE_CHANNEL_KEY = RandomGenerator.next().toString() internal val ALL_EVENT_IDENTIFIERS = listOf( AppIntegrity, @@ -63,7 +65,8 @@ internal sealed class ThreatEvent(override val value: Int) : BaseRaspEvent { LocationSpoofing, UnsecureWifi, Automation, - Bootloader + Bootloader, + OngoingCall ).map { it.value } } } diff --git a/android/src/main/java/com/freeraspreactnative/exceptions/TalsecException.kt b/android/src/main/java/app/talsec/rasp/reactnative/exceptions/TalsecException.kt similarity index 70% rename from android/src/main/java/com/freeraspreactnative/exceptions/TalsecException.kt rename to android/src/main/java/app/talsec/rasp/reactnative/exceptions/TalsecException.kt index 69c76cd..ce38cc6 100644 --- a/android/src/main/java/com/freeraspreactnative/exceptions/TalsecException.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/exceptions/TalsecException.kt @@ -1,3 +1,3 @@ -package com.freeraspreactnative.exceptions +package app.talsec.rasp.reactnative.exceptions class TalsecException(message: String, val code: String? = null, val ex: Exception? = null) : Exception(message) diff --git a/android/src/main/java/com/freeraspreactnative/interfaces/PluginExecutionStateListener.kt b/android/src/main/java/app/talsec/rasp/reactnative/interfaces/PluginExecutionStateListener.kt similarity index 51% rename from android/src/main/java/com/freeraspreactnative/interfaces/PluginExecutionStateListener.kt rename to android/src/main/java/app/talsec/rasp/reactnative/interfaces/PluginExecutionStateListener.kt index 7f10f31..d7f6efb 100644 --- a/android/src/main/java/com/freeraspreactnative/interfaces/PluginExecutionStateListener.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/interfaces/PluginExecutionStateListener.kt @@ -1,6 +1,6 @@ -package com.freeraspreactnative.interfaces +package app.talsec.rasp.reactnative.interfaces -import com.freeraspreactnative.events.RaspExecutionStateEvent +import app.talsec.rasp.reactnative.events.RaspExecutionStateEvent internal interface PluginExecutionStateListener { fun raspExecutionStateChanged(event: RaspExecutionStateEvent) diff --git a/android/src/main/java/com/freeraspreactnative/interfaces/PluginThreatListener.kt b/android/src/main/java/app/talsec/rasp/reactnative/interfaces/PluginThreatListener.kt similarity index 51% rename from android/src/main/java/com/freeraspreactnative/interfaces/PluginThreatListener.kt rename to android/src/main/java/app/talsec/rasp/reactnative/interfaces/PluginThreatListener.kt index cc949b3..4d51bfa 100644 --- a/android/src/main/java/com/freeraspreactnative/interfaces/PluginThreatListener.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/interfaces/PluginThreatListener.kt @@ -1,9 +1,11 @@ -package com.freeraspreactnative.interfaces +package app.talsec.rasp.reactnative.interfaces import app.talsec.rasp.security.api.SuspiciousAppInfo -import com.freeraspreactnative.events.ThreatEvent +import app.talsec.rasp.security.api.ThreatListener.CallState +import app.talsec.rasp.reactnative.events.ThreatEvent internal interface PluginThreatListener { fun threatDetected(threatEventType: ThreatEvent) fun malwareDetected(suspiciousApps: MutableList) + fun ongoingCallDetected(state: CallState) } diff --git a/android/src/main/java/com/freeraspreactnative/models/RNSuspiciousAppInfo.kt b/android/src/main/java/app/talsec/rasp/reactnative/models/RNSuspiciousAppInfo.kt similarity index 92% rename from android/src/main/java/com/freeraspreactnative/models/RNSuspiciousAppInfo.kt rename to android/src/main/java/app/talsec/rasp/reactnative/models/RNSuspiciousAppInfo.kt index 4c6b0e9..20cddc9 100644 --- a/android/src/main/java/com/freeraspreactnative/models/RNSuspiciousAppInfo.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/models/RNSuspiciousAppInfo.kt @@ -1,4 +1,4 @@ -package com.freeraspreactnative.models +package app.talsec.rasp.reactnative.models import kotlinx.serialization.Serializable diff --git a/android/src/main/java/com/freeraspreactnative/utils/Extensions.kt b/android/src/main/java/app/talsec/rasp/reactnative/utils/Extensions.kt similarity index 95% rename from android/src/main/java/com/freeraspreactnative/utils/Extensions.kt rename to android/src/main/java/app/talsec/rasp/reactnative/utils/Extensions.kt index 2da0016..04cf57b 100644 --- a/android/src/main/java/com/freeraspreactnative/utils/Extensions.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/utils/Extensions.kt @@ -1,4 +1,4 @@ -package com.freeraspreactnative.utils +package app.talsec.rasp.reactnative.utils import android.content.pm.PackageInfo import android.util.Base64 @@ -13,9 +13,9 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableArray -import com.freeraspreactnative.exceptions.TalsecException -import com.freeraspreactnative.models.RNPackageInfo -import com.freeraspreactnative.models.RNSuspiciousAppInfo +import app.talsec.rasp.reactnative.exceptions.TalsecException +import app.talsec.rasp.reactnative.models.RNPackageInfo +import app.talsec.rasp.reactnative.models.RNSuspiciousAppInfo import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.json.JSONException diff --git a/android/src/main/java/com/freeraspreactnative/utils/RandomGenerator.kt b/android/src/main/java/app/talsec/rasp/reactnative/utils/RandomGenerator.kt similarity index 92% rename from android/src/main/java/com/freeraspreactnative/utils/RandomGenerator.kt rename to android/src/main/java/app/talsec/rasp/reactnative/utils/RandomGenerator.kt index 6860ca9..e1713c5 100644 --- a/android/src/main/java/com/freeraspreactnative/utils/RandomGenerator.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/utils/RandomGenerator.kt @@ -1,4 +1,4 @@ -package com.freeraspreactnative.utils +package app.talsec.rasp.reactnative.utils import java.security.SecureRandom diff --git a/android/src/main/java/com/freeraspreactnative/utils/Utils.kt b/android/src/main/java/app/talsec/rasp/reactnative/utils/Utils.kt similarity index 98% rename from android/src/main/java/com/freeraspreactnative/utils/Utils.kt rename to android/src/main/java/app/talsec/rasp/reactnative/utils/Utils.kt index 6d0d954..d875bbc 100644 --- a/android/src/main/java/com/freeraspreactnative/utils/Utils.kt +++ b/android/src/main/java/app/talsec/rasp/reactnative/utils/Utils.kt @@ -1,4 +1,4 @@ -package com.freeraspreactnative.utils +package app.talsec.rasp.reactnative.utils import android.content.pm.ApplicationInfo import android.graphics.Bitmap diff --git a/android/src/main/java/com/freeraspreactnative/events/RaspExecutionStateEvent.kt b/android/src/main/java/com/freeraspreactnative/events/RaspExecutionStateEvent.kt deleted file mode 100644 index eb9cfc8..0000000 --- a/android/src/main/java/com/freeraspreactnative/events/RaspExecutionStateEvent.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.freeraspreactnative.events - -import com.freeraspreactnative.utils.RandomGenerator - -internal sealed class RaspExecutionStateEvent(override val value: Int) : BaseRaspEvent { - override val channelName: String get() = CHANNEL_NAME - override val channelKey: String get() = CHANNEL_KEY - - data object AllChecksFinished : RaspExecutionStateEvent(RandomGenerator.next()) - - companion object Companion { - internal val CHANNEL_NAME = RandomGenerator.next().toString() - internal val CHANNEL_KEY = RandomGenerator.next().toString() - internal val ALL_EVENT_IDENTIFIERS = listOf( - AllChecksFinished - ).map { it.value } - } -} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 70fce86..f175588 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + diff --git a/example/src/App.tsx b/example/src/App.tsx index 3fa5992..78661a5 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,7 +3,9 @@ import * as React from 'react'; import { Platform, PermissionsAndroid } from 'react-native'; import { addToWhitelist, + startOngoingCallDetection, useFreeRasp, + type CallState, type SuspiciousAppInfo, type TalsecConfig, } from 'freerasp-react-native'; @@ -11,6 +13,9 @@ import { DemoApp } from './DemoApp'; import { commonChecks, iosChecks, androidChecks } from './checks'; import { useEffect } from 'react'; +const callStateLabel = (state: CallState) => + state.charAt(0) + state.slice(1).toLowerCase(); + const App = () => { const [appChecks, setAppChecks] = React.useState([ ...commonChecks, @@ -22,10 +27,17 @@ const App = () => { const [allChecksStatus, setAllChecksStatus] = React.useState< 'in progress' | 'completed' >('in progress'); + const [postponedChecksStatus, setPostponedChecksStatus] = React.useState< + 'in progress' | 'completed' + >('in progress'); useEffect(() => { (async () => { - Platform.OS === 'android' && (await addItemsToMalwareWhitelist()); + if (Platform.OS === 'android') { + await addItemsToMalwareWhitelist(); + // Report cellular and VoIP calls through the ongoingCall action + await startOngoingCallDetection(); + } await requestLocationPermission(); })(); }, []); @@ -55,7 +67,7 @@ const App = () => { }, }, iosConfig: { - appBundleId: 'org.reactjs.native.example.FreeraspReactNativeExample', + appBundleIds: ['org.reactjs.native.example.FreeraspReactNativeExample'], appTeamId: 'your_team_ID', }, watcherMail: 'your_email_address@example.com', @@ -277,13 +289,32 @@ const App = () => { ) ); }, + // Android only + ongoingCall: (state: CallState) => { + console.info('Ongoing call:', state); + setAppChecks((currentState) => + currentState.map((threat) => + threat.name === 'Ongoing Call' + ? { + ...threat, + status: state === 'STOPPED' ? 'ok' : 'nok', + detail: callStateLabel(state), + } + : threat + ) + ); + }, }; const raspExecutionStateActions = { // Android & iOS - allChecksFinished: () => { + fastChecksFinished: () => { setAllChecksStatus('completed'); }, + // Android only + postponedChecksFinished: () => { + setPostponedChecksStatus('completed'); + }, }; const requestLocationPermission = async () => { @@ -320,10 +351,8 @@ const App = () => { await Promise.all( appsToWhitelist.map(async (app) => { try { - const whitelistResponse = await addToWhitelist(app); - console.info( - `${app} stored to Malware Whitelist: ${whitelistResponse}` - ); + await addToWhitelist(app); + console.info(`${app} stored to Malware Whitelist`); } catch (error) { console.info('Malware whitelist failed: ', error); } @@ -331,13 +360,26 @@ const App = () => { ); }; - useFreeRasp(config, actions, raspExecutionStateActions); + const { error: raspError } = useFreeRasp( + config, + actions, + raspExecutionStateActions + ); + + useEffect(() => { + if (raspError) { + console.error( + `freeRASP failed to start: ${raspError.code}: ${raspError.message}` + ); + } + }, [raspError]); return ( ); }; diff --git a/example/src/DemoApp.tsx b/example/src/DemoApp.tsx index 1e72cb1..3e70a4a 100644 --- a/example/src/DemoApp.tsx +++ b/example/src/DemoApp.tsx @@ -5,6 +5,7 @@ import CheckmarkCircle from '../assets/checkmark-circle-outline.png'; import CloseCircle from '../assets/close-circle-outline.png'; import TalsecLogo from '../assets/talsec-logo.png'; import { + Platform, ScrollView, Image, TouchableOpacity, @@ -19,6 +20,10 @@ import { MalwareModal } from './MalwareModal'; import { isScreenCaptureBlocked, blockScreenCapture, + isScreenControlBlocked, + blockScreenControl, + isOverlayWindowsBlocked, + blockOverlayWindows, type SuspiciousAppInfo, storeExternalId, removeExternalId, @@ -33,12 +38,23 @@ export const DemoApp: React.FC<{ checks: { name: string; status: string; + detail?: string; }[]; suspiciousApps: SuspiciousAppInfo[]; - allChecksFinishedStatus: 'in progress' | 'completed'; -}> = ({ checks, suspiciousApps, allChecksFinishedStatus }) => { + fastChecksFinishedStatus: 'in progress' | 'completed'; + postponedChecksFinishedStatus: 'in progress' | 'completed'; +}> = ({ + checks, + suspiciousApps, + fastChecksFinishedStatus, + postponedChecksFinishedStatus, +}) => { const [hasScreenCaptureBlocked, setHasScreenCaptureBlocked] = React.useState(false); + const [hasScreenControlBlocked, setHasScreenControlBlocked] = + React.useState(false); + const [hasOverlayWindowsBlocked, setHasOverlayWindowsBlocked] = + React.useState(false); const [externalIdValue, setExternalIdValue] = React.useState(''); const [modalVisible, setModalVisible] = React.useState(false); @@ -59,14 +75,82 @@ export const DemoApp: React.FC<{ const toggleScreenCaptureBlock = async () => { try { - const response = await blockScreenCapture(!hasScreenCaptureBlocked); - console.info('Changing Screen Capture Status:', response); + await blockScreenCapture(!hasScreenCaptureBlocked); await updateScreenCaptureStatus(); } catch (error: any) { console.error('Screen capture Error:', error.message); } }; + const toggleScreenControlBlock = async () => { + try { + await blockScreenControl(!hasScreenControlBlocked); + setHasScreenControlBlocked(await isScreenControlBlocked()); + } catch (error: any) { + console.error('Screen control Error:', error); + } + }; + + const toggleOverlayWindowsBlock = async () => { + try { + const applied = await blockOverlayWindows(!hasOverlayWindowsBlocked); + console.info('Changing Overlay Windows Status applied:', applied); + setHasOverlayWindowsBlocked(await isOverlayWindowsBlocked()); + } catch (error: any) { + console.error('Overlay windows Error:', error); + } + }; + + const renderToggle = ( + isBlocked: boolean, + onPress: () => void, + blockLabel: string, + enableLabel: string + ) => ( + + + {isBlocked ? enableLabel : blockLabel} + + + ); + + const renderExecutionState = ( + label: string, + status: 'in progress' | 'completed' + ) => ( + + + + {label} + + {status === 'completed' ? ( + + ) : ( + + )} + + + ); + const handleModalSend = async () => { setModalVisible(false); try { @@ -174,45 +258,33 @@ export const DemoApp: React.FC<{ - RASP Execution State: - + {Platform.OS === 'android' && ( - - All Checks Finished - - {allChecksFinishedStatus === 'completed' ? ( - - ) : ( - + style={{ justifyContent: 'space-evenly', paddingTop: 10 }}> + {renderToggle( + hasScreenControlBlocked, + toggleScreenControlBlock, + 'Block Screen Control', + 'Enable Screen Control' + )} + {renderToggle( + hasOverlayWindowsBlocked, + toggleOverlayWindowsBlock, + 'Block Overlays', + 'Enable Overlays' )} - + )} + RASP Execution State: + {renderExecutionState( + 'Fast Checks Finished', + fastChecksFinishedStatus + )} + {Platform.OS === 'android' && + renderExecutionState( + 'Postponed Checks Finished', + postponedChecksFinishedStatus + )} freeRASP checks: {checks.map((check: any, idx: number) => ( - {check.name} + {check.detail + ? `${check.name}: ${check.detail}` + : check.name} {check.name === 'Malware' && ( = ({ app }) => { const whitelistApp = async (packageName: string) => { try { - const whitelistResponse = await addToWhitelist(packageName); - console.info( - `Malware Whitelist response for ${app}: ${whitelistResponse}` - ); + await addToWhitelist(packageName); + console.info(`${app.packageInfo.packageName} added to Malware Whitelist`); alert('Restart app for whitelist to take effect'); } catch (error: any) { console.info('Error while adding app to malware whitelist: ', error); diff --git a/example/src/checks.ts b/example/src/checks.ts index 1b777d0..c64f78d 100644 --- a/example/src/checks.ts +++ b/example/src/checks.ts @@ -26,4 +26,5 @@ export const androidChecks = [ { name: 'Unsecure Wifi', status: 'ok' }, { name: 'Automation', status: 'ok' }, { name: 'Bootloader', status: 'ok' }, + { name: 'Ongoing Call', status: 'ok', detail: 'Not detected' }, ]; diff --git a/ios/FreeraspReactNative.swift b/ios/FreeraspReactNative.swift index 740ad50..e625ddf 100644 --- a/ios/FreeraspReactNative.swift +++ b/ios/FreeraspReactNative.swift @@ -51,8 +51,8 @@ class FreeraspReactNative: RCTEventEmitter { guard let iosConfig = talsecConfig["iosConfig"] as? NSDictionary else { throw NSError(domain: "Missing iosConfig parameter in Talsec Native Plugin", code: 1) } - guard let appBundleIds = iosConfig["appBundleId"] as? String else { - throw NSError(domain: "Missing appBundleId parameter in Talsec Native Plugin", code: 2) + guard let appBundleIds = iosConfig["appBundleIds"] as? [String] else { + throw NSError(domain: "Missing appBundleIds parameter in Talsec Native Plugin", code: 2) } guard let appTeamId = iosConfig["appTeamId"] as? String else { throw NSError(domain: "Missing appTeamId parameter in Talsec Native Plugin", code: 3) @@ -62,7 +62,7 @@ class FreeraspReactNative: RCTEventEmitter { } let isProd = talsecConfig["isProd"] as? Bool ?? true - let config = TalsecConfig(appBundleIds: [appBundleIds], appTeamId: appTeamId, watcherMailAddress: watcherMailAddress, isProd: isProd) + let config = TalsecConfig(appBundleIds: appBundleIds, appTeamId: appTeamId, watcherMailAddress: watcherMailAddress, isProd: isProd) Talsec.start(config: config) } @@ -107,7 +107,8 @@ class FreeraspReactNative: RCTEventEmitter { */ @objc(getThreatChannelData:withRejecter:) private func getThreatChannelData(resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) -> Void { - resolve([EventIdentifiers.threatChannelName, EventIdentifiers.threatChannelKey, "0"]) + // Malware and ongoing call are Android only, so iOS sends placeholders for their keys + resolve([EventIdentifiers.threatChannelName, EventIdentifiers.threatChannelKey, "0", "0"]) } /** @@ -172,8 +173,9 @@ extension SecurityThreatCenter: @retroactive SecurityThreatHandler, @retroactive ThreatDispatcher.shared.dispatch(threat: securityThreat) } + // The iOS SDK reports its standard checks as a single "all checks" event, which maps to fastChecksFinished. public func onAllChecksFinished() { - ExecutionStateDispatcher.shared.dispatch(event: RaspExecutionStates.allChecksFinished) + ExecutionStateDispatcher.shared.dispatch(event: RaspExecutionStates.fastChecksFinished) } } diff --git a/ios/models/RaspExecutionStates.swift b/ios/models/RaspExecutionStates.swift index 7ceda4e..1aea92d 100644 --- a/ios/models/RaspExecutionStates.swift +++ b/ios/models/RaspExecutionStates.swift @@ -1,14 +1,14 @@ enum RaspExecutionStates : String, Codable, CaseIterable, Equatable { - case allChecksFinished + case fastChecksFinished public static var allCases: [RaspExecutionStates] { - return [.allChecksFinished] + return [.fastChecksFinished] } var callbackIdentifier: Int { switch self { - case .allChecksFinished: + case .fastChecksFinished: return EventIdentifiers.raspExecutionStateIdentifierList[0] } } diff --git a/package.json b/package.json index b61df6a..6128c77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freerasp-react-native", - "version": "5.3.0", + "version": "6.0.0", "description": "React Native plugin for improving app security and threat monitoring on Android and iOS mobile devices.", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/api/listeners/raspExecutionState.ts b/src/api/listeners/raspExecutionState.ts index c39ea18..9552852 100644 --- a/src/api/listeners/raspExecutionState.ts +++ b/src/api/listeners/raspExecutionState.ts @@ -1,6 +1,6 @@ import { NativeEventEmitter, type EmitterSubscription } from 'react-native'; import { FreeraspReactNative } from '../nativeModules'; -import { onInvalidCallback, removeListenerForEvent } from '../methods/native'; +import { onInvalidCallback, removeListenerForEvent } from '../methods/internal'; import type { NativeEvent, RaspExecutionStateEventActions, @@ -47,8 +47,14 @@ export const setRaspExecutionStateListener = async ( } switch (event[executionStateKey]) { - case RaspExecutionState.AllChecksFinished.value: - config.allChecksFinished?.(); + case RaspExecutionState.FastChecksFinished.value: + config.fastChecksFinished?.(); + break; + case RaspExecutionState.CriticalChecksFinished.value: + config.criticalChecksFinished?.(); + break; + case RaspExecutionState.PostponedChecksFinished.value: + config.postponedChecksFinished?.(); break; default: onInvalidCallback(); diff --git a/src/api/listeners/threat.ts b/src/api/listeners/threat.ts index be4d106..99b1e6b 100644 --- a/src/api/listeners/threat.ts +++ b/src/api/listeners/threat.ts @@ -1,8 +1,12 @@ import { NativeEventEmitter, type EmitterSubscription } from 'react-native'; import { FreeraspReactNative } from '../nativeModules'; -import { onInvalidCallback, removeListenerForEvent } from '../methods/native'; +import { onInvalidCallback, removeListenerForEvent } from '../methods/internal'; import { Threat } from '../../models/threat'; -import type { NativeEvent, ThreatEventActions } from '../../types/types'; +import type { + CallState, + NativeEvent, + ThreatEventActions, +} from '../../types/types'; import { parseMalwareData } from '../../utils/malware'; import { getThreatChannelData, @@ -15,6 +19,12 @@ let eventsListener: EmitterSubscription | null = null; let threatChannel: string | null = null; let threatKey: string | null = null; let threatMalwareKey: string | null = null; +let threatCallStateKey: string | null = null; + +const callStates: readonly string[] = ['STARTED', 'ONGOING', 'STOPPED']; + +const isCallState = (value: unknown): value is CallState => + typeof value === 'string' && callStates.includes(value); let isMappingPrepared = false; let isInitializing = false; @@ -28,8 +38,14 @@ export const setThreatListeners = async (config: ThreatEventActions) => { await removeThreatListener(); - if (!threatChannel || !threatKey || !threatMalwareKey) { - [threatChannel, threatKey, threatMalwareKey] = await getThreatChannelData(); + if ( + !threatChannel || + !threatKey || + !threatMalwareKey || + !threatCallStateKey + ) { + [threatChannel, threatKey, threatMalwareKey, threatCallStateKey] = + await getThreatChannelData(); } if (!isMappingPrepared) { @@ -38,7 +54,7 @@ export const setThreatListeners = async (config: ThreatEventActions) => { } const listener = async (event: NativeEvent) => { - if (!threatKey || !threatMalwareKey) { + if (!threatKey || !threatMalwareKey || !threatCallStateKey) { onInvalidCallback(); return; } @@ -116,6 +132,14 @@ export const setThreatListeners = async (config: ThreatEventActions) => { case Threat.Bootloader.value: config.bootloader?.(); break; + case Threat.OngoingCall.value: + const callState = event[threatCallStateKey]; + if (!isCallState(callState)) { + onInvalidCallback(); + break; + } + config.ongoingCall?.(callState); + break; default: onInvalidCallback(); break; diff --git a/src/api/methods/internal.ts b/src/api/methods/internal.ts new file mode 100644 index 0000000..7b4fb32 --- /dev/null +++ b/src/api/methods/internal.ts @@ -0,0 +1,13 @@ +import { FreeraspReactNative } from '../nativeModules'; + +/** + * Terminates the app. Called when the native side sends data that freeRASP + * never sends, which indicates tampering. Exported publicly as `abortApp`. + */ +export const onInvalidCallback = (): void => { + FreeraspReactNative.onInvalidCallback(); +}; + +export const removeListenerForEvent = (channel: string): Promise => { + return FreeraspReactNative.removeListenerForEvent(channel); +}; diff --git a/src/api/methods/native.ts b/src/api/methods/native.ts index cc3d089..ef77da3 100644 --- a/src/api/methods/native.ts +++ b/src/api/methods/native.ts @@ -1,48 +1,152 @@ import { Platform } from 'react-native'; import { FreeraspReactNative } from '../nativeModules'; import type { TalsecConfig } from '../../types/types'; +import { androidOnly, wrapNative } from '../../types/errors'; import { withDefaults } from '../../utils/config'; -export const talsecStart = async (options: TalsecConfig): Promise => { - return FreeraspReactNative.talsecStart(withDefaults(options)); +/** + * Starts freeRASP. Prefer the `useFreeRasp` hook, which also registers the + * listeners. + */ +export const talsecStart = async (options: TalsecConfig): Promise => { + await wrapNative('talsec-failure', () => + FreeraspReactNative.talsecStart(withDefaults(options)) + ); }; -export const addToWhitelist = async (packageName: string): Promise => { +/** + * Android only. Adds an app to the malware whitelist so it is not reported. + */ +export const addToWhitelist = async (packageName: string): Promise => { if (Platform.OS === 'ios') { - return Promise.reject('Malware detection not available on iOS'); + return androidOnly('Malware detection'); } - return FreeraspReactNative.addToWhitelist(packageName); + await wrapNative('malware-failure', () => + FreeraspReactNative.addToWhitelist(packageName) + ); }; -export const blockScreenCapture = (enable: boolean): Promise => { - return FreeraspReactNative.blockScreenCapture(enable); +/** + * Blocks or unblocks screenshots and screen recording of the app screen. + */ +export const blockScreenCapture = async (enable: boolean): Promise => { + await wrapNative('talsec-failure', () => + FreeraspReactNative.blockScreenCapture(enable) + ); }; +/** + * Returns whether screen capture is blocked for the current screen. + */ export const isScreenCaptureBlocked = (): Promise => { - return FreeraspReactNative.isScreenCaptureBlocked(); + return wrapNative('talsec-failure', () => + FreeraspReactNative.isScreenCaptureBlocked() + ); }; -export const storeExternalId = (data: string): Promise => { - return FreeraspReactNative.storeExternalId(data); +/** + * Android only. Prevents accessibility services from reading or controlling + * the app screen. + */ +export const blockScreenControl = async (enable: boolean): Promise => { + if (Platform.OS === 'ios') { + return androidOnly('Screen control blocking'); + } + await wrapNative('talsec-failure', () => + FreeraspReactNative.blockScreenControl(enable) + ); }; -export const removeExternalId = (): Promise => { - return FreeraspReactNative.removeExternalId(); +/** + * Android only. Returns whether screen control is blocked. + */ +export const isScreenControlBlocked = (): Promise => { + if (Platform.OS === 'ios') { + return androidOnly('Screen control blocking'); + } + return wrapNative('talsec-failure', () => + FreeraspReactNative.isScreenControlBlocked() + ); }; -export const getAppIcon = (packageName: string): Promise => { +/** + * Android only. Hides overlay windows of other apps over the app screen. + * Requires Android 12+ and the `HIDE_OVERLAY_WINDOWS` permission. + * + * @returns whether the request was applied + */ +export const blockOverlayWindows = (enable: boolean): Promise => { + if (Platform.OS === 'ios') { + return androidOnly('Overlay windows blocking'); + } + return wrapNative('talsec-failure', () => + FreeraspReactNative.blockOverlayWindows(enable) + ); +}; + +/** + * Android only. Returns whether overlay windows of other apps are hidden. + */ +export const isOverlayWindowsBlocked = (): Promise => { + if (Platform.OS === 'ios') { + return androidOnly('Overlay windows blocking'); + } + return wrapNative('talsec-failure', () => + FreeraspReactNative.isOverlayWindowsBlocked() + ); +}; + +/** + * Android only. Starts the ongoing call (cellular and VoIP) detection. + * Call states are reported through the `ongoingCall` threat action. + */ +export const startOngoingCallDetection = async (): Promise => { + if (Platform.OS === 'ios') { + return androidOnly('Ongoing call detection'); + } + await wrapNative('talsec-failure', () => + FreeraspReactNative.startOngoingCallDetection() + ); +}; + +/** + * Android only. Stops the ongoing call detection. + */ +export const stopOngoingCallDetection = async (): Promise => { if (Platform.OS === 'ios') { - return Promise.reject( - 'App icon retrieval for Malware detection not available on iOS' - ); + return androidOnly('Ongoing call detection'); } - return FreeraspReactNative.getAppIcon(packageName); + await wrapNative('talsec-failure', () => + FreeraspReactNative.stopOngoingCallDetection() + ); }; -export const onInvalidCallback = (): void => { - FreeraspReactNative.onInvalidCallback(); +/** + * Stores an external ID sent with freeRASP logs. + */ +export const storeExternalId = async (data: string): Promise => { + await wrapNative('external-id-failure', () => + FreeraspReactNative.storeExternalId(data) + ); }; -export const removeListenerForEvent = (channel: string): Promise => { - return FreeraspReactNative.removeListenerForEvent(channel); +/** + * Removes the stored external ID. + */ +export const removeExternalId = async (): Promise => { + await wrapNative('external-id-failure', () => + FreeraspReactNative.removeExternalId() + ); +}; + +/** + * Android only. Returns the icon of the given app as a base64-encoded PNG. + */ +export const getAppIcon = (packageName: string): Promise => { + if (Platform.OS === 'ios') { + return androidOnly('App icon retrieval for malware detection'); + } + return wrapNative('malware-failure', () => + FreeraspReactNative.getAppIcon(packageName) + ); }; diff --git a/src/api/methods/reactNative.ts b/src/api/methods/reactNative.ts index 4379446..860feb4 100644 --- a/src/api/methods/reactNative.ts +++ b/src/api/methods/reactNative.ts @@ -1,5 +1,5 @@ import { talsecStart } from './native'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { setRaspExecutionStateListener, removeRaspExecutionStateEventListener, @@ -9,38 +9,66 @@ import type { ThreatEventActions, TalsecConfig, RaspExecutionStateEventActions, + FreeRaspStatus, } from '../../types/types'; -import { onInvalidCallback } from './native'; +import { TalsecError } from '../../types/errors'; let isRaspStarted = false; +const toTalsecError = (e: unknown): TalsecError => + e instanceof TalsecError + ? e + : new TalsecError( + 'talsec-failure', + e instanceof Error ? e.message : String(e) + ); + +/** + * Registers the threat and RASP execution state listeners and starts freeRASP. + * + * freeRASP is started only once per app run; later mounts only re-register + * the listeners. + * + * @returns `started` once freeRASP runs, or the `TalsecError` that prevented + * the listeners from being registered or freeRASP from starting. + */ export const useFreeRasp = ( config: TalsecConfig, actions: ThreatEventActions, raspExecutionStateActions?: RaspExecutionStateEventActions -) => { +): FreeRaspStatus => { + const [status, setStatus] = useState({ + started: isRaspStarted, + error: null, + }); + useEffect(() => { + let isMounted = true; + (async () => { - await setThreatListeners(actions); - raspExecutionStateActions && - (await setRaspExecutionStateListener(raspExecutionStateActions)); + try { + await setThreatListeners(actions); + if (raspExecutionStateActions) { + await setRaspExecutionStateListener(raspExecutionStateActions); + } - if (isRaspStarted) { - return; - } + if (!isRaspStarted) { + await talsecStart(config); + isRaspStarted = true; + } - try { - const response = await talsecStart(config); - if (response !== 'freeRASP started') { - onInvalidCallback(); + if (isMounted) { + setStatus({ started: true, error: null }); + } + } catch (e) { + if (isMounted) { + setStatus({ started: isRaspStarted, error: toTalsecError(e) }); } - isRaspStarted = true; - } catch (e: any) { - console.error(`${e.code}: ${e.message}`); } })(); return () => { + isMounted = false; (async () => { await removeThreatListener(); await removeRaspExecutionStateEventListener(); @@ -48,4 +76,6 @@ export const useFreeRasp = ( }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + + return status; }; diff --git a/src/channels/raspExecutionState.ts b/src/channels/raspExecutionState.ts index 7196f20..82a1f51 100644 --- a/src/channels/raspExecutionState.ts +++ b/src/channels/raspExecutionState.ts @@ -1,6 +1,6 @@ import { getRaspExecutionStateCount, itemsHaveType } from '../utils/utils'; import { FreeraspReactNative } from '../api/nativeModules'; -import { onInvalidCallback } from '../api/methods/native'; +import { onInvalidCallback } from '../api/methods/internal'; import { RaspExecutionState } from '../models/raspExecutionState'; export const getRaspExecutionStateIdentifiers = async (): Promise => { diff --git a/src/channels/threat.ts b/src/channels/threat.ts index f63c8ba..8c62230 100644 --- a/src/channels/threat.ts +++ b/src/channels/threat.ts @@ -1,7 +1,7 @@ import { getThreatCount, itemsHaveType } from '../utils/utils'; import { FreeraspReactNative } from '../api/nativeModules'; import { Threat } from '../models/threat'; -import { onInvalidCallback } from '../api/methods/native'; +import { onInvalidCallback } from '../api/methods/internal'; export const getThreatIdentifiers = async (): Promise => { const identifiers = await FreeraspReactNative.getThreatIdentifiers(); @@ -15,10 +15,10 @@ export const getThreatIdentifiers = async (): Promise => { }; export const getThreatChannelData = async (): Promise< - [string, string, string] + [string, string, string, string] > => { const data = await FreeraspReactNative.getThreatChannelData(); - if (data.length !== 3 || !itemsHaveType(data, 'string')) { + if (data.length !== 4 || !itemsHaveType(data, 'string')) { onInvalidCallback(); } return data; diff --git a/src/index.tsx b/src/index.tsx index 70f0246..b063c57 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,7 @@ export * from './types/types'; +export { TalsecError, type TalsecErrorCode } from './types/errors'; export * from './api/methods/native'; export * from './api/methods/reactNative'; export * from './api/listeners/threat'; export * from './api/listeners/raspExecutionState'; +export { onInvalidCallback as abortApp } from './api/methods/internal'; diff --git a/src/models/raspExecutionState.ts b/src/models/raspExecutionState.ts index a46c800..3c60fd3 100644 --- a/src/models/raspExecutionState.ts +++ b/src/models/raspExecutionState.ts @@ -1,13 +1,23 @@ +import { Platform } from 'react-native'; + export class RaspExecutionState { value: number; - static AllChecksFinished = new RaspExecutionState(0); + static FastChecksFinished = new RaspExecutionState(0); + static CriticalChecksFinished = new RaspExecutionState(0); + static PostponedChecksFinished = new RaspExecutionState(0); constructor(value: number) { this.value = value; } static getValues(): RaspExecutionState[] { - return [this.AllChecksFinished]; + return Platform.OS === 'android' + ? [ + this.FastChecksFinished, + this.CriticalChecksFinished, + this.PostponedChecksFinished, + ] + : [this.FastChecksFinished]; } } diff --git a/src/models/threat.ts b/src/models/threat.ts index 027033d..e7f6db0 100644 --- a/src/models/threat.ts +++ b/src/models/threat.ts @@ -26,6 +26,7 @@ export class Threat { static UnsecureWifi = new Threat(0); static Automation = new Threat(0); static Bootloader = new Threat(0); + static OngoingCall = new Threat(0); constructor(value: number) { this.value = value; @@ -56,6 +57,7 @@ export class Threat { this.UnsecureWifi, this.Automation, this.Bootloader, + this.OngoingCall, ] : [ this.AppIntegrity, diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..0dd2270 --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,57 @@ +/** + * Stable error codes shared with the other freeRASP plugins. + * + * - `talsec-failure` - a native call failed + * - `unsupported-platform` - the feature is not available on this platform + * - `configuration-exception` - the configuration is invalid + * - `external-id-failure` - storing or removing the external ID failed + * - `malware-failure` - a malware-detection call failed + */ +export type TalsecErrorCode = + | 'talsec-failure' + | 'unsupported-platform' + | 'configuration-exception' + | 'external-id-failure' + | 'malware-failure'; + +/** + * The only error freeRASP methods reject with. + */ +export class TalsecError extends Error { + readonly code: TalsecErrorCode; + + constructor(code: TalsecErrorCode, message?: string) { + super(message); + this.name = 'TalsecError'; + this.code = code; + Object.setPrototypeOf(this, TalsecError.prototype); + } +} + +/** + * Runs a native call and rethrows any failure as a {@link TalsecError}. + */ +export const wrapNative = async ( + code: TalsecErrorCode, + fn: () => Promise +): Promise => { + try { + return await fn(); + } catch (e: any) { + if (e instanceof TalsecError) { + throw e; + } + throw new TalsecError(code, e?.message ?? String(e)); + } +}; + +/** + * Rejects with an `unsupported-platform` error for Android-only features. + */ +export const androidOnly = (feature: string): Promise => + Promise.reject( + new TalsecError( + 'unsupported-platform', + `${feature} is not available on iOS` + ) + ); diff --git a/src/types/types.ts b/src/types/types.ts index 93b1edf..a0c8d51 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,3 +1,5 @@ +import type { TalsecError } from './errors'; + export type TalsecConfig = { androidConfig?: TalsecAndroidConfig; iosConfig?: TalsecIosConfig; @@ -37,7 +39,7 @@ export type TalsecAndroidConfig = { }; export type TalsecIosConfig = { - appBundleId: string; + appBundleIds: string[]; appTeamId: string; }; @@ -55,34 +57,74 @@ export type PackageInfo = { installerStore?: string; }; +/** + * State of an ongoing call reported by the ongoing call detection. + * + * - `STARTED` - a call started while the detection was running + * - `ONGOING` - a call was already in progress when the detection started + * - `STOPPED` - a previously reported call has ended + */ +export type CallState = 'STARTED' | 'ONGOING' | 'STOPPED'; + export type ThreatEventActions = { - privilegedAccess?: () => any; - debug?: () => any; - simulator?: () => any; - appIntegrity?: () => any; - unofficialStore?: () => any; - hooks?: () => any; - deviceBinding?: () => any; - deviceID?: () => any; - passcode?: () => any; - secureHardwareNotAvailable?: () => any; - obfuscationIssues?: () => any; - devMode?: () => any; - systemVPN?: () => any; - malware?: (suspiciousApps: SuspiciousAppInfo[]) => any; - adbEnabled?: () => any; - screenshot?: () => any; - screenRecording?: () => any; - multiInstance?: () => any; - timeSpoofing?: () => any; - locationSpoofing?: () => any; - unsecureWifi?: () => any; - automation?: () => any; - bootloader?: () => any; + privilegedAccess?: () => void; + debug?: () => void; + simulator?: () => void; + appIntegrity?: () => void; + unofficialStore?: () => void; + hooks?: () => void; + deviceBinding?: () => void; + deviceID?: () => void; + passcode?: () => void; + secureHardwareNotAvailable?: () => void; + obfuscationIssues?: () => void; + devMode?: () => void; + systemVPN?: () => void; + malware?: (suspiciousApps: SuspiciousAppInfo[]) => void; + adbEnabled?: () => void; + screenshot?: () => void; + screenRecording?: () => void; + multiInstance?: () => void; + timeSpoofing?: () => void; + locationSpoofing?: () => void; + unsecureWifi?: () => void; + automation?: () => void; + bootloader?: () => void; + /** + * Android only. Requires `startOngoingCallDetection()`. + */ + ongoingCall?: (state: CallState) => void; }; -export type NativeEvent = { [key: string]: number | string[] | undefined }; +export type NativeEvent = { + [key: string]: number | string | string[] | undefined; +}; export type RaspExecutionStateEventActions = { - allChecksFinished?: () => any; + /** + * The fast (standard) checks have been completed. On Android, the postponed + * checks may still run afterwards. On iOS, triggered when all checks have + * been completed. + */ + fastChecksFinished?: () => void; + /** + * Android only. The critical checks have been completed, before the + * non-critical checks. + */ + criticalChecksFinished?: () => void; + /** + * Android only. The postponed checks have been completed, which ends the + * initial evaluation. + */ + postponedChecksFinished?: () => void; +}; + +/** + * State returned by `useFreeRasp`. + */ +export type FreeRaspStatus = { + /** `true` once freeRASP has been started. */ + started: boolean; + /** Error that prevented the listeners from being registered or freeRASP from starting. */ + error: TalsecError | null; };