@intincrab/updiff 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +356 -2
- package/dist/agent.js +63 -0
- package/dist/asar.js +91 -0
- package/dist/bundle.js +686 -0
- package/dist/cask-history.js +216 -0
- package/dist/cask.js +79 -0
- package/dist/cli.js +8 -0
- package/dist/codesign.js +103 -0
- package/dist/commands.js +661 -0
- package/dist/diff.js +621 -0
- package/dist/exec.js +19 -0
- package/dist/extensions.js +453 -0
- package/dist/fetch.js +81 -0
- package/dist/hosts.js +137 -0
- package/dist/installer.js +177 -0
- package/dist/macho.js +35 -0
- package/dist/pkg-report.js +211 -0
- package/dist/pkg.js +211 -0
- package/dist/plist.js +196 -0
- package/dist/report.js +153 -0
- package/dist/store.js +113 -0
- package/dist/types.js +1 -0
- package/dist/util.js +73 -0
- package/dist/version.js +2 -0
- package/package.json +55 -4
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { lstat, open, readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { isScannableText } from "./bundle.js";
|
|
6
|
+
import { CATEGORY_ORDER, SEVERITY_ORDER } from "./diff.js";
|
|
7
|
+
import { extractHosts, mergeHosts, registrableDomain } from "./hosts.js";
|
|
8
|
+
import { sortedUnique } from "./util.js";
|
|
9
|
+
const CHROMIUM = [
|
|
10
|
+
{ host: 'chrome', name: 'Chrome', dir: 'Google/Chrome' },
|
|
11
|
+
{ host: 'brave', name: 'Brave', dir: 'BraveSoftware/Brave-Browser' },
|
|
12
|
+
{ host: 'edge', name: 'Edge', dir: 'Microsoft Edge' },
|
|
13
|
+
{ host: 'arc', name: 'Arc', dir: 'Arc/User Data' },
|
|
14
|
+
{ host: 'vivaldi', name: 'Vivaldi', dir: 'Vivaldi' },
|
|
15
|
+
{ host: 'chromium', name: 'Chromium', dir: 'Chromium' },
|
|
16
|
+
];
|
|
17
|
+
const EDITORS = [
|
|
18
|
+
{ host: 'vscode', name: 'VS Code', dir: '.vscode' },
|
|
19
|
+
{ host: 'cursor', name: 'Cursor', dir: '.cursor' },
|
|
20
|
+
{ host: 'windsurf', name: 'Windsurf', dir: '.windsurf' },
|
|
21
|
+
{ host: 'vscodium', name: 'VSCodium', dir: '.vscode-oss' },
|
|
22
|
+
];
|
|
23
|
+
/** Where to look: the user's home, or `$UPDIFF_EXTENSIONS_ROOT` (for tests). */
|
|
24
|
+
export function extensionsRoot() {
|
|
25
|
+
return process.env.UPDIFF_EXTENSIONS_ROOT || homedir();
|
|
26
|
+
}
|
|
27
|
+
/** Compares dotted versions numerically: "1.10.0" > "1.9.2". */
|
|
28
|
+
export function compareVersions(a, b) {
|
|
29
|
+
const pa = a.split(/[._-]/).map((x) => Number.parseInt(x, 10));
|
|
30
|
+
const pb = b.split(/[._-]/).map((x) => Number.parseInt(x, 10));
|
|
31
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
32
|
+
const x = Number.isNaN(pa[i] ?? 0) ? 0 : (pa[i] ?? 0);
|
|
33
|
+
const y = Number.isNaN(pb[i] ?? 0) ? 0 : (pb[i] ?? 0);
|
|
34
|
+
if (x !== y)
|
|
35
|
+
return x - y;
|
|
36
|
+
}
|
|
37
|
+
return a.localeCompare(b);
|
|
38
|
+
}
|
|
39
|
+
async function readJson(path) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse((await readFile(path, 'utf8')).replace(/^/, ''));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
48
|
+
const asObj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
49
|
+
/** Resolves "__MSG_extName__" from the extension's default locale. */
|
|
50
|
+
async function localized(dir, value, defaultLocale) {
|
|
51
|
+
if (typeof value !== 'string')
|
|
52
|
+
return undefined;
|
|
53
|
+
const m = /^__MSG_(.+)__$/.exec(value);
|
|
54
|
+
if (!m)
|
|
55
|
+
return value;
|
|
56
|
+
for (const locale of [defaultLocale, 'en', 'en_US'].filter((x) => typeof x === 'string')) {
|
|
57
|
+
const messages = asObj(await readJson(join(dir, '_locales', locale, 'messages.json')));
|
|
58
|
+
const key = Object.keys(messages).find((k) => k.toLowerCase() === m[1].toLowerCase());
|
|
59
|
+
const msg = key ? asObj(messages[key]).message : undefined;
|
|
60
|
+
if (typeof msg === 'string')
|
|
61
|
+
return msg;
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
async function chromiumExtensions(base, host, hostName) {
|
|
66
|
+
const out = [];
|
|
67
|
+
let profiles;
|
|
68
|
+
try {
|
|
69
|
+
profiles = (await readdir(base, { withFileTypes: true }))
|
|
70
|
+
.filter((d) => d.isDirectory() && (d.name === 'Default' || /^Profile \d+$/.test(d.name)))
|
|
71
|
+
.map((d) => d.name);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
for (const profile of profiles) {
|
|
77
|
+
const extDir = join(base, profile, 'Extensions');
|
|
78
|
+
let ids;
|
|
79
|
+
try {
|
|
80
|
+
ids = (await readdir(extDir)).filter((n) => /^[a-p]{32}$/.test(n));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const id of ids) {
|
|
86
|
+
// Chrome keeps the previous version until restart; the highest one is current.
|
|
87
|
+
const versions = (await readdir(join(extDir, id))).filter((v) => existsSync(join(extDir, id, v, 'manifest.json'))).sort(compareVersions);
|
|
88
|
+
const v = versions.at(-1);
|
|
89
|
+
if (!v)
|
|
90
|
+
continue;
|
|
91
|
+
const dir = join(extDir, id, v);
|
|
92
|
+
const m = asObj(await readJson(join(dir, 'manifest.json')));
|
|
93
|
+
const mv = typeof m.manifest_version === 'number' ? m.manifest_version : undefined;
|
|
94
|
+
// MV2 mixes host patterns into "permissions"; split them out.
|
|
95
|
+
const perms = strings(m.permissions);
|
|
96
|
+
const isPattern = (p) => p === '<all_urls>' || p.includes('://');
|
|
97
|
+
const contentScripts = Array.isArray(m.content_scripts) ? m.content_scripts.flatMap((c) => strings(asObj(c).matches)) : [];
|
|
98
|
+
out.push({
|
|
99
|
+
host,
|
|
100
|
+
hostName,
|
|
101
|
+
kind: 'browser',
|
|
102
|
+
profile,
|
|
103
|
+
id,
|
|
104
|
+
name: (await localized(dir, m.name, m.default_locale)) ?? id,
|
|
105
|
+
version: typeof m.version === 'string' ? m.version : v,
|
|
106
|
+
path: dir,
|
|
107
|
+
permissions: sortedUnique(perms.filter((p) => !isPattern(p))),
|
|
108
|
+
hostPermissions: sortedUnique([...strings(m.host_permissions), ...perms.filter(isPattern)]),
|
|
109
|
+
optionalPermissions: sortedUnique([...strings(m.optional_permissions), ...strings(m.optional_host_permissions)]),
|
|
110
|
+
contentScriptMatches: sortedUnique(contentScripts),
|
|
111
|
+
...(typeof m.update_url === 'string' ? { updateUrl: m.update_url } : {}),
|
|
112
|
+
...(mv !== undefined ? { manifestVersion: mv } : {}),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
async function firefoxExtensions(base) {
|
|
119
|
+
const out = [];
|
|
120
|
+
let profiles;
|
|
121
|
+
try {
|
|
122
|
+
profiles = (await readdir(base, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
for (const profile of profiles) {
|
|
128
|
+
const db = asObj(await readJson(join(base, profile, 'extensions.json')));
|
|
129
|
+
for (const a of Array.isArray(db.addons) ? db.addons.map(asObj) : []) {
|
|
130
|
+
// Only add-ons the user installed: not system add-ons or built-in themes.
|
|
131
|
+
if (a.type !== 'extension' || a.location !== 'app-profile' || typeof a.id !== 'string')
|
|
132
|
+
continue;
|
|
133
|
+
// signedState 3 is "privileged": Mozilla's own add-ons (New Tab and the like), shipped by Firefox itself.
|
|
134
|
+
if (a.signedState === 3)
|
|
135
|
+
continue;
|
|
136
|
+
const user = asObj(a.userPermissions);
|
|
137
|
+
const optional = asObj(a.optionalPermissions);
|
|
138
|
+
out.push({
|
|
139
|
+
host: 'firefox',
|
|
140
|
+
hostName: 'Firefox',
|
|
141
|
+
kind: 'browser',
|
|
142
|
+
// "wm2l86yg.default-release" → "default-release"
|
|
143
|
+
profile: profile.replace(/^[a-z0-9]{8}\./, ''),
|
|
144
|
+
id: a.id,
|
|
145
|
+
name: typeof asObj(a.defaultLocale).name === 'string' ? asObj(a.defaultLocale).name : a.id,
|
|
146
|
+
version: typeof a.version === 'string' ? a.version : '?',
|
|
147
|
+
path: typeof a.path === 'string' ? a.path : join(base, profile),
|
|
148
|
+
permissions: sortedUnique(strings(user.permissions)),
|
|
149
|
+
hostPermissions: sortedUnique(strings(user.origins)),
|
|
150
|
+
optionalPermissions: sortedUnique([...strings(optional.permissions), ...strings(optional.origins)]),
|
|
151
|
+
contentScriptMatches: [],
|
|
152
|
+
...(typeof a.updateURL === 'string' ? { updateUrl: a.updateURL } : {}),
|
|
153
|
+
active: a.active === true,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
const MACHO = new Set([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca]);
|
|
160
|
+
/** Domains in an editor extension's code and the native binaries it ships. */
|
|
161
|
+
async function scanEditorExtension(dir, budgetBytes) {
|
|
162
|
+
const hosts = new Map();
|
|
163
|
+
const native = [];
|
|
164
|
+
let used = 0;
|
|
165
|
+
const queue = [''];
|
|
166
|
+
while (queue.length) {
|
|
167
|
+
const rel = queue.pop();
|
|
168
|
+
let dirents;
|
|
169
|
+
try {
|
|
170
|
+
dirents = await readdir(join(dir, rel), { withFileTypes: true });
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
for (const d of dirents) {
|
|
176
|
+
const r = rel ? `${rel}/${d.name}` : d.name;
|
|
177
|
+
if (d.isDirectory()) {
|
|
178
|
+
queue.push(r);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!d.isFile())
|
|
182
|
+
continue;
|
|
183
|
+
const abs = join(dir, r);
|
|
184
|
+
if (d.name.endsWith('.node') || /\.(dylib|so)$/.test(d.name) || ((await lstat(abs)).mode & 0o111) !== 0) {
|
|
185
|
+
const fh = await open(abs, 'r');
|
|
186
|
+
const head = Buffer.alloc(4);
|
|
187
|
+
await fh.read(head, 0, 4, 0);
|
|
188
|
+
await fh.close();
|
|
189
|
+
if (MACHO.has(head.readUInt32BE(0)))
|
|
190
|
+
native.push(r);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!isScannableText(r) || used >= budgetBytes)
|
|
194
|
+
continue;
|
|
195
|
+
const text = await readFile(abs, 'utf8');
|
|
196
|
+
used += text.length;
|
|
197
|
+
const found = extractHosts(text, r, new Map(), true);
|
|
198
|
+
// A file with over a thousand bare hostnames is a bundled list, not code.
|
|
199
|
+
const bare = [...found.values()].filter((h) => !h.viaUrl).length;
|
|
200
|
+
if (bare > 1000)
|
|
201
|
+
for (const [h, rec] of found)
|
|
202
|
+
if (!rec.viaUrl)
|
|
203
|
+
found.delete(h);
|
|
204
|
+
mergeHosts(hosts, found);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { domains: sortedUnique([...hosts.keys()].map(registrableDomain)), nativeBinaries: native.sort() };
|
|
208
|
+
}
|
|
209
|
+
async function editorExtensions(base, host, hostName, scanCode) {
|
|
210
|
+
const extDir = join(base, 'extensions');
|
|
211
|
+
let dirs;
|
|
212
|
+
try {
|
|
213
|
+
dirs = (await readdir(extDir, { withFileTypes: true })).filter((d) => d.isDirectory() && !d.name.startsWith('.')).map((d) => d.name);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
// Several versions can sit side by side until the editor restarts; keep the highest per id.
|
|
219
|
+
const best = new Map();
|
|
220
|
+
for (const name of dirs) {
|
|
221
|
+
const pkg = asObj(await readJson(join(extDir, name, 'package.json')));
|
|
222
|
+
if (typeof pkg.name !== 'string' || typeof pkg.version !== 'string')
|
|
223
|
+
continue;
|
|
224
|
+
const id = `${typeof pkg.publisher === 'string' ? pkg.publisher : 'unknown'}.${pkg.name}`.toLowerCase();
|
|
225
|
+
const cur = best.get(id);
|
|
226
|
+
if (!cur || compareVersions(pkg.version, cur.pkg.version) > 0)
|
|
227
|
+
best.set(id, { dir: join(extDir, name), pkg });
|
|
228
|
+
}
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const [id, { dir, pkg }] of best) {
|
|
231
|
+
let display = typeof pkg.displayName === 'string' ? pkg.displayName : pkg.name;
|
|
232
|
+
const nls = /^%(.+)%$/.exec(display);
|
|
233
|
+
if (nls) {
|
|
234
|
+
const strs = asObj(await readJson(join(dir, 'package.nls.json')));
|
|
235
|
+
const v = strs[nls[1]];
|
|
236
|
+
if (typeof v === 'string')
|
|
237
|
+
display = v;
|
|
238
|
+
}
|
|
239
|
+
const code = scanCode ? await scanEditorExtension(dir, 64 * 1024 * 1024) : { domains: [], nativeBinaries: [] };
|
|
240
|
+
out.push({
|
|
241
|
+
host,
|
|
242
|
+
hostName,
|
|
243
|
+
kind: 'editor',
|
|
244
|
+
id,
|
|
245
|
+
name: display,
|
|
246
|
+
version: pkg.version,
|
|
247
|
+
path: dir,
|
|
248
|
+
permissions: [],
|
|
249
|
+
hostPermissions: [],
|
|
250
|
+
optionalPermissions: [],
|
|
251
|
+
contentScriptMatches: [],
|
|
252
|
+
...(typeof pkg.publisher === 'string' ? { publisher: pkg.publisher } : {}),
|
|
253
|
+
activationEvents: sortedUnique(strings(pkg.activationEvents)),
|
|
254
|
+
extensionDependencies: sortedUnique(strings(pkg.extensionDependencies).map((x) => x.toLowerCase())),
|
|
255
|
+
extensionPack: sortedUnique(strings(pkg.extensionPack).map((x) => x.toLowerCase())),
|
|
256
|
+
npmDependencies: sortedUnique(Object.keys(asObj(pkg.dependencies))),
|
|
257
|
+
nativeBinaries: code.nativeBinaries,
|
|
258
|
+
domains: code.domains,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
/** Finds installed browser and editor extensions. */
|
|
264
|
+
export async function discoverExtensions(options = {}) {
|
|
265
|
+
const root = options.root ?? extensionsRoot();
|
|
266
|
+
const support = join(root, 'Library', 'Application Support');
|
|
267
|
+
const found = [];
|
|
268
|
+
for (const b of CHROMIUM)
|
|
269
|
+
found.push(...(await chromiumExtensions(join(support, b.dir), b.host, b.name)));
|
|
270
|
+
found.push(...(await firefoxExtensions(join(support, 'Firefox', 'Profiles'))));
|
|
271
|
+
for (const e of EDITORS)
|
|
272
|
+
found.push(...(await editorExtensions(join(root, e.dir), e.host, e.name, options.scanCode !== false)));
|
|
273
|
+
return found.sort((a, b) => a.hostName.localeCompare(b.hostName) || (a.profile ?? '').localeCompare(b.profile ?? '') || a.name.localeCompare(b.name));
|
|
274
|
+
}
|
|
275
|
+
export function extensionKey(e) {
|
|
276
|
+
return `${e.host}|${e.profile ?? ''}|${e.id}`;
|
|
277
|
+
}
|
|
278
|
+
// ── Risk rules ────────────────────────────────────────────────────────────────
|
|
279
|
+
/** Permissions that reach browsing data, the network or other software. */
|
|
280
|
+
const SENSITIVE_PERMISSIONS = new Set([
|
|
281
|
+
'cookies', 'history', 'webRequest', 'webRequestBlocking', 'debugger', 'nativeMessaging', 'management', 'proxy', 'privacy',
|
|
282
|
+
'clipboardRead', 'desktopCapture', 'tabCapture', 'browsingData', 'contentSettings', 'declarativeNetRequestWithHostAccess',
|
|
283
|
+
'webNavigation', 'downloads', 'pageCapture', 'scripting', 'userScripts', 'vpnProvider', 'certificateProvider',
|
|
284
|
+
]);
|
|
285
|
+
/** Permissions with little reach on their own. */
|
|
286
|
+
const LOW_PERMISSIONS = new Set([
|
|
287
|
+
'storage', 'unlimitedStorage', 'alarms', 'contextMenus', 'notifications', 'offscreen', 'idle', 'sidePanel', 'i18n',
|
|
288
|
+
'fontSettings', 'power', 'tts', 'theme', 'menus', 'activeTab', 'action', 'commands',
|
|
289
|
+
]);
|
|
290
|
+
/** A host pattern that matches every website. */
|
|
291
|
+
export function isBroadPattern(p) {
|
|
292
|
+
return p === '<all_urls>' || /^(\*|https?|wss?|file):\/\/\*?\/\*?$/.test(p) || /^(\*|https?):\/\/\*\/.*$/.test(p);
|
|
293
|
+
}
|
|
294
|
+
function permissionSeverity(p) {
|
|
295
|
+
if (SENSITIVE_PERMISSIONS.has(p))
|
|
296
|
+
return 'high';
|
|
297
|
+
return LOW_PERMISSIONS.has(p) ? 'info' : 'notable';
|
|
298
|
+
}
|
|
299
|
+
const ALWAYS_ON = new Set(['*', 'onStartupFinished']);
|
|
300
|
+
function label(e) {
|
|
301
|
+
return `${e.name} (${e.hostName}${e.profile && e.profile !== 'Default' ? `, ${e.profile}` : ''})`;
|
|
302
|
+
}
|
|
303
|
+
function describeNew(e) {
|
|
304
|
+
if (e.kind === 'editor') {
|
|
305
|
+
const parts = [`v${e.version}`];
|
|
306
|
+
if (e.activationEvents?.some((x) => ALWAYS_ON.has(x)))
|
|
307
|
+
parts.push('starts with every window');
|
|
308
|
+
if (e.extensionPack?.length || e.extensionDependencies?.length)
|
|
309
|
+
parts.push(`pulls in ${[...(e.extensionPack ?? []), ...(e.extensionDependencies ?? [])].join(', ')}`);
|
|
310
|
+
if (e.nativeBinaries?.length)
|
|
311
|
+
parts.push(`${e.nativeBinaries.length} native binar${e.nativeBinaries.length === 1 ? 'y' : 'ies'}`);
|
|
312
|
+
return parts.join(' · ');
|
|
313
|
+
}
|
|
314
|
+
const sites = [...e.hostPermissions, ...e.contentScriptMatches];
|
|
315
|
+
return [`v${e.version}`, e.permissions.length ? `permissions: ${e.permissions.join(', ')}` : undefined, sites.length ? `sites: ${sites.some(isBroadPattern) ? 'all websites' : sites.slice(0, 5).join(', ') + (sites.length > 5 ? '…' : '')}` : undefined]
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.join(' · ');
|
|
318
|
+
}
|
|
319
|
+
function added(a, b) {
|
|
320
|
+
const before = new Set(a ?? []);
|
|
321
|
+
return (b ?? []).filter((x) => !before.has(x));
|
|
322
|
+
}
|
|
323
|
+
function diffOne(a, b, out) {
|
|
324
|
+
const who = label(b);
|
|
325
|
+
const where = [`${b.hostName} ${b.id}`];
|
|
326
|
+
const versionNote = a.version !== b.version ? ` in ${b.version}` : '';
|
|
327
|
+
if (b.kind === 'browser') {
|
|
328
|
+
for (const p of added(a.permissions, b.permissions)) {
|
|
329
|
+
out.push({ severity: permissionSeverity(p), category: 'permission', change: 'added', title: `${who} gained the "${p}" permission${versionNote}`, where });
|
|
330
|
+
}
|
|
331
|
+
const sitesA = [...a.hostPermissions, ...a.contentScriptMatches];
|
|
332
|
+
const newSites = added(sitesA, [...b.hostPermissions, ...b.contentScriptMatches]);
|
|
333
|
+
if (newSites.some(isBroadPattern) && !sitesA.some(isBroadPattern)) {
|
|
334
|
+
out.push({ severity: 'high', category: 'permission', change: 'added', title: `${who} can now read and change all websites${versionNote}`, detail: newSites.filter(isBroadPattern).join(', '), where });
|
|
335
|
+
}
|
|
336
|
+
const specific = newSites.filter((s) => !isBroadPattern(s));
|
|
337
|
+
if (specific.length) {
|
|
338
|
+
out.push({ severity: 'notable', category: 'permission', change: 'added', title: `${who} gained access to ${specific.length} more site${specific.length === 1 ? '' : 's'}${versionNote}`, detail: specific.join(', '), where });
|
|
339
|
+
}
|
|
340
|
+
const optional = added(a.optionalPermissions, b.optionalPermissions);
|
|
341
|
+
if (optional.length)
|
|
342
|
+
out.push({ severity: 'info', category: 'permission', change: 'added', title: `${who} can now ask for more permissions later`, detail: optional.join(', '), where });
|
|
343
|
+
const lost = [...added(b.permissions, a.permissions), ...added([...b.hostPermissions, ...b.contentScriptMatches], sitesA)];
|
|
344
|
+
if (lost.length)
|
|
345
|
+
out.push({ severity: 'info', category: 'permission', change: 'removed', title: `${who} dropped permissions`, detail: lost.join(', '), where });
|
|
346
|
+
if (a.updateUrl && b.updateUrl && a.updateUrl !== b.updateUrl) {
|
|
347
|
+
out.push({ severity: 'high', category: 'updater', change: 'changed', title: `${who} now updates from a different server`, detail: `${a.updateUrl} → ${b.updateUrl}`, where });
|
|
348
|
+
}
|
|
349
|
+
if (a.manifestVersion && b.manifestVersion && a.manifestVersion !== b.manifestVersion) {
|
|
350
|
+
out.push({ severity: 'info', category: 'code', change: 'changed', title: `${who} moved to Manifest V${b.manifestVersion}`, where });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const pulled = added([...(a.extensionPack ?? []), ...(a.extensionDependencies ?? [])], [...(b.extensionPack ?? []), ...(b.extensionDependencies ?? [])]);
|
|
355
|
+
if (pulled.length) {
|
|
356
|
+
out.push({ severity: 'high', category: 'install', change: 'added', title: `${who} now installs other extensions${versionNote}`, detail: pulled.join(', '), where });
|
|
357
|
+
}
|
|
358
|
+
if (!a.activationEvents?.some((x) => ALWAYS_ON.has(x)) && b.activationEvents?.some((x) => ALWAYS_ON.has(x))) {
|
|
359
|
+
out.push({ severity: 'notable', category: 'background', change: 'changed', title: `${who} now starts with every editor window${versionNote}`, detail: b.activationEvents.filter((x) => ALWAYS_ON.has(x)).join(', '), where });
|
|
360
|
+
}
|
|
361
|
+
const npm = added(a.npmDependencies, b.npmDependencies);
|
|
362
|
+
if (npm.length)
|
|
363
|
+
out.push({ severity: 'notable', category: 'package', change: 'added', title: `${who} bundles ${npm.length} new npm package${npm.length === 1 ? '' : 's'}${versionNote}`, detail: npm.join(', '), where });
|
|
364
|
+
const bins = added(a.nativeBinaries, b.nativeBinaries);
|
|
365
|
+
if (bins.length)
|
|
366
|
+
out.push({ severity: 'notable', category: 'code', change: 'added', title: `${who} ships new native code${versionNote}`, detail: bins.join(', '), where });
|
|
367
|
+
const domains = added(a.domains, b.domains);
|
|
368
|
+
if (domains.length)
|
|
369
|
+
out.push({ severity: 'notable', category: 'host', change: 'added', title: `${who} mentions new domains${versionNote}`, detail: domains.join(', '), where });
|
|
370
|
+
}
|
|
371
|
+
if (a.version !== b.version)
|
|
372
|
+
out.push({ severity: 'info', category: 'identity', change: 'changed', title: `${who} ${a.version} → ${b.version}`, where });
|
|
373
|
+
}
|
|
374
|
+
/** What changed among installed extensions between two snapshots. */
|
|
375
|
+
export function diffExtensions(a, b) {
|
|
376
|
+
const findings = [];
|
|
377
|
+
const before = new Map(a.extensions.map((e) => [extensionKey(e), e]));
|
|
378
|
+
for (const e of b.extensions) {
|
|
379
|
+
const old = before.get(extensionKey(e));
|
|
380
|
+
before.delete(extensionKey(e));
|
|
381
|
+
if (!old) {
|
|
382
|
+
const broad = [...e.hostPermissions, ...e.contentScriptMatches].some(isBroadPattern);
|
|
383
|
+
const pulls = (e.extensionPack?.length ?? 0) + (e.extensionDependencies?.length ?? 0) > 0;
|
|
384
|
+
findings.push({
|
|
385
|
+
severity: broad || pulls ? 'high' : 'notable',
|
|
386
|
+
category: 'install',
|
|
387
|
+
change: 'added',
|
|
388
|
+
title: `New extension: ${label(e)}${broad ? ' with access to all websites' : ''}`,
|
|
389
|
+
detail: describeNew(e),
|
|
390
|
+
where: [`${e.hostName} ${e.id}`],
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
diffOne(old, e, findings);
|
|
395
|
+
}
|
|
396
|
+
for (const gone of before.values())
|
|
397
|
+
findings.push({ severity: 'info', category: 'install', change: 'removed', title: `Removed extension: ${label(gone)}` });
|
|
398
|
+
findings.sort((x, y) => SEVERITY_ORDER[x.severity] - SEVERITY_ORDER[y.severity] || CATEGORY_ORDER.indexOf(x.category) - CATEGORY_ORDER.indexOf(y.category) || x.title.localeCompare(y.title));
|
|
399
|
+
const counts = { high: 0, notable: 0, info: 0 };
|
|
400
|
+
for (const f of findings)
|
|
401
|
+
counts[f.severity]++;
|
|
402
|
+
const side = (s) => ({ version: `${s.extensions.length} extensions`, build: '', takenAt: s.takenAt, path: '' });
|
|
403
|
+
return {
|
|
404
|
+
app: { name: 'Browser and editor extensions', bundleId: '' },
|
|
405
|
+
from: side(a),
|
|
406
|
+
to: side(b),
|
|
407
|
+
findings,
|
|
408
|
+
counts,
|
|
409
|
+
identical: findings.length === 0,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/** A readable list of installed extensions with what each one can reach. */
|
|
413
|
+
export function renderExtensions(list, options = {}) {
|
|
414
|
+
const warn = (s) => (options.color ? `\x1b[33m${s}\x1b[39m` : s);
|
|
415
|
+
const dim = (s) => (options.color ? `\x1b[2m${s}\x1b[22m` : s);
|
|
416
|
+
const bold = (s) => (options.color ? `\x1b[1m${s}\x1b[22m` : s);
|
|
417
|
+
const lines = [];
|
|
418
|
+
let group = '';
|
|
419
|
+
for (const e of list) {
|
|
420
|
+
const g = `${e.hostName}${e.profile ? ` · ${e.profile}` : ''}`;
|
|
421
|
+
if (g !== group) {
|
|
422
|
+
lines.push('', bold(g));
|
|
423
|
+
group = g;
|
|
424
|
+
}
|
|
425
|
+
const notes = [];
|
|
426
|
+
if (e.kind === 'browser') {
|
|
427
|
+
if ([...e.hostPermissions, ...e.contentScriptMatches].some(isBroadPattern))
|
|
428
|
+
notes.push(warn('all websites'));
|
|
429
|
+
else if (e.hostPermissions.length + e.contentScriptMatches.length) {
|
|
430
|
+
const n = e.hostPermissions.length + e.contentScriptMatches.length;
|
|
431
|
+
notes.push(`${n} site${n === 1 ? '' : 's'}`);
|
|
432
|
+
}
|
|
433
|
+
const sensitive = e.permissions.filter((p) => SENSITIVE_PERMISSIONS.has(p));
|
|
434
|
+
if (sensitive.length)
|
|
435
|
+
notes.push(warn(sensitive.join(', ')));
|
|
436
|
+
if (e.active === false)
|
|
437
|
+
notes.push(dim('disabled'));
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
if (e.activationEvents?.some((x) => ALWAYS_ON.has(x)))
|
|
441
|
+
notes.push(warn('starts with every window'));
|
|
442
|
+
if (e.nativeBinaries?.length)
|
|
443
|
+
notes.push(`${e.nativeBinaries.length} native binar${e.nativeBinaries.length === 1 ? 'y' : 'ies'}`);
|
|
444
|
+
if (e.domains?.length)
|
|
445
|
+
notes.push(`${e.domains.length} domains in code`);
|
|
446
|
+
}
|
|
447
|
+
lines.push(` ${e.name.slice(0, 44).padEnd(44)} ${e.version.slice(0, 16).padEnd(16)} ${notes.join(dim(' · '))}`);
|
|
448
|
+
}
|
|
449
|
+
if (!list.length)
|
|
450
|
+
lines.push('', 'No browser or editor extensions found.');
|
|
451
|
+
lines.push('', dim(`${list.length} extension${list.length === 1 ? '' : 's'}. Browsers: Chrome, Brave, Edge, Arc, Vivaldi, Chromium, Firefox. Editors: VS Code, Cursor, Windsurf, VSCodium.`));
|
|
452
|
+
return lines.join('\n').replace(/^\n/, '') + '\n';
|
|
453
|
+
}
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { scanBundle } from "./bundle.js";
|
|
5
|
+
import { fetchCask, urlForVersion } from "./cask.js";
|
|
6
|
+
import { caskHistory } from "./cask-history.js";
|
|
7
|
+
import { download, extractApp } from "./installer.js";
|
|
8
|
+
/** Turns an app name into the cask token Homebrew would likely use: "Google Chrome" → "google-chrome". */
|
|
9
|
+
export function guessCaskToken(name) {
|
|
10
|
+
return name
|
|
11
|
+
.replace(/\.app$/i, '')
|
|
12
|
+
.trim()
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9@.+]+/g, '-')
|
|
15
|
+
.replace(/^-+|-+$/g, '');
|
|
16
|
+
}
|
|
17
|
+
/** Finds the cask for an app: an explicit token first, then guesses from the names given. */
|
|
18
|
+
export async function resolveCask(candidates) {
|
|
19
|
+
const tried = [];
|
|
20
|
+
for (const c of candidates) {
|
|
21
|
+
const token = guessCaskToken(c);
|
|
22
|
+
if (!token || tried.includes(token))
|
|
23
|
+
continue;
|
|
24
|
+
tried.push(token);
|
|
25
|
+
try {
|
|
26
|
+
return await fetchCask(token);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Try the next guess.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`No Homebrew cask found for ${tried.map((t) => `"${t}"`).join(' or ')}. Pass the cask's name with --cask (see https://formulae.brew.sh/cask/).`);
|
|
33
|
+
}
|
|
34
|
+
/** The cask's version string matched loosely against an app version ("1.172.0" matches "1.172.0,90280"). */
|
|
35
|
+
export function sameVersion(caskVersion, appVersion) {
|
|
36
|
+
if (!appVersion)
|
|
37
|
+
return false;
|
|
38
|
+
return caskVersion === appVersion || caskVersion.split(',')[0] === appVersion;
|
|
39
|
+
}
|
|
40
|
+
/** Picks the release to fetch: the one asked for, or the one before the installed (or current) version. */
|
|
41
|
+
export function pickRelease(releases, wanted, installedVersion) {
|
|
42
|
+
if (wanted) {
|
|
43
|
+
const hit = releases.find((r) => r.version === wanted) ?? releases.find((r) => sameVersion(r.version, wanted));
|
|
44
|
+
if (!hit)
|
|
45
|
+
throw new Error(`Version ${wanted} isn't in the cask's recent history. Run with --list to see the versions there are.`);
|
|
46
|
+
return hit;
|
|
47
|
+
}
|
|
48
|
+
const installedAt = releases.findIndex((r) => sameVersion(r.version, installedVersion));
|
|
49
|
+
const pick = installedAt >= 0 ? releases[installedAt + 1] : releases[1];
|
|
50
|
+
if (!pick)
|
|
51
|
+
throw new Error('The cask has no earlier release in its recent history.');
|
|
52
|
+
return pick;
|
|
53
|
+
}
|
|
54
|
+
/** Downloads a past release, checks it against the cask's checksum, and scans it. Nothing is installed or run. */
|
|
55
|
+
export async function fetchRelease(info, release, options = {}) {
|
|
56
|
+
const log = options.log ?? (() => { });
|
|
57
|
+
const url = release.url ?? urlForVersion(info, release.version);
|
|
58
|
+
if (!url)
|
|
59
|
+
throw new Error(`Can't work out the download URL for ${info.name} ${release.version} from the cask.`);
|
|
60
|
+
const work = await mkdtemp(join(tmpdir(), 'updiff-fetch-'));
|
|
61
|
+
try {
|
|
62
|
+
log(`Downloading ${info.name} ${release.version} from ${url}`);
|
|
63
|
+
const dl = await download(url, join(work, 'download'), { maxBytes: options.maxBytes ?? 3 * 1024 ** 3, expectedSha256: release.sha256 });
|
|
64
|
+
log(release.sha256 ? `Checksum matches the cask (${dl.sha256.slice(0, 12)}…)` : `The cask doesn't pin a checksum for this release (${dl.sha256.slice(0, 12)}…)`);
|
|
65
|
+
const extracted = await extractApp(dl.file, join(work, 'x'), info.appNames);
|
|
66
|
+
let snapshot;
|
|
67
|
+
try {
|
|
68
|
+
log(`Scanning ${extracted.appPath.slice(extracted.appPath.lastIndexOf('/') + 1)} from the ${extracted.kind}`);
|
|
69
|
+
snapshot = await scanBundle(extracted.appPath, options.scan);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await extracted.cleanup();
|
|
73
|
+
}
|
|
74
|
+
snapshot.app.path = `${info.name} ${release.version} (Homebrew cask ${info.token} at ${release.commit.slice(0, 7)})`;
|
|
75
|
+
return { snapshot, release, info, url, verified: release.sha256 !== null, bytes: dl.bytes };
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await rm(work, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export { caskHistory };
|
package/dist/hosts.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
2
|
+
const HOSTNAME = `(?:${LABEL}\\.)+[a-z][a-z0-9-]{0,22}[a-z0-9]`;
|
|
3
|
+
/** A host after a URL scheme, optionally with user info. */
|
|
4
|
+
const URL_RE = new RegExp(`\\b(?:https?|wss?|ftp)://(?:[^\\s"'<>/@\\\\]{1,64}@)?(${HOSTNAME})(?![a-z0-9-])`, 'gi');
|
|
5
|
+
/** A hostname alone inside a string literal: "events.vendor.io". */
|
|
6
|
+
const LITERAL_RE = new RegExp(`["'\`](${HOSTNAME})["'\`]`, 'gi');
|
|
7
|
+
/**
|
|
8
|
+
* Top-level domains accepted for bare string literals. Kept short on purpose: bare
|
|
9
|
+
* strings like "Foo.app", "README.md" and "build.sh" would otherwise read as hosts.
|
|
10
|
+
* Hosts that follow a URL scheme accept any alphabetic top-level domain.
|
|
11
|
+
*/
|
|
12
|
+
const LITERAL_TLDS = new Set(('com net org io ai dev co me us uk de fr nl se no fi dk es it pt pl ch at be ie jp cn kr tw hk sg in au nz ca br mx ' +
|
|
13
|
+
'ru ua cz hu ro gr tr il za eu info biz tv cloud xyz gg ly so to fm gl im ac cc edu gov mil int').split(' '));
|
|
14
|
+
/** Exact hosts and suffixes that are namespaces, documentation placeholders or reserved names. */
|
|
15
|
+
const IGNORED_HOSTS = new Set([
|
|
16
|
+
'www.w3.org',
|
|
17
|
+
'w3.org',
|
|
18
|
+
'schemas.xmlsoap.org',
|
|
19
|
+
'schemas.microsoft.com',
|
|
20
|
+
'schemas.openxmlformats.org',
|
|
21
|
+
'ns.adobe.com',
|
|
22
|
+
'purl.org',
|
|
23
|
+
'xmlns.com',
|
|
24
|
+
'json-schema.org',
|
|
25
|
+
'example.com',
|
|
26
|
+
'example.net',
|
|
27
|
+
'example.org',
|
|
28
|
+
'www.example.com',
|
|
29
|
+
'www.example.net',
|
|
30
|
+
'www.example.org',
|
|
31
|
+
]);
|
|
32
|
+
const IGNORED_SUFFIXES = ['.localhost', '.local', '.test', '.invalid', '.example', '.internal', '.lan', '.home.arpa'];
|
|
33
|
+
export function isIgnoredHost(host) {
|
|
34
|
+
if (host === 'localhost' || IGNORED_HOSTS.has(host))
|
|
35
|
+
return true;
|
|
36
|
+
return IGNORED_SUFFIXES.some((s) => host.endsWith(s));
|
|
37
|
+
}
|
|
38
|
+
function tldOf(host) {
|
|
39
|
+
return host.slice(host.lastIndexOf('.') + 1);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Suffixes under which each subdomain belongs to a different owner. A short,
|
|
43
|
+
* hand-picked part of the Public Suffix List that covers the common cases.
|
|
44
|
+
*/
|
|
45
|
+
const MULTI_PART_SUFFIXES = new Set([
|
|
46
|
+
'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ltd.uk', 'plc.uk',
|
|
47
|
+
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au',
|
|
48
|
+
'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'go.jp',
|
|
49
|
+
'com.br', 'net.br', 'org.br', 'gov.br',
|
|
50
|
+
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
|
|
51
|
+
'co.in', 'net.in', 'org.in', 'gov.in', 'ac.in',
|
|
52
|
+
'co.kr', 'or.kr', 'go.kr',
|
|
53
|
+
'co.nz', 'org.nz', 'govt.nz',
|
|
54
|
+
'com.mx', 'com.tr', 'com.tw', 'com.hk', 'com.sg', 'com.my', 'com.ar', 'com.co', 'com.vn', 'com.ph', 'com.pk',
|
|
55
|
+
'co.za', 'co.il', 'co.id', 'co.th',
|
|
56
|
+
'github.io', 'gitlab.io', 'pages.dev', 'workers.dev', 'vercel.app', 'netlify.app', 'herokuapp.com',
|
|
57
|
+
'appspot.com', 'web.app', 'firebaseapp.com', 'azurewebsites.net', 'cloudfront.net', 'blob.core.windows.net',
|
|
58
|
+
's3.amazonaws.com', 'fly.dev', 'onrender.com', 'ngrok.io', 'ngrok-free.app', 'r2.dev', 'supabase.co',
|
|
59
|
+
]);
|
|
60
|
+
/** The domain a host belongs to: `api.eu.vendor.co.uk` → `vendor.co.uk`. */
|
|
61
|
+
export function registrableDomain(host) {
|
|
62
|
+
const labels = host.split('.');
|
|
63
|
+
for (let take = Math.min(4, labels.length - 1); take >= 2; take--) {
|
|
64
|
+
const suffix = labels.slice(-take).join('.');
|
|
65
|
+
if (MULTI_PART_SUFFIXES.has(suffix))
|
|
66
|
+
return labels.slice(-(take + 1)).join('.');
|
|
67
|
+
}
|
|
68
|
+
return labels.slice(-2).join('.');
|
|
69
|
+
}
|
|
70
|
+
function add(map, host, where, viaUrl) {
|
|
71
|
+
const rec = map.get(host);
|
|
72
|
+
if (rec) {
|
|
73
|
+
rec.count++;
|
|
74
|
+
rec.viaUrl ||= viaUrl;
|
|
75
|
+
if (rec.where.length < 3 && !rec.where.includes(where))
|
|
76
|
+
rec.where.push(where);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
map.set(host, { count: 1, viaUrl, where: [where] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function cleanHost(raw) {
|
|
83
|
+
return raw.toLowerCase().replace(/\.$/, '');
|
|
84
|
+
}
|
|
85
|
+
/** Finds hostnames in text (source code, JSON, HTML, or a binary read as latin1). */
|
|
86
|
+
export function extractHosts(text, where, into = new Map(), literals = true) {
|
|
87
|
+
for (const m of text.matchAll(URL_RE)) {
|
|
88
|
+
const host = cleanHost(m[1]);
|
|
89
|
+
if (!/[a-z]/.test(tldOf(host)) || isIgnoredHost(host))
|
|
90
|
+
continue;
|
|
91
|
+
add(into, host, where, true);
|
|
92
|
+
}
|
|
93
|
+
if (literals) {
|
|
94
|
+
for (const m of text.matchAll(LITERAL_RE)) {
|
|
95
|
+
const host = cleanHost(m[1]);
|
|
96
|
+
if (!LITERAL_TLDS.has(tldOf(host)) || isIgnoredHost(host))
|
|
97
|
+
continue;
|
|
98
|
+
// "this.is" / "e.g" style tokens: a one-letter first label is rarely a real host.
|
|
99
|
+
if (/^[a-z]\./.test(host) && host.split('.').length === 2)
|
|
100
|
+
continue;
|
|
101
|
+
add(into, host, where, false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return into;
|
|
105
|
+
}
|
|
106
|
+
export function mergeHosts(into, from) {
|
|
107
|
+
for (const [host, rec] of from) {
|
|
108
|
+
const cur = into.get(host);
|
|
109
|
+
if (!cur)
|
|
110
|
+
into.set(host, { count: rec.count, viaUrl: rec.viaUrl, where: [...rec.where] });
|
|
111
|
+
else {
|
|
112
|
+
cur.count += rec.count;
|
|
113
|
+
cur.viaUrl ||= rec.viaUrl;
|
|
114
|
+
for (const w of rec.where)
|
|
115
|
+
if (cur.where.length < 3 && !cur.where.includes(w))
|
|
116
|
+
cur.where.push(w);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return into;
|
|
120
|
+
}
|
|
121
|
+
// Only characters that may appear in a URL path: binaries separate strings with NUL bytes.
|
|
122
|
+
const URL_FULL_RE = /\bhttps?:\/\/[a-z0-9.-]+(?::\d+)?\/[A-Za-z0-9._~%\/?=&+:@!$,;#-]*/gi;
|
|
123
|
+
/**
|
|
124
|
+
* Finds Sparkle update feeds in code: URLs with "appcast" in them, or, when the app
|
|
125
|
+
* bundles Sparkle, .xml URLs whose path mentions updates or releases.
|
|
126
|
+
*/
|
|
127
|
+
export function extractFeedUrls(text, sparkle, into = new Set()) {
|
|
128
|
+
for (const m of text.matchAll(URL_FULL_RE)) {
|
|
129
|
+
const url = m[0].replace(/[.,;:]+$/, '');
|
|
130
|
+
const lower = url.toLowerCase();
|
|
131
|
+
const isAppcast = lower.includes('appcast');
|
|
132
|
+
const isUpdateXml = sparkle && /\.xml(\?|$)/.test(lower) && /(update|sparkle|release)/.test(lower);
|
|
133
|
+
if ((isAppcast || isUpdateXml) && !isIgnoredHost(new URL(url).hostname))
|
|
134
|
+
into.add(url);
|
|
135
|
+
}
|
|
136
|
+
return into;
|
|
137
|
+
}
|