intelligence 8 min read

WHOIS Domain Intelligence: Registrant Correlation and Historical Analysis

Deep dive into WHOIS-based intelligence gathering: registrant correlation across domains, historical lookups, privacy-protected domain handling, domain age scoring, and building a WHOIS parser in Elixir.

Tomas Korcak (korczis) 421 words

WHOIS Data as an Intelligence Foundation

WHOIS records are the birth certificates of the internet. Every domain registration creates a record containing registrant contact information, registration dates, nameservers, and registrar details. For intelligence work, WHOIS data enables registrant correlation (linking seemingly unrelated domains to the same actor), infrastructure mapping, and domain age scoring for risk assessment.

Post-GDPR, many registrars redact personal information behind privacy shields. This does not eliminate WHOIS value — it shifts the focus to structural analysis: nameserver patterns, registrar preferences, creation date clusters, and the distinctive fingerprints that remain even when names and emails are hidden.

WHOIS Record Parser

Raw WHOIS responses are unstructured text with no universal standard. Each registrar formats records differently. Our parser handles this chaos through pattern matching and a registry of known formats:

defmodule Prismatic.OSINT.WHOIS.Parser do
  @moduledoc """
  Parses raw WHOIS response text into structured records.
  Handles format variations across registrars and TLDs.
  """

  @type parsed :: %{
    domain: String.t(),
    registrar: String.t() | nil,
    registrant: map(),
    dates: map(),
    nameservers: list(String.t()),
    status: list(String.t()),
    raw: String.t()
  }

  @field_patterns %{
    registrar: ~r/Registrar:\s*(.+)/i,
    created: ~r/Creat(?:ion|ed)\s*Date:\s*(.+)/i,
    updated: ~r/Updated?\s*Date:\s*(.+)/i,
    expires: ~r/Expir(?:y|ation)\s*Date:\s*(.+)/i,
    nameserver: ~r/Name\s*Server:\s*(.+)/i,
    status: ~r/(?:Domain\s*)?Status:\s*(.+)/i,
    registrant_name: ~r/Registrant\s*(?:Name|Organization):\s*(.+)/i,
    registrant_email: ~r/Registrant\s*(?:Email|Contact Email):\s*(.+)/i,
    registrant_country: ~r/Registrant\s*Country:\s*(.+)/i
  }

  @spec parse(String.t(), String.t()) :: {:ok, parsed()} | {:error, term()}
  def parse(raw_text, domain) do
    fields = Enum.reduce(@field_patterns, %{}, fn {key, pattern}, acc ->
      case Regex.scan(pattern, raw_text) do
        [] -> acc
        matches ->
          values = Enum.map(matches, fn [_, value] -> String.trim(value) end)
          Map.put(acc, key, values)
      end
    end)

    {:ok, %{
      domain: domain,
      registrar: fields |> Map.get(:registrar, []) |> List.first(),
      registrant: build_registrant(fields),
      dates: build_dates(fields),
      nameservers: fields |> Map.get(:nameserver, []) |> Enum.map(&String.downcase/1),
      status: Map.get(fields, :status, []),
      privacy_protected: privacy_detected?(fields, raw_text),
      raw: raw_text
    }}
  end

  defp build_registrant(fields) do
    %{
      name: fields |> Map.get(:registrant_name, []) |> List.first(),
      email: fields |> Map.get(:registrant_email, []) |> List.first(),
      country: fields |> Map.get(:registrant_country, []) |> List.first()
    }
  end

  defp build_dates(fields) do
    %{
      created: fields |> Map.get(:created, []) |> List.first() |> parse_date(),
      updated: fields |> Map.get(:updated, []) |> List.first() |> parse_date(),
      expires: fields |> Map.get(:expires, []) |> List.first() |> parse_date()
    }
  end
end

Registrant Correlation Engine

The real power of WHOIS intelligence emerges when you correlate registrant data across thousands of domains. Even with privacy protection, patterns emerge:

