Skip to content

Browser Fingerprint Viewer — Ruby source

See exactly what websites can learn about your browser without cookies — screen, GPU, fonts, timezone, language, and more. Educational, not tracking.

This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# Browser Fingerprint — pure logic (Ruby port): the 18-signal privacy-risk
# registry with canonical grouping, risk counts, and a stable SHA-256
# fingerprint hash.
#
# Language: Ruby 3.2 — standard library only (digest/sha2 for SHA-256).
# Source: CosmoDev polyglot showcase port; canonical = src/lib/browser-fingerprint.ts
#         + this tool's python.py / rust.rs.
# License: display source — part of CosmoDev's polyglot tool pages.
#
# Ported from the TypeScript reference. Covers the portable core: the
# 18-signal registry with privacy-risk classifications, canonical category
# grouping, risk counts, and the stable SHA-256 fingerprint hash. The
# browser-API collection layer (canvas / WebGL / font probes) is DOM-bound
# by design and does not port.

require 'digest'

module BrowserFingerprint
  # Canonical category order (card grid, top to bottom).
  FINGERPRINT_CATEGORIES = %i[hardware graphics network browser input].freeze

  # Category display labels, keyed by the canonical category symbols.
  CATEGORY_LABELS = {
    hardware: 'Hardware',
    graphics: 'Graphics',
    network: 'Network',
    browser: 'Browser',
    input: 'Input'
  }.freeze

  # One collected, classified signal.
  Signal = Struct.new(:id, :label, :value, :risk, :category, keyword_init: true)

  # Classifies one signal id.
  SignalDef = Struct.new(:label, :risk, :category, keyword_init: true)

  # One category and its signals.
  CategoryGroup = Struct.new(:category, :signals, keyword_init: true)

  # Signals per risk level (drives the summary line).
  RiskCounts = Struct.new(:low, :medium, :high, keyword_init: true)

  # Static registry of every signal the tool collects, with its privacy-risk
  # classification. Single source of truth: build_signal derives from it, so
  # a signal can never be collected without being classified.
  SIGNAL_DEFINITIONS = {
    # High risk
    'canvas' => SignalDef.new(label: 'Canvas fingerprint', risk: :high, category: :graphics),
    'webgl-renderer' => SignalDef.new(label: 'WebGL renderer', risk: :high, category: :graphics),
    'webgl-vendor' => SignalDef.new(label: 'WebGL vendor', risk: :high, category: :graphics),
    'fonts' => SignalDef.new(label: 'Installed fonts', risk: :high, category: :browser),
    'timezone' => SignalDef.new(label: 'Timezone', risk: :high, category: :browser),
    # Medium risk
    'screen' => SignalDef.new(label: 'Screen & color depth', risk: :medium, category: :hardware),
    'device-pixel-ratio' => SignalDef.new(label: 'Device pixel ratio', risk: :medium, category: :hardware),
    'hardware-concurrency' => SignalDef.new(label: 'CPU cores', risk: :medium, category: :hardware),
    'device-memory' => SignalDef.new(label: 'Device Memory', risk: :medium, category: :hardware),
    'platform' => SignalDef.new(label: 'Platform', risk: :medium, category: :browser),
    'languages' => SignalDef.new(label: 'Languages', risk: :medium, category: :browser),
    'touch' => SignalDef.new(label: 'Touch support', risk: :medium, category: :input),
    'connection' => SignalDef.new(label: 'Connection type', risk: :medium, category: :network),
    # Low risk
    'user-agent' => SignalDef.new(label: 'User agent', risk: :low, category: :browser),
    'do-not-track' => SignalDef.new(label: 'Do Not Track', risk: :low, category: :browser),
    'cookies-enabled' => SignalDef.new(label: 'Cookies enabled', risk: :low, category: :browser),
    'online' => SignalDef.new(label: 'Online status', risk: :low, category: :network),
    'pdf-viewer' => SignalDef.new(label: 'PDF viewer', risk: :low, category: :browser)
  }.freeze

  module_function

  # Risk level for a known signal id; nil for unknown ids.
  def classify_signal_risk(signal_id)
    SIGNAL_DEFINITIONS[signal_id]&.risk
  end

  # Build a signal from its id and collected value. Raises ArgumentError on
  # unknown ids so a typo'd id fails loudly instead of silently rendering an
  # unclassified row.
  def build_signal(signal_id, value)
    definition = SIGNAL_DEFINITIONS.fetch(signal_id) do
      raise ArgumentError, "Unknown fingerprint signal id: #{signal_id}"
    end
    Signal.new(
      id: signal_id,
      label: definition.label,
      value: value,
      risk: definition.risk,
      category: definition.category
    )
  end

  # Group signals by category in canonical order, omitting empty categories.
  def group_by_category(signals)
    by_category = signals.group_by(&:category)
    FINGERPRINT_CATEGORIES.filter_map do |category|
      members = by_category[category]
      CategoryGroup.new(category: category, signals: members) if members
    end
  end

  # Count signals per risk level.
  def count_by_risk(signals)
    counts = Hash.new(0)
    signals.each { |signal| counts[signal.risk] += 1 }
    RiskCounts.new(low: counts[:low], medium: counts[:medium], high: counts[:high])
  end

  # The exact byte string the fingerprint hash is computed over: every value
  # joined with '|' in signal order.
  def concat_signal_values(signals)
    signals.map(&:value).join('|')
  end

  # SHA-256 hex digest of the UTF-8 encoding of text.
  def sha256_hex(text)
    Digest::SHA256.hexdigest(text)
  end

  # Stable fingerprint ID: SHA-256 over every signal value, joined in signal
  # order. Same browser state -> same hash; any single changed value -> new hash.
  def hash_fingerprint(signals)
    sha256_hex(concat_signal_values(signals))
  end
end

if $PROGRAM_NAME == __FILE__
  signals = [
    BrowserFingerprint.build_signal('timezone', 'Europe/Paris'),
    BrowserFingerprint.build_signal('screen', '2560×1440 @ 24-bit'),
    BrowserFingerprint.build_signal('user-agent', 'Mozilla/5.0 (Macintosh)')
  ]

  BrowserFingerprint.group_by_category(signals).each do |group|
    puts "[#{BrowserFingerprint::CATEGORY_LABELS[group.category]}]"
    group.signals.each do |signal|
      puts "  #{signal.label}: #{signal.value} (#{signal.risk} risk)"
    end
  end

  counts = BrowserFingerprint.count_by_risk(signals)
  puts "risk mix: #{counts.high} high / #{counts.medium} medium / #{counts.low} low"
  puts "fingerprint: #{BrowserFingerprint.hash_fingerprint(signals)}"
end

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →