diff --git a/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt b/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt index 924025d7..1dbb21f9 100644 --- a/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt +++ b/ai/discover/src/main/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilter.kt @@ -56,11 +56,21 @@ internal class DeviceSafetyFilter { reason = "Piracy signal detected", ) } - if (isHighRiskExtension(ext) && !isTrustedDomain(host)) { + if (impersonatesTrustedDomain(host) || hasWebUserInfo(url)) { return SafetyEvaluation( score = 0f, blocked = true, - reason = "High-risk extension .$ext from untrusted source", + reason = "Looks like a trusted domain: $host", + ) + } + // Vendors publish installers on their own sites, which no list covers, so an installer from + // an unlisted host is only scored down; over plain HTTP anyone on the way could swap it. + val untrustedInstaller = isHighRiskExtension(ext) && !isTrustedDomain(host) + if (untrustedInstaller && !lowerUrl.startsWith("https://")) { + return SafetyEvaluation( + score = 0f, + blocked = true, + reason = "High-risk extension .$ext without HTTPS from untrusted source", ) } @@ -68,6 +78,11 @@ internal class DeviceSafetyFilter { var score = BASE_SCORE val notes = mutableListOf() + if (untrustedInstaller) { + score -= UNTRUSTED_INSTALLER_PENALTY + notes.add("High-risk extension .$ext from unlisted domain") + } + if (lowerUrl.startsWith("https://")) { score += 0.1f notes.add("HTTPS") @@ -131,6 +146,36 @@ internal class DeviceSafetyFilter { return TRUSTED_DOMAINS.any { host == it || host.endsWith(".$it") } } + /** + * Whether [host] holds a trusted domain but lies outside the site that registered it, such as + * `fake-github.com` or `github.com.example.net`. Other hosts of that site, such as + * `archive-downloads.apache.org` beside `downloads.apache.org`, are the vendor's own. + */ + private fun impersonatesTrustedDomain(host: String): Boolean { + return TRUSTED_DOMAINS.any { trusted -> + trusted in host && !isUnder(host, registrableDomain(trusted)) + } + } + + private fun isUnder(host: String, domain: String): Boolean { + return host == domain || host.endsWith(".$domain") + } + + /** The site [domain] belongs to: its last two labels, as every trusted domain is a gTLD's. */ + private fun registrableDomain(domain: String): String { + return domain.split('.').takeLast(2).joinToString(".") + } + + /** Whether the HTTP(S) [url] carries user info, which can pose as the host before an `@`. */ + private fun hasWebUserInfo(url: String): Boolean { + return try { + val uri = URI(url) + uri.scheme?.lowercase() in WEB_SCHEMES && uri.rawUserInfo != null + } catch (_: Exception) { + false + } + } + private fun hasPiracySignal(text: String): Boolean { return PIRACY_SIGNALS.any { text.contains(it) } } @@ -161,6 +206,9 @@ internal class DeviceSafetyFilter { companion object { private const val BASE_SCORE = 0.7f private const val BLOCK_THRESHOLD = 0.3f + private const val UNTRUSTED_INSTALLER_PENALTY = 0.2f + + private val WEB_SCHEMES = setOf("http", "https") private val URL_SHORTENERS = setOf( "bit.ly", "t.co", "tinyurl.com", "goo.gl", "ow.ly", diff --git a/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt b/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt index 487e042d..b48286a4 100644 --- a/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt +++ b/ai/discover/src/test/kotlin/com/linroid/ketch/ai/agent/DeviceSafetyFilterTest.kt @@ -51,13 +51,38 @@ class DeviceSafetyFilterTest { } @Test - fun evaluate_highRiskExtFromUntrusted_blocked() { + fun evaluate_highRiskExtFromUnlistedHttps_penalized() { + val unlisted = filter.evaluate( + url = "https://kuromi.drakeet.cn/PureWriter2-4.4.2-macOS-aarch64.dmg", + sourcePageUrl = "https://writer.drakeet.com/desktop2", + ) + val trusted = filter.evaluate( + url = "https://github.com/app/releases/PureWriter2-4.4.2-macOS-aarch64.dmg", + ) + assertFalse(unlisted.blocked) + assertTrue(unlisted.score < trusted.score) + } + + @Test + fun evaluate_highRiskExtFromUnlistedHttp_blocked() { val result = filter.evaluate( - url = "https://random-site.xyz/setup.exe", + url = "http://random-site.xyz/setup.exe", ) assertTrue(result.blocked) } + @Test + fun evaluate_trustedDomainLookalike_blocked() { + val urls = listOf( + "https://fake-github.com/app/releases/v1.0.zip", + "https://github.com.example.net/app/v1.0.zip", + "https://github.com@evil.example/app/v1.0.zip", + ) + for (url in urls) { + assertTrue(filter.evaluate(url = url).blocked, url) + } + } + @Test fun evaluate_blenderInstaller_allowed() { val result = filter.evaluate( @@ -71,7 +96,7 @@ class DeviceSafetyFilterTest { fun evaluate_blenderLookalikes_blocked() { val urls = listOf( "https://download.blender.org.example.com/blender.dmg", - "https://fake-download.blender.org/blender.dmg", + "https://download.blender.org-mirror.example/blender.dmg", "https://download.blender.org@evil.example/blender.dmg" ) for (url in urls) { @@ -84,6 +109,17 @@ class DeviceSafetyFilterTest { } } + @Test + fun evaluate_siblingHostOfATrustedSite_allowed() { + val urls = listOf( + "https://archive-downloads.apache.org/dist/app-1.0.zip", + "https://fake-download.blender.org/blender.dmg", + ) + for (url in urls) { + assertFalse(filter.evaluate(url = url).blocked, url) + } + } + @Test fun evaluate_piracySignalInContext_blocked() { val result = filter.evaluate( diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AiDiscoverController.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AiDiscoverController.kt index c5898d6b..d78bc8b7 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AiDiscoverController.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/state/AiDiscoverController.kt @@ -394,6 +394,11 @@ class AiDiscoverController( selected = selected + visibleIn(turnId).map { it.url } } + /** Deselects every result of the shown session, earlier turns' included. */ + fun clearSelection() { + selected = emptySet() + } + /** Deselects every result of the shown session's turn with [turnId]. */ fun clearSelection(turnId: String) { selected = selected - visibleIn(turnId).mapTo(mutableSetOf()) { it.url } diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/discover/DiscoverFooter.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/discover/DiscoverFooter.kt index ef42a59d..9984e271 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/discover/DiscoverFooter.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/discover/DiscoverFooter.kt @@ -42,6 +42,7 @@ import ketch.app.shared.generated.resources.device_free_space import ketch.app.shared.generated.resources.discover_add_now import ketch.app.shared.generated.resources.discover_add_options import ketch.app.shared.generated.resources.discover_add_to +import ketch.app.shared.generated.resources.discover_clear_selection import ketch.app.shared.generated.resources.discover_review_add import ketch.app.shared.generated.resources.discover_review_add_count import ketch.app.shared.generated.resources.discover_select_to_add @@ -51,9 +52,10 @@ import kotlinx.coroutines.Job import org.jetbrains.compose.resources.stringResource /** - * The add bar over the composer: how much of [session] is selected, saying how much of it comes - * from messages before the newest, the device it goes to (with two devices or more), Add now, - * and Review & add, which opens the add sheet with the selection. + * The add bar over the composer: a button that clears the selection, how much of [session] is + * selected, saying how much of it comes from messages before the newest, the device it goes to + * (with two devices or more), Add now, and Review & add, which opens the add sheet with the + * selection. * * On a phone it is one row, "2 selected" with Add now and a button for a menu that holds Review * & add and the devices to add to. @@ -106,6 +108,15 @@ internal fun DiscoverAddBar( horizontalArrangement = Arrangement.spacedBy(spacing.s2), modifier = Modifier.fillMaxWidth().heightIn(min = KetchTheme.density.buttonMedium), ) { + if (selected.isNotEmpty()) { + // Earlier messages' results may be far up the chat; this lets go of them all at once. + KetchIconButton( + icon = KetchIcon.Close, + onClick = state.aiDiscover::clearSelection, + enabled = !busy, + contentDescription = stringResource(Res.string.discover_clear_selection), + ) + } if (phone) { // A phone's row leaves the summary little room beside the buttons, so the size and how // many come from earlier go under the count. diff --git a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/state/AiDiscoverControllerTest.kt b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/state/AiDiscoverControllerTest.kt index eed4ffb8..8a9b8cc3 100644 --- a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/state/AiDiscoverControllerTest.kt +++ b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/state/AiDiscoverControllerTest.kt @@ -492,6 +492,23 @@ class AiDiscoverControllerTest { assertEquals(listOf(again), controller.selectedCandidates()) } + @Test + fun clearSelection_resultsOfSeveralTurns_deselectsThemAll() = runTest { + val (first, later) = candidate("a.dmg") to candidate("b.dmg") + val saved = savedSession("blender", ListFixtures.START, found = listOf(first)).let { + it.copy(turns = it.turns + it.turns.single().copy(id = "later", candidates = listOf(later))) + } + val history = InMemoryDiscoverHistoryStore(listOf(saved)) + val controller = controller(FakeAiProvider(), history = history) + controller.open(saved.id) + controller.toggle(first) + controller.toggle(later) + + controller.clearSelection() + + assertEquals(emptyList(), controller.selectedCandidates()) + } + @Test fun found_onlyTurnsThatEndWithResults() = runTest { val found = mutableListOf()