@intincrab/updiff 0.1.1 → 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/README.md +64 -18
- package/dist/agent.js +1 -1
- package/dist/bundle.js +63 -5
- package/dist/cask-history.js +216 -0
- package/dist/cask.js +79 -0
- package/dist/codesign.js +41 -0
- package/dist/commands.js +156 -5
- package/dist/diff.js +99 -3
- package/dist/extensions.js +453 -0
- package/dist/fetch.js +81 -0
- package/dist/hosts.js +17 -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/report.js +31 -2
- package/dist/store.js +21 -0
- package/dist/util.js +34 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
CHANGED
|
@@ -118,3 +118,20 @@ export function mergeHosts(into, from) {
|
|
|
118
118
|
}
|
|
119
119
|
return into;
|
|
120
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
|
+
}
|