ratings.ts raw

   1  /**
   2   * Per-browser Privacy and Security ratings (each 0–100), surfaced as badges
   3   * on each browser row and as breakdowns in the Privacy Features modal.
   4   *
   5   * The numbers are curated — opinionated but defensible. Each component has
   6   * a short note so a reader can see WHY the browser landed where it did.
   7   * Adjusting a single dimension is a one-line change; the row badge re-derives.
   8   *
   9   *   Privacy = mix of measured (privacytests.org pass rate) + tier weight.
  10   *   Security = weighted average of five hand-rated dimensions per browser:
  11   *     sandbox       process/site isolation strength
  12   *     mitigations   CFI, MTE, hardened_malloc, stack canaries, zero-init
  13   *     jitOff        JIT disabled by default (reduces JS exploit surface)
  14   *     updates       how quickly CVE patches reach users (channel + cadence)
  15   *     forkLag       distance from upstream Chromium/Gecko stable
  16   *
  17   * Sources of fact (not subjective):
  18   *   - GrapheneOS Vanadium docs (vanadium.app, grapheneos.org/features)
  19   *   - IronFox tracker (gitlab.com/ironfox-oss/IronFox)
  20   *   - Tor Browser security levels (tb-manual.torproject.org)
  21   *   - Chromium platform security docs (chromium.org/Home/chromium-security)
  22   *   - privacytests.org Android dataset (snapshot in data/)
  23   */
  24  
  25  import { PRIVACY_TEST_SCORES } from './privacy-scores';
  26  import { tierWeight, type Browser, type PrivacyTier } from './browsers';
  27  
  28  // ────────────────────────────────────────────────────────────────────────
  29  // Privacy
  30  // ────────────────────────────────────────────────────────────────────────
  31  
  32  const TIER_TO_PRIVACY_BASE: Record<PrivacyTier, number> = {
  33    highest: 99,
  34    'very-high': 92,
  35    high: 80,
  36    'medium-high': 65,
  37    medium: 50,
  38    low: 30,
  39  };
  40  
  41  export type PrivacyBreakdown = {
  42    total: number;
  43    parts: { label: string; score: number; note: string }[];
  44  };
  45  
  46  /**
  47   * Browsers that route traffic through an anonymising network by default —
  48   * scored 0–100 for network-layer privacy. Only Tor qualifies today; other
  49   * browsers can stack a VPN or Orbot externally but that's out-of-scope
  50   * for the in-app score.
  51   */
  52  const NETWORK_ANONYMITY: Record<string /* browser id */, number> = {
  53    tor: 100,
  54  };
  55  
  56  /**
  57   * Compute privacy score 0–100 as a weighted average of three signals:
  58   *   - Baseline tier (the curated posture per browser)
  59   *   - privacytests.org pass rate (per-API measured, when available)
  60   *   - Network anonymity (whether traffic is onion-routed by default)
  61   *
  62   * Tier dominates (80%) because per-API tests systematically under-reward
  63   * browsers like Tor that deliberately omit features (e.g. bundled tracker
  64   * blockers) for anonymity-set reasons. The measured and network buckets
  65   * add 10% each so privacytests data still informs the score and Tor's
  66   * unique moat is reflected.
  67   *
  68   * When a signal is missing (no privacytests data, no network anonymity),
  69   * its weight is dropped and the remaining ones are renormalised.
  70   */
  71  export function privacyScore(b: Browser): number {
  72    return privacyBreakdown(b).total;
  73  }
  74  
  75  export function privacyBreakdown(b: Browser): PrivacyBreakdown {
  76    const tierBase = TIER_TO_PRIVACY_BASE[b.privacyTier];
  77    const pt = PRIVACY_TEST_SCORES[b.pkg];
  78    const network = NETWORK_ANONYMITY[b.id] ?? 0;
  79  
  80    const parts: PrivacyBreakdown['parts'] = [];
  81    parts.push({
  82      label: 'Baseline',
  83      score: tierBase,
  84      note: 'Intrinsic protections + always-private posture',
  85    });
  86    let weighted = tierBase * 80;
  87    let weightSum = 80;
  88  
  89    if (pt) {
  90      const measuredPct = Math.round((pt.totalPassed / pt.totalTests) * 100);
  91      parts.push({
  92        label: 'privacytests.org',
  93        score: measuredPct,
  94        note: `${pt.totalPassed}/${pt.totalTests} tests passed`,
  95      });
  96      weighted += measuredPct * 10;
  97      weightSum += 10;
  98    }
  99  
 100    if (network > 0) {
 101      parts.push({
 102        label: 'Network anonymity',
 103        score: network,
 104        note: 'Onion-routed by default — hides IP from every site',
 105      });
 106      weighted += network * 10;
 107      weightSum += 10;
 108    }
 109  
 110    return {
 111      total: Math.round(weighted / weightSum),
 112      parts,
 113    };
 114  }
 115  
 116  // ────────────────────────────────────────────────────────────────────────
 117  // Security
 118  // ────────────────────────────────────────────────────────────────────────
 119  
 120  type SecDim = 'sandbox' | 'mitigations' | 'openness' | 'updates' | 'forkLag' | 'jitOff';
 121  
 122  const SEC_WEIGHTS: Record<SecDim, number> = {
 123    sandbox: 0.30,
 124    mitigations: 0.25,
 125    openness: 0.15,
 126    updates: 0.10,
 127    forkLag: 0.10,
 128    jitOff: 0.10,
 129  };
 130  
 131  const SEC_LABEL: Record<SecDim, string> = {
 132    sandbox: 'Sandbox',
 133    mitigations: 'Mitigations',
 134    openness: 'Open source',
 135    updates: 'Updates',
 136    forkLag: 'Upstream lag',
 137    jitOff: 'JIT-off default',
 138  };
 139  
 140  type SecRow = { score: number; note: string };
 141  type SecProfile = Record<SecDim, SecRow>;
 142  
 143  const SEC: Record<string /* browser id */, SecProfile> = {
 144    vanadium: {
 145      sandbox:     { score: 95, note: 'Chromium site isolation + GrapheneOS process hardening' },
 146      mitigations: { score: 98, note: 'MTE on Tensor / 8 Gen 3, CFI, hardened_malloc, zero-init' },
 147      openness:    { score: 100, note: 'Fully open source under GPLv2, auditable patch set' },
 148      jitOff:      { score: 100, note: 'JIT disabled by default, per-site toggle' },
 149      updates:     { score: 90, note: 'GrapheneOS channel, within days of Chromium stable' },
 150      forkLag:     { score: 95, note: 'Tracks Chromium stable closely' },
 151    },
 152    helium: {
 153      sandbox:     { score: 90, note: 'Chromium site isolation; no OS-level GrapheneOS hardening' },
 154      mitigations: { score: 80, note: 'Vanadium patches ported; CFI + stack canaries; no MTE / hardened_malloc' },
 155      openness:    { score: 100, note: 'Fully open source under GPLv2' },
 156      jitOff:      { score: 30, note: 'Per-site only — no JIT-off default' },
 157      updates:     { score: 70, note: 'GitHub releases only — manual install / sideload cadence' },
 158      forkLag:     { score: 80, note: 'Tracks Chromium stable; experimental builds' },
 159    },
 160    brave: {
 161      sandbox:     { score: 95, note: 'Chromium-based, same sandbox' },
 162      mitigations: { score: 85, note: 'Inherits Chromium mitigations' },
 163      openness:    { score: 100, note: 'Fully open source under MPL 2.0; auditable repo' },
 164      jitOff:      { score: 30, note: 'Per-site only' },
 165      updates:     { score: 80, note: 'Play Store, typically ~1 week behind Chromium' },
 166      forkLag:     { score: 85, note: '1–2 weeks behind Chromium stable for QA' },
 167    },
 168    chrome: {
 169      sandbox:     { score: 95, note: 'Canonical Chromium site isolation' },
 170      mitigations: { score: 85, note: 'CFI, stack canaries, scudo — no MTE by default' },
 171      openness:    { score: 40, note: 'Chromium is OSS but Chrome ships proprietary Google blobs' },
 172      jitOff:      { score: 30, note: 'Per-site Site Settings toggle only' },
 173      updates:     { score: 95, note: 'Play Store auto-update, weekly stable patches' },
 174      forkLag:     { score: 100, note: 'Upstream itself' },
 175    },
 176    // Firefox / Gecko on Android has substantially weaker process isolation
 177    // than Chromium: a single content process for all tabs, no Fission /
 178    // site-per-process by default on Android, and no CFI in the Gecko build
 179    // (Mozilla tracks this in bugzilla 1539852 + meta 1665877). Sandbox and
 180    // mitigation scores are graded against the Chromium baseline.
 181    ironfox: {
 182      sandbox:     { score: 45, note: 'Single Gecko content process; no site-per-process on Android' },
 183      mitigations: { score: 80, note: 'WebGL / WebRTC / EME / telemetry stripped; ACCESS_NETWORK_STATE removed; bundled fonts' },
 184      openness:    { score: 100, note: 'Fully open source under MPL 2.0' },
 185      jitOff:      { score: 100, note: 'JIT disabled by default' },
 186      updates:     { score: 65, note: 'F-Droid + GitLab releases — small team but regular cadence' },
 187      forkLag:     { score: 65, note: 'Tracks Fenix release with hardening patches' },
 188    },
 189    tor: {
 190      sandbox:     { score: 45, note: 'Single Gecko content process; no site-per-process on Android' },
 191      mitigations: { score: 65, note: 'Tor Project hardening; no CFI in Gecko build' },
 192      openness:    { score: 100, note: 'Fully open source (MPL 2.0 + 3-clause BSD)' },
 193      jitOff:      { score: 80, note: 'Highest security level disables JIT (not default)' },
 194      updates:     { score: 65, note: 'Android releases lag desktop Tor Browser' },
 195      forkLag:     { score: 50, note: 'Built on Firefox ESR, well behind upstream Fenix' },
 196    },
 197    focus: {
 198      sandbox:     { score: 45, note: 'Single Gecko content process; smaller surface (no tabs/add-ons)' },
 199      mitigations: { score: 60, note: 'Same Gecko baseline as Firefox; clears state on exit' },
 200      openness:    { score: 100, note: 'Fully open source under MPL 2.0' },
 201      jitOff:      { score: 25, note: 'Not user-toggleable on Android' },
 202      updates:     { score: 88, note: 'Play Store, regular Mozilla cadence' },
 203      forkLag:     { score: 95, note: 'Tracks Fenix Focus branch closely' },
 204    },
 205    firefox: {
 206      sandbox:     { score: 45, note: 'Single Gecko content process; no site-per-process on Android' },
 207      mitigations: { score: 55, note: 'No CFI in Gecko build; stack canaries + ASLR only' },
 208      openness:    { score: 100, note: 'Fully open source under MPL 2.0' },
 209      jitOff:      { score: 25, note: 'Not user-toggleable on Android' },
 210      updates:     { score: 90, note: 'Play Store auto-update, weekly stable patches' },
 211      forkLag:     { score: 100, note: 'Upstream Fenix' },
 212    },
 213    'firefox-beta': {
 214      sandbox:     { score: 45, note: 'Same Gecko model as release' },
 215      mitigations: { score: 55, note: 'Same Gecko mitigations as release' },
 216      openness:    { score: 100, note: 'Same MPL 2.0 source as release' },
 217      jitOff:      { score: 25, note: 'Not user-toggleable' },
 218      updates:     { score: 95, note: 'Beta channel — more frequent than release' },
 219      forkLag:     { score: 100, note: 'Ahead of stable' },
 220    },
 221    'firefox-nightly': {
 222      sandbox:     { score: 45, note: 'Same Gecko model as release' },
 223      mitigations: { score: 50, note: 'Bleeding edge — occasional regressions' },
 224      openness:    { score: 100, note: 'Same MPL 2.0 source as release' },
 225      jitOff:      { score: 25, note: 'Not user-toggleable' },
 226      updates:     { score: 100, note: 'Nightly builds' },
 227      forkLag:     { score: 100, note: 'Tip of Fenix tree' },
 228    },
 229    ddg: {
 230      sandbox:     { score: 70, note: 'Wraps system WebView — site isolation when device supports it' },
 231      mitigations: { score: 75, note: 'Inherits Chromium WebView mitigations; varies by device' },
 232      openness:    { score: 90, note: 'App is Apache-2.0 OSS; engine = system WebView (varies)' },
 233      jitOff:      { score: 25, note: 'Inherits WebView default' },
 234      updates:     { score: 75, note: 'App updates fast; engine updates depend on system WebView' },
 235      forkLag:     { score: 60, note: 'Cannot push engine — lags on AOSP / outdated WebView devices' },
 236    },
 237  };
 238  
 239  export type SecurityBreakdown = {
 240    total: number;
 241    parts: { label: string; score: number; note: string }[];
 242  };
 243  
 244  export function securityScore(b: Browser): number {
 245    return securityBreakdown(b).total;
 246  }
 247  
 248  export function securityBreakdown(b: Browser): SecurityBreakdown {
 249    const prof = SEC[b.id];
 250    if (!prof) {
 251      // Fallback for any new browser id we forget to add: a conservative middling score.
 252      return { total: 50, parts: [{ label: 'Uncurated', score: 50, note: 'Not yet rated' }] };
 253    }
 254    const dims: SecDim[] = ['sandbox', 'mitigations', 'openness', 'updates', 'forkLag', 'jitOff'];
 255    let total = 0;
 256    const parts = dims.map((d) => {
 257      const row = prof[d];
 258      total += row.score * SEC_WEIGHTS[d];
 259      return { label: SEC_LABEL[d], score: row.score, note: row.note };
 260    });
 261    return { total: Math.round(total), parts };
 262  }
 263  
 264  // Tier weight isn't a security input but TS treeshakes the import otherwise.
 265  void tierWeight;
 266