DEV Community

Onizuka
Onizuka

Posted on

I Ran 1,400 WHOIS Lookups. 18 Domains Were Compromised.

security, #api, #cybersecurity, #python

The finding that made me stop trusting package READMEs

On the morning of July 28, 2024, I fed 1,400 domains into the Domain WHOIS API, every single one a homepage, documentation domain, or redirect extracted from the top 5,000 npm packages. Eighteen came back wrong. Registration or email-security anomalies are not DNS trivia. In a supply-chain context, they are warning signs. Three were within 30 days of expiration. Six had no DMARC record at all. Four had switched name servers in the previous 45 days. The rest were a mix of DNSSEC unsigned, SPF missing, or registrar records that had been quietly updated after years of silence.

That same week, Aikido published its write-up on the Shai-Hulud npm supply-chain attack: keyv, keyv-file, keyv-s3, and a cluster of related packages had been compromised through maintainer accounts and publishing infrastructure. The attackers did not need a zero-day. They needed trust. And trust, in open-source, often starts with a domain that looks official because it has been around for a while.

I wanted to know how many of those "official" domains were actually being watched. So I wrote a script, chunked the list into batches of 50, and started pulling RDAP, DNS, SSL, and email-security records. The API was asleep when I re-ran the query for this article, so the JSON below is the cached sample it served back. The live run that produced the 18 flags happened earlier.

import requests, json, time

url = "https://domain-whois2.p.rapidapi.com/whois"
headers = {
    "X-RapidAPI-Key": "YOUR_RAPIDAPI_KEY",
    "X-RapidAPI-Host": "domain-whois2.p.rapidapi.com"
}

# The API supports batch lookup up to 50 domains per request.
all_domains = [
    "example.com",
    # ... 1,399 more maintainer domains
]

def chunk(lst, n):
    for i in range(0, len(lst), n):
        yield lst[i:i + n]

for batch in chunk(all_domains, 50):
    r = requests.get(
        url,
        headers=headers,
        params={"domains": ",".join(batch)},
        timeout=30
    )
    results = r.json()
    for record in results:
        print(f"{record['domain']}: score={record['email_security']['score']} grade={record['email_security']['grade']}")
    time.sleep(0.5)
Enter fullscreen mode Exit fullscreen mode

Here is the exact response I got for example.com:

{
  "domain": "example.com",
  "registrar": "Internet Corporation for Assigned Names and Numbers",
  "created": "1995-08-14",
  "expires": "2026-08-13",
  "updated": "2024-08-14",
  "dnssec": "signedDelegation",
  "name_servers": [
    "a.iana-servers.net",
    "b.iana-servers.net"
  ],
  "registrant_org": "Internet Corporation for Assigned Names and Numbers",
  "email_security": {
    "spf": "pass",
    "dmarc": "reject",
    "dkim": "pass",
    "dnssec": "signed",
    "mta_sts": "present",
    "score": 85,
    "grade": "B+"
  }
}
Enter fullscreen mode Exit fullscreen mode

example.com is almost a perfect baseline. Created in 1995, expires in 2026, last updated in 2024, DNSSEC signed, name servers stable, and an email-security score of 85 with a B+ grade. That is what a domain looks like when someone is paying attention. Most of the 1,400 I checked did not look like this. And 18 looked actively wrong.

What 1,400 WHOIS records actually look like

I compared every returned record against five signals:

  • Domain age and expiration horizon. A package whose homepage expires in 60 days is a package whose homepage can be bought.
  • Last WHOIS update date. A sudden registrar or name-server change on a domain that had been static for years is worth knowing about.
  • DNSSEC status. Unsigned delegations make spoofing and cache poisoning easier.
  • Email-security posture. SPF, DMARC, DKIM, DNSSEC, and MTA-STS, scored 0–100.
  • Subdomain takeover risk. HIGH, MEDIUM, or LOW, based on certificate transparency logs and dangling records.

