Skip to content

Commit bcbb61d

Browse files
committed
fix(screening): let a carrier-verified call override only stale database rows
STIR_SHAKEN_TRUSTED (5300) sits above GITHUB_DATABASE (5200), so any number on the spam database that arrived with a PASSED verification rang through. PASSED proves the caller owns the line, not that the call is wanted: 44% to 51% of robocalls in 2024 carried A-level attestation. The ranking exists to protect the real owners of numbers spoofed in old complaint data, which argues for an age condition rather than trusting every pass. The trust checker now looks up the matching row and only allows when that row's evidence is stale: its newest date is more than 365 days old, it isn't on the hot list (three distinct reporters), and it carries no corroborated community evidence. Otherwise it passes and DatabaseChecker blocks as it would for any other call. User blocks and allows keep their ranks. Community provenance never reached the device: shard rows carry it in a "sources" field that SpamNumberJson ignored. The field is now parsed, and a row published without its own evidence list gets a community_reports record next to the synthesized database one, with the same corroborated/unverified rule. Rows already on a device pick it up when their shard next changes. Every community row today is from 2026, inside the 365-day window, so none depends on it yet. Tests (new only; no existing assertion changed): - StirShakenTrustCheckerTest pins the decision table: current row, the 365-day boundary, a newer evidence record, corroborated versus single community reports, hot-list rows, and undated rows. - StirShakenDatabaseEvidencePipelineTest runs the whole call pipeline under Robolectric: a verified call from a current row blocks, from a stale row rings through, an unverified call from the stale row still blocks, and a user block still wins. - SpamRepositorySyncTest parses a real shard body and checks the community record survives into the stored evidence. - SpamPipelineIntegrationTest gains the same two pipeline cases on device. They compile; they have not run on a device in this change. Making the allow unconditional again fails five of these tests.
1 parent 7bbaae8 commit bcbb61d

10 files changed

Lines changed: 360 additions & 27 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ All notable changes to CallShield will be documented in this file.
2525
landlines were being blocked. International numbers now skip the model.
2626
- A short but valid international number (the Faroes, Greenland, Andorra, some
2727
German landlines) is no longer counted as a malformed one by the heuristics.
28+
- A call your carrier verified no longer rings through automatically when its
29+
number is on the spam database. Verification proves the caller owns the line,
30+
not that the call is wanted, and about half of all robocalls carry it. It now
31+
overrides a database match only when that entry's newest evidence is more
32+
than a year old, the number isn't trending, and community reports haven't
33+
confirmed it. That still protects people whose numbers were spoofed in old
34+
complaint data. Your own blocks and allows are unaffected.
2835

2936
- External blocklist subscriptions now stay current. A list used to be
3037
fetched only when you added it or switched it back on, so one that changes

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ All detection layers implement a shared `IChecker` interface and run in priority
403403
| 5400 | **Range Patterns** (A5) | Block | Length-locked `#` patterns like `+33162######`, with schedule + coverage safety rail |
404404
| 5350 | **Temporary Allow** | Allow | One-off false-positive recovery from the Blocked Log. Beats all downloaded data, never your own rules |
405405
| 5320 | **Prefix Rules** | Block | Downloaded wangiri country codes, US premium rate (+1900), international premium |
406-
| 5300 | **STIR/SHAKEN Authenticated** | Allow | Carrier-authenticated caller ID can allow through lower-confidence heuristic/ML suspicion while explicit blocks still win first |
406+
| 5300 | **STIR/SHAKEN Authenticated** | Allow | Carrier-authenticated caller ID allows through heuristic/ML suspicion, and through a database match only when that row's newest evidence is over a year old, it isn't trending, and it has no corroborated community reports. Explicit blocks still win first |
407407
| 5200 | **Spam Database** | Block | 51,634 imported spam numbers plus scheduled hot-list data |
408408
| 5150 | **Database Prefix Expansion** | Block | Auto-blocks last-two-digit siblings of confirmed database entries |
409409
| 5000 | **Recently Dialed** | Allow | Numbers you called in the last 24h. They're probably calling back |

