diff --git a/android/build.gradle b/android/build.gradle index 835c06f..b9a0ef1 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -11,6 +11,7 @@ buildscript { classpath "com.android.tools.build:gradle:7.2.1" // noinspection DifferentKotlinGradleVersion classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" } } @@ -20,6 +21,7 @@ def isNewArchitectureEnabled() { apply plugin: "com.android.library" apply plugin: "kotlin-android" +apply plugin: 'kotlinx-serialization' if (isNewArchitectureEnabled()) { apply plugin: "com.facebook.react" @@ -87,6 +89,7 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:$react_native_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" implementation "com.aheaditec.talsec.security:TalsecSecurity-Community-ReactNative:11.1.1" } diff --git a/android/src/main/java/com/freeraspreactnative/FreeraspReactNativeModule.kt b/android/src/main/java/com/freeraspreactnative/FreeraspReactNativeModule.kt index f75ed07..bba5fef 100644 --- a/android/src/main/java/com/freeraspreactnative/FreeraspReactNativeModule.kt +++ b/android/src/main/java/com/freeraspreactnative/FreeraspReactNativeModule.kt @@ -1,5 +1,6 @@ package com.freeraspreactnative +import com.aheaditec.talsec_security.security.api.SuspiciousAppInfo import com.aheaditec.talsec_security.security.api.Talsec import com.aheaditec.talsec_security.security.api.TalsecConfig import com.aheaditec.talsec_security.security.api.ThreatListener @@ -12,8 +13,14 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.UiThreadUtil.runOnUiThread import com.facebook.react.bridge.WritableArray import com.facebook.react.modules.core.DeviceEventManagerModule - -class FreeraspReactNativeModule(val reactContext: ReactApplicationContext) : +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 + +class FreeraspReactNativeModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { private val listener = ThreatListener(FreeraspThreatHandler, FreeraspThreatHandler) @@ -42,8 +49,7 @@ class FreeraspReactNativeModule(val reactContext: ReactApplicationContext) : promise.resolve("freeRASP started") - } - catch (e: Exception) { + } catch (e: Exception) { promise.reject("TalsecInitializationError", e.message, e) } } @@ -65,6 +71,7 @@ class FreeraspReactNativeModule(val reactContext: ReactApplicationContext) : val channelData: WritableArray = Arguments.createArray() channelData.pushString(THREAT_CHANNEL_NAME) channelData.pushString(THREAT_CHANNEL_KEY) + channelData.pushString(MALWARE_CHANNEL_KEY) promise.resolve(channelData) } @@ -87,6 +94,15 @@ class FreeraspReactNativeModule(val reactContext: ReactApplicationContext) : // Remove upstream listeners, stop unnecessary background tasks } + /** + * Method to add apps to Malware whitelist, so they don't get flagged as malware + */ + @ReactMethod + fun addToWhitelist(packageName: String, promise: Promise) { + Talsec.addToWhitelist(reactContext, packageName) + promise.resolve(true) + } + private fun buildTalsecConfig(config: ReadableMap): TalsecConfig { val androidConfig = config.getMapThrowing("androidConfig") val packageName = androidConfig.getStringThrowing("packageName") @@ -97,6 +113,14 @@ class FreeraspReactNativeModule(val reactContext: ReactApplicationContext) : .supportedAlternativeStores(androidConfig.getArraySafe("supportedAlternativeStores")) .prod(config.getBooleanSafe("isProd")) + if (androidConfig.hasKey("malware")) { + val malwareConfig = androidConfig.getMapThrowing("malware") + talsecBuilder.whitelistedInstallationSources(malwareConfig.getArraySafe("whitelistedInstallationSources")) + talsecBuilder.blocklistedHashes(malwareConfig.getArraySafe("blocklistedHashes")) + talsecBuilder.blocklistedPermissions(malwareConfig.getNestedArraySafe("blocklistedPermissions")) + talsecBuilder.blocklistedPackageNames(malwareConfig.getArraySafe("blocklistedPackageNames")) + } + return talsecBuilder.build() } @@ -106,6 +130,8 @@ class FreeraspReactNativeModule(val reactContext: ReactApplicationContext) : .toString() // name of the channel over which threat callbacks are sent val THREAT_CHANNEL_KEY = (10000..999999999).random() .toString() // key of the argument map under which threats are expected + val MALWARE_CHANNEL_KEY = (10000..999999999).random() + .toString() // key of the argument map under which malware data is expected private lateinit var appReactContext: ReactApplicationContext private fun notifyListeners(threat: Threat) { val params = Arguments.createMap() @@ -114,11 +140,30 @@ class FreeraspReactNativeModule(val reactContext: ReactApplicationContext) : .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .emit(THREAT_CHANNEL_NAME, params) } + + /** + * Sends malware detected event to React Native + */ + private fun notifyMalware(suspiciousApps: MutableList) { + val params = Arguments.createMap() + params.putInt(THREAT_CHANNEL_KEY, Threat.Malware.value) + params.putArray( + MALWARE_CHANNEL_KEY, suspiciousApps.toEncodedWritableArray(appReactContext) + ) + + appReactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(THREAT_CHANNEL_NAME, params) + } } internal object ThreatListener : FreeraspThreatHandler.TalsecReactNative { override fun threatDetected(threatType: Threat) { notifyListeners(threatType) } + + override fun malwareDetected(suspiciousApps: MutableList) { + notifyMalware(suspiciousApps) + } } } diff --git a/android/src/main/java/com/freeraspreactnative/FreeraspThreatHandler.kt b/android/src/main/java/com/freeraspreactnative/FreeraspThreatHandler.kt index 4e58a44..d9facd8 100644 --- a/android/src/main/java/com/freeraspreactnative/FreeraspThreatHandler.kt +++ b/android/src/main/java/com/freeraspreactnative/FreeraspThreatHandler.kt @@ -39,7 +39,9 @@ internal object FreeraspThreatHandler : ThreatListener.ThreatDetected, ThreatLis listener?.threatDetected(Threat.ObfuscationIssues) } - override fun onMalwareDetected(p0: MutableList?) {} + override fun onMalwareDetected(suspiciousAppInfos: MutableList?) { + listener?.malwareDetected(suspiciousAppInfos ?: mutableListOf()) + } override fun onUnlockedDeviceDetected() { listener?.threatDetected(Threat.Passcode) @@ -59,5 +61,7 @@ internal object FreeraspThreatHandler : ThreatListener.ThreatDetected, ThreatLis internal interface TalsecReactNative { fun threatDetected(threatType: Threat) + + fun malwareDetected(suspiciousApps: MutableList) } } diff --git a/android/src/main/java/com/freeraspreactnative/Threat.kt b/android/src/main/java/com/freeraspreactnative/Threat.kt index ef53aab..d6fb7d0 100644 --- a/android/src/main/java/com/freeraspreactnative/Threat.kt +++ b/android/src/main/java/com/freeraspreactnative/Threat.kt @@ -23,6 +23,7 @@ internal sealed class Threat(val value: Int) { object ObfuscationIssues : Threat((10000..999999999).random()) object SystemVPN : Threat((10000..999999999).random()) object DevMode : Threat((10000..999999999).random()) + object Malware : Threat((10000..999999999).random()) companion object { internal fun getThreatValues(): WritableArray { @@ -39,7 +40,8 @@ internal sealed class Threat(val value: Int) { DeviceBinding.value, UnofficialStore.value, ObfuscationIssues.value, - DevMode.value + DevMode.value, + Malware.value ) ) } diff --git a/android/src/main/java/com/freeraspreactnative/Utils.kt b/android/src/main/java/com/freeraspreactnative/Utils.kt deleted file mode 100644 index 380dfd5..0000000 --- a/android/src/main/java/com/freeraspreactnative/Utils.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.freeraspreactnative - -import com.facebook.react.bridge.ReadableArray -import com.facebook.react.bridge.ReadableMap -import com.freeraspreactnative.exceptions.TalsecException - -internal fun ReadableMap.getMapThrowing(key: String): ReadableMap { - return this.getMap(key) ?: throw TalsecException("Key missing in configuration: $key") -} - -internal fun ReadableMap.getStringThrowing(key: String): String { - return this.getString(key) ?: throw TalsecException("Key missing in configuration: $key") -} - -internal fun ReadableMap.getBooleanSafe(key: String, defaultValue: Boolean = true): Boolean { - if (this.hasKey(key)) { - return this.getBoolean(key) - } - return defaultValue -} - -internal fun ReadableArray.toArray(): Array { - val output = mutableListOf() - for (i in 0 until this.size()) { - // in RN versions < 0.63, getString is nullable - @Suppress("UNNECESSARY_SAFE_CALL") - this.getString(i)?.let { - output.add(it) - } - } - return output.toTypedArray() -} - -internal fun ReadableMap.getArraySafe(key: String): Array { - if (this.hasKey(key)) { - val inputArray = this.getArray(key)!! - return inputArray.toArray() - } - return arrayOf() -} diff --git a/android/src/main/java/com/freeraspreactnative/models/RNSuspiciousAppInfo.kt b/android/src/main/java/com/freeraspreactnative/models/RNSuspiciousAppInfo.kt new file mode 100644 index 0000000..14f30d5 --- /dev/null +++ b/android/src/main/java/com/freeraspreactnative/models/RNSuspiciousAppInfo.kt @@ -0,0 +1,25 @@ +package com.freeraspreactnative.models + +import kotlinx.serialization.Serializable + + +/** + * Simplified, serializable wrapper for Talsec's SuspiciousAppInfo + */ +@Serializable +data class RNSuspiciousAppInfo( + val packageInfo: RNPackageInfo, + val reason: String, +) + +/** + * Simplified, serializable wrapper for Android's PackageInfo + */ +@Serializable +data class RNPackageInfo( + val packageName: String, + val appName: String?, + val version: String?, + val appIcon: String?, + val installerStore: String? +) diff --git a/android/src/main/java/com/freeraspreactnative/utils/Extensions.kt b/android/src/main/java/com/freeraspreactnative/utils/Extensions.kt new file mode 100644 index 0000000..d33714e --- /dev/null +++ b/android/src/main/java/com/freeraspreactnative/utils/Extensions.kt @@ -0,0 +1,111 @@ +package com.freeraspreactnative.utils + +import android.content.pm.PackageInfo +import android.util.Base64 +import android.util.Log +import com.aheaditec.talsec_security.security.api.SuspiciousAppInfo +import com.facebook.react.bridge.Arguments +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 kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +internal fun ReadableMap.getMapThrowing(key: String): ReadableMap { + return this.getMap(key) ?: throw TalsecException("Key missing in configuration: $key") +} + +internal fun ReadableMap.getStringThrowing(key: String): String { + return this.getString(key) ?: throw TalsecException("Key missing in configuration: $key") +} + +internal fun ReadableMap.getBooleanSafe(key: String, defaultValue: Boolean = true): Boolean { + if (this.hasKey(key)) { + return this.getBoolean(key) + } + return defaultValue +} + +internal fun ReadableArray.toArray(): Array { + val output = mutableListOf() + for (i in 0 until this.size()) { + // in RN versions < 0.63, getString is nullable + @Suppress("UNNECESSARY_SAFE_CALL") + this.getString(i)?.let { + output.add(it) + } + } + return output.toTypedArray() +} + +internal fun ReadableMap.getArraySafe(key: String): Array { + if (this.hasKey(key)) { + val inputArray = this.getArray(key)!! + return inputArray.toArray() + } + return arrayOf() +} + +internal fun ReadableMap.getNestedArraySafe(key: String): Array> { + val outArray = mutableListOf>() + if (this.hasKey(key)) { + val inputArray = this.getArray(key)!! + for (i in 0 until inputArray.size()) { + outArray.add(inputArray.getArray(i).toArray()) + } + } + return outArray.toTypedArray() +} + + +/** + * Converts the Talsec's SuspiciousAppInfo to React Native equivalent + */ +internal fun SuspiciousAppInfo.toRNSuspiciousAppInfo(context: ReactContext): RNSuspiciousAppInfo { + return RNSuspiciousAppInfo( + packageInfo = this.packageInfo.toRNPackageInfo(context), + reason = this.reason, + ) +} + +/** + * Converts the Android's PackageInfo to React Native equivalent + */ +internal fun PackageInfo.toRNPackageInfo(context: ReactContext): RNPackageInfo { + return RNPackageInfo( + packageName = this.packageName, + appName = Utils.getAppName(context, this.applicationInfo), + version = this.versionName, + appIcon = Utils.getAppIconAsBase64String(context, this.packageName), + installerStore = Utils.getInstallationSource(context, this.packageName) + ) +} + +/** + * Convert the Talsec's SuspiciousAppInfo to base64-encoded json array, + * which can be then sent to React Native + */ +internal fun MutableList.toEncodedWritableArray(context: ReactContext): WritableArray { + val output = Arguments.createArray() + this.forEach { suspiciousAppInfo -> + val rnSuspiciousAppInfo = suspiciousAppInfo.toRNSuspiciousAppInfo(context) + try { + val encodedAppInfo = + Base64.encodeToString( + Json.encodeToString(rnSuspiciousAppInfo).toByteArray(), + Base64.DEFAULT + ) + output.pushString(encodedAppInfo) + } catch (e: Exception) { + Log.e("Talsec", "Could not serialize suspicious app data: ${e.message}") + } + + } + return output +} + diff --git a/android/src/main/java/com/freeraspreactnative/utils/Utils.kt b/android/src/main/java/com/freeraspreactnative/utils/Utils.kt new file mode 100644 index 0000000..9627ff9 --- /dev/null +++ b/android/src/main/java/com/freeraspreactnative/utils/Utils.kt @@ -0,0 +1,79 @@ +package com.freeraspreactnative.utils + +import android.content.pm.ApplicationInfo +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.util.Base64 +import com.facebook.react.bridge.ReactContext +import java.io.ByteArrayOutputStream + + +internal object Utils { + + private fun compressBitmap(bitmap: Bitmap): String { + val byteArrayOutputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 10, byteArrayOutputStream) + val byteArray = byteArrayOutputStream.toByteArray() + return Base64.encodeToString(byteArray, Base64.DEFAULT) + } + + /** + * Retrieves human-readable application name + */ + internal fun getAppName(context: ReactContext, applicationInfo: ApplicationInfo): String { + return context.packageManager.getApplicationLabel(applicationInfo) as String + } + + /** + * Retrieves app icon for the given package name as Drawable, transforms it to Bitmap, + * compresses to PNG and finally encodes the data to Base64 + * @param context React Native context + * @param packageName package name for which icon should be retrieved + * @return Base-64 encoded string + */ + internal fun getAppIconAsBase64String(context: ReactContext, packageName: String): String? { + try { + val drawable = context.packageManager.getApplicationIcon(packageName) + + if (drawable is BitmapDrawable && drawable.bitmap != null) { + return compressBitmap(drawable.bitmap) + } + + if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) { + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return compressBitmap(bitmap) + } + return null + } catch (e: Exception) { + return null + } + } + + /** + * Retrieves installation source for the given package name + * @param context React Native context + * @param packageName package name for which installation source should be retrieved + * @return Installation source package name + */ + @Suppress("DEPRECATION") + internal fun getInstallationSource(context: ReactContext, packageName: String): String? { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + context.packageManager.getInstallerPackageName(packageName) + } + } catch (e: Exception) { + null + } + } +} diff --git a/example/assets/arrow-down.png b/example/assets/arrow-down.png new file mode 100644 index 0000000..b24bb77 Binary files /dev/null and b/example/assets/arrow-down.png differ diff --git a/example/assets/arrow-up.png b/example/assets/arrow-up.png new file mode 100644 index 0000000..cd785b0 Binary files /dev/null and b/example/assets/arrow-up.png differ diff --git a/example/src/App.tsx b/example/src/App.tsx index 42b2092..c2bc891 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,21 +1,45 @@ import * as React from 'react'; import { Platform } from 'react-native'; -import { useFreeRasp } from 'freerasp-react-native'; +import { + addToWhitelist, + useFreeRasp, + type SuspiciousAppInfo, +} from 'freerasp-react-native'; import { DemoApp } from './DemoApp'; import { commonChecks, iosChecks, androidChecks } from './checks'; +import { useEffect } from 'react'; const App = () => { const [appChecks, setAppChecks] = React.useState([ ...commonChecks, ...(Platform.OS === 'ios' ? iosChecks : androidChecks), ]); + const [suspiciousApps, setSuspiciousApps] = React.useState< + SuspiciousAppInfo[] + >([]); + + useEffect(() => { + (async () => { + Platform.OS === 'android' && (await addItemsToMalwareWhitelist()); + })(); + }, []); const config = { androidConfig: { packageName: 'com.freeraspreactnativeexample', certificateHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], // supportedAlternativeStores: ['storeOne', 'storeTwo'], + malware: { + blocklistedHashes: ['FgvSehLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0u'], + blocklistedPackageNames: ['com.wultra.app.screenlogger'], + blocklistedPermissions: [ + ['android.permission.BLUETOOTH', 'android.permission.INTERNET'], + ['android.permission.INTERNET'], + ['android.permission.BATTERY_STATS'], + ], + whitelistedInstallationSources: ['com.apkpure.aegon'], + }, }, iosConfig: { appBundleId: 'com.freeraspreactnativeexample', @@ -144,11 +168,37 @@ const App = () => { ) ); }, + // Android only + malware: (detectedApps: SuspiciousAppInfo[]) => { + setSuspiciousApps(detectedApps); + setAppChecks((currentState) => + currentState.map((threat) => + threat.name === 'Malware' ? { ...threat, status: 'nok' } : threat + ) + ); + }, + }; + + const addItemsToMalwareWhitelist = async () => { + const appsToWhitelist = [ + 'com.talsecreactnativesecuritypluginexample', + 'com.example.myApp', + ]; + appsToWhitelist.forEach(async (app) => { + try { + const whitelistResponse = await addToWhitelist(app); + console.info( + `Malware Whitelist response for ${app}: ${whitelistResponse}` + ); + } catch (error: any) { + console.info('Error while adding app to malware whitelist: ', error); + } + }); }; useFreeRasp(config, actions); - return ; + return ; }; export default App; diff --git a/example/src/DemoApp.tsx b/example/src/DemoApp.tsx index f23a16b..8dd4578 100644 --- a/example/src/DemoApp.tsx +++ b/example/src/DemoApp.tsx @@ -6,8 +6,16 @@ import CloseCircle from '../assets/close-circle-outline.png'; import TalsecLogo from '../assets/talsec-logo.png'; import { Image } from 'react-native'; import { Colors } from './styles'; +import { MalwareModal } from './MalwareModal'; +import type { SuspiciousAppInfo } from 'freerasp-react-native'; -export const DemoApp = (props: any) => { +export const DemoApp: React.FC<{ + checks: { + name: string; + status: string; + }[]; + suspiciousApps: SuspiciousAppInfo[]; +}> = ({ checks, suspiciousApps }) => { return ( <> { > freeRASP checks: - {props.checks.map((check: any, idx: number) => ( + {checks.map((check: any, idx: number) => ( { > {check.name} + {check.name === 'Malware' && ( + + )} {check.status === 'ok' ? ( = ({ app }) => { + const [expanded, setExpanded] = useState(false); + + const appUninstall = async () => { + alert('Implement yourself!'); + }; + + const whitelistApp = async (packageName: string) => { + try { + const whitelistResponse = await addToWhitelist(packageName); + console.info( + `Malware Whitelist response for ${app}: ${whitelistResponse}` + ); + alert('Restart app for whitelist to take effect'); + } catch (error: any) { + console.info('Error while adding app to malware whitelist: ', error); + } + }; + + return ( + + setExpanded(!expanded)}> + + + + + {app.packageInfo.appName} + + + + + + + + + {expanded && ( + <> + + Package name: + {app.packageInfo.packageName} + App name: + + {app.packageInfo.appName ?? 'Not specified'} + + App version: + + {app.packageInfo.version ?? 'Not specified'} + + App Icon: + {app.packageInfo.appIcon ? ( + + ) : ( + Not specified + )} + Installer store: + + {app.packageInfo.installerStore ?? 'Not specified'} + + Detection reason: + {app.reason} + + + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + button: { + borderRadius: 20, + paddingHorizontal: 30, + paddingVertical: 10, + marginTop: 15, + elevation: 2, + }, + buttonOpen: { + backgroundColor: '#F194FF', + }, + buttonClose: { + backgroundColor: '#2196F3', + }, + item: { + backgroundColor: '#d4e4ff', + borderRadius: 20, + padding: 20, + marginVertical: 8, + }, + listItemTitle: { + fontSize: 20, + fontWeight: 'bold', + }, + listItem: { + fontSize: 16, + paddingBottom: 5, + }, + titleText: { + fontSize: 20, + }, + icon: { + marginTop: 5, + marginBottom: 5, + width: 50, + height: 50, + }, + iconSmall: { + width: 40, + height: 40, + }, + textView: { + justifyContent: 'center', + flex: 1, + marginLeft: 20, + marginRight: 30, + }, + buttonView: { + justifyContent: 'center', + }, + spacer: { + height: 15, + }, + buttonGroup: { + marginTop: 10, + justifyContent: 'space-between', + }, +}); diff --git a/example/src/MalwareModal.tsx b/example/src/MalwareModal.tsx new file mode 100644 index 0000000..4af0d4d --- /dev/null +++ b/example/src/MalwareModal.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { + Alert, + FlatList, + Modal, + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, + Button, +} from 'react-native'; +import type { SuspiciousAppInfo } from 'freerasp-react-native'; +import { MalwareItem } from './MalwareItem'; + +export const MalwareModal: React.FC<{ + isDisabled: boolean; + suspiciousApps: SuspiciousAppInfo[]; +}> = ({ isDisabled, suspiciousApps }) => { + const [modalVisible, setModalVisible] = useState(false); + + return ( + <> + {!isDisabled && ( + <> + +