@intincrab/updiff 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -6,7 +6,11 @@ import { parseArgs } from 'node:util';
6
6
  import { AGENT_LABEL, agentPlist, agentStatus, installAgent, notify, uninstallAgent } from "./agent.js";
7
7
  import { quickFingerprint, readIdentity, scanBundle } from "./bundle.js";
8
8
  import { diffSnapshots, filterBySeverity } from "./diff.js";
9
+ import { diffExtensions, discoverExtensions, renderExtensions } from "./extensions.js";
10
+ import { caskHistory, fetchRelease, pickRelease, resolveCask, sameVersion } from "./fetch.js";
9
11
  import { renderJson, renderMarkdown, renderScan, renderText } from "./report.js";
12
+ import { inspectPkg } from "./pkg.js";
13
+ import { diffPkgs, renderPkg, renderPkgMarkdown } from "./pkg-report.js";
10
14
  import { Store } from "./store.js";
11
15
  import { mapLimit } from "./util.js";
12
16
  import { TOOL_VERSION } from "./version.js";
@@ -19,6 +23,16 @@ Usage:
19
23
  updiff diff <a> <b> Compare two apps, snapshot files or stored apps
20
24
  updiff check Snapshot apps that changed and report the differences
21
25
  updiff list [app] List stored snapshots
26
+ updiff fetch <app> [version] Download an earlier release from Homebrew's cask
27
+ history, store it as a snapshot and compare it
28
+ with the installed app (no baseline needed)
29
+ updiff extensions [check] List browser and editor extensions with what they
30
+ can reach; 'check' reports what changed since the
31
+ last check (permissions, sites, bundled code)
32
+ updiff pkg <installer.pkg> [older.pkg]
33
+ Show what an installer package would do: its
34
+ scripts, launch daemons and install locations.
35
+ With two packages, what changed. Nothing is run.
22
36
  updiff agent install|uninstall|status
23
37
  Run 'check' automatically after app updates
24
38
 
@@ -37,12 +51,16 @@ Options:
37
51
  /Applications and ~/Applications, or
38
52
  $UPDIFF_APPS_DIRS, colon-separated)
39
53
  --notify check: show a notification for each changed app
54
+ --extensions check: also check browser and editor extensions
40
55
  --settle <seconds> check: wait until apps stop changing for this long
41
56
  --keep <n> check/snapshot: snapshots to keep per app (default 10)
42
57
  --quiet Print less
43
58
  --interval <seconds> agent install: fallback timer (default 21600)
44
59
  --dry-run agent install: print the LaunchAgent, change nothing
45
60
  --no-load agent install/uninstall: write files, skip launchctl
61
+ --no-gatekeeper Skip the Gatekeeper/notarization check (faster)
62
+ --cask <name> fetch: the Homebrew cask, when it isn't the app's name
63
+ --list fetch: list the releases in the cask's history
46
64
  --no-color Plain text output
47
65
  -h, --help Show this help
48
66
  -v, --version Show the version
@@ -73,6 +91,10 @@ function parse(argv) {
73
91
  'dry-run': { type: 'boolean' },
74
92
  'no-load': { type: 'boolean' },
75
93
  'no-color': { type: 'boolean' },
94
+ 'no-gatekeeper': { type: 'boolean' },
95
+ cask: { type: 'string' },
96
+ list: { type: 'boolean' },
97
+ extensions: { type: 'boolean' },
76
98
  help: { type: 'boolean', short: 'h' },
77
99
  version: { type: 'boolean', short: 'v' },
78
100
  },
