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