@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/dist/diff.js ADDED
@@ -0,0 +1,621 @@
1
+ import { registrableDomain } from "./hosts.js";
2
+ import { formatBytes, stableJson } from "./util.js";
3
+ /** Entitlements that change what the app can reach or how safely it runs. */
4
+ const SENSITIVE_ENTITLEMENTS = {
5
+ 'com.apple.security.device.camera': 'use the camera',
6
+ 'com.apple.security.device.audio-input': 'record from the microphone',
7
+ 'com.apple.security.device.microphone': 'record from the microphone',
8
+ 'com.apple.security.device.usb': 'talk to USB devices',
9
+ 'com.apple.security.device.bluetooth': 'use Bluetooth',
10
+ 'com.apple.security.personal-information.location': 'read your location',
11
+ 'com.apple.security.personal-information.addressbook': 'read your contacts',
12
+ 'com.apple.security.personal-information.calendars': 'read your calendars',
13
+ 'com.apple.security.personal-information.photos-library': 'read your photo library',
14
+ 'com.apple.security.automation.apple-events': 'control other apps through Apple Events',
15
+ 'com.apple.security.cs.disable-library-validation': 'load code signed by other developers',
16
+ 'com.apple.security.cs.allow-dyld-environment-variables': 'be injected with libraries through environment variables',
17
+ 'com.apple.security.cs.allow-unsigned-executable-memory': 'run unsigned executable memory',
18
+ 'com.apple.security.cs.disable-executable-page-protection': 'turn off executable page protection',
19
+ 'com.apple.security.cs.debugger': 'debug other processes',
20
+ 'com.apple.security.get-task-allow': 'be debugged and inspected by other processes',
21
+ 'com.apple.developer.endpoint-security.client': 'watch system-wide process and file activity (Endpoint Security)',
22
+ 'com.apple.developer.system-extension.install': 'install system extensions',
23
+ 'com.apple.developer.networking.networkextension': 'filter, proxy or route network traffic (Network Extension)',
24
+ 'com.apple.developer.networking.vpn.api': 'configure VPNs',
25
+ 'com.apple.security.cs.allow-jit': 'generate code at runtime (JIT)',
26
+ };
27
+ /** Entitlements that are common and low-risk enough to stay "notable" when added. */
28
+ const MILD_ENTITLEMENTS = new Set(['com.apple.security.cs.allow-jit']);
29
+ const PRIVACY_NAMES = {
30
+ NSCameraUsageDescription: 'Camera',
31
+ NSMicrophoneUsageDescription: 'Microphone',
32
+ NSAudioCaptureUsageDescription: 'System audio recording',
33
+ NSContactsUsageDescription: 'Contacts',
34
+ NSCalendarsUsageDescription: 'Calendars',
35
+ NSCalendarsFullAccessUsageDescription: 'Calendars (full access)',
36
+ NSCalendarsWriteOnlyAccessUsageDescription: 'Calendars (add events)',
37
+ NSRemindersUsageDescription: 'Reminders',
38
+ NSRemindersFullAccessUsageDescription: 'Reminders (full access)',
39
+ NSPhotoLibraryUsageDescription: 'Photos',
40
+ NSPhotoLibraryAddUsageDescription: 'Photos (add only)',
41
+ NSLocationUsageDescription: 'Location',
42
+ NSLocationWhenInUseUsageDescription: 'Location',
43
+ NSLocationAlwaysUsageDescription: 'Location (always)',
44
+ NSLocationAlwaysAndWhenInUseUsageDescription: 'Location (always)',
45
+ NSAppleEventsUsageDescription: 'Control other apps (Apple Events)',
46
+ NSBluetoothAlwaysUsageDescription: 'Bluetooth',
47
+ NSBluetoothPeripheralUsageDescription: 'Bluetooth',
48
+ NSLocalNetworkUsageDescription: 'Devices on your local network',
49
+ NSSpeechRecognitionUsageDescription: 'Speech recognition',
50
+ NSDesktopFolderUsageDescription: 'Desktop folder',
51
+ NSDocumentsFolderUsageDescription: 'Documents folder',
52
+ NSDownloadsFolderUsageDescription: 'Downloads folder',
53
+ NSRemovableVolumesUsageDescription: 'Removable drives',
54
+ NSNetworkVolumesUsageDescription: 'Network drives',
55
+ NSFileProviderDomainUsageDescription: 'Cloud file providers',
56
+ NSSystemAdministrationUsageDescription: 'Administrator actions',
57
+ NSMotionUsageDescription: 'Motion data',
58
+ NSHomeKitUsageDescription: 'HomeKit',
59
+ NSFocusStatusUsageDescription: 'Focus status',
60
+ NSFaceIDUsageDescription: 'Face ID',
61
+ NSIdentityUsageDescription: 'Identity documents',
62
+ };
63
+ const KIND_LABEL = {
64
+ main: 'main executable',
65
+ helperApp: 'helper app',
66
+ xpcService: 'XPC service',
67
+ appExtension: 'app extension',
68
+ loginItem: 'login item',
69
+ privilegedHelper: 'privileged helper (runs as root)',
70
+ systemExtension: 'system extension',
71
+ framework: 'framework',
72
+ plugin: 'plug-in bundle',
73
+ executable: 'executable',
74
+ nativeModule: 'native Node module',
75
+ };
76
+ const HIGH_RISK_KINDS = new Set(['loginItem', 'privilegedHelper', 'systemExtension']);
77
+ function privacyName(key) {
78
+ return PRIVACY_NAMES[key] ?? key.replace(/^NS/, '').replace(/UsageDescription$/, '');
79
+ }
80
+ /** "Helper app Foo Helper.app", for the start of a finding title. */
81
+ function subjectOf(item) {
82
+ const label = KIND_LABEL[item.kind];
83
+ return `${label[0].toUpperCase()}${label.slice(1)} ${shortName(item.path)}`;
84
+ }
85
+ function show(v) {
86
+ if (v === undefined)
87
+ return '(none)';
88
+ if (typeof v === 'string')
89
+ return v;
90
+ return stableJson(v);
91
+ }
92
+ function entitlementSeverity(key) {
93
+ if (key.startsWith('com.apple.security.temporary-exception.'))
94
+ return 'high';
95
+ if (MILD_ENTITLEMENTS.has(key))
96
+ return 'notable';
97
+ return key in SENSITIVE_ENTITLEMENTS ? 'high' : 'notable';
98
+ }
99
+ function entitlementTitle(key, item) {
100
+ const what = SENSITIVE_ENTITLEMENTS[key];
101
+ const subject = item.kind === 'main' ? 'App' : subjectOf(item);
102
+ return what ? `${subject} can now ${what}` : `${subject} gained entitlement ${key}`;
103
+ }
104
+ function shortName(path) {
105
+ return path.slice(path.lastIndexOf('/') + 1);
106
+ }
107
+ function isEnabled(v) {
108
+ return v !== undefined && v !== false && v !== 0 && !(Array.isArray(v) && v.length === 0);
109
+ }
110
+ function diffEntitlements(a, b, item, out) {
111
+ const where = [item.path];
112
+ for (const key of Object.keys(b)) {
113
+ const before = a[key];
114
+ const after = b[key];
115
+ if (stableJson(before) === stableJson(after))
116
+ continue;
117
+ if (!isEnabled(before) && isEnabled(after)) {
118
+ out.push({
119
+ severity: entitlementSeverity(key),
120
+ category: 'entitlement',
121
+ change: 'added',
122
+ title: entitlementTitle(key, item),
123
+ detail: `${key} = ${show(after)}`,
124
+ where,
125
+ });
126
+ }
127
+ else if (Array.isArray(before) && Array.isArray(after)) {
128
+ const beforeSet = new Set(before.map(stableJson));
129
+ const afterSet = new Set(after.map(stableJson));
130
+ const added = after.filter((x) => !beforeSet.has(stableJson(x)));
131
+ const removed = before.filter((x) => !afterSet.has(stableJson(x)));
132
+ if (added.length) {
133
+ out.push({
134
+ severity: key.startsWith('com.apple.security.temporary-exception.') ? 'high' : 'notable',
135
+ category: 'entitlement',
136
+ change: 'changed',
137
+ title: `${key} gained ${added.length} value${added.length === 1 ? '' : 's'}`,
138
+ detail: added.map(show).join(', '),
139
+ where,
140
+ });
141
+ }
142
+ if (removed.length) {
143
+ out.push({
144
+ severity: 'info',
145
+ category: 'entitlement',
146
+ change: 'changed',
147
+ title: `${key} lost ${removed.length} value${removed.length === 1 ? '' : 's'}`,
148
+ detail: removed.map(show).join(', '),
149
+ where,
150
+ });
151
+ }
152
+ }
153
+ else if (isEnabled(before) && !isEnabled(after)) {
154
+ if (key === 'com.apple.security.app-sandbox')
155
+ out.push(sandboxRemoved(item, 'changed'));
156
+ else
157
+ out.push({ severity: 'info', category: 'entitlement', change: 'removed', title: `Entitlement ${key} turned off`, where });
158
+ }
159
+ else {
160
+ out.push({
161
+ severity: 'notable',
162
+ category: 'entitlement',
163
+ change: 'changed',
164
+ title: `Entitlement ${key} changed`,
165
+ detail: `${show(before)} → ${show(after)}`,
166
+ where,
167
+ });
168
+ }
169
+ }
170
+ for (const key of Object.keys(a)) {
171
+ if (key in b)
172
+ continue;
173
+ if (key === 'com.apple.security.app-sandbox' && isEnabled(a[key]))
174
+ out.push(sandboxRemoved(item, 'removed'));
175
+ else
176
+ out.push({ severity: 'info', category: 'entitlement', change: 'removed', title: `Entitlement ${key} removed`, where });
177
+ }
178
+ }
179
+ function sandboxRemoved(item, change) {
180
+ return {
181
+ severity: 'high',
182
+ category: 'entitlement',
183
+ change,
184
+ title: item.kind === 'main' ? 'App is no longer sandboxed' : `${subjectOf(item)} is no longer sandboxed`,
185
+ where: [item.path],
186
+ };
187
+ }
188
+ function diffSigning(a, b, out) {
189
+ const sa = a.signing;
190
+ const sb = b.signing;
191
+ if (!sa || !sb)
192
+ return;
193
+ const subject = b.kind === 'main' ? 'App' : subjectOf(b);
194
+ const where = [b.path];
195
+ const trusted = (s) => s.status === 'signed';
196
+ if (trusted(sa) && !trusted(sb)) {
197
+ out.push({ severity: 'high', category: 'signing', change: 'changed', title: `${subject} is no longer signed by a developer`, detail: `signature: ${sa.status} → ${sb.status}`, where });
198
+ }
199
+ else if (sa.status !== sb.status) {
200
+ out.push({ severity: 'info', category: 'signing', change: 'changed', title: `${subject} signature changed`, detail: `${sa.status} → ${sb.status}`, where });
201
+ }
202
+ if (sa.teamId && sb.teamId && sa.teamId !== sb.teamId) {
203
+ out.push({
204
+ severity: 'high',
205
+ category: 'signing',
206
+ change: 'changed',
207
+ title: `${subject} is signed by a different developer team`,
208
+ detail: `${sa.teamId} → ${sb.teamId}${sb.authorities[0] ? ` (${sb.authorities[0]})` : ''}`,
209
+ where,
210
+ });
211
+ }
212
+ else if ((sa.authorities[0] ?? '') !== (sb.authorities[0] ?? '') && sa.authorities[0] && sb.authorities[0]) {
213
+ out.push({ severity: 'notable', category: 'signing', change: 'changed', title: `${subject} signing certificate changed`, detail: `${sa.authorities[0]} → ${sb.authorities[0]}`, where });
214
+ }
215
+ if (sa.hardenedRuntime && !sb.hardenedRuntime) {
216
+ out.push({ severity: 'high', category: 'signing', change: 'removed', title: `${subject} dropped the hardened runtime`, where });
217
+ }
218
+ else if (!sa.hardenedRuntime && sb.hardenedRuntime) {
219
+ out.push({ severity: 'info', category: 'signing', change: 'added', title: `${subject} enabled the hardened runtime`, where });
220
+ }
221
+ }
222
+ function matchCode(a, b) {
223
+ const byKeyA = new Map(a.map((c) => [`${c.kind}|${c.key}`, c]));
224
+ const pairs = [];
225
+ const unmatchedB = [];
226
+ const usedA = new Set();
227
+ for (const c of b) {
228
+ const m = byKeyA.get(`${c.kind}|${c.key}`) ?? (c.kind === 'main' ? a.find((x) => x.kind === 'main') : undefined);
229
+ if (m && !usedA.has(m)) {
230
+ pairs.push([m, c]);
231
+ usedA.add(m);
232
+ }
233
+ else
234
+ unmatchedB.push(c);
235
+ }
236
+ // A renamed bundle keeps its bundle identifier.
237
+ const added = [];
238
+ for (const c of unmatchedB) {
239
+ const m = c.bundleId ? a.find((x) => !usedA.has(x) && x.kind === c.kind && x.bundleId === c.bundleId) : undefined;
240
+ if (m) {
241
+ pairs.push([m, c]);
242
+ usedA.add(m);
243
+ }
244
+ else
245
+ added.push(c);
246
+ }
247
+ return { pairs, added, removed: a.filter((x) => !usedA.has(x)) };
248
+ }
249
+ function describeCode(c) {
250
+ const parts = [];
251
+ if (c.bundleId)
252
+ parts.push(c.bundleId);
253
+ if (c.version)
254
+ parts.push(`v${c.version}`);
255
+ if (c.signing?.teamId)
256
+ parts.push(`team ${c.signing.teamId}`);
257
+ const ents = Object.keys(c.entitlements ?? {});
258
+ if (ents.length)
259
+ parts.push(`entitlements: ${ents.join(', ')}`);
260
+ return parts.join(' · ');
261
+ }
262
+ function diffCode(a, b, out) {
263
+ const { pairs, added, removed } = matchCode(a.code, b.code);
264
+ const mainTeam = b.signing.teamId;
265
+ for (const [ca, cb] of pairs) {
266
+ diffSigning(ca, cb, out);
267
+ if (ca.entitlements || cb.entitlements)
268
+ diffEntitlements(ca.entitlements ?? {}, cb.entitlements ?? {}, cb, out);
269
+ if (cb.kind === 'framework' && ca.version !== cb.version && cb.version) {
270
+ const isElectron = cb.path.endsWith('/Electron Framework.framework');
271
+ out.push({
272
+ severity: isElectron ? 'notable' : 'info',
273
+ category: isElectron ? 'electron' : 'code',
274
+ change: 'changed',
275
+ title: isElectron ? `Electron ${ca.version ?? '?'} → ${cb.version}` : `${shortName(cb.path)} ${ca.version ?? '?'} → ${cb.version}`,
276
+ where: [cb.path],
277
+ });
278
+ }
279
+ }
280
+ for (const c of added) {
281
+ out.push({
282
+ severity: HIGH_RISK_KINDS.has(c.kind) ? 'high' : 'notable',
283
+ category: 'code',
284
+ change: 'added',
285
+ title: `New ${KIND_LABEL[c.kind]}: ${shortName(c.path)}`,
286
+ detail: describeCode(c) || undefined,
287
+ where: [c.path],
288
+ });
289
+ if (c.signing?.teamId && mainTeam && c.signing.teamId !== mainTeam) {
290
+ out.push({
291
+ severity: 'high',
292
+ category: 'signing',
293
+ change: 'added',
294
+ title: `New ${KIND_LABEL[c.kind]} ${shortName(c.path)} is signed by another developer team`,
295
+ detail: `${c.signing.teamId}${c.signing.authorities[0] ? ` (${c.signing.authorities[0]})` : ''}; the app is ${mainTeam}`,
296
+ where: [c.path],
297
+ });
298
+ }
299
+ for (const [key, value] of Object.entries(c.entitlements ?? {})) {
300
+ if (entitlementSeverity(key) !== 'high' || !isEnabled(value))
301
+ continue;
302
+ out.push({ severity: 'high', category: 'entitlement', change: 'added', title: entitlementTitle(key, c), detail: `${key} = ${show(value)}`, where: [c.path] });
303
+ }
304
+ }
305
+ for (const c of removed) {
306
+ out.push({ severity: 'info', category: 'code', change: 'removed', title: `Removed ${KIND_LABEL[c.kind]}: ${shortName(c.path)}`, where: [c.path] });
307
+ }
308
+ }
309
+ function describeBackground(i) {
310
+ const parts = [i.label ? `label ${i.label}` : undefined, i.program ? `runs ${i.program}` : undefined];
311
+ if (i.runAtLoad)
312
+ parts.push('starts at login');
313
+ if (i.keepAlive)
314
+ parts.push('kept alive');
315
+ return parts.filter(Boolean).join(' · ');
316
+ }
317
+ function diffBackground(a, b, out) {
318
+ const byFile = new Map(a.map((i) => [i.file, i]));
319
+ for (const i of b) {
320
+ const prev = byFile.get(i.file);
321
+ const kind = i.kind === 'launchAgent' ? 'background agent' : 'background daemon (runs as root)';
322
+ if (!prev) {
323
+ out.push({ severity: 'high', category: 'background', change: 'added', title: `New ${kind}: ${shortName(i.file)}`, detail: describeBackground(i), where: [i.file] });
324
+ continue;
325
+ }
326
+ byFile.delete(i.file);
327
+ if (stableJson(prev) !== stableJson(i)) {
328
+ out.push({
329
+ severity: 'notable',
330
+ category: 'background',
331
+ change: 'changed',
332
+ title: `${shortName(i.file)} changed`,
333
+ detail: `${describeBackground(prev)} → ${describeBackground(i)}`,
334
+ where: [i.file],
335
+ });
336
+ }
337
+ }
338
+ for (const i of byFile.values()) {
339
+ out.push({ severity: 'info', category: 'background', change: 'removed', title: `Removed background item ${shortName(i.file)}`, where: [i.file] });
340
+ }
341
+ }
342
+ function diffInfo(a, b, out) {
343
+ const ia = a.info;
344
+ const ib = b.info;
345
+ for (const [key, text] of Object.entries(ib.privacyUsage)) {
346
+ if (!(key in ia.privacyUsage)) {
347
+ out.push({ severity: 'high', category: 'privacy', change: 'added', title: `Can now ask for access to ${privacyName(key)}`, detail: `${key}: “${text}”`, where: ['Contents/Info.plist'] });
348
+ }
349
+ else if (ia.privacyUsage[key] !== text) {
350
+ out.push({ severity: 'info', category: 'privacy', change: 'changed', title: `Explanation for ${privacyName(key)} access changed`, detail: `“${ia.privacyUsage[key]}” → “${text}”`, where: ['Contents/Info.plist'] });
351
+ }
352
+ }
353
+ for (const key of Object.keys(ia.privacyUsage)) {
354
+ if (!(key in ib.privacyUsage))
355
+ out.push({ severity: 'info', category: 'privacy', change: 'removed', title: `No longer asks for ${privacyName(key)}`, where: ['Contents/Info.plist'] });
356
+ }
357
+ const addedSchemes = ib.urlSchemes.filter((s) => !ia.urlSchemes.includes(s));
358
+ const removedSchemes = ia.urlSchemes.filter((s) => !ib.urlSchemes.includes(s));
359
+ for (const s of addedSchemes)
360
+ out.push({ severity: 'notable', category: 'urlScheme', change: 'added', title: `Opens ${s}:// links`, detail: 'Web pages and other apps can launch it with this URL scheme.', where: ['Contents/Info.plist'] });
361
+ if (removedSchemes.length)
362
+ out.push({ severity: 'info', category: 'urlScheme', change: 'removed', title: `Stopped handling ${removedSchemes.map((s) => `${s}://`).join(', ')}`, where: ['Contents/Info.plist'] });
363
+ if (!ia.atsArbitraryLoads && ib.atsArbitraryLoads) {
364
+ out.push({ severity: 'high', category: 'network', change: 'added', title: 'Allows unencrypted HTTP connections (App Transport Security relaxed)', where: ['Contents/Info.plist'] });
365
+ }
366
+ else if (ia.atsArbitraryLoads && !ib.atsArbitraryLoads) {
367
+ out.push({ severity: 'info', category: 'network', change: 'removed', title: 'No longer allows arbitrary HTTP connections', where: ['Contents/Info.plist'] });
368
+ }
369
+ const newExceptions = ib.atsExceptionDomains.filter((d) => !ia.atsExceptionDomains.includes(d));
370
+ if (newExceptions.length)
371
+ out.push({ severity: 'notable', category: 'network', change: 'added', title: `New App Transport Security exceptions`, detail: newExceptions.join(', '), where: ['Contents/Info.plist'] });
372
+ if (ia.backgroundOnly !== ib.backgroundOnly) {
373
+ out.push({ severity: 'notable', category: 'identity', change: 'changed', title: ib.backgroundOnly ? 'Now runs without a Dock icon (agent app)' : 'Now shows a Dock icon', where: ['Contents/Info.plist'] });
374
+ }
375
+ if (ia.sparkleFeedUrl && ib.sparkleFeedUrl && ia.sparkleFeedUrl !== ib.sparkleFeedUrl) {
376
+ out.push({ severity: 'high', category: 'updater', change: 'changed', title: 'Update feed moved', detail: `${ia.sparkleFeedUrl} → ${ib.sparkleFeedUrl}`, where: ['Contents/Info.plist'] });
377
+ }
378
+ else if (!ia.sparkleFeedUrl && ib.sparkleFeedUrl) {
379
+ out.push({ severity: 'notable', category: 'updater', change: 'added', title: 'Added a Sparkle update feed', detail: ib.sparkleFeedUrl, where: ['Contents/Info.plist'] });
380
+ }
381
+ else if (ia.sparkleFeedUrl && !ib.sparkleFeedUrl) {
382
+ out.push({ severity: 'notable', category: 'updater', change: 'removed', title: 'Removed its Sparkle update feed', detail: ia.sparkleFeedUrl, where: ['Contents/Info.plist'] });
383
+ }
384
+ if (ia.sparklePublicKey && ia.sparklePublicKey !== ib.sparklePublicKey) {
385
+ out.push({
386
+ severity: 'high',
387
+ category: 'updater',
388
+ change: ib.sparklePublicKey ? 'changed' : 'removed',
389
+ title: ib.sparklePublicKey ? 'Update signing key changed' : 'Update signing key removed',
390
+ detail: 'Future updates are verified against a different key. Confirm this with the developer.',
391
+ where: ['Contents/Info.plist'],
392
+ });
393
+ }
394
+ for (const p of ib.privilegedExecutables.filter((x) => !ia.privilegedExecutables.includes(x))) {
395
+ out.push({ severity: 'high', category: 'background', change: 'added', title: `Declares a new privileged helper: ${p}`, detail: 'Helpers installed this way run as root.', where: ['Contents/Info.plist'] });
396
+ }
397
+ if (ia.minimumSystemVersion !== ib.minimumSystemVersion && ib.minimumSystemVersion) {
398
+ out.push({ severity: 'info', category: 'identity', change: 'changed', title: `Minimum macOS ${ia.minimumSystemVersion ?? '?'} → ${ib.minimumSystemVersion}`, where: ['Contents/Info.plist'] });
399
+ }
400
+ }
401
+ /** Apple has vouched for it: notarized, or distributed by Apple (App Store, system). */
402
+ const isApproved = (source) => /notarized|mac app store|apple system/i.test(source ?? '');
403
+ function diffNotarization(a, b, out) {
404
+ const na = a.notarization;
405
+ const nb = b.notarization;
406
+ // Older snapshots (and scans with notarization off) have nothing to compare.
407
+ if (!na || !nb)
408
+ return;
409
+ const where = [b.app.path];
410
+ if (na.assessment === 'accepted' && nb.assessment === 'unknown' && nb.error) {
411
+ out.push({
412
+ severity: 'high',
413
+ category: 'signing',
414
+ change: 'changed',
415
+ title: "Gatekeeper can no longer verify the app's signature",
416
+ detail: nb.error,
417
+ where,
418
+ });
419
+ }
420
+ else if (na.assessment === 'accepted' && nb.assessment === 'rejected') {
421
+ out.push({
422
+ severity: 'high',
423
+ category: 'signing',
424
+ change: 'changed',
425
+ title: 'Gatekeeper now rejects the app',
426
+ detail: `${na.source ?? 'accepted'} → rejected`,
427
+ where,
428
+ });
429
+ }
430
+ else if (isApproved(na.source) && !isApproved(nb.source) && nb.assessment !== 'unknown') {
431
+ out.push({
432
+ severity: 'high',
433
+ category: 'signing',
434
+ change: 'removed',
435
+ title: 'No longer notarized by Apple',
436
+ detail: `${na.source} → ${nb.source ?? nb.assessment}`,
437
+ where,
438
+ });
439
+ }
440
+ else if (na.source && nb.source && na.source !== nb.source) {
441
+ out.push({ severity: 'notable', category: 'signing', change: 'changed', title: 'Distribution changed', detail: `${na.source} → ${nb.source}`, where });
442
+ }
443
+ if (na.stapled === true && nb.stapled === false) {
444
+ out.push({
445
+ severity: 'info',
446
+ category: 'signing',
447
+ change: 'removed',
448
+ title: 'Notarization ticket no longer stapled',
449
+ detail: 'Gatekeeper checks it online instead; first launch offline may fail.',
450
+ where,
451
+ });
452
+ }
453
+ else if (na.stapled === false && nb.stapled === true) {
454
+ out.push({ severity: 'info', category: 'signing', change: 'added', title: 'Notarization ticket now stapled', where });
455
+ }
456
+ }
457
+ function diffSparkleCode(a, b, out) {
458
+ const fa = a.info.sparkleFeedsInCode ?? [];
459
+ const fb = b.info.sparkleFeedsInCode ?? [];
460
+ const added = fb.filter((u) => !fa.includes(u));
461
+ const removed = fa.filter((u) => !fb.includes(u));
462
+ if (added.length && fa.length) {
463
+ out.push({
464
+ severity: 'notable',
465
+ category: 'updater',
466
+ change: 'changed',
467
+ title: 'Update feed in the code changed',
468
+ detail: `${removed.length ? `${removed.join(', ')} → ` : ''}${added.join(', ')}`,
469
+ where: [b.app.path],
470
+ });
471
+ }
472
+ else if (added.length) {
473
+ out.push({ severity: 'notable', category: 'updater', change: 'added', title: 'Update feed set in the code', detail: added.join(', '), where: [b.app.path] });
474
+ }
475
+ else if (removed.length) {
476
+ out.push({ severity: 'info', category: 'updater', change: 'removed', title: 'Update feed no longer in the code', detail: removed.join(', '), where: [b.app.path] });
477
+ }
478
+ }
479
+ function diffElectron(a, b, out) {
480
+ const pa = a.electron?.packages ?? {};
481
+ const pb = b.electron?.packages ?? {};
482
+ const added = Object.keys(pb).filter((n) => !(n in pa)).sort();
483
+ const removed = Object.keys(pa).filter((n) => !(n in pb)).sort();
484
+ const bumped = Object.keys(pb).filter((n) => n in pa && stableJson(pa[n]) !== stableJson(pb[n])).sort();
485
+ const fmt = (n, p) => `${n}@${(p[n] ?? []).join('|')}`;
486
+ if (added.length) {
487
+ out.push({
488
+ severity: 'notable',
489
+ category: 'package',
490
+ change: 'added',
491
+ title: `${added.length} new bundled npm package${added.length === 1 ? '' : 's'}`,
492
+ detail: added.map((n) => fmt(n, pb)).join(', '),
493
+ where: b.electron?.appSource ? [b.electron.appSource] : undefined,
494
+ });
495
+ }
496
+ if (removed.length) {
497
+ out.push({ severity: 'info', category: 'package', change: 'removed', title: `${removed.length} bundled npm package${removed.length === 1 ? '' : 's'} removed`, detail: removed.join(', ') });
498
+ }
499
+ if (bumped.length) {
500
+ out.push({
501
+ severity: 'info',
502
+ category: 'package',
503
+ change: 'changed',
504
+ title: `${bumped.length} bundled npm package${bumped.length === 1 ? '' : 's'} changed version`,
505
+ detail: bumped.map((n) => `${n} ${(pa[n] ?? []).join('|')} → ${(pb[n] ?? []).join('|')}`).join(', '),
506
+ });
507
+ }
508
+ if (!a.electron && b.electron) {
509
+ out.push({ severity: 'notable', category: 'electron', change: 'added', title: 'Now built with Electron', detail: b.electron.electronVersion ? `Electron ${b.electron.electronVersion}` : undefined });
510
+ }
511
+ }
512
+ function groupByDomain(hosts) {
513
+ const out = new Map();
514
+ for (const h of Object.keys(hosts)) {
515
+ const d = registrableDomain(h);
516
+ const list = out.get(d);
517
+ if (list)
518
+ list.push(h);
519
+ else
520
+ out.set(d, [h]);
521
+ }
522
+ return out;
523
+ }
524
+ function diffHosts(a, b, out) {
525
+ const domainsA = groupByDomain(a.hosts);
526
+ const domainsB = groupByDomain(b.hosts);
527
+ for (const [domain, hosts] of [...domainsB].sort(([x], [y]) => x.localeCompare(y))) {
528
+ const before = domainsA.get(domain);
529
+ if (!before) {
530
+ const viaUrl = hosts.some((h) => b.hosts[h].viaUrl);
531
+ const where = [...new Set(hosts.flatMap((h) => b.hosts[h].where))].slice(0, 3);
532
+ out.push({
533
+ severity: 'notable',
534
+ category: 'host',
535
+ change: 'added',
536
+ title: `New domain ${domain}`,
537
+ detail: `${hosts.sort().join(', ')}${viaUrl ? '' : ' (seen only as a bare string, not in a URL)'}`,
538
+ where,
539
+ });
540
+ continue;
541
+ }
542
+ const newHosts = hosts.filter((h) => !before.includes(h)).sort();
543
+ if (newHosts.length) {
544
+ out.push({
545
+ severity: 'info',
546
+ category: 'host',
547
+ change: 'added',
548
+ title: `New host${newHosts.length === 1 ? '' : 's'} under ${domain}`,
549
+ detail: newHosts.join(', '),
550
+ where: [...new Set(newHosts.flatMap((h) => b.hosts[h].where))].slice(0, 3),
551
+ });
552
+ }
553
+ }
554
+ const gone = [...domainsA.keys()].filter((d) => !domainsB.has(d)).sort();
555
+ if (gone.length) {
556
+ out.push({ severity: 'info', category: 'host', change: 'removed', title: `${gone.length} domain${gone.length === 1 ? '' : 's'} no longer mentioned`, detail: gone.join(', ') });
557
+ }
558
+ }
559
+ export const SEVERITY_ORDER = { high: 0, notable: 1, info: 2 };
560
+ export const CATEGORY_ORDER = [
561
+ 'signing', 'privacy', 'permission', 'entitlement', 'background', 'script', 'install', 'updater', 'network', 'code', 'urlScheme', 'electron', 'package', 'host', 'identity',
562
+ ];
563
+ export function diffSnapshots(a, b) {
564
+ const findings = [];
565
+ if (a.app.bundleId !== b.app.bundleId) {
566
+ findings.push({ severity: 'high', category: 'identity', change: 'changed', title: 'These are different apps', detail: `${a.app.bundleId} → ${b.app.bundleId}` });
567
+ }
568
+ diffInfo(a, b, findings);
569
+ diffCode(a, b, findings);
570
+ diffBackground(a.backgroundItems, b.backgroundItems, findings);
571
+ diffElectron(a, b, findings);
572
+ diffNotarization(a, b, findings);
573
+ diffSparkleCode(a, b, findings);
574
+ if (!a.info.appStoreReceipt !== !b.info.appStoreReceipt) {
575
+ findings.push({
576
+ severity: 'notable',
577
+ category: 'identity',
578
+ change: b.info.appStoreReceipt ? 'added' : 'removed',
579
+ title: b.info.appStoreReceipt ? 'Now distributed through the Mac App Store' : 'No longer a Mac App Store build',
580
+ });
581
+ }
582
+ diffHosts(a, b, findings);
583
+ const substantive = findings.length;
584
+ if (a.app.version !== b.app.version || a.app.build !== b.app.build) {
585
+ findings.push({ severity: 'info', category: 'identity', change: 'changed', title: `Version ${a.app.version} (${a.app.build}) → ${b.app.version} (${b.app.build})` });
586
+ }
587
+ if (a.app.sizeBytes !== b.app.sizeBytes) {
588
+ const delta = b.app.sizeBytes - a.app.sizeBytes;
589
+ const pct = a.app.sizeBytes ? ((delta / a.app.sizeBytes) * 100).toFixed(1) : '∞';
590
+ findings.push({ severity: 'info', category: 'identity', change: 'changed', title: `Size ${delta >= 0 ? '+' : ''}${formatBytes(delta)} (${delta >= 0 ? '+' : ''}${pct}%), files ${a.app.fileCount} → ${b.app.fileCount}` });
591
+ }
592
+ findings.sort((x, y) => SEVERITY_ORDER[x.severity] - SEVERITY_ORDER[y.severity] ||
593
+ CATEGORY_ORDER.indexOf(x.category) - CATEGORY_ORDER.indexOf(y.category) ||
594
+ x.title.localeCompare(y.title));
595
+ const counts = { high: 0, notable: 0, info: 0 };
596
+ for (const f of findings)
597
+ counts[f.severity]++;
598
+ const notes = [];
599
+ const encrypted = [...new Set([...(a.encryptedBinaries ?? []), ...(b.encryptedBinaries ?? [])])];
600
+ if (encrypted.length) {
601
+ notes.push(`Encrypted binaries (an iPhone or iPad app) can't be searched for hostnames: ${encrypted.slice(0, 3).join(', ')}${encrypted.length > 3 ? '…' : ''}.`);
602
+ }
603
+ if (!a.notarization !== !b.notarization)
604
+ notes.push('Notarization was only checked on one side, so it was not compared.');
605
+ return {
606
+ app: { name: b.app.name, bundleId: b.app.bundleId },
607
+ from: { version: a.app.version, build: a.app.build, takenAt: a.takenAt, path: a.app.path },
608
+ to: { version: b.app.version, build: b.app.build, takenAt: b.takenAt, path: b.app.path },
609
+ findings,
610
+ counts,
611
+ identical: substantive === 0 && a.fingerprint === b.fingerprint,
612
+ ...(notes.length ? { notes } : {}),
613
+ };
614
+ }
615
+ export function filterBySeverity(report, min) {
616
+ const findings = report.findings.filter((f) => SEVERITY_ORDER[f.severity] <= SEVERITY_ORDER[min]);
617
+ const counts = { high: 0, notable: 0, info: 0 };
618
+ for (const f of findings)
619
+ counts[f.severity]++;
620
+ return { ...report, findings, counts };
621
+ }
package/dist/exec.js ADDED
@@ -0,0 +1,19 @@
1
+ import { execFile } from 'node:child_process';
2
+ /**
3
+ * Runs a command without a shell. Never throws on a non-zero exit.
4
+ * `input` is written to the command's stdin (for prompts such as a disk image licence).
5
+ */
6
+ export function run(file, args, timeoutMs = 60_000, input) {
7
+ return new Promise((resolve) => {
8
+ const child = execFile(file, args, { maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs, encoding: 'utf8' }, (error, stdout, stderr) => {
9
+ let code = 0;
10
+ if (error) {
11
+ const raw = error.code;
12
+ code = typeof raw === 'number' ? raw : 1;
13
+ }
14
+ resolve({ code, stdout: stdout ?? '', stderr: stderr ?? '' });
15
+ });
16
+ if (input !== undefined)
17
+ child.stdin?.end(input);
18
+ });
19
+ }