@intincrab/updiff 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +356 -2
- package/dist/agent.js +63 -0
- package/dist/asar.js +91 -0
- package/dist/bundle.js +686 -0
- package/dist/cask-history.js +216 -0
- package/dist/cask.js +79 -0
- package/dist/cli.js +8 -0
- package/dist/codesign.js +103 -0
- package/dist/commands.js +661 -0
- package/dist/diff.js +621 -0
- package/dist/exec.js +19 -0
- package/dist/extensions.js +453 -0
- package/dist/fetch.js +81 -0
- package/dist/hosts.js +137 -0
- package/dist/installer.js +177 -0
- package/dist/macho.js +35 -0
- package/dist/pkg-report.js +211 -0
- package/dist/pkg.js +211 -0
- package/dist/plist.js +196 -0
- package/dist/report.js +153 -0
- package/dist/store.js +113 -0
- package/dist/types.js +1 -0
- package/dist/util.js +73 -0
- package/dist/version.js +2 -0
- package/package.json +55 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ajay Chauhan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,357 @@
|
|
|
1
|
-
#
|
|
1
|
+
# updiff
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**See what a macOS app update actually changed: in the app, not in the changelog.**
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
[](https://github.com/intincrab/updiff/actions/workflows/ci.yml)
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<img src="docs/demo.svg" width="860" alt="A terminal runs updiff snapshot; the app Lumen then updates to 4.3.0, and updiff check reports 4 high findings (a new camera prompt, the camera entitlement, a background agent that starts at login, a login item) and 3 notable ones (an Electron upgrade, a new analytics package and a new domain).">
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
Release notes say "bug fixes and improvements". The update itself might ask for your
|
|
15
|
+
camera, register something that starts at login, ship a new helper that runs as root,
|
|
16
|
+
bundle a new analytics package, or start talking to a new service. updiff takes a
|
|
17
|
+
snapshot of each app and, after every update, shows you exactly what changed inside it.
|
|
18
|
+
|
|
19
|
+
It also covers what updates without asking: **browser and editor extensions**, and
|
|
20
|
+
**installer packages** you're about to run.
|
|
21
|
+
|
|
22
|
+
- **Read-only.** It never runs the app, an installer or its scripts, and never changes them.
|
|
23
|
+
- **Local.** Snapshots and reports stay in `~/.updiff`. Nothing is uploaded. Only `fetch`
|
|
24
|
+
downloads anything, and Gatekeeper may ask Apple whether an app is notarized.
|
|
25
|
+
- **Fast.** The first snapshot of 28 apps takes about 40 s on an Apple Silicon Mac (11 s
|
|
26
|
+
with `--no-gatekeeper`); later checks skip unchanged apps and take about 0.1 s.
|
|
27
|
+
|
|
28
|
+
## What it looks like
|
|
29
|
+
|
|
30
|
+
A real update, Google Chrome 154 → 156, checked with `--deep`:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
$ updiff check --deep
|
|
34
|
+
Google Chrome 154.0.8037.58 (8037.58) → 156.0.8078.80 (8078.80)
|
|
35
|
+
com.google.Chrome
|
|
36
|
+
0 high · 2 notable · 6 info
|
|
37
|
+
|
|
38
|
+
NOTABLE
|
|
39
|
+
+ New domain chromestorage.goog
|
|
40
|
+
chromestorage.goog
|
|
41
|
+
at Contents/Frameworks/Google Chrome Framework.framework/Versions/156.0.8078.80/Google Chrome Framework
|
|
42
|
+
+ New domain perplexity.ai
|
|
43
|
+
frontend-cdn.perplexity.ai, suggest.perplexity.ai, www.perplexity.ai
|
|
44
|
+
at Contents/Frameworks/Google Chrome Framework.framework/Versions/156.0.8078.80/Google Chrome Framework
|
|
45
|
+
|
|
46
|
+
INFO
|
|
47
|
+
~ Google Chrome Framework.framework 154.0.8037.58 → 156.0.8078.80
|
|
48
|
+
+ New host under chromium.org
|
|
49
|
+
g-issues.chromium.org
|
|
50
|
+
+ New host under google.com
|
|
51
|
+
groups.google.com
|
|
52
|
+
+ New host under googleapis.com
|
|
53
|
+
federatedcompute-pa.googleapis.com
|
|
54
|
+
…
|
|
55
|
+
|
|
56
|
+
Report: ~/.updiff/reports/com.google.Chrome/…__154.0.8037.58__to__156.0.8078.80.md
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A quiet update looks like that. A worrying one looks like this. This is the "update" the
|
|
60
|
+
test suite builds, trimmed:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
Fixture 1.0.0 (100) → 2.0.0 (200)
|
|
64
|
+
com.example.updiff-fixture
|
|
65
|
+
6 high · 6 notable · 2 info
|
|
66
|
+
|
|
67
|
+
HIGH
|
|
68
|
+
- App dropped the hardened runtime
|
|
69
|
+
- Helper app Fixture Helper.app dropped the hardened runtime
|
|
70
|
+
+ Can now ask for access to Camera
|
|
71
|
+
NSCameraUsageDescription: “Fixture can now see you.”
|
|
72
|
+
+ App can now use the camera
|
|
73
|
+
com.apple.security.device.camera = true
|
|
74
|
+
+ New background agent: com.example.updiff-fixture.agent.plist
|
|
75
|
+
label com.example.updiff-fixture.agent · runs Contents/MacOS/Fixture · starts at login · kept alive
|
|
76
|
+
+ New login item: FixtureLauncher.app
|
|
77
|
+
|
|
78
|
+
NOTABLE
|
|
79
|
+
+ New native Node module: fastnative.node
|
|
80
|
+
+ Opens fixture-auth:// links
|
|
81
|
+
~ Electron 33.2.0 → 35.1.0
|
|
82
|
+
+ 1 new bundled npm package
|
|
83
|
+
ai-plugin@0.3.0
|
|
84
|
+
+ New domain newvendor.io
|
|
85
|
+
native.newvendor.io, telemetry.newvendor.io
|
|
86
|
+
at …/fastnative.node, Contents/Resources/app.asar:dist/ai.js
|
|
87
|
+
…
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`+` added, `-` removed, `~` changed. In a terminal, high findings are red, notable
|
|
91
|
+
findings yellow and info cyan.
|
|
92
|
+
|
|
93
|
+
## Install
|
|
94
|
+
|
|
95
|
+
With Homebrew:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
brew install intincrab/tap/updiff
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
With npm, or without installing at all (macOS, Node.js 20 or newer). npm doesn't allow
|
|
102
|
+
the plain name `updiff` (it is too close to the existing `diff` package), so the package
|
|
103
|
+
lives under the `@intincrab` scope; the command is still `updiff`:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm install -g @intincrab/updiff
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npx @intincrab/updiff scan Slack
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
From source:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
git clone https://github.com/intincrab/updiff.git
|
|
117
|
+
cd updiff
|
|
118
|
+
npm install && npm run build && npm link
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Quick start
|
|
122
|
+
|
|
123
|
+
Take a baseline of every app, then check after updates:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
updiff snapshot # once, before the next round of updates
|
|
127
|
+
updiff check # after updating: shows what changed in each updated app
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Or let it check by itself:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
updiff agent install # runs `updiff check` whenever your app folders change
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The agent waits until an update has finished writing, then posts a notification titled
|
|
137
|
+
"Updiff: Google Chrome 156.0.8078.80" that says "0 high, 2 notable changes". The full
|
|
138
|
+
report is saved in `~/.updiff/reports/`. It also checks your browser and editor extensions.
|
|
139
|
+
|
|
140
|
+
No baseline yet? Compare an installed app with its previous release, downloaded from
|
|
141
|
+
Homebrew's history and checked against the checksum Homebrew recorded:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
updiff fetch Ghostty # Ghostty 1.3.0 → 1.3.1: 0 high · 0 notable · 2 info
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Before running an installer, see what it would do:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
updiff pkg ~/Downloads/Installer.pkg
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
And see what your extensions can reach, or what changed since last time:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
updiff extensions
|
|
157
|
+
updiff extensions check
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Commands
|
|
161
|
+
|
|
162
|
+
| Command | What it does |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `updiff scan <app>` | Summarise one app: signing, sandbox, entitlements, privacy prompts, nested code, background items, Electron and hostnames. Stores nothing. |
|
|
165
|
+
| `updiff snapshot [app…]` | Store a snapshot of each app, or of every app when none are named. Unchanged apps are skipped. |
|
|
166
|
+
| `updiff check` | Snapshot every app that changed since its last snapshot and print what's different. Reports are saved as Markdown. |
|
|
167
|
+
| `updiff diff <app>` | If the installed app changed since its newest snapshot, compare the two; otherwise compare its two newest snapshots. |
|
|
168
|
+
| `updiff diff <a> <b>` | Compare any two things: `.app` bundles, snapshot `.json` files or stored apps. Good for a downloaded beta against the installed version. |
|
|
169
|
+
| `updiff list [app]` | List stored apps, or one app's snapshots. |
|
|
170
|
+
| `updiff fetch <app> [version]` | Download an earlier release from the app's Homebrew cask history, verify it against the checksum Homebrew pinned, store it as a snapshot and compare it with the installed app. With no version, the release before the installed one. `--list` shows the history. |
|
|
171
|
+
| `updiff pkg <installer.pkg> [older.pkg]` | Show what an installer package would do before you run it: scripts with their text, installer JavaScript, apps, launch daemons and agents (including ones apps register from inside their bundle), privileged helpers, system extensions, everything written outside /Applications, signature and Gatekeeper's verdict. With two packages, what changed. Nothing is installed or run. |
|
|
172
|
+
| `updiff extensions [check]` | List browser and editor extensions with what they can reach. `check` reports what changed since the last check. |
|
|
173
|
+
| `updiff agent install \| uninstall \| status` | Manage the LaunchAgent that runs `check` after updates. |
|
|
174
|
+
|
|
175
|
+
`<app>` can be a path (`/Applications/Slack.app`), a name from your Applications folders
|
|
176
|
+
(`Slack`, `"Google Chrome"`) or a stored bundle id (`com.tinyspeck.slackmacgap`).
|
|
177
|
+
|
|
178
|
+
## Options
|
|
179
|
+
|
|
180
|
+
| Option | Applies to | Effect |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `--format text\|md\|json` | all | Output format. JSON is for scripts. |
|
|
183
|
+
| `--min-severity high\|notable\|info` | `diff`, `check` | Hide less important findings. |
|
|
184
|
+
| `--out <file>` | `scan`, `diff` | Write the result to a file. |
|
|
185
|
+
| `--deep` | scanning | Also search framework binaries for hostnames. Needed for apps like Chrome that keep their code in a framework. |
|
|
186
|
+
| `--full` | text output | Don't shorten long details. |
|
|
187
|
+
| `--force` | `snapshot` | Store a snapshot even if nothing changed. |
|
|
188
|
+
| `--apps-dir <dir>` | all | Where to look for apps. Repeatable; the default is `/Applications` and `~/Applications`. |
|
|
189
|
+
| `--home <dir>` | all | Data folder. Default `~/.updiff`, or `$UPDIFF_HOME`. |
|
|
190
|
+
| `--keep <n>` | `snapshot`, `check` | Snapshots kept per app (default 10). |
|
|
191
|
+
| `--notify` | `check` | Post a notification for each updated app. |
|
|
192
|
+
| `--extensions` | `check` | Also check browser and editor extensions (the agent does). |
|
|
193
|
+
| `--no-gatekeeper` | scanning | Skip the Gatekeeper/notarization check (faster). |
|
|
194
|
+
| `--cask <name>` | `fetch` | The Homebrew cask, when it isn't the app's name. |
|
|
195
|
+
| `--list` | `fetch` | List the releases in the cask's history. |
|
|
196
|
+
| `--settle <seconds>` | `check` | Wait until no app has changed for this long before scanning. |
|
|
197
|
+
| `--quiet`, `--no-color` | all | Print less; plain text. `NO_COLOR` is respected too. |
|
|
198
|
+
| `--interval <seconds>` | `agent install` | Fallback timer between checks (default 21600, i.e. 6 hours). |
|
|
199
|
+
| `--dry-run` | `agent install` | Print the LaunchAgent without installing it. |
|
|
200
|
+
| `--no-load` | `agent install`, `agent uninstall` | Write or remove the file but don't call `launchctl`. |
|
|
201
|
+
|
|
202
|
+
Exit codes: `0` success, `2` usage error (unknown app, bad option), `1` unexpected error.
|
|
203
|
+
|
|
204
|
+
## What it checks
|
|
205
|
+
|
|
206
|
+
| | High | Notable | Info |
|
|
207
|
+
|---|---|---|---|
|
|
208
|
+
| **Signing** | different developer team; no longer developer-signed; hardened runtime removed; no longer notarized; Gatekeeper rejects it or can't verify its signature | signing certificate changed; distribution changed (Developer ID ↔ App Store) | hardened runtime added; notarization ticket stapled or unstapled |
|
|
209
|
+
| **Entitlements**, for every executable in the bundle | camera, microphone, location, contacts, calendars, photos, Apple Events, USB, Bluetooth, library validation off, debugging, Endpoint Security, network extensions, sandbox exceptions; sandbox removed | any other new entitlement; new values in app groups, keychain groups or associated domains | removals |
|
|
210
|
+
| **Privacy prompts** (`NS…UsageDescription`) | a new prompt | | reworded or removed prompts |
|
|
211
|
+
| **Background** | new LaunchAgent or LaunchDaemon, login item, privileged helper, system extension | changed background item | removals |
|
|
212
|
+
| **Updater** (Sparkle) | update feed moved; update signing key changed | feed added or removed; feed set in the code changed | |
|
|
213
|
+
| **Network** | App Transport Security allows plain HTTP | new ATS exception domains | |
|
|
214
|
+
| **Code** | | new helper apps, XPC services, app extensions, plug-ins, executables, native Node modules, frameworks | framework versions; removals |
|
|
215
|
+
| **Electron** | | Electron version; new bundled npm packages | package version changes and removals |
|
|
216
|
+
| **Hostnames** | | new domains | new hosts under domains already present; domains no longer mentioned |
|
|
217
|
+
| **URL schemes** | | new `scheme://` handlers | removals |
|
|
218
|
+
| **Identity** | the two sides are different apps | Dock icon shown or hidden; moved to or from the Mac App Store | version, minimum macOS, size |
|
|
219
|
+
| **Browser extensions** | access to all websites; sensitive APIs (cookies, history, webRequest, debugger, nativeMessaging, proxy…); a different update server; a new extension with access to all websites | other new permissions; access to specific new sites; a new extension | low-reach permissions (storage, alarms…); optional permissions; removals; versions |
|
|
220
|
+
| **Editor extensions** (VS Code, Cursor, Windsurf, VSCodium) | starts installing other extensions | now starts with every window; new npm packages; new native code; new domains in the code; a new extension | removals; versions |
|
|
221
|
+
|
|
222
|
+
Gatekeeper's verdict comes from `spctl`, which may contact Apple for apps without a
|
|
223
|
+
stapled notarization ticket. Sparkle feeds are read from `Info.plist` and, for apps that
|
|
224
|
+
set them in code (Ghostty, ChatGPT), from URLs in the binary; `scan` also shows this Mac's
|
|
225
|
+
update settings for the app. Binaries that are FairPlay-encrypted (iPhone and iPad apps)
|
|
226
|
+
are flagged, because their strings can't be read. Mac App Store apps for macOS are not
|
|
227
|
+
encrypted, so their hostnames are read like any other app's.
|
|
228
|
+
|
|
229
|
+
Code inside a bundle is matched between versions by its path and, if that fails, by its
|
|
230
|
+
bundle id. So a renamed helper isn't reported as removed plus added, and Chrome's
|
|
231
|
+
`Versions/154…` → `Versions/156…` framework folders count as the same thing.
|
|
232
|
+
|
|
233
|
+
## About hostnames
|
|
234
|
+
|
|
235
|
+
updiff finds hostnames in URLs (`https://`, `wss://`, …) and in quoted strings like
|
|
236
|
+
`"events.vendor.io"`. It looks in executables, native modules, Electron `app.asar`
|
|
237
|
+
archives and text resources, and reports them grouped by domain.
|
|
238
|
+
|
|
239
|
+
> **A hostname in the code is not proof that the app connects to it.** It shows what the
|
|
240
|
+
> code mentions: API endpoints, links, sometimes dead code. Treat a new domain as a lead
|
|
241
|
+
> and confirm it with a network monitor such as Little Snitch or LuLu.
|
|
242
|
+
|
|
243
|
+
To keep reports readable, it ignores:
|
|
244
|
+
- source maps, READMEs, licences and the `package.json` files inside `node_modules`
|
|
245
|
+
- XML namespaces and reserved names (`www.w3.org`, `example.com`, `localhost`, `*.test`)
|
|
246
|
+
- bare strings whose ending reads like a file type (`Foo.app`, `README.md`, `build.sh`)
|
|
247
|
+
- any file with more than 1,000 bare hostnames, which is a bundled list rather than code
|
|
248
|
+
(Discord's main bundle carries one with about 6,100 entries). These are named in a
|
|
249
|
+
warning.
|
|
250
|
+
|
|
251
|
+
Framework binaries are only searched with `--deep`. Electron apps keep their own code in
|
|
252
|
+
`app.asar`, which is always searched.
|
|
253
|
+
|
|
254
|
+
## Where things are stored
|
|
255
|
+
|
|
256
|
+
```text
|
|
257
|
+
~/.updiff/
|
|
258
|
+
├── snapshots/<bundle id>/<time>__<version>__<build>.json one JSON file per snapshot
|
|
259
|
+
├── reports/<bundle id>/<time>__<from>__to__<to>.md one report per detected update
|
|
260
|
+
├── extensions/<time>.json installed extensions (last 10)
|
|
261
|
+
├── reports/extensions/<time>__<from>__to__<to>.md one report per extension change
|
|
262
|
+
└── agent.log output of background checks
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
A snapshot records a fingerprint of the app's code signature seal
|
|
266
|
+
(`_CodeSignature/CodeResources`), so an unchanged app is recognised without a rescan.
|
|
267
|
+
Delete the folder at any time to start over.
|
|
268
|
+
|
|
269
|
+
## Privacy and safety
|
|
270
|
+
|
|
271
|
+
- updiff reads files and runs Apple's own tools:
|
|
272
|
+
- `plutil` and `defaults` to read plists and an app's update settings
|
|
273
|
+
- `codesign`, `spctl` and `stapler` to read signatures, Gatekeeper's verdict and
|
|
274
|
+
notarization tickets
|
|
275
|
+
- `hdiutil` (read-only) and `pkgutil --expand-full` to open disk images and packages
|
|
276
|
+
- `osascript` for notifications, `launchctl` for the agent
|
|
277
|
+
- It never runs app code, installer scripts or installer JavaScript.
|
|
278
|
+
- Nothing it records leaves your Mac. `fetch` downloads from Homebrew, GitHub and the
|
|
279
|
+
app's vendor; `spctl` may ask Apple about notarization. Nothing else uses the network.
|
|
280
|
+
- The agent is a LaunchAgent in your user account (`dev.updiff.check`). It runs with your
|
|
281
|
+
permissions, at low priority. `updiff agent install --dry-run` shows exactly what it
|
|
282
|
+
installs; `updiff agent uninstall` removes it.
|
|
283
|
+
|
|
284
|
+
## Limitations
|
|
285
|
+
|
|
286
|
+
- **Static only.** Plugins, models or config that an app downloads after launch aren't in
|
|
287
|
+
the bundle, so updiff can't see them.
|
|
288
|
+
- **Hostnames are pattern-matched.** Hosts assembled at runtime (`"api." + region + ".x.com"`)
|
|
289
|
+
are missed, and a mentioned host is not necessarily contacted.
|
|
290
|
+
- **Baselines.** `check` compares with the last snapshot. For an app you never snapshotted,
|
|
291
|
+
`updiff fetch` gets its previous release, if the app is in Homebrew.
|
|
292
|
+
- **Update feeds built at runtime** (AltTab assembles its URL from pieces) can't be found;
|
|
293
|
+
`scan` says when Sparkle is bundled but no feed was found.
|
|
294
|
+
- **Not covered yet:** Safari web extensions' permissions, managed browser policies, and
|
|
295
|
+
extensions for JetBrains IDEs.
|
|
296
|
+
|
|
297
|
+
## The ledger
|
|
298
|
+
|
|
299
|
+
[`ledger/`](ledger) runs updiff every day on new releases of about 30 popular Mac apps
|
|
300
|
+
(Slack, Zoom, Cursor, VS Code, Postman, Chrome, Discord, …), found through their
|
|
301
|
+
Homebrew casks. It publishes what changed in each release as a static site with an Atom
|
|
302
|
+
feed. Reports are plain JSON in [`ledger/data`](ledger/data). See
|
|
303
|
+
[ledger/README.md](ledger/README.md) to run it or turn on the daily GitHub Action.
|
|
304
|
+
|
|
305
|
+
## How it works
|
|
306
|
+
|
|
307
|
+
Every scan, a few steps:
|
|
308
|
+
1. Read `Info.plist` with `plutil`.
|
|
309
|
+
2. Walk the bundle without following symlinks, and skip framework versions that
|
|
310
|
+
`Versions/Current` doesn't point to.
|
|
311
|
+
3. Classify nested code by type and location.
|
|
312
|
+
4. Read each item's signature and entitlements with `codesign`.
|
|
313
|
+
5. Read `app.asar` with a built-in parser, without extracting it.
|
|
314
|
+
6. Search text and the arm64 slice of each binary for hostnames.
|
|
315
|
+
|
|
316
|
+
The diff compares two snapshots and assigns each change a severity. The full design and
|
|
317
|
+
the reasoning behind the rules are in [docs/DESIGN.md](docs/DESIGN.md).
|
|
318
|
+
|
|
319
|
+
## Development
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
npm install
|
|
323
|
+
npm test # unit + integration tests
|
|
324
|
+
npm run typecheck
|
|
325
|
+
npm run build # compiles to dist/
|
|
326
|
+
npm start -- scan Ghostty # run from source (Node 22.18+ runs the TypeScript directly)
|
|
327
|
+
|
|
328
|
+
# re-record the README demo: a real run against a generated demo app, rendered to docs/demo.svg
|
|
329
|
+
node scripts/demo/record.ts && node scripts/demo/render-svg.ts
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The integration tests build two versions of a small Electron-style app with clang,
|
|
333
|
+
`@electron/asar` and `codesign`. They plant permission, background, package and network
|
|
334
|
+
changes, then check that each one is reported at the right severity, both through the
|
|
335
|
+
library and through the commands. Without a C compiler, the assertions about hostnames
|
|
336
|
+
inside binaries are skipped.
|
|
337
|
+
|
|
338
|
+
CI runs all of this on a macOS runner for every push, then installs the packed tarball
|
|
339
|
+
and runs it on Node 20, 22 and 24.
|
|
340
|
+
|
|
341
|
+
### Releasing
|
|
342
|
+
|
|
343
|
+
Bump the version in `package.json` and `src/version.ts` (a test checks they match),
|
|
344
|
+
commit, and push a matching tag:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
git tag v0.2.0 && git push origin v0.2.0
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The [release workflow](.github/workflows/release.yml) tests and packs updiff, creates a
|
|
351
|
+
GitHub Release with the tarball, and publishes it to npm with provenance. The
|
|
352
|
+
[Homebrew tap](https://github.com/intincrab/homebrew-tap) picks up the new release within
|
|
353
|
+
a few hours, or straight away if you run its workflow by hand.
|
|
354
|
+
|
|
355
|
+
## License
|
|
356
|
+
|
|
357
|
+
[MIT](LICENSE)
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir, userInfo } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { run } from "./exec.js";
|
|
6
|
+
import { toPlistXml } from "./plist.js";
|
|
7
|
+
export const AGENT_LABEL = 'dev.updiff.check';
|
|
8
|
+
export function launchAgentsDir() {
|
|
9
|
+
return process.env.UPDIFF_LAUNCH_AGENTS_DIR || join(homedir(), 'Library', 'LaunchAgents');
|
|
10
|
+
}
|
|
11
|
+
export function agentPlist(o) {
|
|
12
|
+
const args = [o.nodePath, o.cliPath, 'check', '--extensions', '--notify', '--quiet', '--settle', '90', '--home', o.home];
|
|
13
|
+
for (const d of o.appsDirs)
|
|
14
|
+
args.push('--apps-dir', d);
|
|
15
|
+
return toPlistXml({
|
|
16
|
+
Label: AGENT_LABEL,
|
|
17
|
+
ProgramArguments: args,
|
|
18
|
+
// Runs after anything changes in the app folders, and on a timer as a fallback.
|
|
19
|
+
WatchPaths: o.watchPaths,
|
|
20
|
+
StartInterval: o.intervalSeconds,
|
|
21
|
+
// An update writes many files; wait before running again.
|
|
22
|
+
ThrottleInterval: 120,
|
|
23
|
+
RunAtLoad: false,
|
|
24
|
+
ProcessType: 'Background',
|
|
25
|
+
LowPriorityIO: true,
|
|
26
|
+
Nice: 10,
|
|
27
|
+
StandardOutPath: join(o.home, 'agent.log'),
|
|
28
|
+
StandardErrorPath: join(o.home, 'agent.log'),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function domain() {
|
|
32
|
+
return `gui/${userInfo().uid}`;
|
|
33
|
+
}
|
|
34
|
+
export async function installAgent(o, load) {
|
|
35
|
+
const dir = launchAgentsDir();
|
|
36
|
+
await mkdir(dir, { recursive: true });
|
|
37
|
+
await mkdir(o.home, { recursive: true });
|
|
38
|
+
const path = join(dir, `${AGENT_LABEL}.plist`);
|
|
39
|
+
if (load && existsSync(path))
|
|
40
|
+
await run('/bin/launchctl', ['bootout', `${domain()}/${AGENT_LABEL}`]);
|
|
41
|
+
await writeFile(path, agentPlist(o));
|
|
42
|
+
if (!load)
|
|
43
|
+
return { path, loaded: false };
|
|
44
|
+
const res = await run('/bin/launchctl', ['bootstrap', domain(), path]);
|
|
45
|
+
return { path, loaded: res.code === 0, message: res.code === 0 ? undefined : res.stderr.trim() };
|
|
46
|
+
}
|
|
47
|
+
export async function uninstallAgent(unload) {
|
|
48
|
+
const path = join(launchAgentsDir(), `${AGENT_LABEL}.plist`);
|
|
49
|
+
const existed = existsSync(path);
|
|
50
|
+
if (unload)
|
|
51
|
+
await run('/bin/launchctl', ['bootout', `${domain()}/${AGENT_LABEL}`]);
|
|
52
|
+
await rm(path, { force: true });
|
|
53
|
+
return { path, existed };
|
|
54
|
+
}
|
|
55
|
+
export async function agentStatus() {
|
|
56
|
+
const path = join(launchAgentsDir(), `${AGENT_LABEL}.plist`);
|
|
57
|
+
const res = await run('/bin/launchctl', ['print', `${domain()}/${AGENT_LABEL}`]);
|
|
58
|
+
return { path, installed: existsSync(path), loaded: res.code === 0 };
|
|
59
|
+
}
|
|
60
|
+
export async function notify(title, message) {
|
|
61
|
+
const q = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
62
|
+
await run('/usr/bin/osascript', ['-e', `display notification ${q(message)} with title ${q(title)}`]);
|
|
63
|
+
}
|
package/dist/asar.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, readFileSync } from 'node:fs';
|
|
2
|
+
/**
|
|
3
|
+
* Reads Electron `.asar` archives without extracting them.
|
|
4
|
+
*
|
|
5
|
+
* Layout: an 8-byte pickle that holds the header size, then a pickle whose payload is
|
|
6
|
+
* a length-prefixed JSON header, then the file data. A file's `offset` counts from the
|
|
7
|
+
* start of the data. Files marked `unpacked` live in `<archive>.unpacked/`.
|
|
8
|
+
*/
|
|
9
|
+
export class AsarArchive {
|
|
10
|
+
path;
|
|
11
|
+
header;
|
|
12
|
+
dataOffset;
|
|
13
|
+
fd;
|
|
14
|
+
constructor(path, fd, header, dataOffset) {
|
|
15
|
+
this.path = path;
|
|
16
|
+
this.fd = fd;
|
|
17
|
+
this.header = header;
|
|
18
|
+
this.dataOffset = dataOffset;
|
|
19
|
+
}
|
|
20
|
+
static open(path) {
|
|
21
|
+
const fd = openSync(path, 'r');
|
|
22
|
+
try {
|
|
23
|
+
const sizeBuf = Buffer.alloc(8);
|
|
24
|
+
if (readSync(fd, sizeBuf, 0, 8, 0) !== 8)
|
|
25
|
+
throw new Error('File is too small to be an asar archive');
|
|
26
|
+
const headerPickleSize = sizeBuf.readUInt32LE(4);
|
|
27
|
+
if (headerPickleSize < 8 || headerPickleSize > 512 * 1024 * 1024)
|
|
28
|
+
throw new Error('Invalid asar header size');
|
|
29
|
+
const headerBuf = Buffer.alloc(headerPickleSize);
|
|
30
|
+
if (readSync(fd, headerBuf, 0, headerPickleSize, 8) !== headerPickleSize)
|
|
31
|
+
throw new Error('Truncated asar header');
|
|
32
|
+
const jsonLength = headerBuf.readUInt32LE(4);
|
|
33
|
+
if (jsonLength > headerPickleSize - 8)
|
|
34
|
+
throw new Error('Invalid asar header length');
|
|
35
|
+
const header = JSON.parse(headerBuf.toString('utf8', 8, 8 + jsonLength));
|
|
36
|
+
if (!header.files)
|
|
37
|
+
throw new Error('asar header has no files');
|
|
38
|
+
return new AsarArchive(path, fd, header, 8 + headerPickleSize);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
closeSync(fd);
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
*files() {
|
|
46
|
+
const stack = [['', this.header]];
|
|
47
|
+
while (stack.length) {
|
|
48
|
+
const [prefix, node] = stack.pop();
|
|
49
|
+
for (const [name, child] of Object.entries(node.files ?? {})) {
|
|
50
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
51
|
+
if (child.files)
|
|
52
|
+
stack.push([path, child]);
|
|
53
|
+
else if (child.link === undefined) {
|
|
54
|
+
yield {
|
|
55
|
+
path,
|
|
56
|
+
size: child.size ?? 0,
|
|
57
|
+
offset: Number(child.offset ?? 0),
|
|
58
|
+
unpacked: child.unpacked === true,
|
|
59
|
+
executable: child.executable === true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Reads a file's bytes, or the first `limit` bytes. */
|
|
66
|
+
read(file, limit = Infinity) {
|
|
67
|
+
const length = Math.min(file.size, limit);
|
|
68
|
+
if (file.unpacked) {
|
|
69
|
+
const unpackedPath = `${this.path}.unpacked/${file.path}`;
|
|
70
|
+
if (!existsSync(unpackedPath))
|
|
71
|
+
return Buffer.alloc(0);
|
|
72
|
+
const buf = readFileSync(unpackedPath);
|
|
73
|
+
return length < buf.length ? buf.subarray(0, length) : buf;
|
|
74
|
+
}
|
|
75
|
+
const buf = Buffer.alloc(length);
|
|
76
|
+
let read = 0;
|
|
77
|
+
while (read < length) {
|
|
78
|
+
const n = readSync(this.fd, buf, read, length - read, this.dataOffset + file.offset + read);
|
|
79
|
+
if (n === 0)
|
|
80
|
+
break;
|
|
81
|
+
read += n;
|
|
82
|
+
}
|
|
83
|
+
return read < length ? buf.subarray(0, read) : buf;
|
|
84
|
+
}
|
|
85
|
+
close() {
|
|
86
|
+
if (this.fd >= 0) {
|
|
87
|
+
closeSync(this.fd);
|
|
88
|
+
this.fd = -1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|