DEV Community

szp2005
szp2005

Posted on

A /24 where every address is flagged is weaker evidence, not stronger

A few months ago I wrote here about merging eight IP reputation feeds into one score, and I ended by asking what people do with the /24 neighbor signal. Nobody had a clean answer, so I built it. Then it lied to me, and the way it lied was more interesting than the feature.

The idea seems sound. If the address you're checking sits in a /24 where most of the other addresses are known VPN exits or sit on a spam blocklist, that range is probably being handled as a range by whoever is filtering. Signup forms and fraud rules do CIDR bans constantly. So the neighbor picture is real context even when the address itself looks clean.

I implemented it as a ratio: scan all 256 addresses in the /24 against a local offline list, count how many carry a flag, raise a floor on the score when that share crosses a threshold. Below a quarter it does nothing, past a quarter the floor is 40, past half it's 50. I capped it there on purpose, because a bad neighborhood is not a bad address.

Where it broke

The first version scored 1.19.0.5 and reported 256 of 256 neighbors flagged, verdict toxic. Maximum contamination, perfect signal.

Except there are no 256 neighbors. Spamhaus DROP lists 1.19.0.0/16 as one hijacked network, in one line. My scan expanded that line into thousands of addresses and then dutifully reported that every one of them was independently suspicious. The address already carried its own abuse flag from that same list entry. So one fact got counted as a direct hit on the address, and then again as 256 corroborating witnesses.

That isn't evidence stacking, it's one witness wearing 256 hats.

The tell is that the signal runs backwards. A /24 where 60 addresses are flagged by 60 separate entries really is 60 independent observations. A /24 where all 256 are flagged is almost always one observation about the whole range, and the fuller the coverage, the likelier that's what happened.

There was a second, dumber version of the same mistake. I had host-level evidence (an address appearing on three independent blocklists, or answering as an open proxy) counted into the neighbor numerator. That's evidence about one machine misbehaving, dragged in to convict the people next door. It also made the UI argue with itself: the score up top said high risk while the map below drew that address's own cell bright green.

The fix and what it costs

The check turned out to be one function. Is this /24 fully covered by a single entry in the list?

/** Is [a,b] fully contained in ONE range? A /24 painted by one wide CIDR is a
 *  range-level label, not 256 independent bad neighbors. */
function fullyCovered(ranges: [number, number][], a: number, b: number): boolean {
  let lo = 0, hi = ranges.length - 1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    const [s, e] = ranges[mid];
    if (b < s) hi = mid - 1;
    else if (a > e) lo = mid + 1;
    else return s <= a && e >= b; // the matched entry swallows the whole block
  }
  return false;
}

const net = ipInt - (ipInt & 255);
const blanket = fullyCovered(VPN, net, net + 255) || fullyCovered(ABUSE, net, net + 255);
Enter fullscreen mode Exit fullscreen mode

When blanket is true the neighbor floor is skipped. The address keeps whatever flag it earned on its own and gets nothing extra for the company it keeps. Host-level evidence moved into its own counter that the neighbor ratio never reads.

You can watch both halves from outside without logging in:

curl -s 'https://ipok.io/api/segment?ip=1.19.0.5' | jq .summary
# flagged 256 of 256, verdict "toxic"

curl -s 'https://ipok.io/api/ip?ip=1.19.0.5' | jq '.riskBreakdown.floors'
# only the address's own abuser floor. No neighbor floor at all.

curl -s 'https://ipok.io/api/ip?ip=89.149.52.9' | jq '.riskBreakdown.floors'
# block floor 40, bucket "contextual". 116 flagged, from many separate entries
Enter fullscreen mode Exit fullscreen mode

The cost is that fullyCovered trusts the list to arrive merged and sorted. If a source emits a /24 as two adjacent halves, the block reads as non-blanket and the double count comes back for that range. Merging at build time covers the sources I pull, but it's an assumption rather than a guarantee, and I'd rather say that than pretend the rule is airtight.

The other thing I didn't solve is recency. A neighbor flagged last week and one flagged last spring count the same. Weighting by age is the obvious next move, I don't have a decay curve I can defend, so the flat count stays.

If you run range reputation anywhere, I'd like to know whether you dedupe at ingest instead. Handling it at scoring time still feels like the wrong layer to me.


I build ipok.io, a free IP reputation checker that shows this breakdown instead of one number.

Top comments (0)