Skip to content

Commit 9041df9

Browse files
committed
fix: retire URLhaus and honor OpenPhish feed cadence
URLhaus requires an Auth-Key and has no usable keyless path. Its parser test described a removed source, so ETag, 304 and six-hour expiry tests now cover the live feed. Cache and pin tests use active sources. Legacy stored preference and backup names remain readable.
1 parent a084386 commit 9041df9

18 files changed

Lines changed: 157 additions & 247 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to CallShield will be documented in this file.
44

55
## Unreleased
66

7+
- Removed the inactive URLhaus lookup and its Settings claim. Optional link
8+
checks now name PhishTank and OpenPhish. The OpenPhish feed refreshes every
9+
six hours and reuses its local copy when the server reports no change.
710
- Cached prefix ranges now expire on time, even when no feed sync runs.
811
- Frequency auto-block now counts prior incoming calls that reached the phone's
912
call log as answered or missed. Calls stopped or silenced by CallShield, and

‎README.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ SMS-specific layers (append after the shared chain, in their own priority order)
147147
- **Caller ID Overlay**. Suspicious calls (heuristic score 30-59) can use an explicit, default-off live enrichment option that checks SkipCalls. Clean calls never trigger it
148148
- **Region & caller-name rules**. Opt-in offline blocking outside the US states, Canadian provinces, area codes (`+1809`) and country calling codes (`+39`) you allow, plus bounded `*`/`?` trust and block patterns for carrier-presented caller names. Explicit number, system, prefix and wildcard blocks, and all allow layers, keep priority. Area codes absent from the pinned NANP table pass through this regional rule
149149
- **Opt-in message notification screening**. Google/Samsung Messages are enabled by default. AOSP Messages, SMS Organizer, Signal, WhatsApp, WhatsApp Business, Gmail, Outlook, and Thunderbird can be enabled individually. Private-messenger/email matches show a separate warning without removing the original notification.
150-
- **URL Safety**. Local spam-domain checks stay on-device. Optional URLhaus (abuse.ch) checks are off by default and disclose only the registrable domain
150+
- **URL Safety**. Local spam-domain checks stay on-device. Optional link checks use PhishTank and a six-hour OpenPhish feed. PhishTank receives only the site's base domain
151151
- **STIR/SHAKEN**. Blocks calls failing carrier caller ID verification (Android 11+)
152152
- **Outgoing call check**. Opt-in, through Android's call redirection role. A call you start to a number on your blocklist, in the spam database, on a premium-rate line or on a callback-scam country code is held, and a notification tells you why and offers Call anyway. Contacts and trusted numbers ring through, and a slow check never delays the call. If you wouldn't see the notification, in car mode or under Do Not Disturb for example, the call goes through.
153153
- **Answer & hang up**. Opt-in. A call CallShield would reject is answered without video and hung up after a delay you set (1 to 10 seconds), so spam can't leave a voicemail. Silent voicemail mode, auto-mute and a meeting-mode silence still go to voicemail, and the short answered call never counts toward trusting the caller. Contributed by tikkamasalla
@@ -342,7 +342,9 @@ scrape Nomorobo's restricted carrier feed.
342342
### URL Safety (post-decision)
343343
| Source | What It Checks |
344344
|--------|---------------|
345-
| **URLhaus** (abuse.ch) | Optional, default-off malware-domain checks after local spam-domain matching; only the registrable domain is shared |
345+
| **Local spam-domain list** | Checks known spam domains on the phone |
346+
| **PhishTank** | Optional link lookup; receives only the site's base domain |
347+
| **OpenPhish** | Optional feed checked for updates after six hours of use; matches stay on the phone |
346348

347349
## Security
348350

@@ -364,11 +366,11 @@ scrape Nomorobo's restricted carrier feed.
364366

365367
## Privacy
366368

367-
All detection runs on-device. No personal data is collected. Network requests:
369+
Call and message decisions run on-device. No personal data is collected. Network requests:
368370
- Syncing spam database from GitHub (public)
369371
- Optional live caller enrichment is off by default and runs only for locally suspicious calls. The setting names every destination host before a number is shared
370372
- Community reports to Cloudflare Worker (phone number only, no identity)
371-
- Local spam-domain checks don't disclose SMS/RCS links. Optional URLhaus checks are opt-in and send only the registrable domain
373+
- Local spam-domain checks don't disclose SMS or RCS links. Optional PhishTank lookups send only the site's base domain, and the OpenPhish feed is downloaded for local matching. Neither receives message text
372374

373375
No API keys. None required, none optional, no credential entry anywhere in the app. No accounts. No analytics. No ads.
374376