@@ -184,7 +206,8 @@ async function cmdScan(ctx, args) {
184
206
  const app = await findAppByName(args[0], ctx.appsDirs);
185
207
  if (!app)
186
208
  throw new UsageError(`Can't find app "${args[0]}".`);
187
- const s = await scanBundle(app, ctx.scan);
209
+ // A one-off scan of an installed app also shows this Mac's update settings for it.
210
+ const s = await scanBundle(app, { ...ctx.scan, userDefaults: true });
188
211
  const text = ctx.format === 'json' ? renderJson(s) : renderScan(s, { color: ctx.io.color && !ctx.out });
189
212
  if (ctx.out) {
190
213
  await writeFile(ctx.out, text);
@@ -309,7 +332,48 @@ async function waitUntilSettled(apps, seconds, io, quiet) {
309
332
  await new Promise((r) => setTimeout(r, Math.min(15, seconds - age) * 1000));
310
333
  }
311
334
  }
312
- async function cmdCheck(ctx, args, notifyEnabled, settle) {
335
+ /** Compares installed extensions with the last stored set and stores the new one. */
336
+ async function checkExtensions(ctx) {
337
+ const current = { schema: 1, takenAt: new Date().toISOString(), extensions: await discoverExtensions() };
338
+ const previous = await ctx.store.latestExtensions();
339
+ if (!previous) {
340
+ await ctx.store.saveExtensions(current);
341
+ return { count: current.extensions.length, baseline: true };
342
+ }
343
+ const report = diffExtensions(previous, current);
344
+ if (report.identical)
345
+ return { count: current.extensions.length, baseline: false };
346
+ await ctx.store.saveExtensions(current);
347
+ const file = await ctx.store.saveReport('extensions', previous.takenAt.slice(0, 10), current.takenAt.slice(0, 10), 'md', renderMarkdown(report));
348
+ return { count: current.extensions.length, baseline: false, report, file };
349
+ }
350
+ function describeExtensionCheck(r) {
351
+ if (r.baseline)
352
+ return `Saved a baseline of ${r.count} extension${r.count === 1 ? '' : 's'}; the next check reports what changes.`;
353
+ return r.report ? `Extensions: ${r.report.counts.high} high, ${r.report.counts.notable} notable, ${r.report.counts.info} info.` : `No changes in ${r.count} extension${r.count === 1 ? '' : 's'}.`;
354
+ }
355
+ async function cmdExtensions(ctx, args) {
356
+ const [action, ...rest] = args;
357
+ if (rest.length || (action && action !== 'check' && action !== 'list'))
358
+ throw new UsageError('extensions takes `list` (default) or `check`.');
359
+ if (action !== 'check') {
360
+ const list = await discoverExtensions();
361
+ ctx.io.out(ctx.format === 'json' ? renderJson(list) : renderExtensions(list, { color: ctx.io.color }));
362
+ return 0;
363
+ }
364
+ const r = await checkExtensions(ctx);
365
+ if (ctx.format === 'json') {
366
+ ctx.io.out(renderJson({ count: r.count, baseline: r.baseline, ...(r.report ? { changes: filterBySeverity(r.report, ctx.minSeverity), reportFile: r.file } : {}) }));
367
+ return 0;
368
+ }
369
+ if (r.report) {
370
+ ctx.io.out(renderText(filterBySeverity(r.report, ctx.minSeverity), { color: ctx.io.color, full: ctx.full }));
371
+ ctx.io.out(`Report: ${r.file}\n\n`);
372
+ }
373
+ ctx.io.out(`${describeExtensionCheck(r)}\n`);
374
+ return 0;
375
+ }
376
+ async function cmdCheck(ctx, args, notifyEnabled, settle, withExtensions) {
313
377
  if (args.length)
314
378
  throw new UsageError('check takes no app names; use --apps-dir to choose folders.');
315
379
  let apps = await discoverApps(ctx.appsDirs);
@@ -326,12 +390,14 @@ async function cmdCheck(ctx, args, notifyEnabled, settle) {
326
390
  }
327
391
  const baseline = results.filter((r) => r.status === 'new');
328
392
  const failed = results.filter((r) => r.status === 'failed');
393
+ const ext = withExtensions ? await checkExtensions(ctx) : undefined;
329
394
  if (ctx.format === 'json') {
330
395
  ctx.io.out(renderJson({
331
396
  checked: apps.length,
332
397
  baseline: baseline.map((r) => r.snapshot.app.bundleId),
333
398
  failed: failed.map((r) => ({ app: r.app, error: r.error })),
334
399
  changed: reports.map(({ report, file }) => ({ ...filterBySeverity(report, ctx.minSeverity), reportFile: file })),
400
+ ...(ext ? { extensions: { count: ext.count, baseline: ext.baseline, ...(ext.report ? { changes: filterBySeverity(ext.report, ctx.minSeverity), reportFile: ext.file } : {}) } } : {}),
335
401
  }));
336
402
  }
337
403
  else {
@@ -347,6 +413,12 @@ async function cmdCheck(ctx, args, notifyEnabled, settle) {
347
413
  parts.push(`${failed.length} failed (${failed.map((f) => basename(f.app)).join(', ')})`);
348
414
  ctx.io.out(`${parts.join(', ')}.\n`);
349
415
  }
416
+ if (ext?.report) {
417
+ ctx.io.out('\n' + renderText(filterBySeverity(ext.report, ctx.minSeverity), { color: ctx.io.color, full: ctx.full }));
418
+ ctx.io.out(`Report: ${ext.file}\n`);
419
+ }
420
+ if (ext && (!ctx.quiet || ext.report))
421
+ ctx.io.out(`${describeExtensionCheck(ext)}\n`);
350
422
  }
351
423
  if (notifyEnabled) {
352
424
  for (const { report } of reports) {
@@ -354,7 +426,80 @@ async function cmdCheck(ctx, args, notifyEnabled, settle) {
354
426
  const summary = high || notable ? `${high} high, ${notable} notable change${high + notable === 1 ? '' : 's'}` : 'No notable changes';
355
427
  await notify(`Updiff: ${report.app.name} ${report.to.version}`, `${summary}. Run: updiff diff "${report.app.bundleId}"`);
356
428
  }
429
+ if (ext?.report && (ext.report.counts.high || ext.report.counts.notable)) {
430
+ await notify('Updiff: extensions changed', `${ext.report.counts.high} high, ${ext.report.counts.notable} notable. Report: ${ext.file}`);
431
+ }
432
+ }
433
+ return 0;
434
+ }
435
+ async function cmdFetch(ctx, args, opts) {
436
+ if (args.length < 1 || args.length > 2)
437
+ throw new UsageError('fetch needs an app (or cask) and optionally a version.');
438
+ const [target, wanted] = args;
439
+ const installed = await findAppByName(target, ctx.appsDirs);
440
+ const identity = installed ? await readIdentity(installed) : undefined;
441
+ const candidates = opts.cask ? [opts.cask] : [target, ...(identity ? [identity.name, basename(installed, '.app')] : [])];
442
+ const log = (s) => {
443
+ if (!ctx.quiet)
444
+ ctx.io.err(`${s}\n`);
445
+ };
446
+ const info = await resolveCask(candidates);
447
+ log(`Reading the history of the Homebrew cask "${info.token}"…`);
448
+ const { releases } = await caskHistory(info.token, { info });
449
+ if (!releases.length)
450
+ throw new UsageError(`Found no releases in the history of the ${info.token} cask.`);
451
+ if (opts.list) {
452
+ const rows = releases.map((r) => ({
453
+ version: r.version,
454
+ date: r.date.slice(0, 10),
455
+ checksum: r.sha256 ? 'pinned' : 'not pinned',
456
+ url: r.url ?? '(derived from the current URL)',
457
+ installed: sameVersion(r.version, identity?.version),
458
+ }));
459
+ if (ctx.format === 'json')
460
+ ctx.io.out(renderJson(rows));
461
+ else {
462
+ const w = Math.max(...rows.map((r) => r.version.length), 7);
463
+ for (const r of rows)
464
+ ctx.io.out(`${r.version.padEnd(w)} ${r.date} ${r.checksum.padEnd(10)} ${r.installed ? 'installed' : ''}\n`);
465
+ }
466
+ return 0;
467
+ }
468
+ const release = pickRelease(releases, wanted, identity?.version);
469
+ const fetched = await fetchRelease(info, release, { scan: ctx.scan, log });
470
+ const file = await ctx.store.save(fetched.snapshot);
471
+ log(`Stored ${fetched.snapshot.app.name} ${fetched.snapshot.app.version} as a snapshot (${file}).`);
472
+ if (!installed) {
473
+ log(`${info.name} isn't installed here, so there's nothing to compare it with yet.`);
474
+ return 0;
475
+ }
476
+ if (identity && identity.bundleId !== fetched.snapshot.app.bundleId) {
477
+ ctx.io.err(`Warning: the cask's app (${fetched.snapshot.app.bundleId}) isn't the installed app (${identity.bundleId}); check --cask.\n`);
478
+ }
479
+ log(`Comparing with the installed ${identity?.name ?? basename(installed)} ${identity?.version ?? ''}…\n`);
480
+ await emit(ctx, diffSnapshots(fetched.snapshot, await scanBundle(installed, ctx.scan)));
481
+ return 0;
482
+ }
483
+ async function cmdPkg(ctx, args) {
484
+ if (args.length < 1 || args.length > 2)
485
+ throw new UsageError('pkg needs an installer package, or two to compare.');
486
+ for (const a of args)
487
+ if (!existsSync(a))
488
+ throw new UsageError(`Can't find "${a}".`);
489
+ const reports = await Promise.all(args.map((a) => inspectPkg(resolve(a))));
490
+ if (reports.length === 2) {
491
+ // Arguments are new then old, matching "what does this update change".
492
+ await emit(ctx, diffPkgs(reports[1], reports[0]));
493
+ return 0;
494
+ }
495
+ const r = reports[0];
496
+ const text = ctx.format === 'json' ? renderJson(r) : ctx.format === 'md' ? renderPkgMarkdown(r) : renderPkg(r, { color: ctx.io.color && !ctx.out, full: ctx.full });
497
+ if (ctx.out) {
498
+ await writeFile(ctx.out, text);
499
+ ctx.io.err(`Wrote ${ctx.out}\n`);
357
500
  }
501
+ else
502
+ ctx.io.out(text);
358
503
  return 0;
359
504
  }
360
505
  async function cmdList(ctx, args) {
@@ -473,7 +618,7 @@ export async function main(argv, io) {
473
618
  io: { ...io, color: io.color && !v['no-color'] && format === 'text' },
474
619
  store: new Store(v.home ? resolve(v.home) : undefined),
475
620
  appsDirs: (v['apps-dir'] ?? defaultAppsDirs()).map((d) => resolve(d)),
476
- scan: { deep: v.deep === true },
621
+ scan: { deep: v.deep === true, notarization: !v['no-gatekeeper'] },
477
622
  format,
478
623
  minSeverity,
479
624
  out: v.out,
@@ -490,9 +635,15 @@ export async function main(argv, io) {
490
635
  case 'diff':
491
636
  return await cmdDiff(ctx, args);
492
637
  case 'check':
493
- return await cmdCheck(ctx, args, v.notify === true, intOption(v.settle, 'settle', 0));
638
+ return await cmdCheck(ctx, args, v.notify === true, intOption(v.settle, 'settle', 0), v.extensions === true);
639
+ case 'extensions':
640
+ return await cmdExtensions(ctx, args);
494
641
  case 'list':
495
642
  return await cmdList(ctx, args);
643
+ case 'pkg':
644
+ return await cmdPkg(ctx, args);
645
+ case 'fetch':
646
+ return await cmdFetch(ctx, args, { cask: v.cask, list: v.list === true });
496
647
  case 'agent':
497
648
  return await cmdAgent(ctx, args, { interval: Math.max(600, intOption(v.interval, 'interval', 21600)), dryRun: v['dry-run'] === true, load: !v['no-load'] });
498
649
  default:
@@ -504,7 +655,7 @@ export async function main(argv, io) {
504
655
  io.err(`${err.message}\n`);
505
656
  return 2;
506
657
  }
507
- io.err(`updiff: ${err.stack ?? err}\n`);
658
+ io.err(`updiff: ${process.env.UPDIFF_DEBUG ? (err.stack ?? err) : (err.message ?? err)}\n`);
508
659
  return 1;
509
660
  }
510
661
  }
package/dist/diff.js CHANGED
@@ -398,6 +398,84 @@ function diffInfo(a, b, out) {
398
398
  out.push({ severity: 'info', category: 'identity', change: 'changed', title: `Minimum macOS ${ia.minimumSystemVersion ?? '?'} → ${ib.minimumSystemVersion}`, where: ['Contents/Info.plist'] });
399
399
  }
400
400
  }
401
+ /** Apple has vouched for it: notarized, or distributed by Apple (App Store, system). */
402
+ const isApproved = (source) => /notarized|mac app store|apple system/i.test(source ?? '');
403
+ function diffNotarization(a, b, out) {
404
+ const na = a.notarization;
405
+ const nb = b.notarization;
406
+ // Older snapshots (and scans with notarization off) have nothing to compare.
407
+ if (!na || !nb)
408
+ return;
409
+ const where = [b.app.path];
410
+ if (na.assessment === 'accepted' && nb.assessment === 'unknown' && nb.error) {
411
+ out.push({
412
+ severity: 'high',
413
+ category: 'signing',
414
+ change: 'changed',
415
+ title: "Gatekeeper can no longer verify the app's signature",
416
+ detail: nb.error,
417
+ where,
418
+ });
419
+ }
420
+ else if (na.assessment === 'accepted' && nb.assessment === 'rejected') {
421
+ out.push({
422
+ severity: 'high',
423
+ category: 'signing',
424
+ change: 'changed',
425
+ title: 'Gatekeeper now rejects the app',
426
+ detail: `${na.source ?? 'accepted'} → rejected`,
427
+ where,
428
+ });
429
+ }
430
+ else if (isApproved(na.source) && !isApproved(nb.source) && nb.assessment !== 'unknown') {
431
+ out.push({
432
+ severity: 'high',
433
+ category: 'signing',
434
+ change: 'removed',
435
+ title: 'No longer notarized by Apple',
436
+ detail: `${na.source} → ${nb.source ?? nb.assessment}`,
437
+ where,
438
+ });
439
+ }
440
+ else if (na.source && nb.source && na.source !== nb.source) {
441
+ out.push({ severity: 'notable', category: 'signing', change: 'changed', title: 'Distribution changed', detail: `${na.source} → ${nb.source}`, where });
442
+ }
443
+ if (na.stapled === true && nb.stapled === false) {
444
+ out.push({
445
+ severity: 'info',
446
+ category: 'signing',
447
+ change: 'removed',
448
+ title: 'Notarization ticket no longer stapled',
449
+ detail: 'Gatekeeper checks it online instead; first launch offline may fail.',
450
+ where,
451
+ });
452
+ }
453
+ else if (na.stapled === false && nb.stapled === true) {
454
+ out.push({ severity: 'info', category: 'signing', change: 'added', title: 'Notarization ticket now stapled', where });
455
+ }
456
+ }
457
+ function diffSparkleCode(a, b, out) {
458
+ const fa = a.info.sparkleFeedsInCode ?? [];
459
+ const fb = b.info.sparkleFeedsInCode ?? [];
460
+ const added = fb.filter((u) => !fa.includes(u));
461
+ const removed = fa.filter((u) => !fb.includes(u));
462
+ if (added.length && fa.length) {
463
+ out.push({
464
+ severity: 'notable',
465
+ category: 'updater',
466
+ change: 'changed',
467
+ title: 'Update feed in the code changed',
468
+ detail: `${removed.length ? `${removed.join(', ')} → ` : ''}${added.join(', ')}`,
469
+ where: [b.app.path],
470
+ });
471
+ }
472
+ else if (added.length) {
473
+ out.push({ severity: 'notable', category: 'updater', change: 'added', title: 'Update feed set in the code', detail: added.join(', '), where: [b.app.path] });
474
+ }
475
+ else if (removed.length) {
476
+ out.push({ severity: 'info', category: 'updater', change: 'removed', title: 'Update feed no longer in the code', detail: removed.join(', '), where: [b.app.path] });
477
+ }
478
+ }
401
479
  function diffElectron(a, b, out) {
402
480
  const pa = a.electron?.packages ?? {};
403
481
  const pb = b.electron?.packages ?? {};
@@ -478,9 +556,9 @@ function diffHosts(a, b, out) {
478
556
  out.push({ severity: 'info', category: 'host', change: 'removed', title: `${gone.length} domain${gone.length === 1 ? '' : 's'} no longer mentioned`, detail: gone.join(', ') });
479
557
  }
480
558
  }
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',
559
+ export const SEVERITY_ORDER = { high: 0, notable: 1, info: 2 };
560
+ export const CATEGORY_ORDER = [
561
+ 'signing', 'privacy', 'permission', 'entitlement', 'background', 'script', 'install', 'updater', 'network', 'code', 'urlScheme', 'electron', 'package', 'host', 'identity',
484
562
  ];
485
563
  export function diffSnapshots(a, b) {
486
564
  const findings = [];
@@ -491,6 +569,16 @@ export function diffSnapshots(a, b) {
491
569
  diffCode(a, b, findings);
492
570
  diffBackground(a.backgroundItems, b.backgroundItems, findings);
493
571
  diffElectron(a, b, findings);
572
+ diffNotarization(a, b, findings);
573
+ diffSparkleCode(a, b, findings);
574
+ if (!a.info.appStoreReceipt !== !b.info.appStoreReceipt) {
575
+ findings.push({
576
+ severity: 'notable',
577
+ category: 'identity',
578
+ change: b.info.appStoreReceipt ? 'added' : 'removed',
579
+ title: b.info.appStoreReceipt ? 'Now distributed through the Mac App Store' : 'No longer a Mac App Store build',
580
+ });
581
+ }
494
582
  diffHosts(a, b, findings);
495
583
  const substantive = findings.length;
496
584
  if (a.app.version !== b.app.version || a.app.build !== b.app.build) {
@@ -507,6 +595,13 @@ export function diffSnapshots(a, b) {
507
595
  const counts = { high: 0, notable: 0, info: 0 };
508
596
  for (const f of findings)
509
597
  counts[f.severity]++;
598
+ const notes = [];
599
+ const encrypted = [...new Set([...(a.encryptedBinaries ?? []), ...(b.encryptedBinaries ?? [])])];
600
+ if (encrypted.length) {
601
+ notes.push(`Encrypted binaries (an iPhone or iPad app) can't be searched for hostnames: ${encrypted.slice(0, 3).join(', ')}${encrypted.length > 3 ? '…' : ''}.`);
602
+ }
603
+ if (!a.notarization !== !b.notarization)
604
+ notes.push('Notarization was only checked on one side, so it was not compared.');
510
605
  return {
511
606
  app: { name: b.app.name, bundleId: b.app.bundleId },
512
607
  from: { version: a.app.version, build: a.app.build, takenAt: a.takenAt, path: a.app.path },
@@ -514,6 +609,7 @@ export function diffSnapshots(a, b) {
514
609
  findings,
515
610
  counts,
516
611
  identical: substantive === 0 && a.fingerprint === b.fingerprint,
612
+ ...(notes.length ? { notes } : {}),
517
613
  };
518
614
  }
519
615
  export function filterBySeverity(report, min) {