@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/bundle.js
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { open, readdir, readFile, readlink, lstat, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { AsarArchive } from "./asar.js";
|
|
5
|
+
import { readEntitlements, readSigning } from "./codesign.js";
|
|
6
|
+
import { extractHosts, mergeHosts } from "./hosts.js";
|
|
7
|
+
import { isDict, readPlistFile, str } from "./plist.js";
|
|
8
|
+
import { SNAPSHOT_SCHEMA, } from "./types.js";
|
|
9
|
+
import { mapLimit, sortedUnique } from "./util.js";
|
|
10
|
+
import { TOOL_NAME, TOOL_VERSION } from "./version.js";
|
|
11
|
+
const TEXT_EXTS = new Set([
|
|
12
|
+
'.js', '.cjs', '.mjs', '.jsx', '.json', '.html', '.htm', '.css', '.xml', '.plist', '.yml', '.yaml', '.toml',
|
|
13
|
+
'.ini', '.conf', '.txt', '.env',
|
|
14
|
+
]);
|
|
15
|
+
const DOC_NAME_RE = /^(readme|license|licence|changelog|changes|history|authors|contributors|notice|copying|security|code_of_conduct)(\.|$)/i;
|
|
16
|
+
function extOf(name) {
|
|
17
|
+
const dot = name.lastIndexOf('.');
|
|
18
|
+
return dot > 0 ? name.slice(dot).toLowerCase() : '';
|
|
19
|
+
}
|
|
20
|
+
/** Whether a file is worth reading for hostnames as text. */
|
|
21
|
+
export function isScannableText(path) {
|
|
22
|
+
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
23
|
+
if (DOC_NAME_RE.test(name))
|
|
24
|
+
return false;
|
|
25
|
+
if (name.endsWith('.map') || name.endsWith('.d.ts') || name.endsWith('.md'))
|
|
26
|
+
return false;
|
|
27
|
+
// npm manifests are mostly repository and issue-tracker links.
|
|
28
|
+
if (name === 'package.json' && path.includes('node_modules/'))
|
|
29
|
+
return false;
|
|
30
|
+
return TEXT_EXTS.has(extOf(name));
|
|
31
|
+
}
|
|
32
|
+
/** `X.framework/Versions/154.0.1/Helpers` → `X.framework/Versions/*\/Helpers` */
|
|
33
|
+
export function normalizeKey(rel) {
|
|
34
|
+
return rel.replace(/\/Versions\/[^/]+\//g, '/Versions/*/');
|
|
35
|
+
}
|
|
36
|
+
const MACHO_MAGICS = new Set([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca]);
|
|
37
|
+
async function isMachO(path) {
|
|
38
|
+
let fh;
|
|
39
|
+
try {
|
|
40
|
+
fh = await open(path, 'r');
|
|
41
|
+
const buf = Buffer.alloc(4);
|
|
42
|
+
const { bytesRead } = await fh.read(buf, 0, 4, 0);
|
|
43
|
+
return bytesRead === 4 && MACHO_MAGICS.has(buf.readUInt32BE(0));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await fh?.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Walks a bundle without following symlinks. When a framework's `Versions/` folder
|
|
54
|
+
* holds more than one version (an update in progress, or leftovers), only the one
|
|
55
|
+
* `Versions/Current` points to is kept.
|
|
56
|
+
*/
|
|
57
|
+
async function walk(root, warnings) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const queue = [''];
|
|
60
|
+
const currentVersion = new Map();
|
|
61
|
+
while (queue.length) {
|
|
62
|
+
const rel = queue.pop();
|
|
63
|
+
const abs = rel ? join(root, rel) : root;
|
|
64
|
+
let dirents;
|
|
65
|
+
try {
|
|
66
|
+
dirents = await readdir(abs, { withFileTypes: true });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
warnings.push(`Could not read ${rel || '.'}: ${err.message}`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const entries = await Promise.all(dirents.map(async (d) => {
|
|
73
|
+
const r = rel ? `${rel}/${d.name}` : d.name;
|
|
74
|
+
const a = join(abs, d.name);
|
|
75
|
+
// Symlinks are skipped: framework `Versions/Current` would otherwise be read twice.
|
|
76
|
+
if (d.isSymbolicLink()) {
|
|
77
|
+
if (d.name === 'Current' && basename(abs) === 'Versions') {
|
|
78
|
+
try {
|
|
79
|
+
currentVersion.set(rel, basename(await readlink(a)));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// A broken link leaves every version in place.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (d.isDirectory()) {
|
|
88
|
+
queue.push(r);
|
|
89
|
+
return { rel: r, abs: a, isDir: true, size: 0, mode: 0 };
|
|
90
|
+
}
|
|
91
|
+
if (!d.isFile())
|
|
92
|
+
return null;
|
|
93
|
+
try {
|
|
94
|
+
const st = await lstat(a);
|
|
95
|
+
return { rel: r, abs: a, isDir: false, size: st.size, mode: st.mode };
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
101
|
+
for (const e of entries)
|
|
102
|
+
if (e)
|
|
103
|
+
out.push(e);
|
|
104
|
+
}
|
|
105
|
+
const stale = [];
|
|
106
|
+
for (const [versionsRel, current] of currentVersion) {
|
|
107
|
+
for (const e of out) {
|
|
108
|
+
if (e.isDir && e.rel.startsWith(`${versionsRel}/`) && !e.rel.slice(versionsRel.length + 1).includes('/')) {
|
|
109
|
+
if (e.rel !== `${versionsRel}/${current}`)
|
|
110
|
+
stale.push(e.rel);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!stale.length)
|
|
115
|
+
return out;
|
|
116
|
+
for (const s of stale)
|
|
117
|
+
warnings.push(`Ignored ${s}: Versions/Current points to another version (left over from an update).`);
|
|
118
|
+
return out.filter((e) => !stale.some((s) => e.rel === s || e.rel.startsWith(`${s}/`)));
|
|
119
|
+
}
|
|
120
|
+
function summarizeInfo(info) {
|
|
121
|
+
const urlSchemes = [];
|
|
122
|
+
const urlTypes = info.CFBundleURLTypes;
|
|
123
|
+
if (Array.isArray(urlTypes)) {
|
|
124
|
+
for (const t of urlTypes) {
|
|
125
|
+
if (!isDict(t) || !Array.isArray(t.CFBundleURLSchemes))
|
|
126
|
+
continue;
|
|
127
|
+
for (const s of t.CFBundleURLSchemes)
|
|
128
|
+
if (typeof s === 'string')
|
|
129
|
+
urlSchemes.push(s.toLowerCase());
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const privacyUsage = {};
|
|
133
|
+
for (const [k, v] of Object.entries(info)) {
|
|
134
|
+
if (/UsageDescription$/.test(k))
|
|
135
|
+
privacyUsage[k] = str(v) ?? '';
|
|
136
|
+
}
|
|
137
|
+
const ats = isDict(info.NSAppTransportSecurity) ? info.NSAppTransportSecurity : {};
|
|
138
|
+
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'YES' || v === 'true';
|
|
139
|
+
return {
|
|
140
|
+
executable: str(info.CFBundleExecutable),
|
|
141
|
+
minimumSystemVersion: str(info.LSMinimumSystemVersion),
|
|
142
|
+
urlSchemes: sortedUnique(urlSchemes),
|
|
143
|
+
privacyUsage,
|
|
144
|
+
atsArbitraryLoads: truthy(ats.NSAllowsArbitraryLoads) || truthy(ats.NSAllowsArbitraryLoadsInWebContent) || truthy(ats.NSAllowsArbitraryLoadsForMedia),
|
|
145
|
+
atsExceptionDomains: isDict(ats.NSExceptionDomains) ? sortedUnique(Object.keys(ats.NSExceptionDomains)) : [],
|
|
146
|
+
backgroundOnly: truthy(info.LSUIElement) || truthy(info.LSBackgroundOnly),
|
|
147
|
+
sparkleFeedUrl: str(info.SUFeedURL),
|
|
148
|
+
sparklePublicKey: str(info.SUPublicEDKey) ?? str(info.SUPublicDSAKeyFile),
|
|
149
|
+
privilegedExecutables: isDict(info.SMPrivilegedExecutables) ? sortedUnique(Object.keys(info.SMPrivilegedExecutables)) : [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const BUNDLE_SUFFIXES = [
|
|
153
|
+
['.app', 'helperApp'],
|
|
154
|
+
['.xpc', 'xpcService'],
|
|
155
|
+
['.appex', 'appExtension'],
|
|
156
|
+
['.systemextension', 'systemExtension'],
|
|
157
|
+
['.framework', 'framework'],
|
|
158
|
+
['.bundle', 'plugin'],
|
|
159
|
+
['.plugin', 'plugin'],
|
|
160
|
+
['.docktileplugin', 'plugin'],
|
|
161
|
+
['.qlgenerator', 'plugin'],
|
|
162
|
+
['.mdimporter', 'plugin'],
|
|
163
|
+
['.saver', 'plugin'],
|
|
164
|
+
];
|
|
165
|
+
function findFrameworkInfo(fw, byRel, entries) {
|
|
166
|
+
for (const candidate of [`${fw}/Resources/Info.plist`, `${fw}/Info.plist`]) {
|
|
167
|
+
if (byRel.has(candidate))
|
|
168
|
+
return { info: candidate, versionDir: fw };
|
|
169
|
+
}
|
|
170
|
+
const re = new RegExp(`^${fw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/Versions/([^/]+)/Resources/Info\\.plist$`);
|
|
171
|
+
for (const e of entries) {
|
|
172
|
+
const m = re.exec(e.rel);
|
|
173
|
+
if (m)
|
|
174
|
+
return { info: e.rel, versionDir: `${fw}/Versions/${m[1]}` };
|
|
175
|
+
}
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
function backgroundItemFrom(kind, file, plist) {
|
|
179
|
+
const args = plist?.ProgramArguments;
|
|
180
|
+
const program = str(plist?.Program) ?? str(plist?.BundleProgram) ?? (Array.isArray(args) && typeof args[0] === 'string' ? args[0] : undefined);
|
|
181
|
+
const keepAlive = plist?.KeepAlive;
|
|
182
|
+
return {
|
|
183
|
+
kind,
|
|
184
|
+
file,
|
|
185
|
+
label: str(plist?.Label),
|
|
186
|
+
program,
|
|
187
|
+
runAtLoad: plist?.RunAtLoad === true,
|
|
188
|
+
keepAlive: keepAlive === true || isDict(keepAlive),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function fingerprintOf(appPath, infoPath, mainExe) {
|
|
192
|
+
const hash = createHash('sha256');
|
|
193
|
+
try {
|
|
194
|
+
hash.update(await readFile(join(appPath, 'Contents/_CodeSignature/CodeResources')));
|
|
195
|
+
hash.update('sealed');
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
hash.update(await readFile(infoPath));
|
|
199
|
+
if (mainExe) {
|
|
200
|
+
try {
|
|
201
|
+
const st = await stat(join(appPath, mainExe));
|
|
202
|
+
hash.update(`${st.size}:${st.mtimeMs}`);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// No executable: the Info.plist alone identifies the bundle.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return hash.digest('hex').slice(0, 32);
|
|
210
|
+
}
|
|
211
|
+
/** Reads the Info.plist identity of a bundle without scanning it. */
|
|
212
|
+
export async function readIdentity(appPath) {
|
|
213
|
+
const info = await readPlistFile(join(appPath, 'Contents/Info.plist'));
|
|
214
|
+
if (!info)
|
|
215
|
+
return undefined;
|
|
216
|
+
const name = str(info.CFBundleDisplayName) ?? str(info.CFBundleName) ?? basename(appPath).replace(/\.app$/, '');
|
|
217
|
+
return {
|
|
218
|
+
bundleId: str(info.CFBundleIdentifier) ?? `unknown.${name}`,
|
|
219
|
+
name,
|
|
220
|
+
version: str(info.CFBundleShortVersionString) ?? str(info.CFBundleVersion) ?? '0',
|
|
221
|
+
build: str(info.CFBundleVersion) ?? '',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
export async function quickFingerprint(appPath) {
|
|
225
|
+
const infoPath = join(appPath, 'Contents/Info.plist');
|
|
226
|
+
const info = await readPlistFile(infoPath);
|
|
227
|
+
if (!info)
|
|
228
|
+
return undefined;
|
|
229
|
+
const exe = str(info.CFBundleExecutable);
|
|
230
|
+
return fingerprintOf(appPath, infoPath, exe ? `Contents/MacOS/${exe}` : undefined);
|
|
231
|
+
}
|
|
232
|
+
const CPU_TYPE_ARM64 = 0x0100000c;
|
|
233
|
+
/**
|
|
234
|
+
* Returns the arm64 slice of a universal (fat) Mach-O header, else the first slice.
|
|
235
|
+
* Returns undefined for a thin binary. Exported for tests.
|
|
236
|
+
*/
|
|
237
|
+
export function pickFatSlice(head, fileSize) {
|
|
238
|
+
if (head.length < 8)
|
|
239
|
+
return undefined;
|
|
240
|
+
const magic = head.readUInt32BE(0);
|
|
241
|
+
const is64 = magic === 0xcafebabf;
|
|
242
|
+
if (magic !== 0xcafebabe && !is64)
|
|
243
|
+
return undefined;
|
|
244
|
+
const count = head.readUInt32BE(4);
|
|
245
|
+
// Java class files share 0xcafebabe; their "count" is a version number well above 30.
|
|
246
|
+
if (count === 0 || count > 30)
|
|
247
|
+
return undefined;
|
|
248
|
+
const entrySize = is64 ? 32 : 20;
|
|
249
|
+
let first;
|
|
250
|
+
for (let i = 0; i < count; i++) {
|
|
251
|
+
const at = 8 + i * entrySize;
|
|
252
|
+
if (at + entrySize > head.length)
|
|
253
|
+
break;
|
|
254
|
+
const cpu = head.readUInt32BE(at);
|
|
255
|
+
const offset = is64 ? Number(head.readBigUInt64BE(at + 8)) : head.readUInt32BE(at + 8);
|
|
256
|
+
const size = is64 ? Number(head.readBigUInt64BE(at + 16)) : head.readUInt32BE(at + 12);
|
|
257
|
+
if (offset + size > fileSize)
|
|
258
|
+
continue;
|
|
259
|
+
const slice = { offset, size };
|
|
260
|
+
if (cpu === CPU_TYPE_ARM64)
|
|
261
|
+
return slice;
|
|
262
|
+
first ??= slice;
|
|
263
|
+
}
|
|
264
|
+
return first;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* A file with more bare hostnames than this is treated as a bundled data list (the
|
|
268
|
+
* Public Suffix List, a blocklist…) rather than code that talks to those hosts.
|
|
269
|
+
*/
|
|
270
|
+
export const DOMAIN_LIST_THRESHOLD = 1000;
|
|
271
|
+
class HostScanner {
|
|
272
|
+
hosts = new Map();
|
|
273
|
+
filesScanned = 0;
|
|
274
|
+
bytesScanned = 0;
|
|
275
|
+
truncatedFiles = [];
|
|
276
|
+
domainLists = [];
|
|
277
|
+
totalLimitHit = false;
|
|
278
|
+
maxFile;
|
|
279
|
+
maxTotal;
|
|
280
|
+
constructor(maxFile, maxTotal) {
|
|
281
|
+
this.maxFile = maxFile;
|
|
282
|
+
this.maxTotal = maxTotal;
|
|
283
|
+
}
|
|
284
|
+
budget(size, where) {
|
|
285
|
+
const left = this.maxTotal - this.bytesScanned;
|
|
286
|
+
if (left <= 0) {
|
|
287
|
+
this.totalLimitHit = true;
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
const n = Math.min(size, this.maxFile, left);
|
|
291
|
+
if (n < size)
|
|
292
|
+
this.truncatedFiles.push(where);
|
|
293
|
+
return n;
|
|
294
|
+
}
|
|
295
|
+
/** Merges one file's hosts, dropping bare-string hosts from bundled domain lists. */
|
|
296
|
+
commit(found, where) {
|
|
297
|
+
let bare = 0;
|
|
298
|
+
for (const rec of found.values())
|
|
299
|
+
if (!rec.viaUrl)
|
|
300
|
+
bare++;
|
|
301
|
+
if (bare > DOMAIN_LIST_THRESHOLD) {
|
|
302
|
+
this.domainLists.push({ where, count: bare });
|
|
303
|
+
for (const [host, rec] of found)
|
|
304
|
+
if (!rec.viaUrl)
|
|
305
|
+
found.delete(host);
|
|
306
|
+
}
|
|
307
|
+
mergeHosts(this.hosts, found);
|
|
308
|
+
}
|
|
309
|
+
scanBuffer(buf, where, binary) {
|
|
310
|
+
this.filesScanned++;
|
|
311
|
+
this.bytesScanned += buf.length;
|
|
312
|
+
this.commit(extractHosts(buf.toString(binary ? 'latin1' : 'utf8'), where, new Map(), true), where);
|
|
313
|
+
}
|
|
314
|
+
async scanFile(abs, size, where, binary) {
|
|
315
|
+
let fh;
|
|
316
|
+
try {
|
|
317
|
+
fh = await open(abs, 'r');
|
|
318
|
+
let start = 0;
|
|
319
|
+
let length = size;
|
|
320
|
+
if (binary) {
|
|
321
|
+
// A universal binary repeats its strings in every architecture slice; read one.
|
|
322
|
+
const head = Buffer.alloc(4096);
|
|
323
|
+
const { bytesRead } = await fh.read(head, 0, head.length, 0);
|
|
324
|
+
const slice = pickFatSlice(head.subarray(0, bytesRead), size);
|
|
325
|
+
if (slice)
|
|
326
|
+
({ offset: start, size: length } = slice);
|
|
327
|
+
}
|
|
328
|
+
const n = this.budget(length, where);
|
|
329
|
+
if (n <= 0)
|
|
330
|
+
return;
|
|
331
|
+
this.filesScanned++;
|
|
332
|
+
const CHUNK = 64 * 1024 * 1024;
|
|
333
|
+
const OVERLAP = 1024;
|
|
334
|
+
const found = new Map();
|
|
335
|
+
for (let pos = 0; pos < n; pos += CHUNK) {
|
|
336
|
+
const len = Math.min(CHUNK + OVERLAP, n - pos);
|
|
337
|
+
const buf = Buffer.alloc(len);
|
|
338
|
+
const { bytesRead } = await fh.read(buf, 0, len, start + pos);
|
|
339
|
+
this.bytesScanned += Math.min(bytesRead, CHUNK);
|
|
340
|
+
extractHosts(buf.toString(binary ? 'latin1' : 'utf8', 0, bytesRead), where, found, true);
|
|
341
|
+
if (bytesRead < len)
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
this.commit(found, where);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// Unreadable files are skipped; permissions are reported by the walk.
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await fh?.close();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
scanAsarFile(archive, file, where) {
|
|
354
|
+
const n = this.budget(file.size, where);
|
|
355
|
+
if (n <= 0)
|
|
356
|
+
return;
|
|
357
|
+
this.scanBuffer(archive.read(file, n), where, false);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function addPackage(packages, json) {
|
|
361
|
+
try {
|
|
362
|
+
const pkg = JSON.parse(json);
|
|
363
|
+
if (typeof pkg.name !== 'string' || !pkg.name)
|
|
364
|
+
return;
|
|
365
|
+
const version = typeof pkg.version === 'string' ? pkg.version : '?';
|
|
366
|
+
const list = (packages[pkg.name] ??= []);
|
|
367
|
+
if (!list.includes(version))
|
|
368
|
+
list.push(version);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Not every package.json parses (templates, fixtures).
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const NODE_MODULES_MANIFEST_RE = /(^|\/)node_modules\/(@[^/]+\/)?[^/]+\/package\.json$/;
|
|
375
|
+
/** Scans an app bundle and returns a snapshot. Never modifies the bundle. */
|
|
376
|
+
export async function scanBundle(inputPath, options = {}) {
|
|
377
|
+
const started = Date.now();
|
|
378
|
+
const appPath = await realpath(inputPath);
|
|
379
|
+
const infoPath = join(appPath, 'Contents/Info.plist');
|
|
380
|
+
const info = await readPlistFile(infoPath);
|
|
381
|
+
if (!info)
|
|
382
|
+
throw new Error(`${inputPath} is not an app bundle (no readable Contents/Info.plist)`);
|
|
383
|
+
const warnings = [];
|
|
384
|
+
const deep = options.deep === true;
|
|
385
|
+
const MB = 1024 * 1024;
|
|
386
|
+
const maxFile = options.maxFileBytes ?? (deep ? 2048 : 512) * MB;
|
|
387
|
+
const maxTotal = options.maxTotalBytes ?? (deep ? 8192 : 4096) * MB;
|
|
388
|
+
const scanner = new HostScanner(maxFile, maxTotal);
|
|
389
|
+
const name = str(info.CFBundleDisplayName) ?? str(info.CFBundleName) ?? basename(appPath).replace(/\.app$/, '');
|
|
390
|
+
const summary = summarizeInfo(info);
|
|
391
|
+
const mainExe = summary.executable ? `Contents/MacOS/${summary.executable}` : undefined;
|
|
392
|
+
const entries = await walk(appPath, warnings);
|
|
393
|
+
const byRel = new Map(entries.map((e) => [e.rel, e]));
|
|
394
|
+
const files = entries.filter((e) => !e.isDir);
|
|
395
|
+
// Nested bundles.
|
|
396
|
+
const nested = [];
|
|
397
|
+
for (const e of entries) {
|
|
398
|
+
if (!e.isDir)
|
|
399
|
+
continue;
|
|
400
|
+
const suffix = BUNDLE_SUFFIXES.find(([s]) => e.rel.endsWith(s));
|
|
401
|
+
if (!suffix)
|
|
402
|
+
continue;
|
|
403
|
+
let kind = suffix[1];
|
|
404
|
+
if (kind === 'helperApp' && /(^|\/)Contents\/Library\/LoginItems\/[^/]+\.app$/.test(e.rel))
|
|
405
|
+
kind = 'loginItem';
|
|
406
|
+
if (kind === 'framework') {
|
|
407
|
+
const { info: fwInfo, versionDir } = findFrameworkInfo(e.rel, byRel, entries);
|
|
408
|
+
const fwName = basename(e.rel, '.framework');
|
|
409
|
+
nested.push({ kind, rel: e.rel, infoPath: fwInfo, executable: versionDir ? `${versionDir}/${fwName}` : undefined });
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
const ip = `${e.rel}/Contents/Info.plist`;
|
|
413
|
+
// Resource folders also end in .bundle; only bundles with an Info.plist hold code.
|
|
414
|
+
if (kind === 'plugin' && !byRel.has(ip))
|
|
415
|
+
continue;
|
|
416
|
+
nested.push({ kind, rel: e.rel, infoPath: byRel.has(ip) ? ip : undefined });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const code = [];
|
|
420
|
+
const bundleExecutables = new Set(mainExe ? [mainExe] : []);
|
|
421
|
+
const nestedItems = await mapLimit(nested, 6, async (b) => {
|
|
422
|
+
const plist = b.infoPath ? await readPlistFile(join(appPath, b.infoPath)) : undefined;
|
|
423
|
+
const exeName = str(plist?.CFBundleExecutable);
|
|
424
|
+
if (b.kind === 'framework') {
|
|
425
|
+
if (b.executable) {
|
|
426
|
+
const dir = b.executable.slice(0, b.executable.lastIndexOf('/'));
|
|
427
|
+
bundleExecutables.add(exeName ? `${dir}/${exeName}` : b.executable);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else if (exeName) {
|
|
431
|
+
bundleExecutables.add(`${b.rel}/Contents/MacOS/${exeName}`);
|
|
432
|
+
}
|
|
433
|
+
const item = {
|
|
434
|
+
kind: b.kind,
|
|
435
|
+
key: normalizeKey(b.rel),
|
|
436
|
+
path: b.rel,
|
|
437
|
+
bundleId: str(plist?.CFBundleIdentifier),
|
|
438
|
+
version: str(plist?.CFBundleShortVersionString) ?? str(plist?.CFBundleVersion),
|
|
439
|
+
};
|
|
440
|
+
item.signing = await readSigning(join(appPath, b.rel));
|
|
441
|
+
if (b.kind !== 'framework')
|
|
442
|
+
item.entitlements = await readEntitlements(join(appPath, b.rel));
|
|
443
|
+
return item;
|
|
444
|
+
});
|
|
445
|
+
// Privileged helpers installed through SMJobBless.
|
|
446
|
+
const helperFiles = files.filter((f) => /(^|\/)Contents\/Library\/LaunchServices\/[^/]+$/.test(f.rel));
|
|
447
|
+
const helperItems = await mapLimit(helperFiles, 4, async (f) => {
|
|
448
|
+
bundleExecutables.add(f.rel);
|
|
449
|
+
return {
|
|
450
|
+
kind: 'privilegedHelper',
|
|
451
|
+
key: normalizeKey(f.rel),
|
|
452
|
+
path: f.rel,
|
|
453
|
+
signing: await readSigning(f.abs),
|
|
454
|
+
entitlements: await readEntitlements(f.abs),
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
// Loose executables and native Node modules.
|
|
458
|
+
const candidates = files.filter((f) => (f.mode & 0o111) !== 0 || f.rel.endsWith('.node'));
|
|
459
|
+
const machoFlags = await mapLimit(candidates, 16, (f) => isMachO(f.abs));
|
|
460
|
+
const machoFiles = candidates.filter((_, i) => machoFlags[i]);
|
|
461
|
+
const looseItems = [];
|
|
462
|
+
for (const f of machoFiles) {
|
|
463
|
+
if (bundleExecutables.has(f.rel))
|
|
464
|
+
continue;
|
|
465
|
+
if (/\.(dylib|so)$/.test(f.rel))
|
|
466
|
+
continue;
|
|
467
|
+
if (f.rel.endsWith('.node')) {
|
|
468
|
+
looseItems.push({ kind: 'nativeModule', key: normalizeKey(f.rel), path: f.rel });
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
looseItems.push({ kind: 'executable', key: normalizeKey(f.rel), path: f.rel });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const looseSigned = await mapLimit(looseItems, 6, async (item) => {
|
|
475
|
+
if (item.kind === 'executable') {
|
|
476
|
+
item.signing = await readSigning(join(appPath, item.path));
|
|
477
|
+
item.entitlements = await readEntitlements(join(appPath, item.path));
|
|
478
|
+
}
|
|
479
|
+
return item;
|
|
480
|
+
});
|
|
481
|
+
const mainItem = {
|
|
482
|
+
kind: 'main',
|
|
483
|
+
key: mainExe ?? 'Contents/MacOS',
|
|
484
|
+
path: mainExe ?? 'Contents/MacOS',
|
|
485
|
+
bundleId: str(info.CFBundleIdentifier),
|
|
486
|
+
version: str(info.CFBundleShortVersionString),
|
|
487
|
+
signing: await readSigning(appPath),
|
|
488
|
+
entitlements: await readEntitlements(appPath),
|
|
489
|
+
};
|
|
490
|
+
code.push(mainItem, ...nestedItems, ...helperItems, ...looseSigned);
|
|
491
|
+
code.sort((a, b) => a.key.localeCompare(b.key));
|
|
492
|
+
// Background items registered with SMAppService.
|
|
493
|
+
const backgroundItems = [];
|
|
494
|
+
for (const f of files) {
|
|
495
|
+
const m = /(^|\/)Contents\/Library\/(LaunchAgents|LaunchDaemons)\/[^/]+\.plist$/.exec(f.rel);
|
|
496
|
+
if (!m)
|
|
497
|
+
continue;
|
|
498
|
+
const plist = await readPlistFile(f.abs);
|
|
499
|
+
backgroundItems.push(backgroundItemFrom(m[2] === 'LaunchAgents' ? 'launchAgent' : 'launchDaemon', f.rel, plist));
|
|
500
|
+
}
|
|
501
|
+
backgroundItems.sort((a, b) => a.file.localeCompare(b.file));
|
|
502
|
+
// Hostnames in executables.
|
|
503
|
+
const insideFramework = (rel) => /\.framework\//.test(rel);
|
|
504
|
+
const binaryTargets = machoFiles.filter((f) => deep || !insideFramework(f.rel) || f.rel.endsWith('.node'));
|
|
505
|
+
for (const f of binaryTargets)
|
|
506
|
+
await scanner.scanFile(f.abs, f.size, f.rel, true);
|
|
507
|
+
// Hostnames in plain text resources, including unpacked Electron code.
|
|
508
|
+
for (const f of files) {
|
|
509
|
+
if (!deep && insideFramework(f.rel))
|
|
510
|
+
continue;
|
|
511
|
+
if (!isScannableText(f.rel))
|
|
512
|
+
continue;
|
|
513
|
+
await scanner.scanFile(f.abs, f.size, f.rel, false);
|
|
514
|
+
}
|
|
515
|
+
// Electron.
|
|
516
|
+
const packages = {};
|
|
517
|
+
for (const f of files)
|
|
518
|
+
if (NODE_MODULES_MANIFEST_RE.test(f.rel) && f.size < 1024 * 1024)
|
|
519
|
+
addPackage(packages, await readFile(f.abs, 'utf8'));
|
|
520
|
+
const electronFramework = nestedItems.find((c) => c.kind === 'framework' && c.path.endsWith('/Electron Framework.framework'));
|
|
521
|
+
const asarFiles = files.filter((f) => f.rel.endsWith('.asar') && !insideFramework(f.rel));
|
|
522
|
+
const appDir = entries.find((e) => e.isDir && e.rel === 'Contents/Resources/app');
|
|
523
|
+
let electron;
|
|
524
|
+
if (electronFramework || asarFiles.length || appDir) {
|
|
525
|
+
electron = { electronVersion: electronFramework?.version, packages, fileCount: 0 };
|
|
526
|
+
const primary = asarFiles.find((f) => f.rel === 'Contents/Resources/app.asar');
|
|
527
|
+
if (primary)
|
|
528
|
+
electron.appSource = primary.rel;
|
|
529
|
+
else if (appDir)
|
|
530
|
+
electron.appSource = appDir.rel;
|
|
531
|
+
else if (asarFiles[0])
|
|
532
|
+
electron.appSource = asarFiles[0].rel;
|
|
533
|
+
if (appDir && !primary) {
|
|
534
|
+
const rootPkg = byRel.get('Contents/Resources/app/package.json');
|
|
535
|
+
if (rootPkg) {
|
|
536
|
+
try {
|
|
537
|
+
const pkg = JSON.parse(await readFile(rootPkg.abs, 'utf8'));
|
|
538
|
+
electron.appName = pkg.productName ?? pkg.name;
|
|
539
|
+
electron.appVersion = pkg.version;
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
warnings.push('Could not parse Contents/Resources/app/package.json');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
for (const af of asarFiles) {
|
|
547
|
+
let archive;
|
|
548
|
+
try {
|
|
549
|
+
archive = AsarArchive.open(af.abs);
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
warnings.push(`Could not read ${af.rel}: ${err.message}`);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
for (const file of archive.files()) {
|
|
557
|
+
electron.fileCount++;
|
|
558
|
+
// Unpacked files are on disk and were scanned with the other files.
|
|
559
|
+
if (file.unpacked)
|
|
560
|
+
continue;
|
|
561
|
+
if (file.path === 'package.json' && af === primary) {
|
|
562
|
+
try {
|
|
563
|
+
const pkg = JSON.parse(archive.read(file).toString('utf8'));
|
|
564
|
+
electron.appName = pkg.productName ?? pkg.name;
|
|
565
|
+
electron.appVersion = pkg.version;
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
warnings.push(`Could not parse package.json in ${af.rel}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (NODE_MODULES_MANIFEST_RE.test(file.path) && file.size < 1024 * 1024) {
|
|
572
|
+
addPackage(packages, archive.read(file).toString('utf8'));
|
|
573
|
+
}
|
|
574
|
+
if (isScannableText(file.path))
|
|
575
|
+
scanner.scanAsarFile(archive, file, `${af.rel}:${file.path}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
archive.close();
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
for (const list of Object.values(packages))
|
|
583
|
+
list.sort();
|
|
584
|
+
}
|
|
585
|
+
if (scanner.truncatedFiles.length) {
|
|
586
|
+
const shown = scanner.truncatedFiles.slice(0, 3).join(', ');
|
|
587
|
+
const more = scanner.truncatedFiles.length > 3 ? ` and ${scanner.truncatedFiles.length - 3} more` : '';
|
|
588
|
+
warnings.push(`Read only the first ${Math.round(maxFile / MB)} MB of ${shown}${more} for hostnames.`);
|
|
589
|
+
}
|
|
590
|
+
if (scanner.totalLimitHit)
|
|
591
|
+
warnings.push(`Stopped reading for hostnames after ${Math.round(maxTotal / MB)} MB in total.`);
|
|
592
|
+
for (const list of scanner.domainLists) {
|
|
593
|
+
warnings.push(`Skipped ${list.count} bare hostnames in ${list.where}: it looks like a bundled domain list, not code.`);
|
|
594
|
+
}
|
|
595
|
+
const hosts = {};
|
|
596
|
+
for (const host of [...scanner.hosts.keys()].sort())
|
|
597
|
+
hosts[host] = scanner.hosts.get(host);
|
|
598
|
+
const fileCount = files.length;
|
|
599
|
+
const sizeBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
600
|
+
return {
|
|
601
|
+
schema: SNAPSHOT_SCHEMA,
|
|
602
|
+
tool: { name: TOOL_NAME, version: TOOL_VERSION },
|
|
603
|
+
takenAt: new Date().toISOString(),
|
|
604
|
+
fingerprint: await fingerprintOf(appPath, infoPath, mainExe),
|
|
605
|
+
app: {
|
|
606
|
+
path: appPath,
|
|
607
|
+
name,
|
|
608
|
+
bundleId: str(info.CFBundleIdentifier) ?? `unknown.${name}`,
|
|
609
|
+
version: str(info.CFBundleShortVersionString) ?? str(info.CFBundleVersion) ?? '0',
|
|
610
|
+
build: str(info.CFBundleVersion) ?? '',
|
|
611
|
+
sizeBytes,
|
|
612
|
+
fileCount,
|
|
613
|
+
},
|
|
614
|
+
info: summary,
|
|
615
|
+
signing: mainItem.signing,
|
|
616
|
+
code,
|
|
617
|
+
backgroundItems,
|
|
618
|
+
electron,
|
|
619
|
+
hosts,
|
|
620
|
+
stats: {
|
|
621
|
+
scanMs: Date.now() - started,
|
|
622
|
+
filesScanned: scanner.filesScanned,
|
|
623
|
+
bytesScanned: scanner.bytesScanned,
|
|
624
|
+
deep,
|
|
625
|
+
warnings,
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "./commands.js";
|
|
3
|
+
const code = await main(process.argv.slice(2), {
|
|
4
|
+
out: (s) => process.stdout.write(s),
|
|
5
|
+
err: (s) => process.stderr.write(s),
|
|
6
|
+
color: process.stdout.isTTY === true && !process.env.NO_COLOR,
|
|
7
|
+
});
|
|
8
|
+
process.exitCode = code;
|