@intincrab/updiff 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdir, open, readdir, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { Readable, Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { run } from "./exec.js";
8
+ import { TOOL_VERSION } from "./version.js";
9
+ export const USER_AGENT = `updiff/${TOOL_VERSION} (+https://github.com/intincrab/updiff)`;
10
+ /**
11
+ * Some vendors serve their Homebrew download URL only to Homebrew clients (Warp
12
+ * answers 404 otherwise). The ledger fetches exactly what a cask points to, so it
13
+ * retries those with a User-Agent that says it is Homebrew-compatible and still names itself.
14
+ */
15
+ export const HOMEBREW_COMPAT_USER_AGENT = `Homebrew/4.6 (compatible; updiff/${TOOL_VERSION}; +https://github.com/intincrab/updiff)`;
16
+ /**
17
+ * Downloads `url` to `dest`, hashing as it goes. Fails above `maxBytes` and, when
18
+ * `expectedSha256` is given, on a checksum mismatch.
19
+ */
20
+ export async function download(url, dest, options) {
21
+ const get = (userAgent) => fetch(url, { redirect: 'follow', headers: { 'user-agent': userAgent }, signal: AbortSignal.timeout(30 * 60_000) });
22
+ let res = await get(USER_AGENT);
23
+ if (res.status === 403 || res.status === 404) {
24
+ await res.body?.cancel();
25
+ res = await get(HOMEBREW_COMPAT_USER_AGENT);
26
+ }
27
+ if (!res.ok || !res.body)
28
+ throw new Error(`Download failed: HTTP ${res.status} for ${url}`);
29
+ const declared = Number(res.headers.get('content-length') ?? 0);
30
+ if (declared > options.maxBytes)
31
+ throw new Error(`Download is ${declared} bytes, over the ${options.maxBytes}-byte limit`);
32
+ const hash = createHash('sha256');
33
+ let bytes = 0;
34
+ const meter = new Transform({
35
+ transform(chunk, _enc, cb) {
36
+ bytes += chunk.length;
37
+ if (bytes > options.maxBytes) {
38
+ cb(new Error(`Download exceeded the ${options.maxBytes}-byte limit`));
39
+ return;
40
+ }
41
+ hash.update(chunk);
42
+ cb(null, chunk);
43
+ },
44
+ });
45
+ await pipeline(Readable.fromWeb(res.body), meter, createWriteStream(dest));
46
+ const sha256 = hash.digest('hex');
47
+ if (options.expectedSha256 && options.expectedSha256 !== sha256) {
48
+ throw new Error(`Checksum mismatch: expected ${options.expectedSha256}, got ${sha256}`);
49
+ }
50
+ return { file: dest, bytes, sha256 };
51
+ }
52
+ /**
53
+ * Identifies a downloaded installer. A disk image (UDIF) is recognised by the `koly`
54
+ * block in its last 512 bytes; its first bytes are compressed data and can look like
55
+ * bzip2 ("BZh", UDBZ images such as Slack's and Chrome's) or anything else.
56
+ */
57
+ export function detectContainer(head, tail) {
58
+ if (tail && tail.length >= 512 && tail.toString('latin1', tail.length - 512, tail.length - 508) === 'koly')
59
+ return 'dmg';
60
+ if (head.length >= 4 && head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04)
61
+ return 'zip';
62
+ if (head.length >= 4 && head.toString('latin1', 0, 4) === 'xar!')
63
+ return 'pkg';
64
+ if (head.length >= 2 && head[0] === 0x1f && head[1] === 0x8b)
65
+ return 'tar';
66
+ if (head.length >= 3 && head.toString('latin1', 0, 3) === 'BZh')
67
+ return 'tar';
68
+ if (head.length >= 6 && head[0] === 0xfd && head.toString('latin1', 1, 5) === '7zXZ')
69
+ return 'tar';
70
+ if (head.length >= 262 && head.toString('latin1', 257, 262) === 'ustar')
71
+ return 'tar';
72
+ return 'dmg';
73
+ }
74
+ async function readEnds(file) {
75
+ const fh = await open(file, 'r');
76
+ try {
77
+ const size = (await fh.stat()).size;
78
+ const head = Buffer.alloc(512);
79
+ const { bytesRead } = await fh.read(head, 0, 512, 0);
80
+ const tail = Buffer.alloc(Math.min(512, size));
81
+ await fh.read(tail, 0, tail.length, size - tail.length);
82
+ return { head: head.subarray(0, bytesRead), tail };
83
+ }
84
+ finally {
85
+ await fh.close();
86
+ }
87
+ }
88
+ /** Finds app bundles under `root` without descending into them. */
89
+ export async function findApps(root, maxDepth = 8) {
90
+ const found = [];
91
+ const walk = async (dir, depth) => {
92
+ let dirents;
93
+ try {
94
+ dirents = await readdir(dir, { withFileTypes: true });
95
+ }
96
+ catch {
97
+ return;
98
+ }
99
+ for (const d of dirents) {
100
+ if (!d.isDirectory() || d.isSymbolicLink())
101
+ continue;
102
+ const p = join(dir, d.name);
103
+ if (d.name.endsWith('.app'))
104
+ found.push(p);
105
+ else if (depth < maxDepth)
106
+ await walk(p, depth + 1);
107
+ }
108
+ };
109
+ await walk(root, 0);
110
+ return found.sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
111
+ }
112
+ /**
113
+ * Unpacks an installer into `workDir` and returns the app bundle it holds. Nothing in
114
+ * the installer is run: disk images are mounted read-only, packages are expanded with
115
+ * `pkgutil --expand-full` (scripts are written out, not executed).
116
+ */
117
+ export async function extractApp(file, workDir, appNames) {
118
+ const { head, tail } = await readEnds(file);
119
+ let kind = detectContainer(head, tail);
120
+ const out = join(workDir, 'contents');
121
+ await mkdir(workDir, { recursive: true });
122
+ let mountPoint;
123
+ const cleanup = async () => {
124
+ if (mountPoint) {
125
+ const res = await run('/usr/bin/hdiutil', ['detach', mountPoint, '-quiet']);
126
+ if (res.code !== 0)
127
+ await run('/usr/bin/hdiutil', ['detach', mountPoint, '-force', '-quiet']);
128
+ }
129
+ await rm(workDir, { recursive: true, force: true });
130
+ };
131
+ try {
132
+ const mount = async () => {
133
+ const point = join(workDir, 'mnt');
134
+ await mkdir(point, { recursive: true });
135
+ // "Y" accepts a licence agreement if the image shows one.
136
+ const r = await run('/usr/bin/hdiutil', ['attach', file, '-readonly', '-nobrowse', '-noautoopen', '-noverify', '-mountpoint', point], 10 * 60_000, 'Y\n');
137
+ if (r.code === 0)
138
+ mountPoint = point;
139
+ return r;
140
+ };
141
+ let res;
142
+ if (kind === 'zip') {
143
+ await mkdir(out);
144
+ res = await run('/usr/bin/ditto', ['-x', '-k', file, out], 15 * 60_000);
145
+ }
146
+ else if (kind === 'pkg') {
147
+ res = await run('/usr/sbin/pkgutil', ['--expand-full', file, out], 15 * 60_000);
148
+ }
149
+ else if (kind === 'tar') {
150
+ await mkdir(out);
151
+ res = await run('/usr/bin/tar', ['-xf', file, '-C', out], 15 * 60_000);
152
+ }
153
+ else {
154
+ res = await mount();
155
+ }
156
+ if (res.code !== 0 && (kind === 'zip' || kind === 'tar')) {
157
+ // A guess from the first bytes can be wrong; a disk image is the usual other answer.
158
+ const second = await mount();
159
+ if (second.code === 0) {
160
+ kind = 'dmg';
161
+ res = second;
162
+ }
163
+ }
164
+ if (res.code !== 0)
165
+ throw new Error(`Could not unpack the ${kind}: ${(res.stderr || res.stdout).trim().split('\n').pop()}`);
166
+ const apps = await findApps(mountPoint ?? out);
167
+ const wanted = appNames.map((n) => n.toLowerCase());
168
+ const appPath = apps.find((p) => wanted.includes(p.slice(p.lastIndexOf('/') + 1).toLowerCase())) ?? apps[0];
169
+ if (!appPath)
170
+ throw new Error(`No .app bundle found in the ${kind}`);
171
+ return { appPath, kind, cleanup };
172
+ }
173
+ catch (err) {
174
+ await cleanup();
175
+ throw err;
176
+ }
177
+ }
package/dist/macho.js ADDED
@@ -0,0 +1,35 @@
1
+ const MH_MAGIC = 0xfeedface;
2
+ const MH_MAGIC_64 = 0xfeedfacf;
3
+ const LC_ENCRYPTION_INFO = 0x21;
4
+ const LC_ENCRYPTION_INFO_64 = 0x2c;
5
+ /**
6
+ * Whether a thin Mach-O image is FairPlay-encrypted, from its load commands
7
+ * (LC_ENCRYPTION_INFO / _64 with a non-zero cryptid). `head` must hold the header
8
+ * and the load commands; 64 KB covers real binaries. Returns undefined when `head`
9
+ * isn't a little-endian Mach-O.
10
+ *
11
+ * Mac App Store apps for macOS are not encrypted; iPhone and iPad apps that run on
12
+ * Apple silicon Macs are, and their strings can't be read.
13
+ */
14
+ export function isEncryptedMachO(head) {
15
+ if (head.length < 28)
16
+ return undefined;
17
+ const magic = head.readUInt32LE(0);
18
+ if (magic !== MH_MAGIC && magic !== MH_MAGIC_64)
19
+ return undefined;
20
+ const ncmds = head.readUInt32LE(16);
21
+ let offset = magic === MH_MAGIC_64 ? 32 : 28;
22
+ for (let i = 0; i < ncmds && offset + 8 <= head.length; i++) {
23
+ const cmd = head.readUInt32LE(offset);
24
+ const size = head.readUInt32LE(offset + 4);
25
+ if ((cmd === LC_ENCRYPTION_INFO || cmd === LC_ENCRYPTION_INFO_64) && offset + 20 <= head.length) {
26
+ // struct encryption_info_command { cmd, cmdsize, cryptoff, cryptsize, cryptid }
27
+ if (head.readUInt32LE(offset + 16) !== 0)
28
+ return true;
29
+ }
30
+ if (size < 8)
31
+ break;
32
+ offset += size;
33
+ }
34
+ return false;
35
+ }
@@ -0,0 +1,211 @@
1
+ import { basename } from 'node:path';
2
+ import { CATEGORY_ORDER, SEVERITY_ORDER } from "./diff.js";
3
+ import { formatBytes, lineDiff } from "./util.js";
4
+ const plain = { bold: (s) => s, dim: (s) => s, warn: (s) => s };
5
+ const ansi = {
6
+ bold: (s) => `\x1b[1m${s}\x1b[22m`,
7
+ dim: (s) => `\x1b[2m${s}\x1b[22m`,
8
+ warn: (s) => `\x1b[33m${s}\x1b[39m`,
9
+ };
10
+ function describeLaunch(i) {
11
+ const bits = [i.label ? `label ${i.label}` : undefined, i.program ? `runs ${i.program}` : undefined, i.runAtLoad ? (i.kind === 'daemon' ? 'starts at boot' : 'starts at login') : undefined, i.keepAlive ? 'kept alive' : undefined];
12
+ return bits.filter(Boolean).join(' · ');
13
+ }
14
+ function scriptHeader(s) {
15
+ const what = s.binary ? 'binary' : `${s.interpreter ?? 'no interpreter line'}, ${s.lines} line${s.lines === 1 ? '' : 's'}`;
16
+ return `${s.name} (${what}, ${formatBytes(s.bytes)})`;
17
+ }
18
+ /** What an installer package would do, for reading before running it. */
19
+ export function renderPkg(r, options = {}) {
20
+ const st = options.color ? ansi : plain;
21
+ const maxLines = options.full ? Infinity : (options.maxScriptLines ?? 40);
22
+ const lines = [];
23
+ const title = r.distribution?.title ?? r.components[0]?.id ?? basename(r.file);
24
+ lines.push(st.bold(`${title} ${basename(r.file)}`), st.dim(`sha256 ${r.sha256} · ${formatBytes(r.bytes)}`), '');
25
+ const sig = r.signature;
26
+ lines.push(`Signature ${sig.signer ?? sig.status}${sig.notarized ? ' · notarized' : ''}`);
27
+ lines.push(`Gatekeeper ${r.gatekeeper.assessment}${r.gatekeeper.source ? ` (${r.gatekeeper.source})` : ''}`);
28
+ const root = r.components.some((c) => c.auth === 'root');
29
+ lines.push(`Runs as ${root ? st.warn('root: the installer asks for an administrator password') : 'the current user'}`);
30
+ if (r.distribution) {
31
+ const js = r.distribution.scripts.length + r.distribution.checks.length;
32
+ lines.push(`Installer UI ${js ? st.warn(`${js} JavaScript check${js === 1 ? '' : 's'} run when the installer opens`) : 'no JavaScript'}`);
33
+ }
34
+ for (const c of r.components) {
35
+ lines.push('', st.bold(`Component ${c.id ?? '(unnamed)'}${c.version ? ` ${c.version}` : ''}`) + st.dim(` → ${c.installLocation} · ${c.files} file${c.files === 1 ? '' : 's'}, ${formatBytes(c.bytes)}`));
36
+ for (const a of c.apps)
37
+ lines.push(` App ${a.path}${a.bundleId ? ` (${a.bundleId}${a.version ? ` ${a.version}` : ''}${a.teamId ? `, team ${a.teamId}` : ''})` : ''}`);
38
+ for (const l of c.launchItems)
39
+ lines.push(st.warn(` Launch ${l.kind.padEnd(6)} ${l.path}`) + (describeLaunch(l) ? st.dim(` ${describeLaunch(l)}`) : ''));
40
+ for (const p of c.systemLevel)
41
+ lines.push(st.warn(` System level ${p}`));
42
+ if (c.outsideApplications.length) {
43
+ lines.push(` Outside /Applications: ${c.outsideApplications.slice(0, 12).join(', ')}${c.outsideApplications.length > 12 ? ` and ${c.outsideApplications.length - 12} more` : ''}`);
44
+ }
45
+ if (!c.scripts.length)
46
+ lines.push(st.dim(' No install scripts.'));
47
+ for (const s of c.scripts) {
48
+ lines.push('', ` ${s.hook ? st.warn(`Runs ${s.name === 'preinstall' ? 'before' : 'after'} installing${c.auth === 'root' ? ' as root' : ''}:`) : 'Helper file:'} ${scriptHeader(s)}`);
49
+ if (s.content === undefined) {
50
+ lines.push(st.dim(s.binary ? ' (binary: not shown)' : ' (too large to show)'));
51
+ continue;
52
+ }
53
+ const body = s.content.replace(/\n$/, '').split('\n');
54
+ for (const l of body.slice(0, maxLines))
55
+ lines.push(st.dim(' │ ') + l);
56
+ if (body.length > maxLines)
57
+ lines.push(st.dim(` │ … ${body.length - maxLines} more lines (use --full)`));
58
+ }
59
+ }
60
+ if (r.distribution && (r.distribution.scripts.length || r.distribution.checks.length)) {
61
+ lines.push('', st.bold('Installer JavaScript'));
62
+ for (const check of r.distribution.checks)
63
+ lines.push(` ${check}`);
64
+ for (const js of r.distribution.scripts) {
65
+ const body = js.split('\n');
66
+ for (const l of body.slice(0, maxLines))
67
+ lines.push(st.dim(' │ ') + l);
68
+ if (body.length > maxLines)
69
+ lines.push(st.dim(` │ … ${body.length - maxLines} more lines (use --full)`));
70
+ }
71
+ }
72
+ lines.push('', st.dim('Nothing was installed or run: the package was only unpacked.'));
73
+ return lines.join('\n') + '\n';
74
+ }
75
+ export function renderPkgMarkdown(r) {
76
+ const out = [`# ${r.distribution?.title ?? r.components[0]?.id ?? basename(r.file)}`, '', `\`${basename(r.file)}\` · sha256 \`${r.sha256}\` · ${formatBytes(r.bytes)}`, ''];
77
+ out.push(`- Signature: ${r.signature.signer ?? r.signature.status}${r.signature.notarized ? ' (notarized)' : ''}`);
78
+ out.push(`- Gatekeeper: ${r.gatekeeper.assessment}${r.gatekeeper.source ? ` (${r.gatekeeper.source})` : ''}`);
79
+ out.push(`- Runs as: ${r.components.some((c) => c.auth === 'root') ? 'root' : 'the current user'}`, '');
80
+ for (const c of r.components) {
81
+ out.push(`## ${c.id ?? 'Component'} ${c.version ?? ''}`, '', `Installs to \`${c.installLocation}\` (${c.files} files, ${formatBytes(c.bytes)}).`, '');
82
+ for (const a of c.apps)
83
+ out.push(`- App \`${a.path}\` ${a.bundleId ?? ''} ${a.version ?? ''}`);
84
+ for (const l of c.launchItems)
85
+ out.push(`- Launch ${l.kind} \`${l.path}\`: ${describeLaunch(l)}`);
86
+ for (const p of c.systemLevel)
87
+ out.push(`- System level \`${p}\``);
88
+ if (c.outsideApplications.length)
89
+ out.push(`- Outside /Applications: ${c.outsideApplications.map((p) => `\`${p}\``).join(', ')}`);
90
+ for (const s of c.scripts) {
91
+ out.push('', `### ${s.hook ? 'Script' : 'Helper'}: ${scriptHeader(s)}`, '');
92
+ if (s.content !== undefined)
93
+ out.push('```sh', s.content.replace(/\n$/, ''), '```');
94
+ }
95
+ out.push('');
96
+ }
97
+ return out.join('\n') + '\n';
98
+ }
99
+ function teamOf(signer) {
100
+ return /\(([A-Z0-9]{10})\)\s*$/.exec(signer ?? '')?.[1];
101
+ }
102
+ /** What changed between two versions of an installer package. */
103
+ export function diffPkgs(a, b) {
104
+ const f = [];
105
+ const at = (where) => [where];
106
+ if (a.signature.notarized && !b.signature.notarized)
107
+ f.push({ severity: 'high', category: 'signing', change: 'removed', title: 'Package is no longer notarized' });
108
+ if (teamOf(a.signature.signer) && teamOf(b.signature.signer) && teamOf(a.signature.signer) !== teamOf(b.signature.signer)) {
109
+ f.push({ severity: 'high', category: 'signing', change: 'changed', title: 'Package is signed by a different developer', detail: `${a.signature.signer} → ${b.signature.signer}` });
110
+ }
111
+ else if (a.signature.signer && !b.signature.signer) {
112
+ f.push({ severity: 'high', category: 'signing', change: 'removed', title: 'Package is no longer signed', detail: b.signature.status });
113
+ }
114
+ if (a.gatekeeper.assessment === 'accepted' && b.gatekeeper.assessment === 'rejected') {
115
+ f.push({ severity: 'high', category: 'signing', change: 'changed', title: 'Gatekeeper now rejects the package', detail: b.gatekeeper.source });
116
+ }
117
+ const rootA = a.components.some((c) => c.auth === 'root');
118
+ const rootB = b.components.some((c) => c.auth === 'root');
119
+ if (!rootA && rootB)
120
+ f.push({ severity: 'high', category: 'install', change: 'added', title: 'Now installs as root (asks for an administrator password)' });
121
+ const byId = new Map(a.components.map((c) => [c.id ?? c.installLocation, c]));
122
+ for (const cb of b.components) {
123
+ const key = cb.id ?? cb.installLocation;
124
+ const ca = byId.get(key);
125
+ byId.delete(key);
126
+ const label = cb.id ?? 'component';
127
+ if (!ca) {
128
+ f.push({ severity: 'notable', category: 'install', change: 'added', title: `New component ${label}`, detail: `installs to ${cb.installLocation}` });
129
+ }
130
+ const scriptsA = new Map((ca?.scripts ?? []).map((s) => [s.name, s]));
131
+ for (const s of cb.scripts) {
132
+ const old = scriptsA.get(s.name);
133
+ scriptsA.delete(s.name);
134
+ if (!old) {
135
+ f.push({
136
+ severity: s.hook ? 'high' : 'notable',
137
+ category: 'script',
138
+ change: 'added',
139
+ title: s.hook ? `New ${s.name} script${cb.auth === 'root' ? ' (runs as root)' : ''}` : `New helper file ${s.name}`,
140
+ detail: s.content?.replace(/\n$/, '').split('\n').slice(0, 30).map((l) => `+ ${l}`).join('\n'),
141
+ where: at(label),
142
+ });
143
+ }
144
+ else if (old.content !== s.content || old.bytes !== s.bytes) {
145
+ const changes = old.content !== undefined && s.content !== undefined ? lineDiff(old.content, s.content) : ['(binary or too large to compare)'];
146
+ const added = changes.filter((l) => l.startsWith('+ ')).length;
147
+ const removed = changes.filter((l) => l.startsWith('- ')).length;
148
+ f.push({
149
+ severity: 'notable',
150
+ category: 'script',
151
+ change: 'changed',
152
+ title: `${s.hook ? 'Script' : 'Helper file'} ${s.name} changed (+${added} −${removed} lines)`,
153
+ detail: changes.slice(0, 40).join('\n') + (changes.length > 40 ? `\n… ${changes.length - 40} more changed lines` : ''),
154
+ where: at(label),
155
+ });
156
+ }
157
+ }
158
+ for (const s of scriptsA.values())
159
+ f.push({ severity: 'info', category: 'script', change: 'removed', title: `Removed ${s.name}`, where: at(label) });
160
+ const launchA = new Map((ca?.launchItems ?? []).map((l) => [l.path, l]));
161
+ for (const l of cb.launchItems) {
162
+ const old = launchA.get(l.path);
163
+ if (!old) {
164
+ f.push({ severity: 'high', category: 'background', change: 'added', title: `Installs a new launch ${l.kind}: ${l.path}`, detail: describeLaunch(l), where: at(label) });
165
+ }
166
+ else if (old.program !== l.program || old.runAtLoad !== l.runAtLoad || old.keepAlive !== l.keepAlive) {
167
+ f.push({ severity: 'notable', category: 'background', change: 'changed', title: `Launch ${l.kind} ${l.path} changed`, detail: `${describeLaunch(old)} → ${describeLaunch(l)}`, where: at(label) });
168
+ }
169
+ }
170
+ const sysA = new Set(ca?.systemLevel ?? []);
171
+ for (const p of cb.systemLevel)
172
+ if (!sysA.has(p))
173
+ f.push({ severity: 'high', category: 'install', change: 'added', title: `Installs a new system-level item: ${p}`, where: at(label) });
174
+ const outA = new Set(ca?.outsideApplications ?? []);
175
+ const newOut = cb.outsideApplications.filter((p) => !outA.has(p));
176
+ if (newOut.length)
177
+ f.push({ severity: 'notable', category: 'install', change: 'added', title: 'Installs files in new places outside /Applications', detail: newOut.join(', '), where: at(label) });
178
+ const appsA = new Map((ca?.apps ?? []).map((x) => [x.path, x]));
179
+ for (const app of cb.apps) {
180
+ const old = appsA.get(app.path);
181
+ if (!old)
182
+ f.push({ severity: 'notable', category: 'install', change: 'added', title: `Installs a new app: ${app.path}`, detail: app.bundleId, where: at(label) });
183
+ else if (old.teamId && app.teamId && old.teamId !== app.teamId) {
184
+ f.push({ severity: 'high', category: 'signing', change: 'changed', title: `${app.path} is signed by a different developer team`, detail: `${old.teamId} → ${app.teamId}`, where: at(label) });
185
+ }
186
+ }
187
+ }
188
+ for (const gone of byId.values())
189
+ f.push({ severity: 'info', category: 'install', change: 'removed', title: `Removed component ${gone.id ?? gone.installLocation}` });
190
+ const jsA = (a.distribution?.scripts ?? []).join('\n');
191
+ const jsB = (b.distribution?.scripts ?? []).join('\n');
192
+ if (jsA !== jsB) {
193
+ const changes = lineDiff(jsA, jsB);
194
+ f.push({ severity: 'notable', category: 'script', change: 'changed', title: 'Installer JavaScript changed', detail: changes.slice(0, 40).join('\n') });
195
+ }
196
+ if (a.sha256 === b.sha256)
197
+ f.length = 0;
198
+ f.sort((x, y) => SEVERITY_ORDER[x.severity] - SEVERITY_ORDER[y.severity] || CATEGORY_ORDER.indexOf(x.category) - CATEGORY_ORDER.indexOf(y.category) || x.title.localeCompare(y.title));
199
+ const counts = { high: 0, notable: 0, info: 0 };
200
+ for (const x of f)
201
+ counts[x.severity]++;
202
+ const side = (r) => ({ version: r.components[0]?.version ?? '?', build: r.sha256.slice(0, 12), takenAt: '', path: r.file });
203
+ return {
204
+ app: { name: b.distribution?.title ?? basename(b.file), bundleId: b.components[0]?.id ?? '' },
205
+ from: side(a),
206
+ to: side(b),
207
+ findings: f,
208
+ counts,
209
+ identical: a.sha256 === b.sha256,
210
+ };
211
+ }
package/dist/pkg.js ADDED
@@ -0,0 +1,211 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { lstat, mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, join, posix } from 'node:path';
6
+ import { readIdentity } from "./bundle.js";
7
+ import { parseSpctl, readSigning } from "./codesign.js";
8
+ import { decodeEntities, readPlistFile, str } from "./plist.js";
9
+ import { run } from "./exec.js";
10
+ /** Attribute map of one XML start tag. */
11
+ function attrs(tag) {
12
+ const out = {};
13
+ for (const m of tag.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g))
14
+ out[m[1]] = decodeEntities(m[2]);
15
+ return out;
16
+ }
17
+ /** Parses `pkgutil --check-signature`. */
18
+ export function parseCheckSignature(text) {
19
+ const status = /Status:\s*(.+)/.exec(text)?.[1]?.trim() ?? 'unknown';
20
+ const signer = /^\s*1\.\s*(.+)$/m.exec(text)?.[1]?.trim();
21
+ return { status, ...(signer ? { signer } : {}), notarized: /Notarization:\s*trusted by the Apple notary service/i.test(text) };
22
+ }
23
+ /** JavaScript and checks a Distribution file runs when Installer opens it. */
24
+ export function parseDistribution(xml) {
25
+ const title = /<title>([\s\S]*?)<\/title>/.exec(xml)?.[1]?.trim();
26
+ const scripts = [...xml.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)]
27
+ .map((m) => decodeEntities(m[1].replace(/^\s*<!\[CDATA\[|\]\]>\s*$/g, '')).trim())
28
+ .filter(Boolean);
29
+ const checks = [];
30
+ for (const m of xml.matchAll(/<(installation-check|volume-check)\b([^>]*)>/g)) {
31
+ const a = attrs(m[2]);
32
+ if (a.script)
33
+ checks.push(`${m[1]}: ${a.script}`);
34
+ }
35
+ return { ...(title ? { title: decodeEntities(title) } : {}), scripts, checks };
36
+ }
37
+ function sha256File(file) {
38
+ return new Promise((resolve, reject) => {
39
+ const hash = createHash('sha256');
40
+ let bytes = 0;
41
+ createReadStream(file)
42
+ .on('data', (c) => {
43
+ hash.update(c);
44
+ bytes += c.length;
45
+ })
46
+ .on('end', () => resolve({ sha256: hash.digest('hex'), bytes }))
47
+ .on('error', reject);
48
+ });
49
+ }
50
+ async function walk(root) {
51
+ const out = [];
52
+ const queue = [''];
53
+ while (queue.length) {
54
+ const rel = queue.pop();
55
+ let dirents;
56
+ try {
57
+ dirents = await readdir(join(root, rel), { withFileTypes: true });
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ for (const d of dirents) {
63
+ const r = rel ? `${rel}/${d.name}` : d.name;
64
+ if (d.isDirectory()) {
65
+ out.push({ rel: r, isDir: true, size: 0 });
66
+ queue.push(r);
67
+ }
68
+ else if (d.isFile() || d.isSymbolicLink()) {
69
+ let size = 0;
70
+ try {
71
+ size = (await lstat(join(root, r))).size;
72
+ }
73
+ catch {
74
+ // Unreadable: count it with size 0.
75
+ }
76
+ out.push({ rel: r, isDir: false, size });
77
+ }
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+ async function readScripts(dir, maxBytes) {
83
+ const entries = (await walk(dir)).filter((e) => !e.isDir).sort((a, b) => a.rel.localeCompare(b.rel));
84
+ const scripts = [];
85
+ for (const e of entries) {
86
+ const buf = await readFile(join(dir, e.rel));
87
+ const binary = buf.subarray(0, 4096).includes(0);
88
+ const text = binary ? undefined : buf.toString('utf8');
89
+ const shebang = text?.startsWith('#!') ? text.slice(2, text.indexOf('\n') >>> 0).trim() : undefined;
90
+ scripts.push({
91
+ name: e.rel,
92
+ hook: e.rel === 'preinstall' || e.rel === 'postinstall',
93
+ ...(shebang ? { interpreter: basename(shebang.split(/\s+/)[0] === '/usr/bin/env' ? (shebang.split(/\s+/)[1] ?? shebang) : shebang.split(/\s+/)[0]) } : {}),
94
+ binary,
95
+ bytes: buf.length,
96
+ lines: text ? text.split('\n').length : 0,
97
+ ...(text !== undefined && buf.length <= maxBytes ? { content: text } : {}),
98
+ });
99
+ }
100
+ // Hooks first, in the order they run; then helper files.
101
+ const rank = (x) => (x.name === 'preinstall' ? 0 : x.name === 'postinstall' ? 1 : 2);
102
+ return scripts.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
103
+ }
104
+ async function readComponent(dir, scriptMaxBytes) {
105
+ let info = {};
106
+ try {
107
+ const xml = await readFile(join(dir, 'PackageInfo'), 'utf8');
108
+ info = attrs(/<pkg-info\b([^>]*)>/.exec(xml)?.[1] ?? '');
109
+ }
110
+ catch {
111
+ // A component without PackageInfo still has a payload to show.
112
+ }
113
+ const installLocation = info['install-location'] || '/';
114
+ const payloadDir = join(dir, 'Payload');
115
+ const entries = await walk(payloadDir);
116
+ const dest = (rel) => posix.join(installLocation, rel);
117
+ const apps = [];
118
+ const launchItems = [];
119
+ const outside = new Set();
120
+ const systemLevel = [];
121
+ let files = 0;
122
+ let bytes = 0;
123
+ for (const e of entries) {
124
+ const path = dest(e.rel);
125
+ if (!e.isDir) {
126
+ files++;
127
+ bytes += e.size;
128
+ }
129
+ if (e.isDir && e.rel.endsWith('.app') && !/\.app\//.test(e.rel)) {
130
+ const id = await readIdentity(join(payloadDir, e.rel));
131
+ const signing = await readSigning(join(payloadDir, e.rel));
132
+ apps.push({ path, bundleId: id?.bundleId, version: id?.version, teamId: signing.teamId });
133
+ }
134
+ // System-wide launchd jobs, or ones an app registers from inside its bundle (SMAppService).
135
+ const launchd = /^\/Library\/(LaunchDaemons|LaunchAgents)\/[^/]+\.plist$/.exec(path) ?? /\.app\/Contents\/Library\/(LaunchDaemons|LaunchAgents)\/[^/]+\.plist$/.exec(path);
136
+ if (launchd && !e.isDir) {
137
+ const plist = await readPlistFile(join(payloadDir, e.rel));
138
+ const args = plist?.ProgramArguments;
139
+ launchItems.push({
140
+ path,
141
+ kind: launchd[1] === 'LaunchDaemons' ? 'daemon' : 'agent',
142
+ label: str(plist?.Label),
143
+ program: str(plist?.Program) ?? str(plist?.BundleProgram) ?? (Array.isArray(args) && typeof args[0] === 'string' ? args[0] : undefined),
144
+ runAtLoad: plist?.RunAtLoad === true,
145
+ keepAlive: plist?.KeepAlive === true || (typeof plist?.KeepAlive === 'object' && plist.KeepAlive !== null),
146
+ });
147
+ }
148
+ if ((e.isDir && /\.(kext|systemextension|dext)$/.test(path)) || /^\/Library\/PrivilegedHelperTools\/[^/]+$/.test(path))
149
+ systemLevel.push(path);
150
+ if (!path.startsWith('/Applications/') && path !== '/Applications' && !e.isDir) {
151
+ // Collapse to the first three levels so a framework doesn't list every file.
152
+ outside.add(path.split('/').slice(0, 4).join('/'));
153
+ }
154
+ }
155
+ return {
156
+ ...(info.identifier ? { id: info.identifier } : {}),
157
+ ...(info.version ? { version: info.version } : {}),
158
+ installLocation,
159
+ ...(info.auth ? { auth: info.auth } : {}),
160
+ scripts: await readScripts(join(dir, 'Scripts'), scriptMaxBytes),
161
+ files,
162
+ bytes,
163
+ apps: apps.sort((a, b) => a.path.localeCompare(b.path)),
164
+ launchItems: launchItems.sort((a, b) => a.path.localeCompare(b.path)),
165
+ outsideApplications: [...outside].sort(),
166
+ systemLevel: [...new Set(systemLevel)].sort(),
167
+ };
168
+ }
169
+ /**
170
+ * Expands an installer package and reports what it would do: scripts that run
171
+ * during installation, what it installs and where, launch daemons and agents,
172
+ * system-level items, its signature and Gatekeeper's verdict. Nothing is installed
173
+ * and no script is run: `pkgutil --expand-full` only unpacks.
174
+ */
175
+ export async function inspectPkg(file, options = {}) {
176
+ const work = await mkdtemp(join(tmpdir(), 'updiff-pkg-'));
177
+ try {
178
+ const out = join(work, 'x');
179
+ const res = await run('/usr/sbin/pkgutil', ['--expand-full', file, out], 15 * 60_000);
180
+ if (res.code !== 0)
181
+ throw new Error(`pkgutil could not expand ${file}: ${(res.stderr || res.stdout).trim().split('\n').pop()}`);
182
+ const { sha256, bytes } = await sha256File(file);
183
+ const sig = await run('/usr/sbin/pkgutil', ['--check-signature', file]);
184
+ const spctl = await run('/usr/sbin/spctl', ['-a', '-vv', '-t', 'install', file], 120_000);
185
+ const scriptMaxBytes = options.scriptMaxBytes ?? 256 * 1024;
186
+ let distribution;
187
+ const components = [];
188
+ const top = await readdir(out, { withFileTypes: true });
189
+ if (top.some((d) => d.name === 'Distribution')) {
190
+ distribution = parseDistribution(await readFile(join(out, 'Distribution'), 'utf8'));
191
+ for (const d of top)
192
+ if (d.isDirectory() && d.name.endsWith('.pkg'))
193
+ components.push(await readComponent(join(out, d.name), scriptMaxBytes));
194
+ }
195
+ else {
196
+ components.push(await readComponent(out, scriptMaxBytes));
197
+ }
198
+ return {
199
+ file,
200
+ sha256,
201
+ bytes,
202
+ signature: parseCheckSignature(sig.stdout + sig.stderr),
203
+ gatekeeper: parseSpctl(spctl.stdout + spctl.stderr),
204
+ ...(distribution ? { distribution } : {}),
205
+ components,
206
+ };
207
+ }
208
+ finally {
209
+ await rm(work, { recursive: true, force: true });
210
+ }
211
+ }