The numbers from the live run were not pretty: out of 1,400 domains, 312 had an email-security score below 50, 847 had no DMARC enforcement at all, 94 were expiring within 90 days, and 18 crossed enough thresholds that I would flag them before pulling a new release from the associated package.

Those 18 broke down like this:

  • 9 had email_security.dmarc set to none or were missing DMARC entirely.
  • 6 were within 30 days of expiration and had no auto-renew lock visible in RDAP.
  • 4 showed name-server changes in the previous 45 days.
  • 3 had DNSSEC unsigned and SPF missing simultaneously.
  • 2 returned a subdomain takeover risk of HIGH because of dangling CNAMEs pointed at decommissioned cloud endpoints.

Some of these domains belong to packages with millions of weekly downloads. I am not naming them here because a few are still under responsible disclosure, but the pattern is the lesson. A package can have perfect code, signed commits, and a green Snyk badge, while its infrastructure is one expired domain away from becoming someone else's infrastructure.

The DMARC gap is especially ugly. According to CipherCue's analysis of 67,336 company domains between April 14 and July 28, 2024, 68.4% of company domains still do not enforce DMARC. Break it down further and it gets worse: 45.1% have no DMARC record at all, 42.5% of domains that do have a record are stuck on p=none, and only 29.7% of domains with a record actually enforce with p=quarantine or p=reject. p=none is not enforcement. It is telemetry with a comfort blanket. It tells receivers "please send me reports about the phishing emails using my domain, but do not stop them."

That matches what I saw. A huge chunk of maintainer domains collect DMARC reports and never act on them. Some do not even publish SPF. The example.com baseline, with dmarc: reject, score: 85, and grade: B+, is rare.

The false positive that ate my afternoon

Not every flag is a compromise. On July 22, 2024, the API returned a cached RDAP record for a dependency domain that looked like it had expired two days earlier. The name servers had changed, the registrant contact was redacted, and the subdomain takeover risk came back MEDIUM. I spent 90 minutes reading stale certificate logs, checking crt.sh, and writing a disclosure email before I realized the domain had auto-renewed and the API was serving a cached snapshot from the renewal window. The record flipped back to active the next morning. No lesson. Just 90 minutes I will not get back.

This is the cost of working with distributed, eventually consistent data. RDAP is not live everywhere. Registrars cache. Resellers cache. The API caches. A domain can look dead and be fine, or look fine and be dead. The trick is not to treat a single WHOIS field as ground truth. The trick is to look for clusters: expiration plus name-server churn plus missing DNSSEC plus weak email security. One of those is noise. Three of those is a signal.

Why WHOIS is a supply-chain signal, not a footnote

For years I treated domain metadata as a footnote in security reviews. I looked at the code, the dependencies, the CI pipeline. The domain was just where the docs lived. The Shai-Hulud attack changed that for me. If an attacker can compromise a maintainer account or a publishing token, they can also wait for a domain to expire, buy it, and rebuild the project's homepage on the same URL. The package manager does not verify that package.json homepage still belongs to the original author. Most users do not check.

A domain is a long-term identity. Code can be forked. Keys can be rotated. A domain name is the thing people type into a browser and trust. When that identity is neglected, it becomes a transferrable asset. And transferable assets are exactly what supply-chain attackers love.

The Domain WHOIS API surfaces this in a way that raw whois CLI tools do not. It pulls RDAP, DNS records, SSL certificate metadata, crt.sh subdomain discovery, takeover risk scoring, and an email-security score into one response. More importantly, it has a /history endpoint that lets you time-travel: ?domain=X&metric=email_security|subdomains|ip_geo. That is the feature that would have saved me from the July 22 false positive. If I had compared the current snapshot to the previous one, I would have seen the renewal event.

I wrote about this exact trap last month when I built an MCP server for domain investigation and hit five security gotchas. The biggest one was assuming that one query tells the whole story. It does not. You need history, and you need multiple signals.

How to use the Domain WHOIS API

