@intincrab/updiff 0.0.0-stage → 0.1.1
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 +310 -2
- package/dist/agent.js +63 -0
- package/dist/asar.js +91 -0
- package/dist/bundle.js +628 -0
- package/dist/cli.js +8 -0
- package/dist/codesign.js +62 -0
- package/dist/commands.js +510 -0
- package/dist/diff.js +525 -0
- package/dist/exec.js +19 -0
- package/dist/hosts.js +120 -0
- package/dist/plist.js +196 -0
- package/dist/report.js +124 -0
- package/dist/store.js +92 -0
- package/dist/types.js +1 -0
- package/dist/util.js +39 -0
- package/dist/version.js +2 -0
- package/package.json +55 -4
package/dist/diff.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
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
|
+
function diffElectron(a, b, out) {
|
|
402
|
+
const pa = a.electron?.packages ?? {};
|
|
403
|
+
const pb = b.electron?.packages ?? {};
|
|
404
|
+
const added = Object.keys(pb).filter((n) => !(n in pa)).sort();
|
|
405
|
+
const removed = Object.keys(pa).filter((n) => !(n in pb)).sort();
|
|
406
|
+
const bumped = Object.keys(pb).filter((n) => n in pa && stableJson(pa[n]) !== stableJson(pb[n])).sort();
|
|
407
|
+
const fmt = (n, p) => `${n}@${(p[n] ?? []).join('|')}`;
|
|
408
|
+
if (added.length) {
|
|
409
|
+
out.push({
|
|
410
|
+
severity: 'notable',
|
|
411
|
+
category: 'package',
|
|
412
|
+
change: 'added',
|
|
413
|
+
title: `${added.length} new bundled npm package${added.length === 1 ? '' : 's'}`,
|
|
414
|
+
detail: added.map((n) => fmt(n, pb)).join(', '),
|
|
415
|
+
where: b.electron?.appSource ? [b.electron.appSource] : undefined,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
if (removed.length) {
|
|
419
|
+
out.push({ severity: 'info', category: 'package', change: 'removed', title: `${removed.length} bundled npm package${removed.length === 1 ? '' : 's'} removed`, detail: removed.join(', ') });
|
|
420
|
+
}
|
|
421
|
+
if (bumped.length) {
|
|
422
|
+
out.push({
|
|
423
|
+
severity: 'info',
|
|
424
|
+
category: 'package',
|
|
425
|
+
change: 'changed',
|
|
426
|
+
title: `${bumped.length} bundled npm package${bumped.length === 1 ? '' : 's'} changed version`,
|
|
427
|
+
detail: bumped.map((n) => `${n} ${(pa[n] ?? []).join('|')} → ${(pb[n] ?? []).join('|')}`).join(', '),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (!a.electron && b.electron) {
|
|
431
|
+
out.push({ severity: 'notable', category: 'electron', change: 'added', title: 'Now built with Electron', detail: b.electron.electronVersion ? `Electron ${b.electron.electronVersion}` : undefined });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function groupByDomain(hosts) {
|
|
435
|
+
const out = new Map();
|
|
436
|
+
for (const h of Object.keys(hosts)) {
|
|
437
|
+
const d = registrableDomain(h);
|
|
438
|
+
const list = out.get(d);
|
|
439
|
+
if (list)
|
|
440
|
+
list.push(h);
|
|
441
|
+
else
|
|
442
|
+
out.set(d, [h]);
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
function diffHosts(a, b, out) {
|
|
447
|
+
const domainsA = groupByDomain(a.hosts);
|
|
448
|
+
const domainsB = groupByDomain(b.hosts);
|
|
449
|
+
for (const [domain, hosts] of [...domainsB].sort(([x], [y]) => x.localeCompare(y))) {
|
|
450
|
+
const before = domainsA.get(domain);
|
|
451
|
+
if (!before) {
|
|
452
|
+
const viaUrl = hosts.some((h) => b.hosts[h].viaUrl);
|
|
453
|
+
const where = [...new Set(hosts.flatMap((h) => b.hosts[h].where))].slice(0, 3);
|
|
454
|
+
out.push({
|
|
455
|
+
severity: 'notable',
|
|
456
|
+
category: 'host',
|
|
457
|
+
change: 'added',
|
|
458
|
+
title: `New domain ${domain}`,
|
|
459
|
+
detail: `${hosts.sort().join(', ')}${viaUrl ? '' : ' (seen only as a bare string, not in a URL)'}`,
|
|
460
|
+
where,
|
|
461
|
+
});
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const newHosts = hosts.filter((h) => !before.includes(h)).sort();
|
|
465
|
+
if (newHosts.length) {
|
|
466
|
+
out.push({
|
|
467
|
+
severity: 'info',
|
|
468
|
+
category: 'host',
|
|
469
|
+
change: 'added',
|
|
470
|
+
title: `New host${newHosts.length === 1 ? '' : 's'} under ${domain}`,
|
|
471
|
+
detail: newHosts.join(', '),
|
|
472
|
+
where: [...new Set(newHosts.flatMap((h) => b.hosts[h].where))].slice(0, 3),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const gone = [...domainsA.keys()].filter((d) => !domainsB.has(d)).sort();
|
|
477
|
+
if (gone.length) {
|
|
478
|
+
out.push({ severity: 'info', category: 'host', change: 'removed', title: `${gone.length} domain${gone.length === 1 ? '' : 's'} no longer mentioned`, detail: gone.join(', ') });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const SEVERITY_ORDER = { high: 0, notable: 1, info: 2 };
|
|
482
|
+
const CATEGORY_ORDER = [
|
|
483
|
+
'signing', 'privacy', 'entitlement', 'background', 'updater', 'network', 'code', 'urlScheme', 'electron', 'package', 'host', 'identity',
|
|
484
|
+
];
|
|
485
|
+
export function diffSnapshots(a, b) {
|
|
486
|
+
const findings = [];
|
|
487
|
+
if (a.app.bundleId !== b.app.bundleId) {
|
|
488
|
+
findings.push({ severity: 'high', category: 'identity', change: 'changed', title: 'These are different apps', detail: `${a.app.bundleId} → ${b.app.bundleId}` });
|
|
489
|
+
}
|
|
490
|
+
diffInfo(a, b, findings);
|
|
491
|
+
diffCode(a, b, findings);
|
|
492
|
+
diffBackground(a.backgroundItems, b.backgroundItems, findings);
|
|
493
|
+
diffElectron(a, b, findings);
|
|
494
|
+
diffHosts(a, b, findings);
|
|
495
|
+
const substantive = findings.length;
|
|
496
|
+
if (a.app.version !== b.app.version || a.app.build !== b.app.build) {
|
|
497
|
+
findings.push({ severity: 'info', category: 'identity', change: 'changed', title: `Version ${a.app.version} (${a.app.build}) → ${b.app.version} (${b.app.build})` });
|
|
498
|
+
}
|
|
499
|
+
if (a.app.sizeBytes !== b.app.sizeBytes) {
|
|
500
|
+
const delta = b.app.sizeBytes - a.app.sizeBytes;
|
|
501
|
+
const pct = a.app.sizeBytes ? ((delta / a.app.sizeBytes) * 100).toFixed(1) : '∞';
|
|
502
|
+
findings.push({ severity: 'info', category: 'identity', change: 'changed', title: `Size ${delta >= 0 ? '+' : ''}${formatBytes(delta)} (${delta >= 0 ? '+' : ''}${pct}%), files ${a.app.fileCount} → ${b.app.fileCount}` });
|
|
503
|
+
}
|
|
504
|
+
findings.sort((x, y) => SEVERITY_ORDER[x.severity] - SEVERITY_ORDER[y.severity] ||
|
|
505
|
+
CATEGORY_ORDER.indexOf(x.category) - CATEGORY_ORDER.indexOf(y.category) ||
|
|
506
|
+
x.title.localeCompare(y.title));
|
|
507
|
+
const counts = { high: 0, notable: 0, info: 0 };
|
|
508
|
+
for (const f of findings)
|
|
509
|
+
counts[f.severity]++;
|
|
510
|
+
return {
|
|
511
|
+
app: { name: b.app.name, bundleId: b.app.bundleId },
|
|
512
|
+
from: { version: a.app.version, build: a.app.build, takenAt: a.takenAt, path: a.app.path },
|
|
513
|
+
to: { version: b.app.version, build: b.app.build, takenAt: b.takenAt, path: b.app.path },
|
|
514
|
+
findings,
|
|
515
|
+
counts,
|
|
516
|
+
identical: substantive === 0 && a.fingerprint === b.fingerprint,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
export function filterBySeverity(report, min) {
|
|
520
|
+
const findings = report.findings.filter((f) => SEVERITY_ORDER[f.severity] <= SEVERITY_ORDER[min]);
|
|
521
|
+
const counts = { high: 0, notable: 0, info: 0 };
|
|
522
|
+
for (const f of findings)
|
|
523
|
+
counts[f.severity]++;
|
|
524
|
+
return { ...report, findings, counts };
|
|
525
|
+
}
|
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
|
+
}
|
package/dist/hosts.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
}
|