mirror of
https://github.com/xtclovver/RKNHardering.git
synced 2026-07-09 17:19:25 +00:00
feat: HUSI like scan
This commit is contained in:
parent
c0e194cb68
commit
e43c0f04ed
20 changed files with 870 additions and 64 deletions
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
|
|
@ -1,4 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.notcvnt.rknhardering.model.IpConsensusResult
|
|||
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
|
||||
import com.notcvnt.rknhardering.model.LocalProxyOwner
|
||||
import com.notcvnt.rknhardering.model.MatchedVpnApp
|
||||
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
|
||||
import com.notcvnt.rknhardering.probe.ProxyEndpoint
|
||||
import com.notcvnt.rknhardering.probe.XrayApiScanResult
|
||||
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
|
||||
|
|
@ -192,6 +193,7 @@ internal object CheckResultJsonExportFormatter {
|
|||
put("source", app.source.name)
|
||||
put("active", app.active)
|
||||
put("confidence", app.confidence.name)
|
||||
app.technicalMetadata?.let { put("technicalMetadata", technicalMetadataToJson(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +205,20 @@ internal object CheckResultJsonExportFormatter {
|
|||
put("kind", app.kind?.name)
|
||||
put("source", app.source.name)
|
||||
put("confidence", app.confidence.name)
|
||||
app.technicalMetadata?.let { put("technicalMetadata", technicalMetadataToJson(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun technicalMetadataToJson(metadata: VpnAppTechnicalMetadata): JSONObject {
|
||||
return JSONObject().apply {
|
||||
put("versionName", metadata.versionName)
|
||||
put("serviceNames", JSONArray().apply { metadata.serviceNames.forEach { put(it) } })
|
||||
put("appType", metadata.appType)
|
||||
put("coreType", metadata.coreType)
|
||||
put("corePath", metadata.corePath)
|
||||
put("goVersion", metadata.goVersion)
|
||||
put("systemApp", metadata.systemApp)
|
||||
put("matchedByNameHeuristic", metadata.matchedByNameHeuristic)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.notcvnt.rknhardering.model.IpConsensusResult
|
|||
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
|
||||
import com.notcvnt.rknhardering.model.MatchedVpnApp
|
||||
import com.notcvnt.rknhardering.model.Verdict
|
||||
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
|
||||
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
|
||||
|
||||
internal object CheckResultMarkdownExportFormatter {
|
||||
|
|
@ -350,6 +351,7 @@ internal object CheckResultMarkdownExportFormatter {
|
|||
add("source=${app.source}")
|
||||
add("active=${app.active}")
|
||||
add("confidence=${app.confidence}")
|
||||
addAll(formatTechnicalMetadata(app.technicalMetadata))
|
||||
}.joinToString(" | ")
|
||||
}
|
||||
|
||||
|
|
@ -361,9 +363,24 @@ internal object CheckResultMarkdownExportFormatter {
|
|||
add("kind=${app.kind ?: "<none>"}")
|
||||
add("source=${app.source}")
|
||||
add("confidence=${app.confidence}")
|
||||
addAll(formatTechnicalMetadata(app.technicalMetadata))
|
||||
}.joinToString(" | ")
|
||||
}
|
||||
|
||||
private fun formatTechnicalMetadata(metadata: VpnAppTechnicalMetadata?): List<String> {
|
||||
if (metadata == null) return emptyList()
|
||||
return buildList {
|
||||
metadata.versionName?.let { add("version=$it") }
|
||||
metadata.appType?.let { add("appType=$it") }
|
||||
metadata.coreType?.let { add("coreType=$it") }
|
||||
metadata.corePath?.let { add("corePath=$it") }
|
||||
metadata.goVersion?.let { add("goVersion=$it") }
|
||||
if (metadata.serviceNames.isNotEmpty()) add("services=${metadata.serviceNames.joinToString()}")
|
||||
if (metadata.systemApp) add("systemApp=true")
|
||||
if (metadata.matchedByNameHeuristic) add("matchedByName=true")
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCallTransportLeak(
|
||||
leak: CallTransportLeakResult,
|
||||
privacyMode: Boolean,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.notcvnt.rknhardering.model.IpComparisonResult
|
|||
import com.notcvnt.rknhardering.model.LocalProxyOwner
|
||||
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
|
||||
import com.notcvnt.rknhardering.model.MatchedVpnApp
|
||||
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
|
||||
import com.notcvnt.rknhardering.probe.NativeInterfaceProbe
|
||||
import com.notcvnt.rknhardering.probe.NativeSignsBridge
|
||||
import com.notcvnt.rknhardering.probe.XrayApiScanResult
|
||||
|
|
@ -589,6 +590,7 @@ object DebugDiagnosticsFormatter {
|
|||
add("source=${app.source}")
|
||||
add("active=${app.active}")
|
||||
add("confidence=${app.confidence}")
|
||||
addAll(formatTechnicalMetadata(app.technicalMetadata))
|
||||
}.joinToString(" ")
|
||||
}
|
||||
|
||||
|
|
@ -600,9 +602,24 @@ object DebugDiagnosticsFormatter {
|
|||
add("kind=${app.kind ?: "<none>"}")
|
||||
add("source=${app.source}")
|
||||
add("confidence=${app.confidence}")
|
||||
addAll(formatTechnicalMetadata(app.technicalMetadata))
|
||||
}.joinToString(" ")
|
||||
}
|
||||
|
||||
private fun formatTechnicalMetadata(metadata: VpnAppTechnicalMetadata?): List<String> {
|
||||
if (metadata == null) return emptyList()
|
||||
return buildList {
|
||||
metadata.versionName?.let { add("version=$it") }
|
||||
if (metadata.serviceNames.isNotEmpty()) add("services=${metadata.serviceNames.joinToString(",")}")
|
||||
metadata.appType?.let { add("appType=$it") }
|
||||
metadata.coreType?.let { add("coreType=$it") }
|
||||
metadata.corePath?.let { add("corePath=$it") }
|
||||
metadata.goVersion?.let { add("goVersion=$it") }
|
||||
add("systemApp=${metadata.systemApp}")
|
||||
add("matchedByNameHeuristic=${metadata.matchedByNameHeuristic}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCallTransportLeak(leak: CallTransportLeakResult): String {
|
||||
return buildList {
|
||||
add("service=${leak.service}")
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import com.notcvnt.rknhardering.model.StunProbeGroupResult
|
|||
import com.notcvnt.rknhardering.model.StunScope
|
||||
import com.notcvnt.rknhardering.model.TargetGroup
|
||||
import com.notcvnt.rknhardering.model.Verdict
|
||||
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
|
||||
import com.notcvnt.rknhardering.network.DnsResolverConfig
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -1852,9 +1853,10 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
if (infoSection != null && infoDivider != null) {
|
||||
val infoFindings = category.findings.filter { it.isInformational }
|
||||
val husiModelFindings = buildHusiModelFindings(category)
|
||||
val checkFindings = category.findings.filterNot { it.isInformational || it.isError }
|
||||
|
||||
bindInfoSection(infoFindings, infoSection, infoDivider, checkFindings.isNotEmpty(), privacyMode)
|
||||
bindInfoSection(infoFindings + husiModelFindings, infoSection, infoDivider, checkFindings.isNotEmpty(), privacyMode)
|
||||
findingsContainer.removeAllViews()
|
||||
for (finding in checkFindings) {
|
||||
if (finding.description.startsWith("network_mcc_ru:")) continue
|
||||
|
|
@ -1864,13 +1866,83 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
findingsContainer.removeAllViews()
|
||||
for (finding in category.findings) {
|
||||
for (finding in category.findings + buildHusiModelFindings(category)) {
|
||||
if (finding.isError) continue
|
||||
if (finding.description.startsWith("network_mcc_ru:")) continue
|
||||
findingsContainer.addView(createFindingView(finding, privacyMode))
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildHusiModelFindings(category: CategoryResult): List<Finding> {
|
||||
val findings = buildList {
|
||||
category.matchedApps.forEach { app ->
|
||||
husiModelFinding(
|
||||
label = app.appName,
|
||||
packageName = app.packageName,
|
||||
serviceName = app.technicalMetadata?.serviceNames?.firstOrNull(),
|
||||
metadata = app.technicalMetadata,
|
||||
source = app.source,
|
||||
)?.let(::add)
|
||||
}
|
||||
category.activeApps.forEach { app ->
|
||||
husiModelFinding(
|
||||
label = app.packageName ?: app.serviceName ?: "active VPN",
|
||||
packageName = app.packageName,
|
||||
serviceName = app.serviceName,
|
||||
metadata = app.technicalMetadata,
|
||||
source = app.source,
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
return findings.distinctBy { it.packageName.orEmpty() to it.description }
|
||||
}
|
||||
|
||||
private fun husiModelFinding(
|
||||
label: String,
|
||||
packageName: String?,
|
||||
serviceName: String?,
|
||||
metadata: VpnAppTechnicalMetadata?,
|
||||
source: com.notcvnt.rknhardering.model.EvidenceSource?,
|
||||
): Finding? {
|
||||
val hasMeaningfulMetadata = metadata != null || !serviceName.isNullOrBlank() || !packageName.isNullOrBlank()
|
||||
if (!hasMeaningfulMetadata) return null
|
||||
|
||||
val description = buildString {
|
||||
append("HUSI model: ")
|
||||
append(label)
|
||||
packageName?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" · package: ")
|
||||
append(it)
|
||||
}
|
||||
metadata?.versionName?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" · app version: ")
|
||||
append(it)
|
||||
}
|
||||
append(" · app type: ")
|
||||
append(metadata?.appType ?: "Other")
|
||||
append(" · core type: ")
|
||||
append(metadata?.coreType ?: "Unknown")
|
||||
metadata?.corePath?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" · core path: ")
|
||||
append(it)
|
||||
}
|
||||
metadata?.goVersion?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" · Go: ")
|
||||
append(it)
|
||||
}
|
||||
serviceName?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" · service: ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
return Finding(
|
||||
description = description,
|
||||
isInformational = true,
|
||||
source = source,
|
||||
packageName = packageName,
|
||||
)
|
||||
}
|
||||
|
||||
private fun bindInfoSection(
|
||||
infoFindings: List<Finding>,
|
||||
infoSection: LinearLayout,
|
||||
|
|
@ -1967,6 +2039,18 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
row.addView(indicator)
|
||||
row.addView(description)
|
||||
finding.packageName
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { packageName ->
|
||||
row.isClickable = true
|
||||
row.isFocusable = true
|
||||
row.setOnClickListener {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,11 +33,14 @@ import com.notcvnt.rknhardering.probe.UnderlyingNetworkProber
|
|||
import com.notcvnt.rknhardering.probe.XrayApiScanResult
|
||||
import com.notcvnt.rknhardering.probe.XrayApiScanner
|
||||
import com.notcvnt.rknhardering.vpn.VpnAppCatalog
|
||||
import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetAddress
|
||||
|
||||
object BypassChecker {
|
||||
|
|
@ -396,7 +399,7 @@ object BypassChecker {
|
|||
)
|
||||
}
|
||||
|
||||
private fun reportProxyResults(
|
||||
private suspend fun reportProxyResults(
|
||||
context: Context,
|
||||
directIp: String?,
|
||||
proxyChecks: List<LocalProxyCheckResult>,
|
||||
|
|
@ -416,7 +419,7 @@ object BypassChecker {
|
|||
}
|
||||
}
|
||||
|
||||
private fun reportProxyCheck(
|
||||
private suspend fun reportProxyCheck(
|
||||
context: Context,
|
||||
proxyCheck: LocalProxyCheckResult,
|
||||
findings: MutableList<Finding>,
|
||||
|
|
@ -426,6 +429,7 @@ object BypassChecker {
|
|||
val proxyEndpoint = proxyCheck.endpoint
|
||||
val candidateFamilies = VpnAppCatalog.familiesForPort(proxyEndpoint.port)
|
||||
val familySuffix = candidateFamilies.takeIf { it.isNotEmpty() }?.joinToString()
|
||||
val ownerMetadataSuffix = formatProxyOwnerMetadataSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus)
|
||||
val description = buildString {
|
||||
append(
|
||||
context.getString(
|
||||
|
|
@ -440,6 +444,7 @@ object BypassChecker {
|
|||
append("]")
|
||||
}
|
||||
append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus))
|
||||
append(ownerMetadataSuffix)
|
||||
if (proxyCheck.status != LocalProxyCheckStatus.CONFIRMED_BYPASS) {
|
||||
append(context.getString(R.string.checker_bypass_open_proxy_review_suffix))
|
||||
}
|
||||
|
|
@ -463,6 +468,7 @@ object BypassChecker {
|
|||
description = buildString {
|
||||
append("Detected open ${proxyEndpoint.type.name} proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)}")
|
||||
append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus))
|
||||
append(ownerMetadataSuffix)
|
||||
},
|
||||
family = familySuffix,
|
||||
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
|
||||
|
|
@ -915,6 +921,19 @@ object BypassChecker {
|
|||
return context.getString(R.string.checker_proxy_owner_suffix, ownerText)
|
||||
}
|
||||
|
||||
private suspend fun formatProxyOwnerMetadataSuffix(
|
||||
context: Context,
|
||||
owner: LocalProxyOwner?,
|
||||
status: LocalProxyOwnerStatus,
|
||||
): String {
|
||||
if (status != LocalProxyOwnerStatus.RESOLVED) return ""
|
||||
val packageName = LocalProxyOwnerFormatter.packageName(owner) ?: return ""
|
||||
val metadata = withContext(Dispatchers.IO) {
|
||||
VpnAppMetadataScanner.scan(context, packageName)
|
||||
}
|
||||
return VpnAppMetadataScanner.formatMetadataSuffix(metadata)
|
||||
}
|
||||
|
||||
private fun normalizeHost(host: String): String = host.substringBefore('%').lowercase()
|
||||
|
||||
private fun isAnyAddress(host: String): Boolean = host == "0.0.0.0" || host == "::" || host == ":::"
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ import com.notcvnt.rknhardering.model.EvidenceSource
|
|||
import com.notcvnt.rknhardering.model.Finding
|
||||
import com.notcvnt.rknhardering.network.DnsResolverConfig
|
||||
import com.notcvnt.rknhardering.network.NetworkInterfaceNameNormalizer
|
||||
import com.notcvnt.rknhardering.probe.LocalSocketInspector
|
||||
import com.notcvnt.rknhardering.probe.LocalSocketListener
|
||||
import com.notcvnt.rknhardering.vpn.VpnAppCatalog
|
||||
import com.notcvnt.rknhardering.vpn.VpnClientSignal
|
||||
import com.notcvnt.rknhardering.vpn.VpnDumpsysParser
|
||||
import com.notcvnt.rknhardering.probe.LocalSocketInspector
|
||||
import com.notcvnt.rknhardering.probe.LocalSocketListener
|
||||
import com.notcvnt.rknhardering.vpn.VpnAppCatalog
|
||||
import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner
|
||||
import com.notcvnt.rknhardering.vpn.VpnClientSignal
|
||||
import com.notcvnt.rknhardering.vpn.VpnDumpsysParser
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.FileReader
|
||||
|
|
@ -1129,20 +1130,28 @@ object IndirectSignsChecker {
|
|||
return SignalOutcome()
|
||||
}
|
||||
|
||||
var detected = false
|
||||
var needsReview = false
|
||||
for (record in records) {
|
||||
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
|
||||
val confidence = when {
|
||||
signature != null -> EvidenceConfidence.HIGH
|
||||
record.packageName != null -> EvidenceConfidence.MEDIUM
|
||||
else -> EvidenceConfidence.LOW
|
||||
}
|
||||
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
|
||||
val description = buildString {
|
||||
append(context.getString(R.string.checker_indirect_dumpsys_vpn_line, record.rawLine))
|
||||
append(familySuffix)
|
||||
}
|
||||
var detected = false
|
||||
var needsReview = false
|
||||
for (record in records) {
|
||||
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
|
||||
val metadata = VpnAppMetadataScanner.scan(
|
||||
context = context,
|
||||
packageName = record.packageName,
|
||||
serviceNames = listOfNotNull(record.serviceName),
|
||||
)
|
||||
val appLabel = VpnAppMetadataScanner.resolveAppLabel(context, record.packageName)
|
||||
val confidence = when {
|
||||
signature != null -> EvidenceConfidence.HIGH
|
||||
record.packageName != null -> EvidenceConfidence.MEDIUM
|
||||
else -> EvidenceConfidence.LOW
|
||||
}
|
||||
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
|
||||
val description = buildString {
|
||||
append(context.getString(R.string.checker_indirect_dumpsys_vpn_line, record.rawLine))
|
||||
appLabel?.let { append(" ($it)") }
|
||||
append(familySuffix)
|
||||
append(VpnAppMetadataScanner.formatMetadataSuffix(metadata))
|
||||
}
|
||||
findings.add(
|
||||
Finding(
|
||||
description = description,
|
||||
|
|
@ -1158,13 +1167,13 @@ object IndirectSignsChecker {
|
|||
source = EvidenceSource.ACTIVE_VPN,
|
||||
detected = true,
|
||||
confidence = confidence,
|
||||
description = record.rawLine,
|
||||
family = signature?.family,
|
||||
packageName = record.packageName,
|
||||
kind = signature?.kind,
|
||||
),
|
||||
)
|
||||
activeApps.add(
|
||||
description = record.rawLine,
|
||||
family = signature?.family,
|
||||
packageName = record.packageName,
|
||||
kind = signature?.kind,
|
||||
),
|
||||
)
|
||||
activeApps.add(
|
||||
ActiveVpnApp(
|
||||
packageName = record.packageName,
|
||||
serviceName = record.serviceName,
|
||||
|
|
@ -1172,8 +1181,9 @@ object IndirectSignsChecker {
|
|||
kind = signature?.kind,
|
||||
source = EvidenceSource.ACTIVE_VPN,
|
||||
confidence = confidence,
|
||||
),
|
||||
)
|
||||
technicalMetadata = metadata,
|
||||
),
|
||||
)
|
||||
detected = true
|
||||
needsReview = needsReview || signature == null
|
||||
}
|
||||
|
|
@ -1207,13 +1217,19 @@ object IndirectSignsChecker {
|
|||
return SignalOutcome()
|
||||
}
|
||||
|
||||
var detected = false
|
||||
var needsReview = false
|
||||
for (record in records) {
|
||||
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
|
||||
val confidence = when {
|
||||
signature != null -> EvidenceConfidence.HIGH
|
||||
record.packageName != null -> EvidenceConfidence.MEDIUM
|
||||
var detected = false
|
||||
var needsReview = false
|
||||
for (record in records) {
|
||||
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
|
||||
val metadata = VpnAppMetadataScanner.scan(
|
||||
context = context,
|
||||
packageName = record.packageName,
|
||||
serviceNames = listOfNotNull(record.serviceName),
|
||||
)
|
||||
val appLabel = VpnAppMetadataScanner.resolveAppLabel(context, record.packageName)
|
||||
val confidence = when {
|
||||
signature != null -> EvidenceConfidence.HIGH
|
||||
record.packageName != null -> EvidenceConfidence.MEDIUM
|
||||
else -> EvidenceConfidence.LOW
|
||||
}
|
||||
val serviceDisplay = if (record.packageName != null && record.serviceName != null) {
|
||||
|
|
@ -1221,11 +1237,13 @@ object IndirectSignsChecker {
|
|||
} else {
|
||||
record.rawLine
|
||||
}
|
||||
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
|
||||
val description = buildString {
|
||||
append(context.getString(R.string.checker_indirect_dumpsys_service_active, serviceDisplay))
|
||||
append(familySuffix)
|
||||
}
|
||||
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
|
||||
val description = buildString {
|
||||
append(context.getString(R.string.checker_indirect_dumpsys_service_active, serviceDisplay))
|
||||
appLabel?.let { append(" ($it)") }
|
||||
append(familySuffix)
|
||||
append(VpnAppMetadataScanner.formatMetadataSuffix(metadata))
|
||||
}
|
||||
findings.add(
|
||||
Finding(
|
||||
description = description,
|
||||
|
|
@ -1241,22 +1259,23 @@ object IndirectSignsChecker {
|
|||
source = EvidenceSource.ACTIVE_VPN,
|
||||
detected = true,
|
||||
confidence = confidence,
|
||||
description = serviceDisplay,
|
||||
family = signature?.family,
|
||||
packageName = record.packageName,
|
||||
kind = signature?.kind,
|
||||
),
|
||||
)
|
||||
activeApps.add(
|
||||
description = serviceDisplay,
|
||||
family = signature?.family,
|
||||
packageName = record.packageName,
|
||||
kind = signature?.kind,
|
||||
),
|
||||
)
|
||||
activeApps.add(
|
||||
ActiveVpnApp(
|
||||
packageName = record.packageName,
|
||||
serviceName = record.serviceName,
|
||||
family = signature?.family,
|
||||
kind = signature?.kind,
|
||||
source = EvidenceSource.ACTIVE_VPN,
|
||||
confidence = confidence,
|
||||
),
|
||||
)
|
||||
kind = signature?.kind,
|
||||
source = EvidenceSource.ACTIVE_VPN,
|
||||
confidence = confidence,
|
||||
technicalMetadata = metadata,
|
||||
),
|
||||
)
|
||||
detected = true
|
||||
needsReview = needsReview || signature == null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ object VpnCheckRunner {
|
|||
}
|
||||
} else null
|
||||
|
||||
val directDeferred = safeAsync(fallback = { Fallbacks.direct(context, it) }) {
|
||||
val directDeferred = safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.direct(context, it) }) {
|
||||
dependencies.directCheck(
|
||||
context,
|
||||
tunActiveProbeDeferred?.await(),
|
||||
|
|
|
|||
|
|
@ -129,6 +129,17 @@ enum class VpnAppKind {
|
|||
GENERIC_VPN,
|
||||
}
|
||||
|
||||
data class VpnAppTechnicalMetadata(
|
||||
val versionName: String? = null,
|
||||
val serviceNames: List<String> = emptyList(),
|
||||
val appType: String? = null,
|
||||
val coreType: String? = null,
|
||||
val corePath: String? = null,
|
||||
val goVersion: String? = null,
|
||||
val systemApp: Boolean = false,
|
||||
val matchedByNameHeuristic: Boolean = false,
|
||||
)
|
||||
|
||||
data class Finding(
|
||||
val description: String,
|
||||
val detected: Boolean = false,
|
||||
|
|
@ -159,6 +170,7 @@ data class MatchedVpnApp(
|
|||
val source: EvidenceSource,
|
||||
val active: Boolean,
|
||||
val confidence: EvidenceConfidence,
|
||||
val technicalMetadata: VpnAppTechnicalMetadata? = null,
|
||||
)
|
||||
|
||||
data class ActiveVpnApp(
|
||||
|
|
@ -168,6 +180,7 @@ data class ActiveVpnApp(
|
|||
val kind: VpnAppKind?,
|
||||
val source: EvidenceSource,
|
||||
val confidence: EvidenceConfidence,
|
||||
val technicalMetadata: VpnAppTechnicalMetadata? = null,
|
||||
)
|
||||
|
||||
data class LocalProxyOwner(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.notcvnt.rknhardering.vpn
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
|
|
@ -22,7 +23,6 @@ data class InstalledVpnDetectionResult(
|
|||
)
|
||||
|
||||
object InstalledVpnAppDetector {
|
||||
private const val TAG = "VpnAppDetector"
|
||||
|
||||
fun detect(context: Context): InstalledVpnDetectionResult {
|
||||
val pm = context.packageManager
|
||||
|
|
@ -60,6 +60,7 @@ object InstalledVpnAppDetector {
|
|||
for (signature in VpnAppCatalog.signatures) {
|
||||
if (!isPackageInstalled(pm, signature.packageName)) continue
|
||||
|
||||
val metadata = VpnAppMetadataScanner.scan(context, signature.packageName)
|
||||
val confidence = when (signature.kind) {
|
||||
VpnAppKind.TARGETED_BYPASS -> EvidenceConfidence.MEDIUM
|
||||
VpnAppKind.GENERIC_VPN -> EvidenceConfidence.LOW
|
||||
|
|
@ -68,7 +69,7 @@ object InstalledVpnAppDetector {
|
|||
R.string.checker_vpn_installed_app,
|
||||
signature.appName,
|
||||
signature.packageName,
|
||||
)
|
||||
) + VpnAppMetadataScanner.formatMetadataSuffix(metadata)
|
||||
|
||||
findings.add(
|
||||
Finding(
|
||||
|
|
@ -101,6 +102,7 @@ object InstalledVpnAppDetector {
|
|||
source = EvidenceSource.INSTALLED_APP,
|
||||
active = false,
|
||||
confidence = confidence,
|
||||
technicalMetadata = metadata,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -115,6 +117,11 @@ object InstalledVpnAppDetector {
|
|||
) {
|
||||
for ((packageName, serviceNames) in queryVpnServiceProviders(pm)) {
|
||||
val signature = VpnAppCatalog.findByPackageName(packageName)
|
||||
val metadata = VpnAppMetadataScanner.scan(
|
||||
context = context,
|
||||
packageName = packageName,
|
||||
serviceNames = serviceNames,
|
||||
)
|
||||
val appName = signature?.appName ?: resolveAppName(pm, packageName)
|
||||
val family = signature?.family
|
||||
val kind = signature?.kind ?: VpnAppKind.GENERIC_VPN
|
||||
|
|
@ -135,6 +142,7 @@ object InstalledVpnAppDetector {
|
|||
append(" -> ")
|
||||
append(serviceSuffix)
|
||||
}
|
||||
append(VpnAppMetadataScanner.formatMetadataSuffix(metadata))
|
||||
}
|
||||
|
||||
findings.add(
|
||||
|
|
@ -167,6 +175,7 @@ object InstalledVpnAppDetector {
|
|||
source = EvidenceSource.VPN_SERVICE_DECLARATION,
|
||||
active = false,
|
||||
confidence = confidence,
|
||||
technicalMetadata = metadata,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -197,12 +206,17 @@ object InstalledVpnAppDetector {
|
|||
val normalizedAppName = appName.uppercase(Locale.ROOT)
|
||||
if (!normalizedAppName.contains("VPN")) continue
|
||||
|
||||
val metadata = VpnAppMetadataScanner.scan(
|
||||
context = context,
|
||||
packageName = packageName,
|
||||
matchedByNameHeuristic = true,
|
||||
)
|
||||
val confidence = EvidenceConfidence.LOW
|
||||
val description = context.getString(
|
||||
R.string.checker_vpn_installed_app_by_name,
|
||||
appName,
|
||||
packageName,
|
||||
)
|
||||
) + VpnAppMetadataScanner.formatMetadataSuffix(metadata)
|
||||
|
||||
findings.add(
|
||||
Finding(
|
||||
|
|
@ -233,6 +247,7 @@ object InstalledVpnAppDetector {
|
|||
source = EvidenceSource.INSTALLED_APP,
|
||||
active = false,
|
||||
confidence = confidence,
|
||||
technicalMetadata = metadata,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -281,9 +296,13 @@ object InstalledVpnAppDetector {
|
|||
}
|
||||
|
||||
return resolveInfos
|
||||
.asSequence()
|
||||
.mapNotNull { resolveInfo ->
|
||||
val serviceInfo = resolveInfo.serviceInfo ?: return@mapNotNull null
|
||||
serviceInfo.packageName to serviceInfo.name
|
||||
if (serviceInfo.permission != Manifest.permission.BIND_VPN_SERVICE) return@mapNotNull null
|
||||
val packageName = serviceInfo.packageName?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val serviceName = serviceInfo.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
packageName to serviceName
|
||||
}
|
||||
.groupBy(keySelector = { it.first }, valueTransform = { it.second })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package com.notcvnt.rknhardering.vpn
|
||||
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
data class VpnApkInspectionResult(
|
||||
val appType: String? = null,
|
||||
val coreType: String? = null,
|
||||
val corePath: String? = null,
|
||||
val goVersion: String? = null,
|
||||
) {
|
||||
val isEmpty: Boolean
|
||||
get() = appType == null && coreType == null && corePath == null && goVersion == null
|
||||
}
|
||||
|
||||
object VpnApkInspector {
|
||||
private const val MAX_DEX_BYTES = 15L * 1024L * 1024L
|
||||
private const val MAX_NATIVE_LIB_BYTES = 64L * 1024L * 1024L
|
||||
|
||||
fun inspect(apkPaths: List<String>): VpnApkInspectionResult {
|
||||
var appType: String? = null
|
||||
var core: VpnCoreInspectionResult? = null
|
||||
|
||||
for (path in apkPaths.filter { it.isNotBlank() }.distinct()) {
|
||||
val file = File(path)
|
||||
if (!file.isFile) continue
|
||||
|
||||
runCatching {
|
||||
ZipFile(file).use { zip ->
|
||||
val entries = zip.entries().asSequence().filterNot(ZipEntry::isDirectory).toList()
|
||||
if (appType == null) {
|
||||
appType = inspectAppType(zip, entries)
|
||||
}
|
||||
if (core == null) {
|
||||
core = inspectCoreType(zip, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (appType != null && core != null) break
|
||||
}
|
||||
|
||||
return VpnApkInspectionResult(
|
||||
appType = appType,
|
||||
coreType = core?.coreType,
|
||||
corePath = core?.corePath,
|
||||
goVersion = core?.goVersion,
|
||||
)
|
||||
}
|
||||
|
||||
private fun inspectAppType(zip: ZipFile, entries: List<ZipEntry>): String? {
|
||||
var fallbackType: String? = null
|
||||
for (entry in entries) {
|
||||
if (!entry.name.startsWith("classes") || !entry.name.endsWith(".dex")) continue
|
||||
if (entry.size < 0L || entry.size > MAX_DEX_BYTES) continue
|
||||
val content = runCatching { zip.getInputStream(entry).use { it.readBytes() } }.getOrNull() ?: continue
|
||||
val appType = VpnAppClassifier.detectAppType(content) ?: continue
|
||||
if (appType == VpnAppClassifier.APP_TYPE_SHADOWSOCKS_ANDROID) {
|
||||
fallbackType = appType
|
||||
} else {
|
||||
return appType
|
||||
}
|
||||
}
|
||||
return fallbackType
|
||||
}
|
||||
|
||||
private fun inspectCoreType(zip: ZipFile, entries: List<ZipEntry>): VpnCoreInspectionResult? {
|
||||
for (entry in entries) {
|
||||
if (!entry.name.startsWith("lib/") || !entry.name.endsWith(".so")) continue
|
||||
if (entry.size < 0L || entry.size > MAX_NATIVE_LIB_BYTES) continue
|
||||
val content = runCatching { zip.getInputStream(entry).use { it.readBytes() } }.getOrNull() ?: continue
|
||||
VpnAppClassifier.detectCore(content, entry.name)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package com.notcvnt.rknhardering.vpn
|
||||
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
data class VpnCoreInspectionResult(
|
||||
val coreType: String,
|
||||
val corePath: String? = null,
|
||||
val goVersion: String? = null,
|
||||
)
|
||||
|
||||
object VpnAppClassifier {
|
||||
const val APP_TYPE_V2RAYNG = "V2RayNG"
|
||||
const val APP_TYPE_CLASH_FOR_ANDROID = "ClashForAndroid"
|
||||
const val APP_TYPE_SING_BOX = "sing-box"
|
||||
const val APP_TYPE_SAGERNET = "SagerNet"
|
||||
const val APP_TYPE_SHADOWSOCKS_ANDROID = "shadowsocks-android"
|
||||
|
||||
const val CORE_TYPE_SING_BOX = "sing-box"
|
||||
const val CORE_TYPE_XRAY_V2RAY = "Xray/V2Ray"
|
||||
const val CORE_TYPE_CLASH_MIHOMO = "Clash/Mihomo"
|
||||
const val CORE_TYPE_WIREGUARD = "WireGuard"
|
||||
const val CORE_TYPE_OPENVPN = "OpenVPN"
|
||||
const val CORE_TYPE_SHADOWSOCKS = "Shadowsocks"
|
||||
|
||||
private val appTypeMarkers = listOf(
|
||||
APP_TYPE_V2RAYNG to listOf(
|
||||
"com.v2ray.ang",
|
||||
".dto.V2rayConfig",
|
||||
".service.V2RayVpnService",
|
||||
),
|
||||
APP_TYPE_CLASH_FOR_ANDROID to listOf(
|
||||
"com.github.kr328.clash",
|
||||
".core.Clash",
|
||||
".service.TunService",
|
||||
),
|
||||
APP_TYPE_SING_BOX to listOf("io.nekohasekai.sfa"),
|
||||
APP_TYPE_SAGERNET to listOf(
|
||||
"io.nekohasekai.sagernet",
|
||||
".fmt.ConfigBuilder",
|
||||
),
|
||||
APP_TYPE_SHADOWSOCKS_ANDROID to listOf(
|
||||
"com.github.shadowsocks",
|
||||
".bg.VpnService",
|
||||
"GuardedProcessPool",
|
||||
),
|
||||
)
|
||||
|
||||
private val coreTypeMarkers = listOf(
|
||||
CORE_TYPE_SING_BOX to listOf("sing-box", "singbox"),
|
||||
CORE_TYPE_XRAY_V2RAY to listOf("xray", "v2ray", "v2fly"),
|
||||
CORE_TYPE_CLASH_MIHOMO to listOf("mihomo", "clash"),
|
||||
CORE_TYPE_WIREGUARD to listOf("wireguard"),
|
||||
CORE_TYPE_OPENVPN to listOf("openvpn", "ovpn"),
|
||||
CORE_TYPE_SHADOWSOCKS to listOf("shadowsocks"),
|
||||
)
|
||||
|
||||
fun detectAppType(content: ByteArray): String? {
|
||||
return detectFirst(content, appTypeMarkers, ignoreCase = false)
|
||||
}
|
||||
|
||||
fun detectCore(content: ByteArray, entryName: String? = null): VpnCoreInspectionResult? {
|
||||
val searchContent = if (entryName.isNullOrBlank()) {
|
||||
content
|
||||
} else {
|
||||
entryName.encodeToByteArray() + byteArrayOf(0) + content
|
||||
}
|
||||
val coreType = detectFirst(searchContent, coreTypeMarkers, ignoreCase = true) ?: return null
|
||||
return VpnCoreInspectionResult(
|
||||
coreType = coreType,
|
||||
corePath = entryName,
|
||||
goVersion = findGoVersion(content),
|
||||
)
|
||||
}
|
||||
|
||||
fun findGoVersion(content: ByteArray): String? {
|
||||
val text = content.toString(StandardCharsets.ISO_8859_1)
|
||||
return Regex("""go1(?:\.\d+){1,2}""").find(text)?.value
|
||||
}
|
||||
|
||||
private fun detectFirst(
|
||||
content: ByteArray,
|
||||
typedMarkers: List<Pair<String, List<String>>>,
|
||||
ignoreCase: Boolean,
|
||||
): String? {
|
||||
val text = content.toString(StandardCharsets.ISO_8859_1)
|
||||
val normalizedDexText = text.replace('/', '.').replace('$', '.')
|
||||
return typedMarkers.firstOrNull { (_, markers) ->
|
||||
markers.any { marker ->
|
||||
text.contains(marker, ignoreCase = ignoreCase) ||
|
||||
normalizedDexText.contains(marker, ignoreCase = ignoreCase)
|
||||
}
|
||||
}?.first
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
package com.notcvnt.rknhardering.vpn
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
|
||||
|
||||
object VpnAppMetadataScanner {
|
||||
|
||||
fun scan(
|
||||
context: Context,
|
||||
packageName: String?,
|
||||
serviceNames: List<String> = emptyList(),
|
||||
matchedByNameHeuristic: Boolean = false,
|
||||
): VpnAppTechnicalMetadata? {
|
||||
if (packageName.isNullOrBlank()) {
|
||||
return serviceNames
|
||||
.normalizeServiceNames()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { VpnAppTechnicalMetadata(serviceNames = it, matchedByNameHeuristic = matchedByNameHeuristic) }
|
||||
}
|
||||
|
||||
val pm = context.packageManager
|
||||
val packageInfo = getPackageInfo(pm, packageName)
|
||||
val appInfo = packageInfo?.applicationInfo
|
||||
val apkInspection = appInfo
|
||||
?.let(::apkPaths)
|
||||
?.let(VpnApkInspector::inspect)
|
||||
?.takeUnless { it.isEmpty }
|
||||
|
||||
if (packageInfo == null && apkInspection == null && serviceNames.isEmpty() && !matchedByNameHeuristic) {
|
||||
return null
|
||||
}
|
||||
|
||||
return VpnAppTechnicalMetadata(
|
||||
versionName = packageInfo?.versionName?.takeIf { it.isNotBlank() },
|
||||
serviceNames = (serviceNames + packageInfo.vpnServiceNames()).normalizeServiceNames(),
|
||||
appType = apkInspection?.appType,
|
||||
coreType = apkInspection?.coreType,
|
||||
corePath = apkInspection?.corePath,
|
||||
goVersion = apkInspection?.goVersion,
|
||||
systemApp = appInfo?.isSystemApp() == true,
|
||||
matchedByNameHeuristic = matchedByNameHeuristic,
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveAppLabel(context: Context, packageName: String?): String? {
|
||||
if (packageName.isNullOrBlank()) return null
|
||||
val pm = context.packageManager
|
||||
return runCatching {
|
||||
val appInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0L))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getApplicationInfo(packageName, 0)
|
||||
}
|
||||
pm.getApplicationLabel(appInfo)?.toString()?.trim()?.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun formatMetadataSuffix(metadata: VpnAppTechnicalMetadata?): String {
|
||||
val details = metadataDetails(metadata)
|
||||
return if (details.isEmpty()) "" else " [${details.joinToString(", ")}]"
|
||||
}
|
||||
|
||||
fun metadataDetails(metadata: VpnAppTechnicalMetadata?): List<String> {
|
||||
if (metadata == null) return emptyList()
|
||||
return buildList {
|
||||
metadata.versionName?.let { add("version=$it") }
|
||||
metadata.appType?.let { add("app=$it") }
|
||||
metadata.coreType?.let { add("core=$it") }
|
||||
metadata.corePath?.let { add("path=$it") }
|
||||
metadata.goVersion?.let { add("go=$it") }
|
||||
if (metadata.serviceNames.isNotEmpty()) {
|
||||
add("services=${metadata.serviceNames.take(3).joinToString()}")
|
||||
}
|
||||
if (metadata.matchedByNameHeuristic) add("nameHeuristic=true")
|
||||
if (metadata.systemApp) add("systemApp=true")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPackageInfo(pm: PackageManager, packageName: String): PackageInfo? {
|
||||
return runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_SERVICES.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getPackageInfo(packageName, PackageManager.GET_SERVICES)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun PackageInfo?.vpnServiceNames(): List<String> {
|
||||
return this
|
||||
?.services
|
||||
?.filter { it.permission == Manifest.permission.BIND_VPN_SERVICE }
|
||||
?.mapNotNull { it.name?.trim()?.takeIf(String::isNotEmpty) }
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun apkPaths(appInfo: ApplicationInfo): List<String> {
|
||||
return buildList {
|
||||
appInfo.publicSourceDir?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
appInfo.splitPublicSourceDirs?.filter { it.isNotBlank() }?.let(::addAll)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ApplicationInfo.isSystemApp(): Boolean {
|
||||
val system = flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
val updatedSystem = flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0
|
||||
return system || updatedSystem
|
||||
}
|
||||
|
||||
private fun List<String>.normalizeServiceNames(): List<String> =
|
||||
map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import com.notcvnt.rknhardering.model.ObservedIp
|
|||
import com.notcvnt.rknhardering.model.TargetGroup
|
||||
import com.notcvnt.rknhardering.model.Verdict
|
||||
import com.notcvnt.rknhardering.model.VpnAppKind
|
||||
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
|
||||
import com.notcvnt.rknhardering.probe.ProxyEndpoint
|
||||
import com.notcvnt.rknhardering.probe.ProxyType
|
||||
import com.notcvnt.rknhardering.probe.XrayApiEndpoint
|
||||
|
|
@ -192,6 +193,14 @@ internal fun exportRichCheckResult(): CheckResult {
|
|||
source = EvidenceSource.INSTALLED_APP,
|
||||
active = true,
|
||||
confidence = EvidenceConfidence.HIGH,
|
||||
technicalMetadata = VpnAppTechnicalMetadata(
|
||||
versionName = "1.2.3",
|
||||
serviceNames = listOf("ExampleService"),
|
||||
appType = "V2RayNG",
|
||||
coreType = "Xray/V2Ray",
|
||||
corePath = "lib/arm64-v8a/libxray.so",
|
||||
goVersion = "go1.24.1",
|
||||
),
|
||||
),
|
||||
),
|
||||
activeApps = listOf(
|
||||
|
|
@ -202,6 +211,14 @@ internal fun exportRichCheckResult(): CheckResult {
|
|||
kind = VpnAppKind.TARGETED_BYPASS,
|
||||
source = EvidenceSource.ACTIVE_VPN,
|
||||
confidence = EvidenceConfidence.HIGH,
|
||||
technicalMetadata = VpnAppTechnicalMetadata(
|
||||
versionName = "1.2.3",
|
||||
serviceNames = listOf("ExampleService"),
|
||||
appType = "V2RayNG",
|
||||
coreType = "Xray/V2Ray",
|
||||
corePath = "lib/arm64-v8a/libxray.so",
|
||||
goVersion = "go1.24.1",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -151,4 +151,32 @@ class CheckResultJsonExportFormatterTest {
|
|||
assertTrue(icmp.getBoolean("needsReview"))
|
||||
assertEquals("ICMP spoofing", icmp.getString("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `json export includes vpn app technical metadata`() {
|
||||
val json = JSONObject(
|
||||
CheckResultJsonExportFormatter.format(
|
||||
context = context,
|
||||
snapshot = createCompletedExportSnapshot(
|
||||
result = exportRichCheckResult(),
|
||||
privacyMode = false,
|
||||
finishedAtMillis = 0L,
|
||||
),
|
||||
appVersionName = "1.0",
|
||||
buildType = "debug",
|
||||
),
|
||||
)
|
||||
|
||||
val metadata = json
|
||||
.getJSONObject("results")
|
||||
.getJSONObject("directSigns")
|
||||
.getJSONArray("matchedApps")
|
||||
.getJSONObject(0)
|
||||
.getJSONObject("technicalMetadata")
|
||||
|
||||
assertEquals("V2RayNG", metadata.getString("appType"))
|
||||
assertEquals("Xray/V2Ray", metadata.getString("coreType"))
|
||||
assertEquals("1.2.3", metadata.getString("versionName"))
|
||||
assertEquals("ExampleService", metadata.getJSONArray("serviceNames").getString(0))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,4 +108,22 @@ class CheckResultMarkdownExportFormatterTest {
|
|||
|
||||
assertFalse(markdown.contains("## IP каналы"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markdown export includes vpn app technical metadata`() {
|
||||
val markdown = CheckResultMarkdownExportFormatter.format(
|
||||
context = context,
|
||||
snapshot = createCompletedExportSnapshot(
|
||||
result = exportRichCheckResult(),
|
||||
privacyMode = false,
|
||||
finishedAtMillis = 0L,
|
||||
),
|
||||
appVersionName = "1.0",
|
||||
buildType = "debug",
|
||||
)
|
||||
|
||||
assertTrue(markdown.contains("appType=V2RayNG"))
|
||||
assertTrue(markdown.contains("coreType=Xray/V2Ray"))
|
||||
assertTrue(markdown.contains("goVersion=go1.24.1"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,32 @@ class VerdictEngineTest {
|
|||
assertEquals(Verdict.NOT_DETECTED, verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata-only installed app evidence does not affect verdict`() {
|
||||
val verdict = VerdictEngine.evaluate(
|
||||
geoIp = category(),
|
||||
directSigns = CategoryResult(
|
||||
name = "direct",
|
||||
detected = false,
|
||||
findings = emptyList(),
|
||||
evidence = listOf(
|
||||
EvidenceItem(
|
||||
source = EvidenceSource.INSTALLED_APP,
|
||||
detected = false,
|
||||
confidence = EvidenceConfidence.LOW,
|
||||
description = "Installed app with VPN in name",
|
||||
),
|
||||
),
|
||||
),
|
||||
indirectSigns = category(),
|
||||
locationSignals = category(),
|
||||
bypassResult = bypass(),
|
||||
ipConsensus = IpConsensusResult.empty(),
|
||||
)
|
||||
|
||||
assertEquals(Verdict.NOT_DETECTED, verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `R3a probeTargetDivergence with geoCountryMismatch yields detected`() {
|
||||
val verdict = VerdictEngine.evaluate(
|
||||
|
|
|
|||
|
|
@ -22,10 +22,19 @@ class InstalledVpnAppDetectorByNameTest {
|
|||
private val pm get() = context.packageManager
|
||||
private val shadow get() = shadowOf(pm)
|
||||
|
||||
private fun installApp(packageName: String, label: String, systemApp: Boolean = false) {
|
||||
private fun installApp(
|
||||
packageName: String,
|
||||
label: String,
|
||||
systemApp: Boolean = false,
|
||||
updatedSystemApp: Boolean = false,
|
||||
) {
|
||||
val appInfo = ApplicationInfo().apply {
|
||||
this.packageName = packageName
|
||||
flags = if (systemApp) ApplicationInfo.FLAG_SYSTEM else 0
|
||||
flags = when {
|
||||
systemApp -> ApplicationInfo.FLAG_SYSTEM
|
||||
updatedSystemApp -> ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
else -> 0
|
||||
}
|
||||
nonLocalizedLabel = label
|
||||
}
|
||||
val pkgInfo = PackageInfo().apply {
|
||||
|
|
@ -91,6 +100,16 @@ class InstalledVpnAppDetectorByNameTest {
|
|||
assertFalse(result.evidence.any { it.packageName == "com.android.vpndialogs" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updated system app with VPN in label is not detected`() {
|
||||
installApp("com.android.updatedvpn", "Updated VPN", updatedSystemApp = true)
|
||||
|
||||
val result = InstalledVpnAppDetector.detect(context)
|
||||
|
||||
assertFalse(result.matchedApps.any { it.packageName == "com.android.updatedvpn" })
|
||||
assertFalse(result.evidence.any { it.packageName == "com.android.updatedvpn" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `app already matched by catalog is not duplicated by name heuristic`() {
|
||||
val catalogPkg = VpnAppCatalog.signatures.first { it.kind == VpnAppKind.TARGETED_BYPASS }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
package com.notcvnt.rknhardering.vpn
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class VpnApkInspectorTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `detects app type from dex marker`() {
|
||||
val apk = createApk(
|
||||
"classes.dex" to "Lio/nekohasekai/sagernet/fmt/ConfigBuilder;".encodeToByteArray(),
|
||||
)
|
||||
|
||||
val result = VpnApkInspector.inspect(listOf(apk))
|
||||
|
||||
assertEquals(VpnAppClassifier.APP_TYPE_SAGERNET, result.appType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detects core type and go version from native library`() {
|
||||
val apk = createApk(
|
||||
"lib/arm64-v8a/libbox.so" to "github.com/sagernet/sing-box\u0000go1.24.2".encodeToByteArray(),
|
||||
)
|
||||
|
||||
val result = VpnApkInspector.inspect(listOf(apk))
|
||||
|
||||
assertEquals(VpnAppClassifier.CORE_TYPE_SING_BOX, result.coreType)
|
||||
assertEquals("lib/arm64-v8a/libbox.so", result.corePath)
|
||||
assertEquals("go1.24.2", result.goVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupt apk falls back without crashing`() {
|
||||
val file = temp.newFile("corrupt.apk").apply {
|
||||
writeText("not a zip")
|
||||
}
|
||||
|
||||
val result = VpnApkInspector.inspect(listOf(file.absolutePath))
|
||||
|
||||
assertNull(result.appType)
|
||||
assertNull(result.coreType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized dex is skipped`() {
|
||||
val oversizedDex = ByteArray(15 * 1024 * 1024 + 1) { 0 }
|
||||
val apk = createApk(
|
||||
"classes.dex" to oversizedDex,
|
||||
"classes2.dex" to "Lcom/github/shadowsocks/bg/VpnService;".encodeToByteArray(),
|
||||
)
|
||||
|
||||
val result = VpnApkInspector.inspect(listOf(apk))
|
||||
|
||||
assertEquals(VpnAppClassifier.APP_TYPE_SHADOWSOCKS_ANDROID, result.appType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shadowsocks marker is fallback when stronger app type appears later`() {
|
||||
val apk = createApk(
|
||||
"classes.dex" to "Lcom/github/shadowsocks/bg/VpnService;".encodeToByteArray(),
|
||||
"classes2.dex" to "Lio/nekohasekai/sagernet/fmt/ConfigBuilder;".encodeToByteArray(),
|
||||
)
|
||||
|
||||
val result = VpnApkInspector.inspect(listOf(apk))
|
||||
|
||||
assertEquals(VpnAppClassifier.APP_TYPE_SAGERNET, result.appType)
|
||||
}
|
||||
|
||||
private fun createApk(vararg entries: Pair<String, ByteArray>): String {
|
||||
val file = temp.newFile("test-${System.nanoTime()}.apk")
|
||||
ZipOutputStream(file.outputStream()).use { zip ->
|
||||
for ((name, content) in entries) {
|
||||
zip.putNextEntry(ZipEntry(name))
|
||||
zip.write(content)
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return file.absolutePath
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package com.notcvnt.rknhardering.vpn
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Application
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.ServiceInfo
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class VpnAppMetadataScannerTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
private val pm get() = context.packageManager
|
||||
private val shadow get() = shadowOf(pm)
|
||||
|
||||
@Test
|
||||
fun `scanner enriches version service app type and core type`() {
|
||||
val apk = createApk(
|
||||
"classes.dex" to "Lio/nekohasekai/sagernet/fmt/ConfigBuilder;".encodeToByteArray(),
|
||||
"lib/arm64-v8a/libbox.so" to "github.com/sagernet/sing-box\u0000go1.24.1".encodeToByteArray(),
|
||||
)
|
||||
installVpnPackage(
|
||||
packageName = "com.example.husi.clone",
|
||||
label = "Husi Clone",
|
||||
versionName = "1.2.3",
|
||||
apkPath = apk,
|
||||
serviceName = "com.example.husi.clone.VpnService",
|
||||
)
|
||||
|
||||
val metadata = VpnAppMetadataScanner.scan(context, "com.example.husi.clone")
|
||||
|
||||
assertEquals("1.2.3", metadata?.versionName)
|
||||
assertEquals(listOf("com.example.husi.clone.VpnService"), metadata?.serviceNames)
|
||||
assertEquals(VpnAppClassifier.APP_TYPE_SAGERNET, metadata?.appType)
|
||||
assertEquals(VpnAppClassifier.CORE_TYPE_SING_BOX, metadata?.coreType)
|
||||
assertEquals("go1.24.1", metadata?.goVersion)
|
||||
assertFalse(metadata?.systemApp ?: true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declared unknown VpnService app is matched with metadata`() {
|
||||
val apk = createApk(
|
||||
"classes.dex" to "Lio/nekohasekai/sfa/Main;".encodeToByteArray(),
|
||||
)
|
||||
installVpnPackage(
|
||||
packageName = "com.example.unknownvpn",
|
||||
label = "Unknown VPN",
|
||||
versionName = "9.8.7",
|
||||
apkPath = apk,
|
||||
serviceName = "com.example.unknownvpn.TunnelService",
|
||||
)
|
||||
|
||||
val result = InstalledVpnAppDetector.detect(context)
|
||||
val matched = result.matchedApps.firstOrNull { it.packageName == "com.example.unknownvpn" }
|
||||
|
||||
assertTrue(matched != null)
|
||||
assertEquals(VpnAppClassifier.APP_TYPE_SING_BOX, matched?.technicalMetadata?.appType)
|
||||
assertEquals("9.8.7", matched?.technicalMetadata?.versionName)
|
||||
assertEquals(listOf("com.example.unknownvpn.TunnelService"), matched?.technicalMetadata?.serviceNames)
|
||||
}
|
||||
|
||||
private fun installVpnPackage(
|
||||
packageName: String,
|
||||
label: String,
|
||||
versionName: String,
|
||||
apkPath: String,
|
||||
serviceName: String,
|
||||
): ServiceInfo {
|
||||
val appInfo = ApplicationInfo().apply {
|
||||
this.packageName = packageName
|
||||
nonLocalizedLabel = label
|
||||
sourceDir = apkPath
|
||||
publicSourceDir = apkPath
|
||||
}
|
||||
val service = ServiceInfo().apply {
|
||||
this.packageName = packageName
|
||||
name = serviceName
|
||||
permission = Manifest.permission.BIND_VPN_SERVICE
|
||||
applicationInfo = appInfo
|
||||
}
|
||||
val pkgInfo = PackageInfo().apply {
|
||||
this.packageName = packageName
|
||||
applicationInfo = appInfo
|
||||
this.versionName = versionName
|
||||
services = arrayOf(service)
|
||||
}
|
||||
shadow.installPackage(pkgInfo)
|
||||
return service
|
||||
}
|
||||
|
||||
private fun createApk(vararg entries: Pair<String, ByteArray>): String {
|
||||
val file = temp.newFile("metadata-${System.nanoTime()}.apk")
|
||||
ZipOutputStream(file.outputStream()).use { zip ->
|
||||
for ((name, content) in entries) {
|
||||
zip.putNextEntry(ZipEntry(name))
|
||||
zip.write(content)
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return file.absolutePath
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue