Skip to content

Commit 4d8a65e

Browse files
committed
fix(pipeline): count hot-list and domain reporters on one UTC day
The Worker's reporter buckets are HMACs of the UTC day, so one reporter whose reports straddle midnight shows up as two, or four with a Wi-Fi and a mobile network. The hot list counted identities over a rolling 24 hours (three needed) and the domain feed over its whole queue (two needed), so two people either side of midnight could put a number on every phone's hot list. Both now count reporters on the busiest UTC day, the same rule the community merge uses, and the /48 device cap still applies within the day. A domain report without a parseable reported_at no longer counts, since its bucket can't be placed on a day. The Worker always stamps one.
1 parent 2a5ce03 commit 4d8a65e

7 files changed

Lines changed: 98 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ All notable changes to CallShield will be documented in this file.
4343
version, Android version, phone and Protection test result.
4444
- Database v49 re-reads FCC's newest 500,000 complaints and adds 200 new FTC
4545
ones, for 51,362 numbers.
46+
- The hot list and the spam domain feed count reporters on one UTC day. The
47+
report Worker's reporter ID changes at midnight, so two people reporting
48+
either side of it counted as four and could put a number on every phone's
49+
hot list.
4650
- Calls from outside North America no longer get a US or Caribbean place name
4751
in the caller-ID popup. A +65 call used to read as Birmingham, AL, and Number
4852
details offered to block that US area code. On a phone outside North America,

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,8 @@ scrape Nomorobo's restricted carrier feed.
334334
### Hot List (checked every 30 minutes, regenerated by hand)
335335
| File | Contents |
336336
|------|----------|
337-
| `hot_numbers.json` | Top 500 trending numbers (last 24h) |
338-
| `hot_ranges.json` | NPA-NXX prefixes where at least 4 trending numbers drew reports from at least 6 reporters between them |
337+
| `hot_numbers.json` | Top 500 trending numbers (last 24h, with three reporters on the same UTC day) |
338+
| `hot_ranges.json` | NPA-NXX prefixes where at least 4 trending numbers drew reports from at least 6 reporters between them on one UTC day |
339339
| `spam_domains.json` | Phishing/spam domains from community SMS reports |
340340

341341
### Real-Time Lookup (overlay only)

‎scripts/extract_spam_domains.py‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@
3030
parse_cleared_feeds,
3131
report_queue_digest,
3232
)
33-
from report_dedup import validated_report_id, validated_reporter_bucket
33+
from report_dedup import (
34+
busiest_day_reporters,
35+
parse_reported_at,
36+
reporter_day_key,
37+
validated_report_id,
38+
validated_reporter_bucket,
39+
)
3440

3541
DATA_DIR = Path(os.environ.get("CALLSHIELD_DATA_DIR", Path(__file__).parent.parent / "data"))
3642
REPORTS_DIR = Path(os.environ.get("CALLSHIELD_REPORTS_DIR", DATA_DIR / "reports"))
@@ -167,8 +173,10 @@ def main(argv: list[str] | None = None) -> int:
167173
domain_numbers: dict[str, set[str]] = {}
168174
# Reporters count per /48 group here, not per /64 device as on the hot list:
169175
# with MIN_REPORTERS at 2, the hot list's two-devices-per-/48 cap would let
170-
# one /48 meet the quorum alone.
171-
domain_reporters: dict[str, set[str]] = {}
176+
# one /48 meet the quorum alone. Each is kept with its UTC day, because the
177+
# bucket changes at midnight and only one day's buckets are known to be
178+
# different people.
179+
domain_reporters: dict[str, set[tuple[str, str]]] = {}
172180
# A report the app resent under the same id (from another network, so from
173181
# another reporter bucket) must not count as a second reporter.
174182
seen_report_ids: set[str] = set()
@@ -188,8 +196,11 @@ def main(argv: list[str] | None = None) -> int:
188196
continue
189197

190198
number = validated_report_number(report.get("number", ""))
191-
reporter_bucket = validated_reporter_bucket(report.get("reporter_bucket"))
192-
if not number or not reporter_bucket:
199+
reporter_day = reporter_day_key(
200+
validated_reporter_bucket(report.get("reporter_bucket")),
201+
parse_reported_at(report.get("reported_at")),
202+
)
203+
if not number or reporter_day is None:
193204
continue
194205
report_id = validated_report_id(report.get("report_id"))
195206
if report_id:
@@ -200,7 +211,7 @@ def main(argv: list[str] | None = None) -> int:
200211
reports_scanned += 1
201212
for domain in domains:
202213
domain_numbers.setdefault(domain, set()).add(number)
203-
domain_reporters.setdefault(domain, set()).add(reporter_bucket)
214+
domain_reporters.setdefault(domain, set()).add((reporter_day[1], reporter_day[0]))
204215

205216
except Exception as e:
206217
print(f" Skipping {report_file.name}: {e}")
@@ -212,7 +223,7 @@ def main(argv: list[str] | None = None) -> int:
212223
candidates = [
213224
domain
214225
for domain, count in domain_counts.most_common()
215-
if count >= MIN_REPORTS and len(domain_reporters.get(domain, set())) >= MIN_REPORTERS
226+
if count >= MIN_REPORTS and busiest_day_reporters(domain_reporters.get(domain, set()), count=len) >= MIN_REPORTERS
216227
]
217228
try:
218229
approved = approved_domains()
@@ -229,7 +240,7 @@ def main(argv: list[str] | None = None) -> int:
229240
{
230241
"domain": domain,
231242
"distinct_numbers": domain_counts[domain],
232-
"distinct_reporters": len(domain_reporters[domain]),
243+
"distinct_reporters": busiest_day_reporters(domain_reporters[domain], count=len),
233244
}
234245
for domain in candidates
235246
if domain not in approved

‎scripts/generate_hot_list.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from report_dedup import (
3131
BURST_DUPLICATE_SECONDS,
3232
MAX_DEVICES_PER_GROUP,
33-
capped_reporter_count,
33+
busiest_day_reporters,
3434
find_burst_duplicates,
3535
find_resent_reports,
3636
parse_reported_at,
@@ -187,6 +187,7 @@ def main(argv: list[str] | None = None) -> int:
187187
collapsed_group += 1
188188
continue
189189
seen_reporter_days.add(identity_key)
190+
dated_identity = (day_key[1], identity)
190191
entry = velocity.get(number)
191192
if entry is None:
192193
velocity[number] = {
@@ -199,13 +200,13 @@ def main(argv: list[str] | None = None) -> int:
199200
"first_seen": reported_at_str,
200201
"last_seen": reported_at_str,
201202
"description": "Trending community report",
202-
"_reporters": {identity},
203+
"_reporters": {dated_identity},
203204
"_devices_by_group": {group: {device}},
204205
"_times": [reported_at],
205206
}
206207
else:
207208
entry["reports"] += 1
208-
entry["_reporters"].add(identity)
209+
entry["_reporters"].add(dated_identity)
209210
entry["_devices_by_group"].setdefault(group, set()).add(device)
210211
entry["_times"].append(reported_at)
211212
if reported_at_str > entry["last_seen"]:
@@ -263,7 +264,7 @@ def main(argv: list[str] | None = None) -> int:
263264
if not times:
264265
continue
265266
span_minutes = int((max(times) - min(times)).total_seconds() // 60)
266-
entry["distinct_reporters"] = capped_reporter_count(reporters)
267+
entry["distinct_reporters"] = busiest_day_reporters(reporters)
267268
entry["report_span_minutes"] = span_minutes
268269
if (
269270
entry["reports"] >= MIN_REPORTS_HOT
@@ -299,7 +300,7 @@ def main(argv: list[str] | None = None) -> int:
299300
for entry in hot_internal:
300301
if (
301302
entry["reports"] < CAMPAIGN_REPORTS_PER_NUMBER
302-
or capped_reporter_count(entry["_reporters"]) < CAMPAIGN_REPORTERS_PER_NUMBER
303+
or busiest_day_reporters(entry["_reporters"]) < CAMPAIGN_REPORTERS_PER_NUMBER
303304
or entry["report_span_minutes"] < CAMPAIGN_MIN_SPAN_MINUTES
304305
):
305306
continue
@@ -310,7 +311,7 @@ def main(argv: list[str] | None = None) -> int:
310311
hot_ranges = []
311312
for npanxx, entries in sorted(npanxx_entries.items(), key=lambda item: -len(item[1])):
312313
# The cap applies across the range too, so one /48 can't fill the union.
313-
union_reporters = capped_reporter_count(set().union(*(entry["_reporters"] for entry in entries)))
314+
union_reporters = busiest_day_reporters(set().union(*(entry["_reporters"] for entry in entries)))
314315
if len(entries) < CAMPAIGN_THRESHOLD or union_reporters < CAMPAIGN_MIN_UNION_REPORTERS:
315316
continue
316317
hot_ranges.append(

‎scripts/report_dedup.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ def capped_reporter_count(identities) -> int:
6464
return sum(min(len(devices), MAX_DEVICES_PER_GROUP) for devices in devices_by_group.values())
6565

6666

67+
def busiest_day_reporters(dated_identities, count=capped_reporter_count) -> int:
68+
"""Reporters shown to be different people: the count on the busiest UTC day.
69+
70+
The Worker derives every bucket from the UTC day, so one reporter's reports
71+
either side of midnight carry different buckets. Only identities seen on the
72+
same day are known to be different reporters. `dated_identities` holds
73+
`(day, identity)` pairs and `count` counts one day's identities.
74+
"""
75+
by_day: dict[str, set] = {}
76+
for day, identity in dated_identities:
77+
by_day.setdefault(day, set()).add(identity)
78+
return max((count(identities) for identities in by_day.values()), default=0)
79+
80+
6781
def validated_report_id(value: object) -> str:
6882
"""Return the id the app gave a report, lowercased, or an empty string."""
6983
if not isinstance(value, str):

‎scripts/test_report_dedup.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from report_dedup import (
88
BURST_DUPLICATE_SECONDS,
99
MAX_DEVICES_PER_GROUP,
10+
busiest_day_reporters,
1011
capped_reporter_count,
1112
find_burst_duplicates,
1213
find_resent_reports,
@@ -174,6 +175,18 @@ def main() -> None:
174175
for minimum in (MIN_REPORTERS_HOT, CAMPAIGN_REPORTERS_PER_NUMBER, CAMPAIGN_MIN_UNION_REPORTERS):
175176
assert minimum > MAX_DEVICES_PER_GROUP, minimum
176177

178+
# ── buckets rotate at UTC midnight, so only one day's count as distinct ──
179+
before, after = "2026-07-29", "2026-07-30"
180+
one_reporter_either_side = [(before, (group, device)), (after, ("00000000000a0009", "00000000000d0009"))]
181+
assert busiest_day_reporters(one_reporter_either_side) == 1
182+
three_on_one_day = [(after, (f"00000000000a000{n}", f"00000000000d000{n}")) for n in range(1, 4)]
183+
# The day after midnight holds the three plus the second of those buckets.
184+
assert busiest_day_reporters(three_on_one_day + one_reporter_either_side) == 4
185+
# The /48 cap still applies within the day.
186+
assert busiest_day_reporters([(after, identity) for identity in rotating]) == MAX_DEVICES_PER_GROUP
187+
assert busiest_day_reporters([(before, "a"), (after, "b"), (after, "c")], count=len) == 2
188+
assert busiest_day_reporters([]) == 0
189+
177190
print("report_dedup tests passed")
178191

179192

‎scripts/test_report_pipeline.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,42 @@ def assert_reporters_count_per_device_with_group_cap(data_dir: Path) -> None:
795795
raise AssertionError(f"one /48 rotating /64s must count as two reporters at most: {hot[delegated_number]}")
796796

797797

798+
def assert_reporters_count_on_one_utc_day(data_dir: Path) -> None:
799+
"""The Worker's buckets change at UTC midnight, so one reporter either side of it counts once."""
800+
previous_day = (datetime.fromisoformat(BASE_DAY) - timedelta(days=1)).date().isoformat()
801+
straddle = [f"{previous_day}T22:00:00+00:00", f"{previous_day}T23:30:00+00:00",
802+
f"{BASE_DAY}T01:00:00+00:00", f"{BASE_DAY}T03:00:00+00:00"]
803+
two_either_side, third_same_day = "+12122340301", "+12122340302"
804+
for number, times in ((two_either_side, straddle), (third_same_day, straddle + [f"{BASE_DAY}T05:00:00+00:00"])):
805+
for index, reported_at in enumerate(times, start=1):
806+
write_report(data_dir, f"{number[1:]}-{index}.json", number, f"00000000000b{index:04x}", reported_at,
807+
device=f"00000000000c{index:04x}")
808+
# A domain seen by one bucket before midnight and another after, and one seen
809+
# by two buckets on the same day.
810+
for index, (domain, reported_at, bucket) in enumerate([
811+
("straddle.example", f"{previous_day}T23:50:00+00:00", BUCKETS[0]),
812+
("straddle.example", f"{previous_day}T23:55:00+00:00", BUCKETS[0]),
813+
("straddle.example", f"{BASE_DAY}T00:10:00+00:00", BUCKETS[1]),
814+
("sameday.example", f"{BASE_DAY}T01:00:00+00:00", BUCKETS[0]),
815+
("sameday.example", f"{BASE_DAY}T01:30:00+00:00", BUCKETS[0]),
816+
("sameday.example", f"{BASE_DAY}T02:00:00+00:00", BUCKETS[1]),
817+
]):
818+
write_report(data_dir, f"domain-{index}.json", f"+1212234032{index}", bucket, reported_at,
819+
report_type="sms_spam", domains=[domain])
820+
821+
run_script("generate_hot_list.py", data_dir, ["--allow-collapse", "--cleared", "numbers,ranges"])
822+
run_script("extract_spam_domains.py", data_dir, ["--allow-collapse", "--cleared", "domains"])
823+
hot = {entry["number"]: entry for entry in json.loads((data_dir / "hot_numbers.json").read_text(encoding="utf-8"))["numbers"]}
824+
if two_either_side in hot:
825+
raise AssertionError(f"two reporters either side of midnight were counted as four: {hot[two_either_side]}")
826+
if hot.get(third_same_day, {}).get("distinct_reporters") != 3:
827+
raise AssertionError(f"three reporters on one day should trend: {hot.get(third_same_day)}")
828+
review = json.loads((data_dir / "spam_domains_review.json").read_text(encoding="utf-8"))
829+
reporters = {candidate["domain"]: candidate["distinct_reporters"] for candidate in review["candidates"]}
830+
if "straddle.example" in reporters or reporters.get("sameday.example") != 2:
831+
raise AssertionError(f"domain reporters should be counted on one UTC day: {reporters}")
832+
833+
798834
def run_drain(data_dir: Path) -> None:
799835
# Each drain here leaves the derived feeds empty, which their collapse
800836
# guards refuse to publish without being told, and an empty feed has to be
@@ -990,6 +1026,9 @@ def main() -> None:
9901026
with tempfile.TemporaryDirectory() as tmp:
9911027
assert_reporters_count_per_device_with_group_cap(Path(tmp) / "data")
9921028

1029+
with tempfile.TemporaryDirectory() as tmp:
1030+
assert_reporters_count_on_one_utc_day(Path(tmp) / "data")
1031+
9931032

9941033
if __name__ == "__main__":
9951034
main()

0 commit comments

Comments
 (0)