better url risk scorer, icon, language, views reduced.

This commit is contained in:
Hadrian Burkhardt
2026-05-08 18:09:57 +02:00
parent a0646273bc
commit 4c443a0b86
55 changed files with 879 additions and 321 deletions
@@ -1,10 +1,10 @@
package com.clean.scanner
package de.softwareapp_hb.privateqrscanner
import android.content.Context
import androidx.room.Room
import com.clean.scanner.data.local.CleanScannerDatabase
import com.clean.scanner.data.repository.ScanRepository
import com.clean.scanner.settings.SettingsRepository
import de.softwareapp_hb.privateqrscanner.data.local.CleanScannerDatabase
import de.softwareapp_hb.privateqrscanner.data.repository.ScanRepository
import de.softwareapp_hb.privateqrscanner.settings.SettingsRepository
class AppContainer(context: Context) {
private val appContext = context.applicationContext
@@ -1,4 +1,4 @@
package com.clean.scanner
package de.softwareapp_hb.privateqrscanner
import android.app.Application
@@ -1,12 +1,12 @@
package com.clean.scanner
package de.softwareapp_hb.privateqrscanner
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.clean.scanner.ui.CleanScannerAppRoot
import com.clean.scanner.ui.theme.CleanScannerTheme
import de.softwareapp_hb.privateqrscanner.ui.CleanScannerAppRoot
import de.softwareapp_hb.privateqrscanner.ui.theme.CleanScannerTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -1,4 +1,4 @@
package com.clean.scanner.data.local
package de.softwareapp_hb.privateqrscanner.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@@ -1,4 +1,4 @@
package com.clean.scanner.data.local
package de.softwareapp_hb.privateqrscanner.data.local
import androidx.room.Dao
import androidx.room.Insert
@@ -1,4 +1,4 @@
package com.clean.scanner.data.local
package de.softwareapp_hb.privateqrscanner.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -1,9 +1,9 @@
package com.clean.scanner.data.repository
package de.softwareapp_hb.privateqrscanner.data.repository
import com.clean.scanner.data.local.ScanDao
import com.clean.scanner.data.local.ScanEntity
import com.clean.scanner.domain.ScanRecord
import com.clean.scanner.settings.SettingsRepository
import de.softwareapp_hb.privateqrscanner.data.local.ScanDao
import de.softwareapp_hb.privateqrscanner.data.local.ScanEntity
import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import de.softwareapp_hb.privateqrscanner.settings.SettingsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -1,11 +1,11 @@
package com.clean.scanner.data.scanner
package de.softwareapp_hb.privateqrscanner.data.scanner
import android.graphics.Rect
import android.os.SystemClock
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.clean.scanner.domain.ScanResult
import com.clean.scanner.util.readablePayload
import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import de.softwareapp_hb.privateqrscanner.util.readablePayload
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode
@@ -1,4 +1,4 @@
package com.clean.scanner.domain
package de.softwareapp_hb.privateqrscanner.domain
data class ScanRecord(
val id: Long,
@@ -1,4 +1,4 @@
package com.clean.scanner.domain
package de.softwareapp_hb.privateqrscanner.domain
data class ScanResult(
val content: String,
@@ -1,4 +1,4 @@
package com.clean.scanner.domain
package de.softwareapp_hb.privateqrscanner.domain
data class UrlRiskResult(
val score: Int,
@@ -1,11 +1,11 @@
package com.clean.scanner.settings
package de.softwareapp_hb.privateqrscanner.settings
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import com.clean.scanner.ui.UseCaseView
import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -1,10 +1,10 @@
package com.clean.scanner.ui
package de.softwareapp_hb.privateqrscanner.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.clean.scanner.AppContainer
import com.clean.scanner.domain.ScanRecord
import de.softwareapp_hb.privateqrscanner.AppContainer
import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -1,4 +1,4 @@
package com.clean.scanner.ui
package de.softwareapp_hb.privateqrscanner.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
@@ -15,11 +15,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.clean.scanner.AppContainer
import com.clean.scanner.R
import com.clean.scanner.ui.screens.HistoryScreen
import com.clean.scanner.ui.screens.ScannerScreen
import com.clean.scanner.ui.screens.SettingsScreen
import de.softwareapp_hb.privateqrscanner.AppContainer
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.ui.screens.HistoryScreen
import de.softwareapp_hb.privateqrscanner.ui.screens.ScannerScreen
import de.softwareapp_hb.privateqrscanner.ui.screens.SettingsScreen
private enum class RootTab { Scanner, History, Settings }
@@ -1,10 +1,10 @@
package com.clean.scanner.ui
package de.softwareapp_hb.privateqrscanner.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.clean.scanner.AppContainer
import com.clean.scanner.domain.ScanResult
import de.softwareapp_hb.privateqrscanner.AppContainer
import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -1,6 +1,6 @@
package com.clean.scanner.ui
package de.softwareapp_hb.privateqrscanner.ui
import com.clean.scanner.R
import de.softwareapp_hb.privateqrscanner.R
enum class UseCaseView(
val storageKey: String,
@@ -13,38 +13,6 @@ enum class UseCaseView(
EventTicketing(
storageKey = "event_ticketing",
titleRes = R.string.use_case_event_ticketing
),
InventoryOperations(
storageKey = "inventory_operations",
titleRes = R.string.use_case_inventory_operations
),
FieldWorkServiceTeams(
storageKey = "field_work_service_teams",
titleRes = R.string.use_case_field_work
),
OfficeAdmin(
storageKey = "office_admin",
titleRes = R.string.use_case_office_admin
),
CommunicationShortcuts(
storageKey = "communication_shortcuts",
titleRes = R.string.use_case_communication_shortcuts
),
SecurityBrowsing(
storageKey = "security_browsing",
titleRes = R.string.use_case_security_browsing
),
OfflineLowConnectivity(
storageKey = "offline_low_connectivity",
titleRes = R.string.use_case_offline_low_connectivity
),
AccessibilitySpeed(
storageKey = "accessibility_speed",
titleRes = R.string.use_case_accessibility_speed
),
TeamHandoverTransfer(
storageKey = "team_handover_transfer",
titleRes = R.string.use_case_team_handover_transfer
);
companion object {
@@ -90,85 +58,5 @@ fun UseCaseView.capabilities(): UseCaseCapabilities {
allowOpenUrl = false,
allowBatchShare = true
)
UseCaseView.InventoryOperations -> UseCaseCapabilities(
allowScanFromImage = true,
allowBatchMode = true,
allowCopy = true,
allowShare = true,
allowOpenUrl = false,
allowHistoryExport = true,
allowBatchShare = true
)
UseCaseView.FieldWorkServiceTeams -> UseCaseCapabilities(
allowScanFromImage = true,
allowBatchMode = true,
allowCopy = true,
allowShare = true,
allowOpenUrl = true,
allowHistoryExport = true,
allowBatchShare = true
)
UseCaseView.OfficeAdmin -> UseCaseCapabilities(
allowScanFromImage = true,
allowBatchMode = false,
allowCopy = true,
allowShare = true,
allowOpenUrl = true,
allowAddContact = true,
allowDialPhone = true,
allowSendSms = true,
allowSendEmail = true,
allowOpenWifiSettings = true,
allowAddCalendarEvent = true
)
UseCaseView.CommunicationShortcuts -> UseCaseCapabilities(
allowScanFromImage = true,
allowBatchMode = false,
allowCopy = true,
allowShare = true,
allowOpenUrl = false,
allowDialPhone = true,
allowSendSms = true,
allowSendEmail = true
)
UseCaseView.SecurityBrowsing -> UseCaseCapabilities(
allowScanFromImage = true,
allowBatchMode = false,
allowCopy = true,
allowShare = true,
allowOpenUrl = true
)
UseCaseView.OfflineLowConnectivity -> UseCaseCapabilities(
allowScanFromImage = true,
allowBatchMode = true,
allowCopy = true,
allowShare = true,
allowOpenUrl = false,
allowBatchShare = true
)
UseCaseView.AccessibilitySpeed -> UseCaseCapabilities(
allowScanFromImage = true,
allowBatchMode = false,
allowCopy = true,
allowShare = true,
allowOpenUrl = true
)
UseCaseView.TeamHandoverTransfer -> UseCaseCapabilities(
allowScanFromImage = true,
allowBatchMode = true,
allowCopy = true,
allowShare = true,
allowOpenUrl = false,
allowHistoryExport = true,
allowBatchShare = true
)
}
}
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.components
package de.softwareapp_hb.privateqrscanner.ui.components
import android.annotation.SuppressLint
import android.util.Size
@@ -22,8 +22,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.clean.scanner.data.scanner.DetectionBox
import com.clean.scanner.data.scanner.MlKitBarcodeAnalyzer
import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox
import de.softwareapp_hb.privateqrscanner.data.scanner.MlKitBarcodeAnalyzer
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import kotlin.math.max
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens
package de.softwareapp_hb.privateqrscanner.ui.screens
import com.google.mlkit.vision.barcode.common.Barcode
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens
package de.softwareapp_hb.privateqrscanner.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -24,12 +24,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import com.clean.scanner.domain.ScanRecord
import com.clean.scanner.ui.UseCaseView
import com.clean.scanner.ui.capabilities
import com.clean.scanner.util.HistoryExportFormatter
import com.clean.scanner.util.Intents
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import de.softwareapp_hb.privateqrscanner.ui.capabilities
import de.softwareapp_hb.privateqrscanner.util.HistoryExportFormatter
import de.softwareapp_hb.privateqrscanner.util.Intents
import java.text.DateFormat
import java.util.Date
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens
package de.softwareapp_hb.privateqrscanner.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -20,7 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import de.softwareapp_hb.privateqrscanner.R
@Composable
fun HomeScreen(
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens
package de.softwareapp_hb.privateqrscanner.ui.screens
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -50,11 +50,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import com.clean.scanner.data.scanner.DetectionBox
import com.clean.scanner.data.scanner.DetectionPoint
import com.clean.scanner.domain.ScanResult
import com.clean.scanner.util.readablePayload
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox
import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint
import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import de.softwareapp_hb.privateqrscanner.util.readablePayload
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens
package de.softwareapp_hb.privateqrscanner.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -27,10 +27,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import com.clean.scanner.ui.BatchScanRecord
import com.clean.scanner.util.ClipboardUtil
import com.clean.scanner.util.Intents
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.ui.BatchScanRecord
import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil
import de.softwareapp_hb.privateqrscanner.util.Intents
import java.text.DateFormat
import java.util.Date
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens
package de.softwareapp_hb.privateqrscanner.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
@@ -25,10 +25,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.clean.scanner.domain.ScanResult
import com.clean.scanner.util.ParsedContact
import com.clean.scanner.util.ScanContentParsers
import com.clean.scanner.util.UrlRiskScorer
import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import de.softwareapp_hb.privateqrscanner.util.ParsedContact
import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer
import java.text.DateFormat
import java.util.Date
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens
package de.softwareapp_hb.privateqrscanner.ui.screens
import android.Manifest
import android.app.Activity
@@ -72,20 +72,20 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.clean.scanner.R
import com.clean.scanner.data.scanner.DetectionBox
import com.clean.scanner.data.scanner.DetectionPoint
import com.clean.scanner.domain.ScanResult
import com.clean.scanner.ui.BatchScanRecord
import com.clean.scanner.ui.EventTicketScanDecision
import com.clean.scanner.ui.UseCaseView
import com.clean.scanner.ui.components.CameraPreview
import com.clean.scanner.ui.capabilities
import com.clean.scanner.util.ClipboardUtil
import com.clean.scanner.util.Intents
import com.clean.scanner.util.ScanContentParsers
import com.clean.scanner.util.UrlRiskScorer
import com.clean.scanner.util.readablePayload
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionBox
import de.softwareapp_hb.privateqrscanner.data.scanner.DetectionPoint
import de.softwareapp_hb.privateqrscanner.domain.ScanResult
import de.softwareapp_hb.privateqrscanner.ui.BatchScanRecord
import de.softwareapp_hb.privateqrscanner.ui.EventTicketScanDecision
import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import de.softwareapp_hb.privateqrscanner.ui.components.CameraPreview
import de.softwareapp_hb.privateqrscanner.ui.capabilities
import de.softwareapp_hb.privateqrscanner.util.ClipboardUtil
import de.softwareapp_hb.privateqrscanner.util.Intents
import de.softwareapp_hb.privateqrscanner.util.ScanContentParsers
import de.softwareapp_hb.privateqrscanner.util.UrlRiskScorer
import de.softwareapp_hb.privateqrscanner.util.readablePayload
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode
@@ -127,15 +127,7 @@ fun ScannerScreen(
val haptic = LocalHapticFeedback.current
val capabilities = remember(useCaseView) { useCaseView.capabilities() }
val forceBatchMode = useCaseView == UseCaseView.EventTicketing
val showBatchModeToggle = remember(useCaseView) {
when (useCaseView) {
UseCaseView.InventoryOperations,
UseCaseView.FieldWorkServiceTeams,
UseCaseView.OfflineLowConnectivity,
UseCaseView.TeamHandoverTransfer -> true
else -> false
}
}
val showBatchModeToggle = capabilities.allowBatchMode && !forceBatchMode
val isBatchModeActive = forceBatchMode || batchMode
val duplicateSnackbarHostState = remember { SnackbarHostState() }
val toneGenerator = remember { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) }
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.screens
package de.softwareapp_hb.privateqrscanner.ui.screens
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
@@ -20,9 +20,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.clean.scanner.R
import com.clean.scanner.ui.UseCaseView
import com.clean.scanner.util.Intents
import de.softwareapp_hb.privateqrscanner.R
import de.softwareapp_hb.privateqrscanner.ui.UseCaseView
import de.softwareapp_hb.privateqrscanner.util.Intents
@Composable
fun SettingsScreen(
@@ -1,4 +1,4 @@
package com.clean.scanner.ui.theme
package de.softwareapp_hb.privateqrscanner.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
@@ -1,4 +1,4 @@
package com.clean.scanner.util
package de.softwareapp_hb.privateqrscanner.util
import com.google.mlkit.vision.barcode.common.Barcode
@@ -1,4 +1,4 @@
package com.clean.scanner.util
package de.softwareapp_hb.privateqrscanner.util
import android.content.ClipData
import android.content.ClipboardManager
@@ -1,6 +1,6 @@
package com.clean.scanner.util
package de.softwareapp_hb.privateqrscanner.util
import com.clean.scanner.domain.ScanRecord
import de.softwareapp_hb.privateqrscanner.domain.ScanRecord
import java.text.DateFormat
import java.util.Date
@@ -1,4 +1,4 @@
package com.clean.scanner.util
package de.softwareapp_hb.privateqrscanner.util
import android.content.ContentValues
import android.content.Context
@@ -1,4 +1,4 @@
package com.clean.scanner.util
package de.softwareapp_hb.privateqrscanner.util
import at.bitfire.vcard4android.Contact
import java.io.StringReader
@@ -1,45 +1,598 @@
package com.clean.scanner.util
package de.softwareapp_hb.privateqrscanner.util
import com.clean.scanner.domain.UrlRiskResult
import de.softwareapp_hb.privateqrscanner.domain.UrlRiskResult
import java.net.IDN
import java.net.URI
import java.net.URLDecoder
import java.util.Locale
object UrlRiskScorer {
fun score(raw: String): UrlRiskResult {
val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return UrlRiskResult(0, emptyList())
val host = uri.host.orEmpty()
val trimmed = raw.trim()
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return UrlRiskResult(0, emptyList())
val scheme = uri.scheme?.lowercase(Locale.US) ?: return UrlRiskResult(0, emptyList())
val hostDetails = extractHost(uri)
val host = hostDetails.asciiHost
val labels = host.split('.').filter { it.isNotBlank() }
val reasons = mutableListOf<String>()
var score = 0
if (host.matches(Regex("^\\d{1,3}(\\.\\d{1,3}){3}$"))) {
score += 2
reasons += "Host is an IP address"
fun add(points: Int, reason: String) {
if (reason !in reasons) {
score += points
reasons += reason
}
}
if (uri.scheme.equals("http", ignoreCase = true)) {
score += 2
reasons += "URL uses HTTP"
if (trimmed.any { it.isISOControl() || it.isWhitespace() }) {
add(3, "Contains whitespace or control characters")
}
if (host.contains("xn--", ignoreCase = true)) {
score += 2
reasons += "Host contains punycode"
when (scheme) {
"http" -> add(2, "URL uses HTTP")
"https" -> Unit
in HIGH_RISK_SCHEMES -> add(5, "Uses a potentially unsafe URL scheme")
else -> add(2, "Uses a non-web URL scheme")
}
if (scheme in WEB_SCHEMES && hostDetails.rawHost.isBlank()) {
add(3, "Web URL has no host")
}
if (!uri.userInfo.isNullOrBlank() || uri.rawAuthority.orEmpty().contains("@")) {
add(2, "Contains userinfo")
}
if (PERCENT_ENCODING.containsMatchIn(hostDetails.rawHost)) {
add(2, "Host contains percent-encoding")
}
if (hostDetails.hasPunycode) {
add(2, "Host contains punycode")
}
if (hostDetails.hasUnicode) {
add(2, "Host contains non-ASCII characters")
}
if (host.length > 40) {
score += 1
reasons += "Host is unusually long"
add(1, "Host is unusually long")
}
if ((uri.rawQuery?.length ?: 0) > 120) {
score += 1
reasons += "Query is unusually long"
}
val percentEncodedCount = Regex("%[0-9a-fA-F]{2}").findAll(raw).count()
if (percentEncodedCount > 10) {
score += 1
reasons += "Many percent-encodings"
}
if (!uri.userInfo.isNullOrBlank()) {
score += 2
reasons += "Contains userinfo"
val ipv4 = parseIpv4(host)
if (ipv4 != null) {
add(2, "Host is an IP address")
if (isPrivateOrReservedIpv4(ipv4)) {
add(2, "Host is a private or reserved IP address")
}
} else if (isIpv6Literal(hostDetails.rawHost)) {
add(2, "Host is an IP address")
if (isPrivateOrReservedIpv6(hostDetails.rawHost)) {
add(2, "Host is a private or reserved IP address")
}
} else if (host.isNotBlank()) {
scoreDomainShape(host, labels, ::add)
scoreKnownRiskyDomains(host, labels, ::add)
scoreBrandImpersonation(host, labels, ::add)
}
scoreUrlStructure(trimmed, uri, host, ::add)
return UrlRiskResult(score = score, reasons = reasons)
}
private fun scoreDomainShape(
host: String,
labels: List<String>,
add: (Int, String) -> Unit
) {
val registeredDomain = registrableDomain(labels)
val registeredLabel = registeredDomain?.substringBefore('.').orEmpty()
if (host == "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
add(2, "Host points to a local name")
}
if (labels.size == 1 && host != "localhost") {
add(1, "Host has no public suffix")
}
if (labels.size > 4) {
add(1, "Host has many subdomains")
}
if (labels.any { it.length > 30 }) {
add(1, "Host contains an unusually long label")
}
if (labels.any { it.contains('_') }) {
add(1, "Host contains invalid hostname characters")
}
if (registeredLabel.count { it == '-' } >= 3) {
add(1, "Registered domain contains many hyphens")
}
if (registeredLabel.length >= 10 && registeredLabel.count(Char::isDigit) * 100 / registeredLabel.length >= 35) {
add(1, "Registered domain contains many digits")
}
if (labels.any { REPEATED_CHARACTER.containsMatchIn(it) }) {
add(1, "Host contains repeated characters")
}
}
private fun scoreKnownRiskyDomains(
host: String,
labels: List<String>,
add: (Int, String) -> Unit
) {
val tld = labels.lastOrNull().orEmpty()
val registeredDomain = registrableDomain(labels)
if (registeredDomain in URL_SHORTENERS) {
add(2, "Host is a URL shortener")
}
if (tld in WATCHLIST_TLDS) {
add(1, "Host uses a commonly abused top-level domain")
}
}
private fun scoreBrandImpersonation(
host: String,
labels: List<String>,
add: (Int, String) -> Unit
) {
val registeredDomain = registrableDomain(labels)
val labelsToInspect = labels.filterNot { it in PUBLIC_SUFFIX_PARTS }
for (brand in BRAND_PROFILES) {
if (brand.officialDomains.any { host == it || host.endsWith(".$it") }) continue
val registeredLabel = registeredDomain?.substringBefore('.').orEmpty()
if (registeredLabel == brand.name) continue
val exactBrandLabel = labelsToInspect.any { it == brand.name }
val embeddedBrand = labelsToInspect.any { label ->
label != brand.name && label.contains(brand.name)
}
if (exactBrandLabel || embeddedBrand) {
add(3, "Host embeds a known brand outside its official domain")
return
}
val candidateLabels = (labelsToInspect + registeredLabel).filter { it.length >= 4 }.distinct()
if (candidateLabels.any { isLookalikeBrandLabel(it, brand.name) }) {
add(3, "Host resembles a known brand")
return
}
}
}
private fun scoreUrlStructure(
raw: String,
uri: URI,
host: String,
add: (Int, String) -> Unit
) {
val rawPath = uri.rawPath.orEmpty()
val rawQuery = uri.rawQuery.orEmpty()
val lowerPathAndQuery = "$rawPath?$rawQuery".lowercase(Locale.US)
val lowerRaw = raw.lowercase(Locale.US)
when {
raw.length > 500 -> add(2, "URL is extremely long")
raw.length > 250 -> add(1, "URL is unusually long")
}
if (uri.port != -1 && !isStandardPort(uri.scheme.orEmpty(), uri.port)) {
add(1, "URL uses a non-standard port")
}
if (rawPath.length > 120) {
add(1, "Path is unusually long")
}
if ((uri.rawQuery?.length ?: 0) > 120) {
add(1, "Query is unusually long")
}
if (rawPath.split('/').count { it.isNotBlank() } > 8) {
add(1, "Path has many segments")
}
val percentEncodedCount = PERCENT_ENCODING.findAll(raw).count()
if (percentEncodedCount > 10) {
add(1, "Many percent-encodings")
}
if (ENCODED_CONTROL_CHARACTER.containsMatchIn(raw)) {
add(3, "URL contains encoded control characters")
}
if (raw.contains('\\')) {
add(2, "URL contains backslashes")
}
if (rawPath.contains("//")) {
add(1, "Path contains nested URL separators")
}
if (rawPath.contains('@') || rawQuery.contains('@')) {
add(1, "Path or query contains an at-sign")
}
if (DANGEROUS_FILE_EXTENSIONS.any { lowerPathAndQuery.contains(it) }) {
add(2, "URL points to a potentially executable file")
} else if (ARCHIVE_FILE_EXTENSIONS.any { lowerPathAndQuery.contains(it) }) {
add(1, "URL points to an archive file")
}
if (CREDENTIAL_KEYWORDS.any { lowerPathAndQuery.contains(it) }) {
add(1, "URL contains account or credential keywords")
}
if (hasExternalRedirect(rawQuery, host)) {
add(2, "Query redirects to another domain")
}
if (hasLongOpaqueToken(rawPath) || hasLongOpaqueToken(rawQuery)) {
add(1, "URL contains a long opaque token")
}
if (lowerRaw.contains("%2f%2f") || lowerRaw.contains("%5c")) {
add(1, "URL contains encoded path separators")
}
}
private fun extractHost(uri: URI): HostDetails {
val rawAuthority = uri.rawAuthority.orEmpty()
val rawHostFromAuthority = if (rawAuthority.isBlank()) {
""
} else {
val withoutUserInfo = rawAuthority.substringAfterLast('@')
when {
withoutUserInfo.startsWith("[") -> withoutUserInfo.substringAfter('[').substringBefore(']')
withoutUserInfo.count { it == ':' } == 1 &&
withoutUserInfo.substringAfterLast(':').all(Char::isDigit) ->
withoutUserInfo.substringBeforeLast(':')
else -> withoutUserInfo
}
}
val rawHost = (uri.host ?: rawHostFromAuthority)
.trim()
.trim('.')
.replace('\u3002', '.')
.replace('\uFF0E', '.')
.replace('\uFF61', '.')
val asciiHost = toAsciiHost(rawHost)
return HostDetails(
rawHost = rawHost,
asciiHost = asciiHost,
hasUnicode = rawHost.any { it.code > 127 },
hasPunycode = asciiHost.split('.').any { it.startsWith("xn--") } ||
rawHost.contains("xn--", ignoreCase = true)
)
}
private fun toAsciiHost(rawHost: String): String {
if (rawHost.isBlank()) return ""
return rawHost
.removeSurrounding("[", "]")
.split('.')
.filter { it.isNotBlank() }
.joinToString(".") { label ->
runCatching { IDN.toASCII(label, IDN.ALLOW_UNASSIGNED).lowercase(Locale.US) }
.getOrElse { label.lowercase(Locale.US) }
}
}
private fun parseIpv4(host: String): IntArray? {
if (!IPV4_PATTERN.matches(host)) return null
val octets = host.split('.').mapNotNull { it.toIntOrNull() }
if (octets.size != 4 || octets.any { it !in 0..255 }) return null
return octets.toIntArray()
}
private fun isPrivateOrReservedIpv4(octets: IntArray): Boolean {
val first = octets[0]
val second = octets[1]
val third = octets[2]
return first == 0 ||
first == 10 ||
first == 127 ||
first >= 224 ||
(first == 100 && second in 64..127) ||
(first == 169 && second == 254) ||
(first == 172 && second in 16..31) ||
(first == 192 && second == 168) ||
(first == 192 && second == 0 && third == 0) ||
(first == 192 && second == 0 && third == 2) ||
(first == 198 && second in 18..19) ||
(first == 198 && second == 51 && third == 100) ||
(first == 203 && second == 0 && third == 113)
}
private fun isIpv6Literal(rawHost: String): Boolean {
val host = rawHost.removeSurrounding("[", "]")
return host.contains(':') && host.all {
it == ':' || it == '.' || it.isDigit() || it.lowercaseChar() in 'a'..'f'
}
}
private fun isPrivateOrReservedIpv6(rawHost: String): Boolean {
val host = rawHost.removeSurrounding("[", "]").lowercase(Locale.US)
return host == "::" ||
host == "::1" ||
host.startsWith("fc") ||
host.startsWith("fd") ||
host.startsWith("fe80") ||
host.startsWith("2001:db8")
}
private fun registrableDomain(labels: List<String>): String? {
if (labels.isEmpty()) return null
if (labels.size == 1) return labels.first()
val lastTwo = labels.takeLast(2).joinToString(".")
return if (lastTwo in COMMON_SECOND_LEVEL_SUFFIXES && labels.size >= 3) {
labels.takeLast(3).joinToString(".")
} else {
lastTwo
}
}
private fun isLookalikeBrandLabel(label: String, brand: String): Boolean {
if (label == brand) return false
val normalized = normalizeLookalikes(label)
if (normalized == brand) return true
if (kotlin.math.abs(normalized.length - brand.length) > 1) return false
return levenshteinDistance(normalized, brand) <= 1
}
private fun normalizeLookalikes(value: String): String {
return value
.replace('0', 'o')
.replace('1', 'l')
.replace('3', 'e')
.replace('5', 's')
.replace('7', 't')
.replace("rn", "m")
}
private fun levenshteinDistance(left: String, right: String): Int {
if (left == right) return 0
if (left.isEmpty()) return right.length
if (right.isEmpty()) return left.length
var previous = IntArray(right.length + 1) { it }
var current = IntArray(right.length + 1)
for (i in left.indices) {
current[0] = i + 1
for (j in right.indices) {
val substitutionCost = if (left[i] == right[j]) 0 else 1
current[j + 1] = minOf(
current[j] + 1,
previous[j + 1] + 1,
previous[j] + substitutionCost
)
}
val swap = previous
previous = current
current = swap
}
return previous[right.length]
}
private fun isStandardPort(scheme: String, port: Int): Boolean {
return (scheme.equals("http", ignoreCase = true) && port == 80) ||
(scheme.equals("https", ignoreCase = true) && port == 443)
}
private fun hasExternalRedirect(rawQuery: String, sourceHost: String): Boolean {
if (rawQuery.isBlank()) return false
return queryParams(rawQuery).any { (key, value) ->
key in REDIRECT_PARAMETER_NAMES && value.startsWith("http", ignoreCase = true) &&
runCatching {
val destinationHost = extractHost(URI(value)).asciiHost
val sourceDomain = registrableDomain(sourceHost.split('.').filter { it.isNotBlank() })
val destinationDomain = registrableDomain(destinationHost.split('.').filter { it.isNotBlank() })
destinationDomain != null && sourceDomain != null && destinationDomain != sourceDomain
}.getOrDefault(true)
}
}
private fun queryParams(rawQuery: String): List<Pair<String, String>> {
return rawQuery
.split('&', ';')
.mapNotNull { token ->
val key = token.substringBefore('=', "").takeIf { it.isNotBlank() } ?: return@mapNotNull null
val value = token.substringAfter('=', "")
decodeUrlComponent(key).lowercase(Locale.US) to decodeUrlComponent(value)
}
}
private fun decodeUrlComponent(value: String): String {
return runCatching { URLDecoder.decode(value, Charsets.UTF_8.name()) }.getOrDefault(value)
}
private fun hasLongOpaqueToken(value: String): Boolean {
return value
.split('/', '&', '=', '?', '-', '_')
.any { token ->
token.length >= 48 &&
token.toSet().size >= 8 &&
token.count { it.isLetterOrDigit() || it == '+' || it == '/' } * 100 / token.length >= 85
}
}
private data class HostDetails(
val rawHost: String,
val asciiHost: String,
val hasUnicode: Boolean,
val hasPunycode: Boolean
)
private data class BrandProfile(
val name: String,
val officialDomains: Set<String>
)
private val WEB_SCHEMES = setOf("http", "https")
private val HIGH_RISK_SCHEMES = setOf("javascript", "data", "file", "content", "intent")
private val IPV4_PATTERN = Regex("""^\d{1,3}(\.\d{1,3}){3}$""")
private val PERCENT_ENCODING = Regex("""%[0-9a-fA-F]{2}""")
private val ENCODED_CONTROL_CHARACTER = Regex("""(?i)%(00|0a|0d|09)""")
private val REPEATED_CHARACTER = Regex("""(.)\1{3,}""")
private val COMMON_SECOND_LEVEL_SUFFIXES = setOf(
"ac.uk",
"co.in",
"co.jp",
"co.nz",
"co.uk",
"com.au",
"com.br",
"com.cn",
"com.hk",
"com.mx",
"com.sg",
"com.tr",
"ne.jp",
"net.au",
"org.uk"
)
private val PUBLIC_SUFFIX_PARTS = setOf(
"ac",
"co",
"com",
"edu",
"gov",
"mil",
"ne",
"net",
"org"
)
private val URL_SHORTENERS = setOf(
"bit.ly",
"buff.ly",
"cutt.ly",
"goo.gl",
"is.gd",
"lnkd.in",
"ow.ly",
"rebrand.ly",
"s.id",
"t.co",
"tiny.cc",
"tinyurl.com",
"trib.al",
"v.gd"
)
private val WATCHLIST_TLDS = setOf(
"cam",
"click",
"country",
"download",
"gq",
"kim",
"loan",
"men",
"ml",
"mov",
"quest",
"rest",
"surf",
"tk",
"top",
"work",
"xyz",
"zip"
)
private val CREDENTIAL_KEYWORDS = setOf(
"2fa",
"account",
"auth",
"billing",
"confirm",
"login",
"mfa",
"password",
"payment",
"recovery",
"reset",
"secure",
"security",
"signin",
"sign-in",
"unlock",
"update",
"verify",
"wallet"
)
private val REDIRECT_PARAMETER_NAMES = setOf(
"continue",
"dest",
"destination",
"next",
"redirect",
"redirect_uri",
"return",
"return_url",
"target",
"u",
"url"
)
private val DANGEROUS_FILE_EXTENSIONS = setOf(
".apk",
".bat",
".cmd",
".dmg",
".exe",
".jar",
".js",
".msi",
".ps1",
".scr",
".vbs"
)
private val ARCHIVE_FILE_EXTENSIONS = setOf(
".7z",
".gz",
".rar",
".tar",
".zip"
)
private val BRAND_PROFILES = listOf(
BrandProfile("adobe", setOf("adobe.com")),
BrandProfile("amazon", setOf("amazon.com")),
BrandProfile("apple", setOf("apple.com")),
BrandProfile("binance", setOf("binance.com")),
BrandProfile("chase", setOf("chase.com")),
BrandProfile("citibank", setOf("citibank.com")),
BrandProfile("coinbase", setOf("coinbase.com")),
BrandProfile("discord", setOf("discord.com", "discord.gg")),
BrandProfile("docusign", setOf("docusign.com")),
BrandProfile("dropbox", setOf("dropbox.com")),
BrandProfile("ebay", setOf("ebay.com")),
BrandProfile("facebook", setOf("facebook.com", "fb.com")),
BrandProfile("github", setOf("github.com")),
BrandProfile("google", setOf("google.com")),
BrandProfile("instagram", setOf("instagram.com")),
BrandProfile("microsoft", setOf("microsoft.com", "microsoftonline.com", "office.com", "live.com")),
BrandProfile("netflix", setOf("netflix.com")),
BrandProfile("paypal", setOf("paypal.com")),
BrandProfile("steam", setOf("steampowered.com", "steamcommunity.com")),
BrandProfile("telegram", setOf("telegram.org", "t.me")),
BrandProfile("whatsapp", setOf("whatsapp.com", "wa.me")),
BrandProfile("wellsfargo", setOf("wellsfargo.com"))
)
}