@@ -533,7 +535,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
533535
| Settings | DataStore Preferences 1.2.1 |
534536
| Background | WorkManager 2.11.2 |
535537
| Community API | Cloudflare Workers |
536-
| URL Safety | Local spam-domain data; optional URLhaus (abuse.ch) |
538+
| URL Safety | Local spam-domain data; optional PhishTank and OpenPhish |
537539
| Verification | Local Gradle, lint, and release-artifact checks |
538540
| Tests | 1556 JVM unit tests (JUnit) |
539541
| Strings | 1583 string resources and 38 plural groups (translation-ready) |

‎app/src/main/java/com/sysadmindoc/callshield/data/BackupSettingsPreferences.kt‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ internal fun Preferences.toBackupSettings(): BackupSettings =
2929
heuristicsEnabled = this[SpamRepository.KEY_HEURISTICS] ?: true,
3030
smsContentEnabled = this[SpamRepository.KEY_SMS_CONTENT] ?: true,
3131
smsBurstEnabled = this[SpamRepository.KEY_SMS_BURST] ?: true,
32-
urlhausStripQueryEnabled = this[SpamRepository.KEY_URLHAUS_STRIP_QUERY] ?: true,
33-
urlhausRemoteLookupEnabled = this[SpamRepository.KEY_URLHAUS_REMOTE_LOOKUP] ?: false,
32+
urlhausStripQueryEnabled = this[SpamRepository.KEY_URL_STRIP_QUERY] ?: true,
33+
urlhausRemoteLookupEnabled = this[SpamRepository.KEY_REMOTE_URL_LOOKUP] ?: false,
3434
liveCallerEnrichmentEnabled = this[SpamRepository.KEY_LIVE_CALLER_ENRICHMENT] ?: false,
3535
contactWhitelistEnabled = this[SpamRepository.KEY_CONTACT_WHITELIST] ?: true,
3636
contactsOnlyEnabled = this[SpamRepository.KEY_CONTACTS_ONLY] ?: false,
@@ -148,8 +148,8 @@ internal fun BackupSettings.writeTo(preferences: MutablePreferences) {
148148
preferences[SpamRepository.KEY_HEURISTICS] = heuristicsEnabled
149149
preferences[SpamRepository.KEY_SMS_CONTENT] = smsContentEnabled
150150
preferences[SpamRepository.KEY_SMS_BURST] = smsBurstEnabled
151-
preferences[SpamRepository.KEY_URLHAUS_STRIP_QUERY] = urlhausStripQueryEnabled
152-
preferences[SpamRepository.KEY_URLHAUS_REMOTE_LOOKUP] = urlhausRemoteLookupEnabled
151+
preferences[SpamRepository.KEY_URL_STRIP_QUERY] = urlhausStripQueryEnabled
152+
preferences[SpamRepository.KEY_REMOTE_URL_LOOKUP] = urlhausRemoteLookupEnabled
153153
preferences[SpamRepository.KEY_LIVE_CALLER_ENRICHMENT] = liveCallerEnrichmentEnabled
154154
preferences[SpamRepository.KEY_CONTACT_WHITELIST] = contactWhitelistEnabled
155155
preferences[SpamRepository.KEY_CONTACTS_ONLY] = contactsOnlyEnabled

‎app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,10 @@ class SpamRepository(
182182
val KEY_HEURISTICS = booleanPreferencesKey("heuristics_enabled")
183183
val KEY_SMS_CONTENT = booleanPreferencesKey("sms_content_analysis_enabled")
184184
val KEY_SMS_BURST = booleanPreferencesKey("sms_burst_detection_enabled")
185-
val KEY_URLHAUS_STRIP_QUERY = booleanPreferencesKey("urlhaus_strip_query_enabled")
186-
val KEY_URLHAUS_REMOTE_LOOKUP = booleanPreferencesKey("urlhaus_remote_lookup_enabled")
185+
186+
// Keep the old storage names so an app update preserves both settings.
187+
val KEY_URL_STRIP_QUERY = booleanPreferencesKey("urlhaus_strip_query_enabled")
188+
val KEY_REMOTE_URL_LOOKUP = booleanPreferencesKey("urlhaus_remote_lookup_enabled")
187189
val KEY_LIVE_CALLER_ENRICHMENT = booleanPreferencesKey("live_caller_enrichment_enabled")
188190
val KEY_CONTACT_WHITELIST = booleanPreferencesKey("contact_whitelist_enabled")
189191
val KEY_CONTACTS_ONLY = booleanPreferencesKey("contacts_only_mode_enabled")
@@ -393,8 +395,8 @@ class SpamRepository(
393395
val heuristicsEnabled: Flow<Boolean> = settingsRepository.heuristicsEnabled
394396
val smsContentEnabled: Flow<Boolean> = settingsRepository.smsContentEnabled
395397
val smsBurstEnabled: Flow<Boolean> = settingsRepository.smsBurstEnabled
396-
val urlhausStripQueryEnabled: Flow<Boolean> = settingsRepository.urlhausStripQueryEnabled
397-
val urlhausRemoteLookupEnabled: Flow<Boolean> = settingsRepository.urlhausRemoteLookupEnabled
398+
val urlStripQueryEnabled: Flow<Boolean> = settingsRepository.urlStripQueryEnabled
399+
val remoteUrlLookupEnabled: Flow<Boolean> = settingsRepository.remoteUrlLookupEnabled
398400
val liveCallerEnrichmentEnabled: Flow<Boolean> = settingsRepository.liveCallerEnrichmentEnabled
399401
val contactWhitelistEnabled: Flow<Boolean> = settingsRepository.contactWhitelistEnabled
400402
val contactsOnlyEnabled: Flow<Boolean> = settingsRepository.contactsOnlyEnabled
@@ -575,9 +577,9 @@ class SpamRepository(
575577

576578
suspend fun setSmsBurst(enabled: Boolean) = settingsRepository.setSmsBurst(enabled)
577579

578-
suspend fun setUrlhausStripQuery(enabled: Boolean) = settingsRepository.setUrlhausStripQuery(enabled)
580+
suspend fun setUrlStripQuery(enabled: Boolean) = settingsRepository.setUrlStripQuery(enabled)
579581

580-
suspend fun setUrlhausRemoteLookup(enabled: Boolean) = settingsRepository.setUrlhausRemoteLookup(enabled)
582+
suspend fun setRemoteUrlLookup(enabled: Boolean) = settingsRepository.setRemoteUrlLookup(enabled)
581583

582584
suspend fun setLiveCallerEnrichment(enabled: Boolean) = settingsRepository.setLiveCallerEnrichment(enabled)
583585

‎app/src/main/java/com/sysadmindoc/callshield/data/remote/HttpClient.kt‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,6 @@ object HttpClient {
7171
"sha256/kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=",
7272
"sha256/mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=",
7373
),
74-
"urlhaus-api.abuse.ch" to
75-
listOf(
76-
"sha256/Yz0ts4M9B9b1XBQTQtITniseuxd86RWgvl5+aekJM8Q=",
77-
"sha256/A7AXWj1rjKywVBFqQcQvoHEEWHeViDOFXrwzRs984Xc=",
78-
"sha256/68l4rg3Z5YItaxllJZb2IMk9fK76lSGRywUKYyypAF8=",
79-
),
8074
)
8175

8276
internal val certificatePinner: CertificatePinner =

‎app/src/main/java/com/sysadmindoc/callshield/data/remote/UrlSafetyChecker.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ object UrlSafetyChecker {
2323
val isMalicious: Boolean,
2424
val threat: String = "",
2525
val tags: List<String> = emptyList(),
26-
val source: UrlThreatSource = UrlThreatSource.URLHAUS,
27-
val sourceVersion: String = UrlThreatSource.URLHAUS.defaultVersion,
26+
val source: UrlThreatSource = UrlThreatSource.NONE,
27+
val sourceVersion: String = UrlThreatSource.NONE.defaultVersion,
2828
val category: UrlThreatCategory = UrlThreatCategory.UNKNOWN,
2929
val verdict: UrlThreatVerdict = if (isMalicious) UrlThreatVerdict.MALICIOUS else UrlThreatVerdict.CLEAN,
3030
val expiresAtMillis: Long = 0L,

‎app/src/main/java/com/sysadmindoc/callshield/data/remote/UrlThreatAdapters.kt‎

Lines changed: 20 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import java.time.Instant
1313
import java.util.concurrent.TimeUnit
1414

1515
private const val URL_THREAT_USER_AGENT = "CallShield/1.0 (URL threat lookup)"
16-
private const val URLHAUS_ENDPOINT = "https://urlhaus-api.abuse.ch/v1/url/"
1716
private const val PHISHTANK_ENDPOINT = "https://checkurl.phishtank.com/checkurl/"
1817
private const val OPENPHISH_FEED_URL =
1918
"https://raw.githubusercontent.com/openphish/public_feed/refs/heads/main/feed.txt"
@@ -23,7 +22,7 @@ private const val WEB_RISK_ENDPOINT = "https://webrisk.googleapis.com/v1/uris:se
2322
private const val MAX_URL_LOOKUP_RESPONSE_BYTES = 256L * 1024L
2423
private const val MAX_OPENPHISH_FEED_BYTES = 16L * 1024L * 1024L
2524
private const val MAX_OPENPHISH_HOSTS = 250_000
26-
private const val OPENPHISH_FEED_TTL_MILLIS = 30L * 60L * 1_000L
25+
private const val OPENPHISH_FEED_TTL_MILLIS = 6L * 60L * 60L * 1_000L
2726
private const val MAX_REMOTE_TTL_MILLIS = 24L * 60L * 60L * 1_000L
2827

2928
private val urlThreatClient: OkHttpClient =
@@ -33,118 +32,6 @@ private val urlThreatClient: OkHttpClient =
3332
.readTimeout(8, TimeUnit.SECONDS)
3433
.build()
3534

36-
internal class UrlhausThreatAdapter(
37-
private val authKey: String? = null,
38-
private val client: OkHttpClient = urlThreatClient,
39-
) : UrlThreatAdapter {
40-
override val source: UrlThreatSource = UrlThreatSource.URLHAUS
41-
override val sourceVersion: String = source.defaultVersion
42-
override val isConfigured: Boolean
43-
get() = !authKey.isNullOrBlank()
44-
45-
override suspend fun lookup(
46-
canonicalUrl: String,
47-
nowMillis: Long,
48-
): UrlThreatResult =
49-
withContext(Dispatchers.IO) {
50-
if (!isConfigured) {
51-
return@withContext UrlThreatResult.unknown(
52-
source,
53-
sourceVersion,
54-
canonicalUrl,
55-
nowMillis,
56-
detail = "auth_key_unconfigured",
57-
)
58-
}
59-
if (!canonicalUrl.isSafeLookupUrl()) {
60-
return@withContext UrlThreatResult.unknown(source, sourceVersion, canonicalUrl, nowMillis, "invalid_url")
61-
}
62-
try {
63-
val requestBody = FormBody.Builder().add("url", canonicalUrl).build()
64-
val request =
65-
Request
66-
.Builder()
67-
.url(URLHAUS_ENDPOINT)
68-
.post(requestBody)
69-
.header("Auth-Key", authKey.orEmpty())
70-
.header("User-Agent", URL_THREAT_USER_AGENT)
71-
.build()
72-
client.newCall(request).execute().use { response ->
73-
if (!response.isSuccessful) {
74-
return@withContext UrlThreatResult.unknown(
75-
source,
76-
sourceVersion,
77-
canonicalUrl,
78-
nowMillis,
79-
detail = "http_${response.code}",
80-
)
81-
}
82-
val body = response.body.readUtf8Bounded(MAX_URL_LOOKUP_RESPONSE_BYTES)
83-
if (body !is BoundedResponseBody.Text) {
84-
return@withContext UrlThreatResult.unknown(source, sourceVersion, canonicalUrl, nowMillis, "empty_body")
85-
}
86-
parseUrlhausResponse(body.value, canonicalUrl, nowMillis)
87-
}
88-
} catch (_: IOException) {
89-
UrlThreatResult.unknown(source, sourceVersion, canonicalUrl, nowMillis, "unavailable")
90-
} catch (_: RuntimeException) {
91-
UrlThreatResult.unknown(source, sourceVersion, canonicalUrl, nowMillis, "unavailable")
92-
}
93-
}
94-
95-
companion object {
96-
internal fun parseUrlhausResponse(
97-
body: String,
98-
canonicalUrl: String,
99-
nowMillis: Long,
100-
): UrlThreatResult {
101-
val queryStatus = jsonString(body, "query_status").orEmpty()
102-
val threat = jsonString(body, "threat").orEmpty()
103-
val tags = jsonStringArray(body, "tags")
104-
val category =
105-
if (threat.contains("phish", ignoreCase = true) ||
106-
queryStatus.contains("phish", ignoreCase = true)
107-
) {
108-
UrlThreatCategory.PHISHING
109-
} else {
110-
UrlThreatCategory.MALWARE
111-
}
112-
val positive =
113-
queryStatus in setOf("is_malware", "is_phishing", "is_botnet_cc") ||
114-
(
115-
queryStatus == "ok" &&
116-
(jsonString(body, "url_status") != null || jsonArrayHasEntries(body, "urls"))
117-
)
118-
return if (positive) {
119-
UrlThreatResult.malicious(
120-
source = UrlThreatSource.URLHAUS,
121-
sourceVersion = UrlThreatSource.URLHAUS.defaultVersion,
122-
canonicalUrl = canonicalUrl,
123-
category = category,
124-
nowMillis = nowMillis,
125-
tags = tags,
126-
detail = threat.ifBlank { queryStatus },
127-
)
128-
} else if (queryStatus == "no_results") {
129-
UrlThreatResult.clean(
130-
UrlThreatSource.URLHAUS,
131-
UrlThreatSource.URLHAUS.defaultVersion,
132-
canonicalUrl,
133-
nowMillis,
134-
)
135-
} else {
136-
UrlThreatResult.unknown(
137-
UrlThreatSource.URLHAUS,
138-
UrlThreatSource.URLHAUS.defaultVersion,
139-
canonicalUrl,
140-
nowMillis,
141-
detail = queryStatus.ifBlank { "unrecognized_response" },
142-
)
143-
}
144-
}
145-
}
146-
}
147-
14835
internal class PhishTankThreatAdapter(
14936
private val appKey: String? = null,
15037
private val client: OkHttpClient = urlThreatClient,
@@ -269,6 +156,11 @@ internal class OpenPhishThreatAdapter(
269156
loadSnapshot(nowMillis)
270157
?: return UrlThreatResult.unknown(source, sourceVersion, canonicalUrl, nowMillis, "feed_unavailable")
271158
val host = canonicalUrl.toHttpUrlOrNull()?.host?.removePrefix("www.")
159+
val resultTtlMillis =
160+
minOf(
161+
UrlThreatCache.DEFAULT_TTL_MILLIS,
162+
(snapshot.expiresAtMillis - nowMillis).coerceAtLeast(1L),
163+
)
272164
return if (host != null && host in snapshot.hosts) {
273165
UrlThreatResult.malicious(
274166
source = source,
@@ -277,9 +169,10 @@ internal class OpenPhishThreatAdapter(
277169
category = UrlThreatCategory.PHISHING,
278170
nowMillis = nowMillis,
279171
detail = "feed_domain_match",
172+
ttlMillis = resultTtlMillis,
280173
)
281174
} else {
282-
UrlThreatResult.clean(source, sourceVersion, canonicalUrl, nowMillis)
175+
UrlThreatResult.clean(source, sourceVersion, canonicalUrl, nowMillis, ttlMillis = resultTtlMillis)
283176
}
284177
}
285178

@@ -293,8 +186,14 @@ internal class OpenPhishThreatAdapter(
293186
.Builder()
294187
.url(OPENPHISH_FEED_URL)
295188
.header("User-Agent", URL_THREAT_USER_AGENT)
189+
.apply { feedSnapshot?.etag?.let { header("If-None-Match", it) } }
296190
.build()
297191
client.newCall(request).execute().use { response ->
192+
if (response.code == 304) {
193+
return@withContext feedSnapshot?.copy(expiresAtMillis = nowMillis + OPENPHISH_FEED_TTL_MILLIS)?.also {
194+
feedSnapshot = it
195+
}
196+
}
298197
if (!response.isSuccessful) return@withContext null
299198
val body = response.body.readUtf8Bounded(MAX_OPENPHISH_FEED_BYTES)
300199
if (body !is BoundedResponseBody.Text) return@withContext null
@@ -311,7 +210,11 @@ internal class OpenPhishThreatAdapter(
311210
}.distinct()
312211
.take(MAX_OPENPHISH_HOSTS)
313212
.toSet()
314-
FeedSnapshot(hosts = hosts, expiresAtMillis = nowMillis + OPENPHISH_FEED_TTL_MILLIS).also {
213+
FeedSnapshot(
214+
hosts = hosts,
215+
expiresAtMillis = nowMillis + OPENPHISH_FEED_TTL_MILLIS,
216+
etag = response.header("ETag")?.takeIf { it.isNotBlank() && it.length <= 256 },
217+
).also {
315218
feedSnapshot = it
316219
}
317220
}
@@ -326,6 +229,7 @@ internal class OpenPhishThreatAdapter(
326229
private data class FeedSnapshot(
327230
val hosts: Set<String>,
328231
val expiresAtMillis: Long,
232+
val etag: String?,
329233
)
330234
}
331235

@@ -610,13 +514,3 @@ private fun jsonStringArray(
610514
.take(32)
611515
.toList()
612516
}
613-
614-
private fun jsonArrayHasEntries(
615-
body: String,
616-
key: String,
617-
): Boolean =
618-
Regex("\\\"${Regex.escape(key)}\\\"\\s*:\\s*\\[([^]]*)]", RegexOption.IGNORE_CASE)
619-
.find(body)
620-
?.groupValues
621-
?.getOrNull(1)
622-
?.isNotBlank() == true

0 commit comments

Comments
 (0)