Correlation SignalPrivacy Resilient?Strength
Registrant email matchNo (redacted post-GDPR)Very High
Registrant organization matchPartial (sometimes visible)High
Nameserver clusterYesHigh
Registrar + creation date clusterYesMedium
Nameserver + registrar comboYesMedium-High
Country + registrar patternYesLow-Medium
defmodule Prismatic.OSINT.WHOIS.Correlator do
  @moduledoc """
  Correlates WHOIS records across domains to identify
  common registrants, infrastructure patterns, and actor clusters.
  """

  @spec correlate(list(map())) :: list(map())
  def correlate(whois_records) when is_list(whois_records) do
    records = Enum.filter(whois_records, &(&1.domain != nil))

    email_clusters = cluster_by(:registrant_email, records)
    ns_clusters = cluster_by(:nameservers, records)
    registrar_date_clusters = cluster_by_registrar_date(records)

    merge_clusters([email_clusters, ns_clusters, registrar_date_clusters])
    |> Enum.map(&score_cluster/1)
    |> Enum.sort_by(& &1.confidence, :desc)
  end

  defp cluster_by(:registrant_email, records) do
    records
    |> Enum.filter(&(&1.registrant.email != nil))
    |> Enum.reject(&privacy_email?(&1.registrant.email))
    |> Enum.group_by(& &1.registrant.email)
    |> Enum.filter(fn {_email, domains} -> length(domains) > 1 end)
    |> Enum.map(fn {email, domains} ->
      %{type: :email, key: email, domains: Enum.map(domains, & &1.domain),
        confidence: 0.95}
    end)
  end

  defp cluster_by(:nameservers, records) do
    records
    |> Enum.filter(&(&1.nameservers != []))
    |> Enum.group_by(fn r -> Enum.sort(r.nameservers) end)
    |> Enum.filter(fn {_ns, domains} -> length(domains) > 1 end)
    |> Enum.map(fn {ns, domains} ->
      %{type: :nameserver, key: Enum.join(ns, ","),
        domains: Enum.map(domains, & &1.domain),
        confidence: 0.80}
    end)
  end

  defp privacy_email?(email) do
    privacy_indicators = [
      "privacy", "proxy", "whoisguard", "contactprivacy",
      "domainsbyproxy", "withheld", "redacted"
    ]

    downcased = String.downcase(email)
    Enum.any?(privacy_indicators, &String.contains?(downcased, &1))
  end
end

Domain Age Scoring

Domain age is a powerful risk indicator. Freshly registered domains are disproportionately used in phishing, spam, and malware campaigns. Our scoring function applies a non-linear curve that penalizes very young domains while rewarding established ones:

defmodule Prismatic.OSINT.WHOIS.AgeScorer do
  @moduledoc """
  Scores domains based on registration age and lifecycle indicators.
  Young domains receive lower trust scores.
  """

  @spec score(map()) :: float()
  def score(%{dates: %{created: nil}}), do: 0.3

  def score(%{dates: %{created: created}}) do
    age_days = Date.diff(Date.utc_today(), created)

    base_score = cond do
      age_days < 1 -> 0.05
      age_days < 7 -> 0.10
      age_days < 30 -> 0.20
      age_days < 90 -> 0.35
      age_days < 365 -> 0.55
      age_days < 730 -> 0.75
      age_days < 1825 -> 0.85
      true -> 0.95
    end

    base_score
  end

  @spec risk_category(float()) :: atom()
  def risk_category(score) when score < 0.2, do: :critical
  def risk_category(score) when score < 0.4, do: :high
  def risk_category(score) when score < 0.6, do: :medium
  def risk_category(score) when score < 0.8, do: :low
  def risk_category(_score), do: :minimal
end
Age RangeBase ScoreRisk CategoryTypical Use Case
< 1 day0.05CriticalActive phishing campaign
1-7 days0.10CriticalFresh spam infrastructure
7-30 days0.20HighRecently provisioned attack domain
30-90 days0.35HighShort-lived campaign domain
90 days - 1 year0.55MediumPossibly legitimate new business
1-2 years0.75LowEstablished presence
2-5 years0.85LowWell-established domain
5+ years0.95MinimalLong-standing legitimate domain

Historical WHOIS Lookups

Current WHOIS data shows only the present state. Historical WHOIS services reveal past registrant changes, nameserver migrations, and domain lifecycle events. These changes often correlate with ownership transfers, infrastructure compromises, or deliberate obfuscation:

@spec historical_lookup(String.t(), keyword()) :: {:ok, list(map())} | {:error, term()}
def historical_lookup(domain, opts \\ []) do
  provider = Keyword.get(opts, :provider, :whoisxml)

  case fetch_historical(provider, domain) do
    {:ok, records} ->
      parsed =
        records
        |> Enum.map(&parse_historical_record/1)
        |> Enum.sort_by(& &1.date, {:asc, Date})
        |> detect_changes()

      {:ok, parsed}

    {:error, reason} ->
      {:error, reason}
  end
end

defp detect_changes(sorted_records) do
  sorted_records
  |> Enum.chunk_every(2, 1, :discard)
  |> Enum.flat_map(fn [prev, curr] ->
    changes = diff_records(prev, curr)
    if changes != [] do
      [%{date: curr.date, changes: changes, from: prev, to: curr}]
    else
      []
    end
  end)
end

Integration Points

WHOIS intelligence feeds into multiple platform workflows. During due diligence investigations, domain age and registrant data are automatically pulled for every entity's web presence. The correlator runs nightly across all domains discovered in active cases, surfacing hidden connections between seemingly unrelated entities. Phishing detection combines WHOIS age scoring with Certificate Transparency alerts — a newly registered domain appearing in CT logs for a brand name is a high-confidence phishing indicator.