Repository navigation
Expand file tree
/
Copy pathbackground-startup.js
More file actions
347 lines (331 loc) · 17.1 KB
/
Copy pathbackground-startup.js
File metadata and controls
347 lines (331 loc) · 17.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
/* WardenOne — Copyright (C) 2026 iri
Licensed under the GNU General Public License v3 or later. See LICENSE.
Official source: https://github.com/iri-dev/WardenOne
Upstream filter-list attribution: CREDITS.md
Redistributing a modified copy? GPLv3 section 5(a) requires you to mark it as changed,
with the date, and to keep these notices intact. */
/*
* WardenOne -- startup security check module (MV3 service worker)
* Loaded synchronously from background.js with importScripts().
*
* Shared globals provided by background.js/domain-utils.js:
* DEFAULT_CONFIG, localGet, regDomainBg, BLOCKED_DOMAINS,
* reconcileExtensionChanges, refreshExtensionAttentionBadge
* Shared globals exposed for background.js message handlers:
* STARTUP_REPORT_KEY, runStartupCheck(), loginRiskVerdict()
*/
/* global chrome, DEFAULT_CONFIG, localGet, regDomainBg, BLOCKED_DOMAINS, reconcileExtensionChanges, refreshExtensionAttentionBadge */
/* Startup security check: review restored tabs and extension changes, then publish a popup report.
This check does not close tabs or remove extensions. */
var STARTUP_REPORT_KEY = 'wardenone_startup_report';
/* Private reports belong in storage.session: split-mode workers share storage.local.
Do not retain titles, which can contain search terms, document names or message subjects (PRIV-06). */
var STARTUP_REPORT_TTL_MS = 24 * 60 * 60 * 1000;
function startupReportPrivate() {
try { return typeof INCOGNITO_CONTEXT !== 'undefined' && INCOGNITO_CONTEXT; } catch (_) { return false; }
}
function startupReportGet() {
return (startupReportPrivate() ? chrome.storage.session.get(STARTUP_REPORT_KEY) : localGet(STARTUP_REPORT_KEY))
.then((s) => {
const rep = s && s[STARTUP_REPORT_KEY];
/* A report is a moment, not a record: older than a day, it is not shown again. */
if (!rep || typeof rep.when !== 'number' || (Date.now() - rep.when) > STARTUP_REPORT_TTL_MS) return null;
return rep;
});
}
function startupReportSet(report) {
return startupReportPrivate()
? chrome.storage.session.set({ [STARTUP_REPORT_KEY]: report })
: localSet({ [STARTUP_REPORT_KEY]: report });
}
try { globalThis.startupReportGet = startupReportGet; globalThis.startupReportSet = startupReportSet; } catch (_) {}
async function startupCheckEnabled() {
try {
const s = await localGet('wardenone_config');
const cfg = (s && s.wardenone_config) || {};
return cfg.enabled !== false && cfg.startupCheck !== false;
} catch (_) { return false; }
}
async function extensionUiAllowed() {
try {
const s = await localGet('wardenone_config');
const cfg = Object.assign({}, DEFAULT_CONFIG, (s && s.wardenone_config) || {});
return cfg.enabled !== false && cfg.silentMode !== true;
} catch (_) { return true; }
}
var LOGIN_BRAND_PROFILES = [
{ label: 'PayPal', token: 'paypal', domains: ['paypal.com', 'paypal.me', 'paypalobjects.com'] },
{ label: 'Google', token: 'google', domains: ['google.com', 'google.co.uk', 'g.co', 'goo.gle', 'withgoogle.com', 'googleblog.com', 'accounts.google.com', 'googleapis.com', 'gstatic.com', 'googleusercontent.com', 'googlemail.com', 'youtube.com', 'gmail.com'] },
{ label: 'Microsoft', token: 'microsoft', domains: ['microsoft.com', 'microsoft365.com', 'live.com', 'outlook.com', 'office.com', 'office365.com', 'microsoftonline.com', 'sharepoint.com', 'onmicrosoft.com', 'msftauth.net', 'msauth.net', 'azure.com', 'azureedge.net', 'aka.ms', 'windows.com', 'xbox.com'] },
{ label: 'Apple', token: 'apple', domains: ['apple.com', 'icloud.com'] },
{ label: 'Amazon', token: 'amazon', domains: ['amazon.com', 'amazon.co.uk', 'amazon.ca', 'amazon.de', 'amazon.in', 'amazon.co.jp', 'amazon.com.au', 'amazon.fr', 'amazon.it', 'amazon.es', 'amazonaws.com', 'amzn.com', 'amzn.to', 'amazonpay.com'] },
{ label: 'Facebook', token: 'facebook', domains: ['facebook.com', 'fb.com', 'fb.me', 'messenger.com', 'meta.com', 'facebookmail.com', 'fbcdn.net'] },
{ label: 'Instagram', token: 'instagram', domains: ['instagram.com'] },
{ label: 'Netflix', token: 'netflix', domains: ['netflix.com'] },
{ label: 'Discord', token: 'discord', domains: ['discord.com', 'discordapp.com'] },
{ label: 'Steam', token: 'steam', domains: ['steampowered.com', 'steamcommunity.com'] },
{ label: 'Coinbase', token: 'coinbase', domains: ['coinbase.com'] },
{ label: 'Binance', token: 'binance', domains: ['binance.com'] },
{ label: 'MetaMask', token: 'metamask', domains: ['metamask.io'] },
{ label: 'GitHub', token: 'github', domains: ['github.com', 'githubusercontent.com', 'githubassets.com', 'github.io'] },
{ label: 'Dropbox', token: 'dropbox', domains: ['dropbox.com', 'dropboxusercontent.com'] },
{ label: '1Password', token: '1password', domains: ['1password.com'] },
{ label: 'Bitwarden', token: 'bitwarden', domains: ['bitwarden.com'] },
{ label: 'Proton', token: 'proton', domains: ['proton.me', 'protonvpn.com'] },
{ label: 'Bank of America', token: 'bankofamerica', domains: ['bankofamerica.com', 'bofa.com'] },
{ label: 'Chase', token: 'chase', domains: ['chase.com', 'jpmorganchase.com'] },
{ label: 'Wells Fargo', token: 'wellsfargo', domains: ['wellsfargo.com'] },
];
function loginHostMatchesOfficialBrand(host, profile) {
const h = String(host || '').replace(/^www\./, '').toLowerCase();
return (profile.domains || []).some((d) => h === d || h.endsWith('.' + d));
}
function normalizeBrandish(value) {
return String(value || '')
.toLowerCase()
.replace(/rn/g, 'm')
.replace(/vv/g, 'w')
.replace(/[@]/g, 'a')
.replace(/[0]/g, 'o')
.replace(/[1]/g, 'l')
.replace(/[!]/g, 'i')
.replace(/[2]/g, 'z')
.replace(/[3]/g, 'e')
.replace(/[4]/g, 'a')
.replace(/[5$]/g, 's')
.replace(/[6]/g, 'g')
.replace(/[7]/g, 't')
.replace(/[8]/g, 'b')
.replace(/[9]/g, 'g')
.replace(/[^a-z0-9]/g, '');
}
function editDistanceWithin(a, b, limit) {
a = String(a || '');
b = String(b || '');
if (Math.abs(a.length - b.length) > limit) return false;
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
let last = prev[0];
prev[0] = i;
let rowMin = prev[0];
for (let j = 1; j <= b.length; j++) {
const old = prev[j];
prev[j] = a[i - 1] === b[j - 1] ? last : Math.min(last, prev[j - 1], prev[j]) + 1;
last = old;
rowMin = Math.min(rowMin, prev[j]);
}
if (rowMin > limit) return false;
}
return prev[b.length] > 0 && prev[b.length] <= limit;
}
function oneAdjacentSwap(a, b) {
if (a.length !== b.length || a === b) return false;
let i = 0;
while (i < a.length && a[i] === b[i]) i++;
return i + 1 < a.length && a[i] === b[i + 1] && a[i + 1] === b[i]
&& a.slice(i + 2) === b.slice(i + 2);
}
/* Brand words that are also ordinary words, mirrored from the engine's COMMON_WORD_BRANDS: the
exact word on some other TLD proves nothing by itself (chase.co.uk is a bank; steam.xyz is a
word), so for these only a subdomain, a phishing word, a typo or a moved official domain counts. */
var LOGIN_BRAND_COMMON_WORDS = new Set(['apple', 'chase', 'steam', 'discord', 'proton']);
/* The names a brand is known by in a hostname: its token, and the second-level labels of its
official domains that carry the token (steampowered, steamcommunity, paypalobjects,
microsoftonline...). A typo or a TLD move of one of those is the classic kit --
steamcommunlty.com, steamcommunity.ru -- and the bare token would never see it. */
function loginBrandTokens(profile) {
const brand = normalizeBrandish(profile.token);
const tokens = [brand];
for (const d of profile.domains || []) {
const sld = normalizeBrandish(String(d).split('.')[0]);
if (sld !== brand && sld.length > brand.length && sld.includes(brand) && !tokens.includes(sld)) tokens.push(sld);
}
return tokens;
}
/* Match the engine’s brand shapes: login-like hostnames, misleading subdomains, TLD swaps and typos.
A brand substring or an independent site’s /login path alone is not impersonation evidence. */
function loginBrandRiskForHost(host, url) {
const h = String(host || '').replace(/^www\./, '').toLowerCase();
const rd = regDomainBg(h);
if (!rd) return null;
const core = rd.split('.')[0] || '';
const compact = normalizeBrandish(core);
const fullCompact = normalizeBrandish(rd);
const labels = h.split('.').filter(Boolean);
const subLabels = labels.slice(0, Math.max(0, labels.length - rd.split('.').length));
const subTokens = [];
for (const label of subLabels) for (const piece of label.split(/[-_]/)) if (piece) subTokens.push(normalizeBrandish(piece));
const authWord = /(login|logon|signin|sign-in|verify|verification|account|secure|security|password|passwd|mfa|2fa|oauth|session|billing|invoice|payment|wallet|bank|recover|recovery|confirm|unlock|update|suspended)/i;
const normalizedHost = normalizeBrandish(h);
const urlAuthish = authWord.test(String(url || ''));
const idnLabels = wardenOneIdnLabels(h);
for (const profile of LOGIN_BRAND_PROFILES) {
if (loginHostMatchesOfficialBrand(h, profile)) continue;
const brand = normalizeBrandish(profile.token);
/* The sign-in word must be outside the brand's own name. Otherwise 1Password
always contains "password", Bank of America contains "bank", and even
"steamfans" accidentally contains "mfa" across the two words. */
const hostAuthish = authWord.test(normalizedHost.replace(brand, ''));
const authish = hostAuthish || urlAuthish;
let kind = '';
for (const token of loginBrandTokens(profile)) {
if (idnLabels.some((label) => normalizeBrandish(label.skeleton) === token)) { kind = 'homograph'; break; }
const common = token === brand && LOGIN_BRAND_COMMON_WORDS.has(brand);
const visualSwap = compact === token && core !== token;
/* Two edits from a seven-letter brand is too broad: disboard is a Discord directory,
not a Discord typo. For common-word brands a plain one-letter suffix is also an
ordinary name shape (discords), unless it repeats the final letter (discordd). Keep visual
substitutions and one-edit mistakes; rn/m and vv/w are folded above. */
const plainSuffix = common && compact.length === token.length + 1 && compact.startsWith(token)
&& compact[compact.length - 1] !== compact[compact.length - 2];
if (visualSwap || oneAdjacentSwap(compact, token)
|| (!plainSuffix && editDistanceWithin(compact, token, 1))) { kind = 'typosquat'; break; }
if (compact === token && !common) { kind = 'tld-swap'; break; }
if (subTokens.includes(token)) { kind = 'subdomain'; break; }
if (fullCompact.includes(token) && hostAuthish) { kind = 'brand-in-name'; break; }
}
if (!kind) continue;
const reason = kind === 'homograph' ? 'uses look-alike characters to imitate ' + profile.label
: kind === 'typosquat' ? 'resembles ' + profile.label
: kind === 'tld-swap' ? 'uses the exact name of ' + profile.label
: kind === 'subdomain' ? 'puts the name of ' + profile.label + ' in a subdomain'
: 'uses the name of ' + profile.label + ' beside a sign-in word';
return {
brand: profile.label,
matched: rd,
kind,
reason: reason + ' but is not an official ' + profile.label + ' domain',
authish,
};
}
return null;
}
function loginRiskVerdict(host, url, age, maxDays) {
const h = String(host || '').replace(/^www\./, '').toLowerCase();
const rd = regDomainBg(h) || h;
const reasons = [];
const brand = loginBrandRiskForHost(h, url);
if (brand) reasons.push(brand.reason);
const ageDays = age && typeof age.ageDays === 'number' ? age.ageDays : null;
const isNew = typeof ageDays === 'number' && ageDays < maxDays;
if (isNew) reasons.push('domain is only ' + ageDays + ' day(s) old');
return {
risky: reasons.length > 0,
hardBlock: reasons.length > 0,
isNew,
ageDays,
brand: (brand && brand.brand) || '',
matched: (brand && brand.matched) || rd,
reasons,
};
}
function looksLikeLookalikeHost(host) {
// lightweight reuse of the punycode + digit-substitution heuristics
const h = String(host || '').replace(/^www\./, '').toLowerCase();
const rd = regDomainBg(h);
if (!rd) return false;
const brand = loginBrandRiskForHost(h, '');
if (brand) {
if (brand.kind !== 'subdomain' || brand.authish) return true;
const profile = LOGIN_BRAND_PROFILES.find((p) => p.label === brand.brand);
if (profile && profile.domains.some((d) => h.startsWith(d + '.') || h.includes('.' + d + '.'))) return true;
}
// brand with digit substitution (paypa1, g00gle, micros0ft, amaz0n)
const BRANDS = ['paypal', 'google', 'microsoft', 'amazon', 'apple', 'facebook', 'netflix', 'coinbase', 'binance', 'instagram'];
const core = rd.split('.')[0];
for (const b of BRANDS) {
if (core === b) return false; // exact brand word handled by real-domain check elsewhere
// same length, <=2 char differences, looks like the brand
if (Math.abs(core.length - b.length) <= 1) {
let diff = 0; const n = Math.max(core.length, b.length);
for (let i = 0; i < n; i++) if (core[i] !== b[i]) diff++;
if (diff > 0 && diff <= 2 && /[0-9]/.test(core)) return true;
}
}
return false;
}
async function runStartupCheck(reason, opts = {}) {
if (!opts.force && !(await startupCheckEnabled())) return null;
// The startup fast-path (setTimeout) and its backstop (alarm) can both fire for a
// single browser start. If a startup sweep already ran in the last 2 minutes, reuse
// it instead of repeating the work and re-notifying. Reads storage so it holds even
// if the worker was torn down and restarted between the two triggers.
if (!opts.force && (reason || 'startup') === 'startup') {
try {
const rep = await startupReportGet();
if (rep && typeof rep.when === 'number' && (Date.now() - rep.when) < 120000) return rep;
} catch (_) {}
}
const findings = { tabs: [], extensions: [], downloads: [], when: Date.now(), reason: reason || 'startup' };
// 1) open/restored tabs on risky domains
try {
const tabs = await new Promise((r) => { try { chrome.tabs.query({}, (t) => r(t || [])); } catch { r([]); } });
for (const t of tabs) {
const url = t.url || '';
if (!/^https?:/i.test(url)) continue;
let host = '';
try { host = new URL(url).hostname; } catch (_) { continue; }
const rd = regDomainBg(host);
const blocked = rd && (BLOCKED_DOMAINS.has(rd) || BLOCKED_DOMAINS.has(host));
const lookalike = looksLikeLookalikeHost(host);
if (blocked || lookalike) {
findings.tabs.push({ id: t.id, host: rd, why: blocked ? 'on a known malware/scam blocklist' : 'a look-alike of a real brand domain' });
}
}
} catch (_) {}
// 2) extension changes use the same serialized reconciler as live events and
// the periodic backstop. The old startup path independently snapshotted the
// inventory and could erase the exact change the live watcher needed to show.
try {
const events = await reconcileExtensionChanges('startup');
for (const event of events) {
findings.extensions.push({
id: event.id,
name: String(event.name || '').slice(0, 80),
enabled: event.enabled !== false,
risky: event.severity === 'medium' || event.severity === 'high' || event.severity === 'critical',
severity: event.severity,
change: event.summary,
});
}
} catch (_) {}
// Downloads are intentionally NOT scanned at startup any more. The Download Guard
// already reviews risky downloads live as they happen (and logs them to Activity),
// so re-scanning old download entries on every launch just re-nagged about the same
// files. findings.downloads stays empty only for old-report/schema compatibility.
const total = findings.tabs.length + findings.extensions.length;
findings.total = total;
try { await startupReportSet(findings); } catch (_) {}
const uiAllowed = await extensionUiAllowed();
if (uiAllowed) {
try { await refreshExtensionAttentionBadge(); } catch (_) {}
}
if (total > 0 && uiAllowed) {
try {
queueHistory({
type: 'startup_review',
url: '',
at: Date.now(),
detail: { message: total + ' item' + (total === 1 ? '' : 's') + ' need review.' },
});
} catch (_) {}
try {
await showWardenSystemNotification('wo-startup-' + Date.now(), {
type: 'basic', iconUrl: 'icons/icon128.png',
title: 'WardenOne startup check', priority: 1,
message: total + ' thing' + (total === 1 ? '' : 's') + ' to review: ' +
[findings.tabs.length ? findings.tabs.length + ' tab(s)' : '', findings.extensions.length ? findings.extensions.length + ' extension change(s)' : ''].filter(Boolean).join(', '),
}, 'startup_review');
} catch (_) {}
}
return findings;
}
// run shortly after startup (give tabs/extensions time to settle)
try {
chrome.runtime.onStartup?.addListener(() => {
// Fast path runs shortly after startup; the alarm is a backstop in case the MV3
// worker is torn down before the setTimeout fires (it would otherwise be lost).
setTimeout(() => runStartupCheck('startup'), 2500);
try { chrome.alarms.create('wardenone-startup-check', { delayInMinutes: 0.5 }); } catch (_) {}
});
} catch (_) {}