Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 4 min read

Score Zero Has Two Meanings: Branch on evidence_state First

If your antifraud rule treats every risk.score == 0 as "clean," you will misread two very different situations the same way. On IP99 by ThreatHunter, score zero can mean never seen (evidence_state: none) or seen before,

If your antifraud rule treats every risk.score == 0 as "clean," you will misread two very different situations the same way. On IP99 by ThreatHunter, score zero can mean never seen (evidence_state: none) or seen before, evidence window expired (evidence_state: stale). Both print 0. Only one of them is a coverage gap; the other is a fading memory with leftover signal slugs. The documented read order starts with evidence_state for that reason.

This walkthrough stays on the free, no-key GET /v1/ip/{ip} endpoint. Live samples were captured on 2026-11-03 (~13:11 UTC).

What evidence_state actually means

IP99's risk score is freshness and strength of verifiable risk evidence, on a 0โ€“100 scale that decays as evidence ages. It is not the probability that an address is an attacker. risk.level is derived from the score: 0 โ†’ none, 1โ€“29 โ†’ low, 30โ€“69 โ†’ medium, 70โ€“100 โ†’ high.

evidence_state tells you whether that score is even in play:

evidence_state Meaning Score behavior
active Fresh enough evidence to participate in scoring Score can be 1โ€“100
stale Address was seen before; evidence window expired Forced to 0; leftover signals are a weak feature
none Never seen in the risk evidence set Also 0; coverage gap โ€” not "safe"

Official disposition guidance (generic advice, not a contract): score 0 โ†’ do not mark the IP clean; 1โ€“29 โ†’ observe; 30โ€“69 โ†’ step-up (captcha, throttle, second factor) rather than a hard ban; 70โ€“100 โ†’ block only when business cost allows. hijacked_proxy should not automatically ban the account holder โ€” that address is often a victim of malware-turned-exit capacity.

Example A: score 0 + none (never seen as risk evidence)

curl https://ip99.com/v1/ip/8.8.8.8

Captured 2026-11-03T13:11:32Z:

{
  "ip": "8.8.8.8",
  "computed_at": "2026-11-03T13:11:32Z",
  "evidence_state": "none",
  "geo": {
    "continent": "NA",
    "country": "US",
    "region": "California",
    "city": "Mountain View",
    "district": "",
    "tz": "America/Los_Angeles"
  },
  "network": { "asn": 15169, "usage_type": "IDC" },
  "risk": {
    "score": 0,
    "level": "none",
    "signals": ["hosting"]
  }
}

usage_type: IDC and signals: ["hosting"] are network attributes, not a proxy verdict โ€” Google Public DNS lives in hosting space. evidence_state: none with score 0 means IP99 holds no current verifiable risk evidence for this address. That is not a clean bill of health; it is "we have nothing risk-shaped on file." Do not invent Tor or other signals that are not in the response.

Example B: score 0 + stale (same demo IP, different day)

Public docs often demonstrate with a residential dial-up / proxy-supply address. Full address works in curl; prose and tickets should mask the last octet.

curl https://ip99.com/v1/ip/27.22.66.200

Captured 2026-11-03T13:11:48Z (last octet masked in the body):

{
  "ip": "27.22.66.x",
  "computed_at": "2026-11-03T13:11:48Z",
  "evidence_state": "stale",
  "geo": {
    "continent": "AS",
    "country": "CN",
    "region": "Hubei",
    "city": "Xiangyang",
    "district": "Yicheng",
    "tz": "Asia/Shanghai"
  },
  "network": { "asn": 4134, "usage_type": "DYN" },
  "risk": {
    "score": 0,
    "level": "none",
    "signals": ["dialup_pool", "hijacked_proxy", "proxy"]
  }
}

Same score shape as Example A โ€” 0 / none โ€” but a different story. usage_type: DYN is residential broadband. The signal list still carries proxy, dialup_pool, and hijacked_proxy. On 2026-11-01, this same demo address returned evidence_state: active with a low non-zero score. Today the window has expired: score is forced to 0, state is stale, and those slugs survive as a weak feature โ€” useful for logging and soft routing, not as a fresh "ban now" trigger.

If your code only checks score == 0, Examples A and B collapse into one bucket. Branch on evidence_state first and they stay distinct: A is a gap; B is expired evidence that still names the historical observation types.

The decision order (free /v1/ip)

IP99's documented order is intentionally boring:

  1. evidence_state โ€” is the conclusion even scorable right now?
  2. risk.score / risk.level โ€” how strong/fresh is the evidence when active?
  3. risk.signals โ€” branch by type (proxy / vpn / dialup_pool / cloud_phone vs crawler vs network attributes hosting / mobile).
  4. Ban scope โ€” on Pulse, share_tier (T0โ€“T3) decides whether an IP ban hits one tenant or a whole CGNAT building. The free /v1/ip response does not include share_tier; keep that in mind before hard-blocking.
  5. degraded โ€” if present, some upstreams were unavailable; discount the answer.

A small branch that matches the schema:

if evidence_state == "none" and score == 0:
    do_not_mark_clean()          # coverage gap
elif evidence_state == "stale" and score == 0:
    log_weak_feature(signals)    # expired memory; soft weight only
elif evidence_state == "active":
    if score >= 70:
        block_if_business_allows()
    elif score >= 30:
        step_up()                # captcha / throttle / review
    else:
        observe()
# hosting / mobile in signals โ†’ network context only; never sole ban reason
# hijacked_proxy โ†’ step-up, not automatic account ban

Anti-patterns this avoids: treating every zero as trusted; caching yesterday's label without storing computed_at + evidence_state; equating hosting with fraud; hard-banning on hijacked_proxy alone.

Try it without a key

curl https://ip99.com/v1/ip/8.8.8.8
curl https://ip99.com/v1/ip/27.22.66.200
curl https://ip99.com/v1/ip          # caller's own egress
curl https://ip99.com/v1/me          # quota / plan headers context

Anonymous quota is 500 lookups/day per client IP and 60/min, resetting at midnight Asia/Shanghai. CORS is open (Access-Control-Allow-Origin: *). Machine-readable contract: OpenAPI. Field deep-dives: evidence freshness, risk score, detect proxy/VPN, and the English API reference.

Paid volume plans change quotas, not the free answer shape. Per-evidence rows, share_tier, and ?at= history sit on Pulse (professional).

Takeaway

Score zero is ambiguous until you read evidence_state. none means no risk evidence on file. stale means evidence existed, the scoring window expired, and leftover signal slugs are weak features โ€” not a fresh verdict. Branch on state first, then score, then signal type. For the free lookup and field definitions, start at ip99.com.

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.