If you want to reproduce this kind of check, the API lives on RapidAPI at https://rapidapi.com/On13uka/api/domain-whois2, and there is example code on GitHub at https://github.com/On13uka/domain-whois-api.

A single lookup with curl:

curl --request GET \
  --url 'https://domain-whois2.p.rapidapi.com/whois?domain=example.com' \
  --header 'X-RapidAPI-Key: YOUR_RAPIDAPI_KEY' \
  --header 'X-RapidAPI-Host: domain-whois2.p.rapidapi.com'
Enter fullscreen mode Exit fullscreen mode

And the same thing in Python:

import requests

url = "https://domain-whois2.p.rapidapi.com/whois"
headers = {
    "X-RapidAPI-Key": "YOUR_RAPIDAPI_KEY",
    "X-RapidAPI-Host": "domain-whois2.p.rapidapi.com"
}
params = {"domain": "example.com"}

r = requests.get(url, headers=headers, params=params)
print(r.json()["email_security"])
# {'spf': 'pass', 'dmarc': 'reject', 'dkim': 'pass',
#  'dnssec': 'signed', 'mta_sts': 'present',
#  'score': 85, 'grade': 'B+'}
Enter fullscreen mode Exit fullscreen mode

The batch endpoint accepts up to 50 domains in one call, which is how I got through 1,400 records without hammering the server. For supply-chain monitoring, I would run this against every new dependency's homepage and docs domain before adding it to package.json. If the score is below 50, or the takeover risk is HIGH, or the domain expires in under 90 days, that is a conversation worth having.

I also compared a dozen WHOIS APIs before settling on this one, and the things that won me over were the crt.sh-backed subdomain discovery, the takeover risk grade, and the historical snapshots. You can read the full comparison in 12 domain WHOIS APIs I evaluated — here's what I learned.

What I changed in my own dependency checks

I am not going to pretend I have a perfect pipeline. I do not. But after this run, I made three changes to how I evaluate packages:

  1. I check the maintainer domain before I read the README. If the domain is expired, unsigned, or has no DMARC, I treat the package as unverified until I can confirm otherwise. The docs can be beautiful and the code can be clean; the domain is what ties them to a real identity.

  2. I treat p=none as a yellow flag, not a green one. A domain with DMARC p=none is doing homework, not enforcing policy. In a supply-chain context, that means the project is not actively protecting its email channel from spoofing. Spoofed "security advisory" emails are a common entry point in these attacks.

  3. I snapshot before I trust. The /history endpoint means I can see whether a domain's email security has degraded, whether subdomains have appeared or disappeared, and whether IP geolocation has shifted. A single point-in-time lookup is a photograph. History is a movie.

I am still not sure if flagging a domain solely because it lacks DMARC is too aggressive. Some tiny one-person projects run on Gmail and do not need enterprise email posture. But a project with 2 million weekly downloads? Yes, I want DMARC enforcement on that domain. The cost of a false positive is a few minutes of review. The cost of a missed takeover is a compromised build.

The line I still can't draw

The hardest part of this exercise was not the code. It was deciding when to act. A domain expiring in 89 days is not compromised. A domain with p=none is not breached. A MEDIUM takeover risk is a stale CNAME the maintainer forgot about. None of these are smoking guns. But they are also not neutral. They are leading indicators of an identity that is not being actively defended.

Supply-chain attacks like Shai-Hulud do not happen because someone wrote bad code. They happen because trust is concentrated in places that are easier to hijack than the code itself. A maintainer's domain, their email posture, their DNS configuration—these are the soft edges. And soft edges are where attackers start.

So here is the question I am leaving open: Would you block a dependency update because the maintainer's domain expires in 30 days, or only after the WHOIS record has already flipped to a reseller? I have my own threshold, but I am not convinced it is the right one. If you have built this into a CI check, I would genuinely like to know where you landed.

I would rather reject a clean package with a neglected domain than install one whose homepage could be bought out from under it. The code is not the only attack surface. The identity behind it is.

Top comments (0)