‎app/src/androidTest/java/com/sysadmindoc/callshield/data/SpamPipelineIntegrationTest.kt‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import org.junit.Assert.assertTrue
2121
import org.junit.Before
2222
import org.junit.Test
2323
import org.junit.runner.RunWith
24+
import java.time.LocalDate
25+
import java.time.ZoneOffset
2426

2527
@RunWith(AndroidJUnit4::class)
2628
class SpamPipelineIntegrationTest {
@@ -129,6 +131,44 @@ class SpamPipelineIntegrationTest {
129131
assertEquals("user_blocklist", result.matchSource)
130132
}
131133

134+
@Test
135+
fun trustedStirSignalYieldsToACurrentDatabaseRow() =
136+
runBlocking {
137+
val number = "+12125550104"
138+
dao.insertNumber(
139+
SpamNumber(
140+
number = number,
141+
type = "robocall",
142+
lastSeen = LocalDate.now(ZoneOffset.UTC).minusDays(30).toString(),
143+
source = "github",
144+
),
145+
)
146+
147+
val result = repo.isSpam(number = number, callerIdentity = CallerIdentity(verificationStatus = 1))
148+
149+
assertTrue(result.isSpam)
150+
assertEquals("database", result.matchSource)
151+
}
152+
153+
@Test
154+
fun trustedStirSignalOverridesAStaleDatabaseRow() =
155+
runBlocking {
156+
val number = "+12125550105"
157+
dao.insertNumber(
158+
SpamNumber(
159+
number = number,
160+
type = "robocall",
161+
lastSeen = LocalDate.now(ZoneOffset.UTC).minusDays(900).toString(),
162+
source = "github",
163+
),
164+
)
165+
166+
val result = repo.isSpam(number = number, callerIdentity = CallerIdentity(verificationStatus = 1))
167+
168+
assertFalse(result.isSpam)
169+
assertEquals("stir_shaken_trusted", result.matchSource)
170+
}
171+
132172
@Test
133173
fun nationalAndE164FormsShareOneInjectedRegionalIdentity() =
134174
runBlocking {

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

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,21 +1070,7 @@ internal fun sanitizeDatabaseNumbers(
10701070
if (normalizedNumber.isBlank()) {
10711071
null
10721072
} else {
1073-
val evidence =
1074-
json.evidence.ifEmpty {
1075-
listOf(
1076-
SourceEvidenceJson(
1077-
sourceId = "github_database",
1078-
evidenceType = "aggregate_database",
1079-
license = "CallShield database terms",
1080-
attribution = "CallShield maintained spam database",
1081-
firstSeen = json.firstSeen,
1082-
lastSeen = json.lastSeen,
1083-
confidenceTier = if (json.reports >= 2) "corroborated" else "unverified",
1084-
parserVersion = "legacy-v1",
1085-
),
1086-
)
1087-
}
1073+
val evidence = json.evidence.ifEmpty { synthesizedDatabaseEvidence(json) }
10881074
SpamNumber(
10891075
number = normalizedNumber,
10901076
type = json.type.trim().ifBlank { "unknown" },
@@ -1101,6 +1087,41 @@ internal fun sanitizeDatabaseNumbers(
11011087
}
11021088
}
11031089

1090+
/**
1091+
* Evidence for a row the pipeline published without an evidence list of its
1092+
* own. A row that came from community reports says so, because the
1093+
* STIR/SHAKEN trust allow treats corroborated community evidence as current
1094+
* however old the row's date is.
1095+
*/
1096+
private fun synthesizedDatabaseEvidence(json: SpamNumberJson): List<SourceEvidenceJson> {
1097+
val tier = if (json.reports >= 2) "corroborated" else "unverified"
1098+
val database =
1099+
SourceEvidenceJson(
1100+
sourceId = "github_database",
1101+
evidenceType = "aggregate_database",
1102+
license = "CallShield database terms",
1103+
attribution = "CallShield maintained spam database",
1104+
firstSeen = json.firstSeen,
1105+
lastSeen = json.lastSeen,
1106+
confidenceTier = tier,
1107+
parserVersion = "legacy-v1",
1108+
)
1109+
if (json.sources.none { it == "community" || it == "community_reports" }) return listOf(database)
1110+
return listOf(
1111+
database,
1112+
SourceEvidenceJson(
1113+
sourceId = "community_reports",
1114+
evidenceType = "user_report",
1115+
license = "CallShield community report policy",
1116+
attribution = "CallShield users",
1117+
firstSeen = json.firstSeen,
1118+
lastSeen = json.lastSeen,
1119+
confidenceTier = tier,
1120+
parserVersion = "community-v2",
1121+
),
1122+
)
1123+
}
1124+
11041125
internal fun mergeHotListNumbers(
11051126
hotNumbers: Collection<SpamNumber>,
11061127
existingByNumber: Map<String, SpamNumber>,

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

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,19 @@ import com.sysadmindoc.callshield.data.HashWildcardMatcher
1111
import com.sysadmindoc.callshield.data.RegionRules
1212
import com.sysadmindoc.callshield.data.SmsContentAnalyzer
1313
import com.sysadmindoc.callshield.data.SmsContextChecker
14+
import com.sysadmindoc.callshield.data.SourceEvidenceCodec
1415
import com.sysadmindoc.callshield.data.SpamHeuristics
1516
import com.sysadmindoc.callshield.data.SpamMLScorer
1617
import com.sysadmindoc.callshield.data.SpamRepository
1718
import com.sysadmindoc.callshield.data.SystemBlockList
19+
import com.sysadmindoc.callshield.data.model.SpamNumber
1820
import com.sysadmindoc.callshield.data.repository.SpamRepositoryImpl
1921
import com.sysadmindoc.callshield.domain.model.CallerIdentitySignals
2022
import com.sysadmindoc.callshield.service.CallerIdOverlayService
2123
import kotlinx.coroutines.withTimeoutOrNull
24+
import java.time.LocalDate
25+
import java.time.ZoneOffset
26+
import java.time.format.DateTimeParseException
2227
import java.util.Calendar
2328

2429
/** Absolute safety floor for recognized emergency and public-safety codes. */
@@ -157,10 +162,19 @@ internal class ContactsOnlyChecker(
157162
* do NOT treat C as a sole block signal. We only fire the allow on a
158163
* positive PASSED signal, never on the absence of one.
159164
*
165+
* A PASSED attestation proves the caller owns the line, not that the call
166+
* is wanted: 44% to 51% of robocalls in 2024 carried A-level attestation.
167+
* The allow sits above the downloaded database to protect the real owners
168+
* of numbers that were spoofed in old complaint data, so it only overrides
169+
* a database row whose evidence is stale (see [hasCurrentEvidence]). A row
170+
* with current evidence falls through to [DatabaseChecker] and blocks.
171+
*
160172
* Gated on the user setting and the runtime ability to read a
161173
* verification status (non-null); skipped for historical scans and SMS.
162174
*/
163-
internal class StirShakenTrustChecker : IChecker {
175+
internal class StirShakenTrustChecker(
176+
private val repo: SpamRepositoryImpl,
177+
) : IChecker {
164178
override val priority = CheckerPriority.STIR_SHAKEN_TRUSTED
165179
override val name = "stir_shaken_trusted"
166180

@@ -170,7 +184,10 @@ internal class StirShakenTrustChecker : IChecker {
170184
verificationStatus = ctx.verificationStatus,
171185
)
172186

173-
override suspend fun check(ctx: CheckContext): BlockResult? = decidePure(ctx.verificationStatus)
187+
override suspend fun check(ctx: CheckContext): BlockResult? {
188+
if (ctx.verificationStatus != VERIFICATION_STATUS_PASSED) return null
189+
return decidePure(ctx.verificationStatus, repo.findByNumberInternal(ctx.number))
190+
}
174191

175192
companion object {
176193
// android.telecom.Connection.VERIFICATION_STATUS_PASSED == 1 (AOSP).
@@ -187,12 +204,57 @@ internal class StirShakenTrustChecker : IChecker {
187204
verificationStatus: Int?,
188205
): Boolean = settingEnabled && verificationStatus != null
189206

190-
/** Pure-logic helper — returns the allow result iff the carrier signed PASSED. */
191-
internal fun decidePure(verificationStatus: Int?): BlockResult? =
192-
if (verificationStatus == VERIFICATION_STATUS_PASSED) {
193-
BlockResult.allow("stir_shaken_trusted")
194-
} else {
195-
null
207+
/** Database evidence at most this old outranks a PASSED attestation. */
208+
internal const val CURRENT_EVIDENCE_DAYS = 365L
209+
210+
private const val COMMUNITY_SOURCE_ID = "community_reports"
211+
private const val CORROBORATED_TIER = "corroborated"
212+
private const val HOT_LIST_SOURCE = "hot_list"
213+
private const val ISO_DATE_LENGTH = 10
214+
215+
/**
216+
* Pure-logic helper. Allows iff the carrier signed PASSED and the
217+
* matching database row, if any, has no current evidence against it.
218+
*/
219+
internal fun decidePure(
220+
verificationStatus: Int?,
221+
row: SpamNumber? = null,
222+
today: LocalDate = LocalDate.now(ZoneOffset.UTC),
223+
): BlockResult? =
224+
when {
225+
verificationStatus != VERIFICATION_STATUS_PASSED -> null
226+
row != null && hasCurrentEvidence(row, today) -> null
227+
else -> BlockResult.allow("stir_shaken_trusted")
228+
}
229+
230+
/**
231+
* Whether a row is recent or corroborated enough to block a caller
232+
* the carrier verified: seen within [CURRENT_EVIDENCE_DAYS], trending
233+
* on the hot list (which requires three distinct reporters), or
234+
* backed by corroborated community reports. A row with none of those
235+
* is old complaint data, where the real owners of spoofed numbers sit.
236+
*/
237+
internal fun hasCurrentEvidence(
238+
row: SpamNumber,
239+
today: LocalDate,
240+
): Boolean {
241+
if (row.source == HOT_LIST_SOURCE) return true
242+
val cutoff = today.minusDays(CURRENT_EVIDENCE_DAYS)
243+
if (seenSince(row.lastSeen, cutoff)) return true
244+
val evidence = SourceEvidenceCodec.decode(row.evidenceJson)
245+
return evidence.any { seenSince(it.lastSeen, cutoff) } ||
246+
evidence.any { it.sourceId == COMMUNITY_SOURCE_ID && it.confidenceTier == CORROBORATED_TIER }
247+
}
248+
249+
/** An undated or unreadable stamp is no evidence of recency. */
250+
private fun seenSince(
251+
date: String,
252+
cutoff: LocalDate,
253+
): Boolean =
254+
try {
255+
!LocalDate.parse(date.take(ISO_DATE_LENGTH)).isBefore(cutoff)
256+
} catch (_: DateTimeParseException) {
257+
false
196258
}
197259
}
198260
}

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,10 @@ object CheckerPriority {
239239
// STIR_SHAKEN_TRUSTED: a carrier-signed PASSED attestation is a strong
240240
// trust signal, but the user's explicit blocklist / wildcard rules AND
241241
// the categorical prefix feed are authoritative and MUST win against it
242-
// (SIM-box fraud can carry A-attestation). It still
243-
// beats exact downloaded rows and every statistical layer below.
242+
// (SIM-box fraud can carry A-attestation). It beats every statistical
243+
// layer below, but an exact downloaded row only when that row's evidence
244+
// is stale: older than a year, not trending, and without corroborated
245+
// community reports (StirShakenTrustChecker.hasCurrentEvidence).
244246
// Paired with STIR_SHAKEN (block side) above.
245247
const val TEMPORARY_ALLOW = 5_350 // one-off false-positive recovery
246248
const val PREFIX_MATCH = 5_320 // downloaded prefix reputation rows (country/NPA ranges)
@@ -442,7 +444,7 @@ object SpamCheckers {
442444
add(WhitelistChecker(repo))
443445
add(ContactWhitelistChecker(appContext, dependencies.spamHeuristics))
444446
add(ContactsOnlyChecker(appContext, dependencies.spamHeuristics))
445-
add(StirShakenTrustChecker())
447+
add(StirShakenTrustChecker(repo))
446448
add(StirShakenChecker())
447449
add(UserBlocklistChecker(repo))
448450
add(TemporaryAllowChecker(repo))

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ data class SpamNumberJson(
156156
@param:Json(name = "last_seen") val lastSeen: String = "",
157157
val description: String = "",
158158
val evidence: List<SourceEvidenceJson> = emptyList(),
159+
/** Where the pipeline got the row, such as `legacy_import` or `community`. */
160+
val sources: List<String> = emptyList(),
159161
)
160162

161163
/** Independent source evidence retained on an imported database row. */

‎app/src/test/java/com/sysadmindoc/callshield/data/SpamRepositorySyncTest.kt‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,40 @@ package com.sysadmindoc.callshield.data
33
import com.sysadmindoc.callshield.data.model.SourceEvidenceJson
44
import com.sysadmindoc.callshield.data.model.SpamNumber
55
import com.sysadmindoc.callshield.data.model.SpamNumberJson
6+
import com.sysadmindoc.callshield.data.remote.GitHubDataSource
67
import org.junit.Assert.assertEquals
78
import org.junit.Assert.assertFalse
89
import org.junit.Assert.assertNull
910
import org.junit.Assert.assertTrue
1011
import org.junit.Test
1112

1213
class SpamRepositorySyncTest {
14+
@Test
15+
fun `a community row keeps its community provenance on the device`() {
16+
// The shape the pipeline publishes: provenance in `sources`, no
17+
// evidence list. The STIR/SHAKEN trust allow reads it back.
18+
val shard =
19+
GitHubDataSource()
20+
.parseSpamShardJson(
21+
"""
22+
{"shard_id": "00", "prefixes": [], "numbers": [
23+
{"number": "+18002990523", "type": "spam", "reports": 2, "first_seen": "2026-08-25",
24+
"last_seen": "2026-08-25", "description": "Community reported", "sources": ["community"]},
25+
{"number": "+12012302420", "type": "robocall", "reports": 2, "first_seen": "2016-08-17",
26+
"last_seen": "2016-08-17", "description": "FCC caller ID: Robocalls", "sources": ["legacy_import"]}
27+
]}
28+
""".trimIndent(),
29+
).getOrThrow()
30+
31+
val sanitized = sanitizeDatabaseNumbers(shard.numbers, normalizeNumber = { it }, preservedUserBlockedNumbers = emptyMap())
32+
33+
val community = SourceEvidenceCodec.decode(sanitized[0].evidenceJson)
34+
assertEquals(listOf("github_database", "community_reports"), community.map { it.sourceId })
35+
assertEquals("corroborated", community.last().confidenceTier)
36+
assertEquals("2026-08-25", community.last().lastSeen)
37+
assertEquals(listOf("github_database"), SourceEvidenceCodec.decode(sanitized[1].evidenceJson).map { it.sourceId })
38+
}
39+
1340
@Test
1441
fun `sanitizeDatabaseNumbers preserves user block flag for matching remote numbers`() {
1542
val sanitized =

0 commit comments

Comments
 (0)