Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,33 @@ 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",
)
}

// Scoring
var score = BASE_SCORE
val notes = mutableListOf<String>()

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")
Expand Down Expand Up @@ -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) }
}
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DiscoverFound>()
Expand Down
Loading