@pipobscure/bundle 0.0.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/HISTORY.md +1924 -0
- package/README.md +623 -0
- package/bundle.run +0 -0
- package/dist/api.d.ts +147 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +174 -0
- package/dist/api.js.map +1 -0
- package/dist/archive.d.ts +115 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +188 -0
- package/dist/archive.js.map +1 -0
- package/dist/audit.d.ts +78 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +119 -0
- package/dist/audit.js.map +1 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +555 -0
- package/dist/cli.js.map +1 -0
- package/dist/files.d.ts +53 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +118 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/launch.d.ts +97 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +267 -0
- package/dist/launch.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +19 -0
- package/dist/main.js.map +1 -0
- package/dist/manifest.d.ts +139 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +504 -0
- package/dist/manifest.js.map +1 -0
- package/dist/oidc.d.ts +40 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +320 -0
- package/dist/oidc.js.map +1 -0
- package/dist/preload.d.ts +14 -0
- package/dist/preload.d.ts.map +1 -0
- package/dist/preload.js +38 -0
- package/dist/preload.js.map +1 -0
- package/dist/provider.d.ts +83 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +206 -0
- package/dist/provider.js.map +1 -0
- package/dist/record.d.ts +2 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +23 -0
- package/dist/record.js.map +1 -0
- package/dist/recorder.d.ts +64 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +111 -0
- package/dist/recorder.js.map +1 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +28 -0
- package/dist/register.js.map +1 -0
- package/dist/sea.d.ts +97 -0
- package/dist/sea.d.ts.map +1 -0
- package/dist/sea.js +220 -0
- package/dist/sea.js.map +1 -0
- package/dist/sigstore.d.ts +112 -0
- package/dist/sigstore.d.ts.map +1 -0
- package/dist/sigstore.js +385 -0
- package/dist/sigstore.js.map +1 -0
- package/dist/skill.d.ts +36 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +108 -0
- package/dist/skill.js.map +1 -0
- package/package.json +84 -0
- package/shell-base +2 -0
- package/skills/audit-bundle/SKILL.md +271 -0
- package/src/api.ts +293 -0
- package/src/archive.ts +312 -0
- package/src/audit.ts +206 -0
- package/src/cli.ts +575 -0
- package/src/files.ts +156 -0
- package/src/index.ts +114 -0
- package/src/launch.ts +336 -0
- package/src/main.ts +20 -0
- package/src/manifest.ts +615 -0
- package/src/oidc.ts +372 -0
- package/src/preload.ts +40 -0
- package/src/provider.ts +270 -0
- package/src/record.ts +25 -0
- package/src/recorder.ts +166 -0
- package/src/register.ts +30 -0
- package/src/sea.ts +341 -0
- package/src/sigstore.ts +492 -0
- package/src/skill.ts +132 -0
- package/src/types/node-vfs.d.ts +90 -0
- package/src/types/node-zip.d.ts +85 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: audit-bundle
|
|
3
|
+
description: Verify, extract, and security-review a bundle archive (.bundle, app.run, a SEA, or any archive built by @pipobscure/bundle). Use in two situations: before SIGNING a bundle you just built — the step between `bundle create` and `bundle sign` — and before RUNNING or shipping one you received. Triggers on "audit/review/vet/inspect/check this bundle", "is this safe to sign", "is this safe to run", "what is in this bundle", or diffing one against a previously approved version.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Auditing a bundle
|
|
7
|
+
|
|
8
|
+
A signature answers *who produced these bytes*. It does not answer *are these bytes
|
|
9
|
+
safe*. Those are different questions, and the second one is why this skill exists: every
|
|
10
|
+
significant npm compromise of recent years shipped a correctly published, correctly
|
|
11
|
+
signed package from a legitimately compromised account. Provenance would have confirmed
|
|
12
|
+
it came from the real maintainer, and been useless.
|
|
13
|
+
|
|
14
|
+
What makes the second question tractable here is that a bundle is a **closed set**.
|
|
15
|
+
Nothing resolves later, nothing is fetched at install, no lifecycle script pulls in more
|
|
16
|
+
code. Unlike a review of a dependency tree, a review over a bundle can actually be
|
|
17
|
+
complete — so aim for completeness, and say so in the report.
|
|
18
|
+
|
|
19
|
+
## The same review, at two points
|
|
20
|
+
|
|
21
|
+
This is one task run at either end of the same life-cycle, and the checklist below does
|
|
22
|
+
not change between them:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
observe a run → bundle create → AUDIT → bundle sign → … ship … → AUDIT → bundle run
|
|
26
|
+
↑ ↑
|
|
27
|
+
before you sign it before you run it
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Before signing.** The archive is one you just built and are about to put your name on.
|
|
31
|
+
A signature is a claim about bytes you stand behind, so the standard is the one you would
|
|
32
|
+
hold someone else's bundle to — that is the whole point of caring what you publish.
|
|
33
|
+
|
|
34
|
+
**Before running.** The archive arrived from somewhere else. Same review, and provenance
|
|
35
|
+
is now part of the picture too.
|
|
36
|
+
|
|
37
|
+
Only two things differ, and both are noted where they arise: what the verification states
|
|
38
|
+
*mean* (an unsigned archive is expected in the first case and a red flag in the second),
|
|
39
|
+
and how the verdict is phrased (*should this be signed* against *should this run*). Ask
|
|
40
|
+
which one you are in if it is not obvious from how you were called; if nobody says, assume
|
|
41
|
+
before-running, which is the stricter reading.
|
|
42
|
+
|
|
43
|
+
Work in three phases, in order. Do not reorder them and do not skip phase 1.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Phase 1 — Verify (mandatory)
|
|
48
|
+
|
|
49
|
+
An audit that silently reviewed a tampered archive would be worse than no audit, so
|
|
50
|
+
this phase gates the rest. It is mandatory in both situations: pre-signing, it is what
|
|
51
|
+
proves the archive on disk is the one the build just wrote and that its member digests
|
|
52
|
+
are internally consistent.
|
|
53
|
+
|
|
54
|
+
Find the CLI. Installed, it is `bundle`; without an install, `npx @pipobscure/bundle`
|
|
55
|
+
works; in a checkout of the tool itself it is `node dist/main.js` (after `npm run build`).
|
|
56
|
+
Then:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
bundle verify --json <archive>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Read the `state` field and act on it:
|
|
63
|
+
|
|
64
|
+
| `state` | Exit | Before signing | Before running |
|
|
65
|
+
|---|---|---|---|
|
|
66
|
+
| `unsigned` | 3 | **Expected. Continue, no warning.** This is what `bundle create` produces and what you are here to clear. | **Continue, with a prominent warning.** Nothing attests to origin at all. |
|
|
67
|
+
| `valid` | 0 | Already signed. Say so and ask whether re-signing is intended, then continue. | Continue. Record the identity. |
|
|
68
|
+
| `valid-untrusted` | 1 | Already signed, by someone you cannot place. Same question, then continue. | **Continue, with a prominent warning.** The bytes are intact and genuinely signed; you just cannot place the signer. Reviewing exactly this case is the useful one. |
|
|
69
|
+
| `invalid` | 2 | **Stop.** The archive does not hold together; rebuild it rather than review it. | **Stop.** Report the reason and go no further. The member list, the content, or the signature has been altered since signing. There is nothing worth reviewing, because what you would review is not what was signed. |
|
|
70
|
+
|
|
71
|
+
Record from the JSON, for the report:
|
|
72
|
+
|
|
73
|
+
- `identity` and `issuer` — for a sigstore signature, this is the real answer to "who
|
|
74
|
+
signed this". A GitHub Actions identity looks like
|
|
75
|
+
`https://github.com/OWNER/REPO/.github/workflows/release.yml@refs/heads/main`.
|
|
76
|
+
- `subject` — the certificate subject, for an archive signed against an ordinary CA.
|
|
77
|
+
- `signedAt` — when, per the transparency log.
|
|
78
|
+
- `members` — the full file list. **This is the review's scope.** Nothing outside it can run.
|
|
79
|
+
|
|
80
|
+
If the state is `valid-untrusted` with a reason mentioning the sigstore trust root, run
|
|
81
|
+
`bundle trust` once and re-verify — that is a missing local cache, not a problem with
|
|
82
|
+
the archive.
|
|
83
|
+
|
|
84
|
+
For an unsigned archive there is no `identity` or `signedAt` to record; `members` is
|
|
85
|
+
still there, and it is still the review's scope.
|
|
86
|
+
|
|
87
|
+
## Phase 2 — Extract
|
|
88
|
+
|
|
89
|
+
It is a real ZIP, so this needs no special tooling. Extract to a scratch directory
|
|
90
|
+
outside the user's project:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
unzip -o <archive> -d <scratch>/extracted
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A prefixed archive (`app.run`, or a SEA binary) extracts correctly too — the offsets
|
|
97
|
+
are absolute, which is the point of building it that way. `unzip` echoes the EOCD
|
|
98
|
+
comment, so the `SIGNED:<hash>:<signature>` marker scrolls past first; that is the
|
|
99
|
+
signature itself, not output to act on. Some `unzip` builds additionally warn about
|
|
100
|
+
leading bytes before the archive. Neither is a finding.
|
|
101
|
+
|
|
102
|
+
Then reconcile:
|
|
103
|
+
|
|
104
|
+
- `AUTHORITY.PEM` is the manifest — algorithms and the signing chain. Read its first
|
|
105
|
+
few lines. It is *not* application code; exclude it from the review but confirm the
|
|
106
|
+
identity it names matches what phase 1 reported.
|
|
107
|
+
- Compare the extracted file list against `members` from phase 1. They must match. A
|
|
108
|
+
file on disk that is not in the signed member list is a finding in itself.
|
|
109
|
+
|
|
110
|
+
## Phase 3 — Review every file
|
|
111
|
+
|
|
112
|
+
Read **every** extracted file. The set is finite and closed — that is the whole
|
|
113
|
+
advantage — so do not sample, and do not stop at the entry point. Use `grep` to
|
|
114
|
+
triage and prioritise, never as the review itself; obfuscated code is specifically
|
|
115
|
+
designed to survive a grep.
|
|
116
|
+
|
|
117
|
+
Hunt for these, which is what the recent supply-chain attacks actually did:
|
|
118
|
+
|
|
119
|
+
**Runs at load time.** Anything executing on import rather than on call: top-level
|
|
120
|
+
side effects, install/lifecycle hooks, `postinstall`-style scripts in `package.json`,
|
|
121
|
+
self-invoking functions. A bundle has no install step, so a lifecycle hook here is
|
|
122
|
+
either dead weight or an attempt to run something the reviewer did not expect.
|
|
123
|
+
|
|
124
|
+
**Obfuscated, minified, or encoded payloads.** Long base64 or hex string literals,
|
|
125
|
+
`Buffer.from(..., 'base64')` feeding execution, hex-escaped identifiers, deeply
|
|
126
|
+
mangled names in a file that is otherwise source, unusually long single lines. Ask
|
|
127
|
+
why any of it is in a bundle whose file list was produced by observation.
|
|
128
|
+
|
|
129
|
+
**Outbound network calls.** `fetch`, `http`/`https`, `net`, `dgram`, WebSocket. Weigh
|
|
130
|
+
each against what the module is for: a network call in an argument parser or a date
|
|
131
|
+
formatter is the signal. Note every destination host, and flag hardcoded IPs, raw
|
|
132
|
+
`.onion`/dynamic-DNS hosts, and URLs assembled from fragments at runtime.
|
|
133
|
+
|
|
134
|
+
**Credential and environment reads.** `process.env` (especially broad enumeration
|
|
135
|
+
rather than named lookups), `~/.aws`, `~/.ssh`, `~/.npmrc`, `.git-credentials`,
|
|
136
|
+
keychains, browser profile and cookie stores, cryptocurrency wallet paths.
|
|
137
|
+
|
|
138
|
+
**CI and cloud metadata endpoints.** `169.254.169.254`, `metadata.google.internal`,
|
|
139
|
+
`ACTIONS_ID_TOKEN_REQUEST_URL`, `GITHUB_TOKEN`, `NPM_TOKEN`, `AWS_*`. A bundle
|
|
140
|
+
reaching for a CI token is exfiltration until proven otherwise.
|
|
141
|
+
|
|
142
|
+
**Indirection that hides intent.** `child_process` (`exec`, `spawn`, `execSync`),
|
|
143
|
+
`eval`, `new Function`, dynamic `require`/`import` with a computed specifier,
|
|
144
|
+
`process.binding`, prototype mutation of built-ins, monkey-patching `fs` or `http`.
|
|
145
|
+
|
|
146
|
+
**Files nothing references.** Cross-check the member list against what is actually
|
|
147
|
+
imported from the entry point. A member no code reaches is not automatically
|
|
148
|
+
malicious — data files and assets are normal — but it is worth naming, because the
|
|
149
|
+
member list came from observing a real run and anything unreferenced did not come
|
|
150
|
+
from that observation.
|
|
151
|
+
|
|
152
|
+
**Anything at odds with the stated purpose.** The strongest signal is usually not a
|
|
153
|
+
single dangerous call but a mismatch: a module whose name and documented job do not
|
|
154
|
+
explain what its code does.
|
|
155
|
+
|
|
156
|
+
## The report
|
|
157
|
+
|
|
158
|
+
Write it as prose with a short table, not a wall of findings. Cover, in this order:
|
|
159
|
+
|
|
160
|
+
1. **Verification** — state, identity, issuer, signing time. Before running, lead with a
|
|
161
|
+
clear warning if the archive was unsigned or untrusted. Before signing, say plainly
|
|
162
|
+
that it is unsigned because it has not been signed yet, and do not dress that up as a
|
|
163
|
+
finding.
|
|
164
|
+
2. **Scope** — how many files, how many lines, and the explicit claim that every one
|
|
165
|
+
was read. Name anything excluded and why.
|
|
166
|
+
3. **Findings** — each with the file and line (`path/to/file.js:42`), what the code
|
|
167
|
+
does, and why it is or is not a concern. Order by severity. Distinguish
|
|
168
|
+
*confirmed malicious*, *suspicious and worth an explanation*, and *benign but
|
|
169
|
+
notable*. Do not pad the list to look thorough; an empty findings section over a
|
|
170
|
+
genuinely clean bundle is a good result, and say so plainly.
|
|
171
|
+
4. **Verdict** — a direct answer to the question the caller actually has. Before signing,
|
|
172
|
+
that is *should this be signed*: say **sign** or **do not sign**, and if the latter,
|
|
173
|
+
say what to change and rebuild rather than what risk to accept — an unsigned archive
|
|
174
|
+
costs nothing to throw away, which is exactly why this is the cheap place to catch
|
|
175
|
+
things. Before running, it is *should this run*, with the residual risk stated. Either
|
|
176
|
+
way, name the bundle's whole-file hash (`bundle verify --json` reports it once signed;
|
|
177
|
+
before that, `sha256sum` the file) so the verdict is pinned to the bytes it was reached
|
|
178
|
+
over and cannot be quietly carried to a later build.
|
|
179
|
+
|
|
180
|
+
Be specific about what an audit of this kind cannot tell you: it is a review of source
|
|
181
|
+
that will run with the full authority of the process. Verification is provenance, not
|
|
182
|
+
confinement — a mounted bundle is not sandboxed, and nothing here limits what the code
|
|
183
|
+
can do once it starts.
|
|
184
|
+
|
|
185
|
+
## A verdict something else can gate on
|
|
186
|
+
|
|
187
|
+
When the caller asks for a machine-readable result — or when `BUNDLE_AUDIT_VERDICT` names
|
|
188
|
+
a path in the environment, which is how CI asks — write the verdict there as JSON, *in
|
|
189
|
+
addition to* the prose report. A build step reads it and refuses to sign or ship on
|
|
190
|
+
anything but a clean result.
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"bundle": "build/cli.bundle",
|
|
195
|
+
"sha256": "<sha256sum of the archive file, lower-case hex>",
|
|
196
|
+
"baseline": "build/baseline.bundle",
|
|
197
|
+
"baselineSha256": "<sha256sum of the archive it was reviewed against>",
|
|
198
|
+
"mode": "sign",
|
|
199
|
+
"state": "unsigned",
|
|
200
|
+
"identity": null,
|
|
201
|
+
"members": 679,
|
|
202
|
+
"reviewed": 679,
|
|
203
|
+
"verdict": "pass",
|
|
204
|
+
"summary": "One sentence. What was reviewed and what was concluded.",
|
|
205
|
+
"findings": [
|
|
206
|
+
{ "severity": "high", "file": "node_modules/x/index.js", "line": 42,
|
|
207
|
+
"what": "reads process.env wholesale and POSTs it to a hardcoded IP",
|
|
208
|
+
"why": "no plausible reason in a module that formats dates" }
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Rules for the file, because a gate is only as good as what it refuses:
|
|
214
|
+
|
|
215
|
+
- **`sha256` is mandatory and is the sha256 of the archive file itself**, not the
|
|
216
|
+
`SIGNED:` hash. It is what pins the verdict to the bytes you actually reviewed; a
|
|
217
|
+
consumer must re-hash the file and refuse a verdict that does not match.
|
|
218
|
+
- **`mode`** is `"sign"` or `"run"` — which of the two questions you answered.
|
|
219
|
+
- **`verdict`** is `"pass"` or `"fail"`. Nothing else. Write `"fail"` whenever you would
|
|
220
|
+
not sign or would not run it, including when you could not complete the review.
|
|
221
|
+
- **`findings`** is every finding, `[]` when there are none. `severity` is `high`,
|
|
222
|
+
`medium`, `low`, or `note`. Anything above `note` means the verdict is `"fail"` unless
|
|
223
|
+
you explain in `summary` why a specific finding is understood and accepted — do not
|
|
224
|
+
quietly downgrade a severity to reach a pass.
|
|
225
|
+
- **`baseline` / `baselineSha256`** are present only when the review was a diff, and then
|
|
226
|
+
they name the archive it was against. Omitting them says the review was of everything;
|
|
227
|
+
a consumer that fetched a baseline will treat a verdict without them as a full review
|
|
228
|
+
and one with the *wrong* hash as stale, so do not guess at either.
|
|
229
|
+
- **`reviewed`** is how many members you actually read. In diff mode that is the changed
|
|
230
|
+
set, not the whole archive, and `summary` should say which — an audit that reviewed 12
|
|
231
|
+
of 679 members is the right outcome for a small release and the wrong one if nobody
|
|
232
|
+
knows that is what happened.
|
|
233
|
+
- Write the file **last**, after the prose report, and only once. If phase 1 stopped the
|
|
234
|
+
audit, still write it, with `verdict: "fail"` and the reason in `summary`.
|
|
235
|
+
|
|
236
|
+
## Diff mode
|
|
237
|
+
|
|
238
|
+
When there is a previously approved archive, review only what changed. This is the
|
|
239
|
+
realistic repeat-use case and it is more valuable than a fresh full review, not less: a
|
|
240
|
+
full re-read of an unchanged dependency tree every release is the kind of review that
|
|
241
|
+
quietly decays into a rubber stamp, while a small diff gets read properly.
|
|
242
|
+
|
|
243
|
+
It is also the default in this project's release pipeline, where the baseline is the
|
|
244
|
+
**currently published release**, already verified as genuinely signed by the release
|
|
245
|
+
workflow. That matters: the diff is only meaningful if the thing you are diffing against
|
|
246
|
+
is the thing it claims to be, because everything the baseline already contained reads as
|
|
247
|
+
"unchanged" and is therefore not read at all.
|
|
248
|
+
|
|
249
|
+
Verify **both** archives first — a baseline that is `invalid`, or `unsigned` when it
|
|
250
|
+
should not be, is a reason to stop and review everything instead. Then extract both and:
|
|
251
|
+
|
|
252
|
+
```sh
|
|
253
|
+
diff -ru <scratch>/baseline <scratch>/candidate
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Report on the changed files with the full checklist above, and state explicitly which
|
|
257
|
+
files were unchanged and therefore carried over. Call out every member that was **added**
|
|
258
|
+
or **removed** — a changed file list deserves scrutiny even when each individual diff
|
|
259
|
+
looks harmless, and an added member is the cheapest place for something to arrive
|
|
260
|
+
unnoticed.
|
|
261
|
+
|
|
262
|
+
Two things a diff cannot tell you, so say so rather than implying otherwise:
|
|
263
|
+
|
|
264
|
+
- An unchanged member is only as trustworthy as the review that cleared it last time.
|
|
265
|
+
A diff inherits every earlier verdict.
|
|
266
|
+
- A member removed from the archive is not a member removed from the world; check that
|
|
267
|
+
nothing left behind still references it.
|
|
268
|
+
|
|
269
|
+
Record `baseline` and `baselineSha256` in the verdict so the review is pinned to the
|
|
270
|
+
comparison it actually made, and set `reviewed` to the number of members you read rather
|
|
271
|
+
than the number in the archive.
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import * as FS from 'node:fs';
|
|
2
|
+
import * as PATH from 'node:path';
|
|
3
|
+
import * as ZLIB from 'node:zlib';
|
|
4
|
+
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
|
|
5
|
+
import type { Writable } from 'node:stream';
|
|
6
|
+
import { bundle, rebundle, keySigner, members, type EmitResult, type Signer } from './archive.ts';
|
|
7
|
+
import {
|
|
8
|
+
verify, verifySync, signatureOf, parseManifest, AUTHORITY,
|
|
9
|
+
type VerificationResult, type VerifyOptions, type ArchiveSource, type ManifestFields,
|
|
10
|
+
} from './manifest.ts';
|
|
11
|
+
|
|
12
|
+
// The programmatic face of the tool: bundling, signing, verifying and running,
|
|
13
|
+
// with the file plumbing that the CLI would otherwise be the only user of.
|
|
14
|
+
//
|
|
15
|
+
// Everything here is a thin arrangement of `archive.ts` and `manifest.ts`. What
|
|
16
|
+
// it adds is that a caller says where the output goes rather than building a
|
|
17
|
+
// `Writable` and remembering to wait for it to flush, and that the functions
|
|
18
|
+
// the CLI calls are exactly the ones an embedder gets — `cli.ts` holds no logic
|
|
19
|
+
// of its own beyond argument parsing, for that reason.
|
|
20
|
+
|
|
21
|
+
/** What a build wrote, and what it signed. */
|
|
22
|
+
export interface BuildResult extends EmitResult {
|
|
23
|
+
/** Where the archive was written, or null when it went to a stream. */
|
|
24
|
+
output: string | null;
|
|
25
|
+
/** Member names in the finished archive, excluding `AUTHORITY.PEM`. */
|
|
26
|
+
members: string[];
|
|
27
|
+
/** Size of the written file in bytes, when it went to a path. */
|
|
28
|
+
size?: number | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where a build's bytes go: a path, a caller's stream, or stdout. */
|
|
32
|
+
interface Destination {
|
|
33
|
+
/** Where to write the archive. */
|
|
34
|
+
output?: string | undefined;
|
|
35
|
+
/** An open stream to write to instead. The caller closes it. */
|
|
36
|
+
stream?: Writable | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CreateOptions extends Destination {
|
|
40
|
+
/** Base directory the file list is relative to (default: '.'). */
|
|
41
|
+
base?: string | undefined;
|
|
42
|
+
/** Member names, relative to `base`. */
|
|
43
|
+
files: string[];
|
|
44
|
+
/** A launcher or binary to prepend, making the result self-running. */
|
|
45
|
+
prefix?: string | undefined;
|
|
46
|
+
hashAlg?: string | undefined;
|
|
47
|
+
signAlg?: string | undefined;
|
|
48
|
+
/** Sign as it is built. Both must be given together. */
|
|
49
|
+
key?: Buffer | string | undefined;
|
|
50
|
+
chain?: string | undefined;
|
|
51
|
+
/** A two-phase signer, instead of `key`/`chain`. */
|
|
52
|
+
signer?: Signer | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SignOptions extends Destination {
|
|
56
|
+
/** Path to the archive whose members are re-emitted and signed. */
|
|
57
|
+
source: string;
|
|
58
|
+
prefix?: string | undefined;
|
|
59
|
+
/** Make the output executable; implied by `prefix`. */
|
|
60
|
+
executable?: boolean | undefined;
|
|
61
|
+
hashAlg?: string | undefined;
|
|
62
|
+
signAlg?: string | undefined;
|
|
63
|
+
key?: Buffer | string | undefined;
|
|
64
|
+
chain?: string | undefined;
|
|
65
|
+
signer?: Signer | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface VerifyBundleOptions extends VerifyOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Extra trusted roots, as PEM text or as paths to PEM files — the
|
|
71
|
+
* convenience form of `extraRoots`, which takes PEM text only.
|
|
72
|
+
*/
|
|
73
|
+
roots?: string[] | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RunOptions {
|
|
77
|
+
/** Extra trusted roots, as PEM text or paths to PEM files. */
|
|
78
|
+
roots?: string[] | undefined;
|
|
79
|
+
identity?: string | undefined;
|
|
80
|
+
issuer?: string | undefined;
|
|
81
|
+
/** Run an archive whose signature is good but whose chain is unanchored. */
|
|
82
|
+
allowUntrusted?: boolean | undefined;
|
|
83
|
+
/** Arguments handed to the application inside the archive. */
|
|
84
|
+
args?: string[] | undefined;
|
|
85
|
+
/** Extra environment for the child, merged over `process.env`. */
|
|
86
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
87
|
+
/** How the child's stdio is wired (default: 'inherit'). */
|
|
88
|
+
stdio?: 'inherit' | 'pipe' | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RunResult {
|
|
92
|
+
/** The child's exit status, or null when it was killed by a signal. */
|
|
93
|
+
status: number | null;
|
|
94
|
+
signal: NodeJS.Signals | null;
|
|
95
|
+
/** Captured only when `stdio` was 'pipe'. */
|
|
96
|
+
stdout?: string | undefined;
|
|
97
|
+
stderr?: string | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What an archive says about itself, with no trust decision attached. */
|
|
101
|
+
export interface Inspection {
|
|
102
|
+
/** Member names, excluding `AUTHORITY.PEM`. */
|
|
103
|
+
members: string[];
|
|
104
|
+
/** Whether the archive carries a signature marker at all. */
|
|
105
|
+
signed: boolean;
|
|
106
|
+
/** The whole-file hash the marker records, hex. */
|
|
107
|
+
hash?: string | undefined;
|
|
108
|
+
/** Names of the unsigned attributes carried beside the signature. */
|
|
109
|
+
fields: string[];
|
|
110
|
+
/** The manifest's declared algorithms and certificate chain. */
|
|
111
|
+
manifest?: ManifestFields | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build an archive from a base directory and a list of files, optionally
|
|
116
|
+
* signing it and optionally prepending a launcher or binary.
|
|
117
|
+
*/
|
|
118
|
+
export async function createBundle(options: CreateOptions): Promise<BuildResult> {
|
|
119
|
+
const { base = '.', files, prefix, hashAlg, signAlg, key, chain, signer } = options;
|
|
120
|
+
if (!files.length) throw new Error('create: the file list is empty');
|
|
121
|
+
if (!signer && Boolean(key) !== Boolean(chain)) throw new Error('create: key and chain must be given together');
|
|
122
|
+
|
|
123
|
+
return await produce(options, Boolean(prefix), (out) => bundle({
|
|
124
|
+
base, files, prefix, hashAlg, signAlg, key, chain, signer, out,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sign an existing archive into a new file. The input is never modified: its
|
|
130
|
+
* members are read out, laid down again behind whatever prefix was asked for,
|
|
131
|
+
* and the finished bytes are hashed and signed as a whole. One unsigned archive
|
|
132
|
+
* therefore yields every shape — a `#!` launcher, a self-contained binary, or a
|
|
133
|
+
* plain mountable archive — each correctly offset and each signed over itself.
|
|
134
|
+
*/
|
|
135
|
+
export async function signBundle(options: SignOptions): Promise<BuildResult> {
|
|
136
|
+
const { source, output, prefix, executable, hashAlg, signAlg, key, chain, signer } = options;
|
|
137
|
+
if (!source) throw new Error('sign: an archive path is required');
|
|
138
|
+
if (!signer && Boolean(key) !== Boolean(chain)) throw new Error('sign: key and chain must be given together');
|
|
139
|
+
if (output && PATH.resolve(output) === PATH.resolve(source)) {
|
|
140
|
+
throw new Error('sign: the output must differ from the input archive');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return await produce(options, Boolean(prefix || executable), (out) => rebundle({
|
|
144
|
+
source, prefix, hashAlg, signAlg, key, chain, signer, out,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A signer backed by a private key and certificate chain read from disk. */
|
|
149
|
+
export function fileSigner({ key, chain, signAlg = 'sha256' }: {
|
|
150
|
+
key: string;
|
|
151
|
+
chain: string;
|
|
152
|
+
signAlg?: string | undefined;
|
|
153
|
+
}): Signer {
|
|
154
|
+
return keySigner({ key: FS.readFileSync(key), chain: FS.readFileSync(chain, 'utf-8'), signAlg });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Verify an archive: recompute the whole-file hash, check the signature over it
|
|
159
|
+
* against the leaf certificate, check every member's own digest, and decide
|
|
160
|
+
* whether the certificate chain means anything to us.
|
|
161
|
+
*/
|
|
162
|
+
export async function verifyBundle(source: ArchiveSource, options?: VerifyBundleOptions): Promise<VerificationResult> {
|
|
163
|
+
return verify(source, withRoots(options));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The synchronous form, for callers on a path that cannot await — a mount. */
|
|
167
|
+
export function verifyBundleSync(source: ArchiveSource, options?: VerifyBundleOptions): VerificationResult {
|
|
168
|
+
return verifySync(source, withRoots(options));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What an archive claims about itself — its members, whether it is signed at
|
|
173
|
+
* all, and what its manifest declares. This is the cheap "what am I looking at"
|
|
174
|
+
* call; `verifyBundle` is the expensive one that answers whether any of it is
|
|
175
|
+
* true, and nothing here should be believed until it has run.
|
|
176
|
+
*/
|
|
177
|
+
export function inspectBundle(source: string): Inspection {
|
|
178
|
+
const marker = signatureOf(source);
|
|
179
|
+
const names = members(source);
|
|
180
|
+
let manifest: ManifestFields | undefined;
|
|
181
|
+
const zip = ZLIB.ZipFile.openSync(PATH.resolve(source));
|
|
182
|
+
try {
|
|
183
|
+
if (zip.has(AUTHORITY)) manifest = parseManifest(zip.getSync(AUTHORITY).contentSync());
|
|
184
|
+
} finally {
|
|
185
|
+
zip.closeSync();
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
members: names,
|
|
189
|
+
signed: marker !== null,
|
|
190
|
+
hash: marker?.hash,
|
|
191
|
+
fields: marker ? [...marker.fields.keys()] : [],
|
|
192
|
+
manifest,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Mount a signed archive and run the application inside it, in a child process
|
|
198
|
+
* with the verifying provider preloaded — so the archive is checked and mounted
|
|
199
|
+
* by the child's own bootstrap, and what runs is what was verified.
|
|
200
|
+
*
|
|
201
|
+
* Verification happens here too, before the child is spawned. That buys nothing
|
|
202
|
+
* the child does not already enforce; it buys a legible refusal instead of an
|
|
203
|
+
* uncaught error thrown from inside node's startup.
|
|
204
|
+
*/
|
|
205
|
+
export function runBundle(archive: string, options: RunOptions = {}): RunResult {
|
|
206
|
+
const roots = options.roots ?? [];
|
|
207
|
+
const res = verifyBundleSync(archive, {
|
|
208
|
+
roots, deep: false, identity: options.identity, issuer: options.issuer,
|
|
209
|
+
});
|
|
210
|
+
const acceptable = res.state === 'valid' || (Boolean(options.allowUntrusted) && res.state === 'valid-untrusted');
|
|
211
|
+
if (!acceptable) {
|
|
212
|
+
throw Object.assign(new Error(`refusing to run '${archive}': ${res.state} — ${res.reason}`),
|
|
213
|
+
{ code: 'ERR_BUNDLE_UNTRUSTED', state: res.state });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const env: NodeJS.ProcessEnv = { ...process.env, ...options.env };
|
|
217
|
+
if (roots.length) env['BUNDLE_ROOTS'] = roots.join(PATH.delimiter);
|
|
218
|
+
if (options.identity) env['BUNDLE_IDENTITY'] = options.identity;
|
|
219
|
+
if (options.issuer) env['BUNDLE_ISSUER'] = options.issuer;
|
|
220
|
+
if (options.allowUntrusted) env['BUNDLE_ALLOW_UNTRUSTED'] = '1';
|
|
221
|
+
|
|
222
|
+
const child: SpawnSyncReturns<string> = spawnSync(
|
|
223
|
+
process.execPath,
|
|
224
|
+
[...mountArgv(archive), ...(options.args ?? [])],
|
|
225
|
+
{ stdio: options.stdio ?? 'inherit', env, encoding: 'utf-8' },
|
|
226
|
+
);
|
|
227
|
+
if (child.error) throw child.error;
|
|
228
|
+
return { status: child.status, signal: child.signal, stdout: child.stdout, stderr: child.stderr };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The node arguments that mount `archive` as the filesystem and run the program
|
|
233
|
+
* inside it, with this package's verifying provider preloaded. Anything after
|
|
234
|
+
* these is the application's own argv — the trailing `--` is what makes that
|
|
235
|
+
* true, since without it node claims any argument that looks like one of its
|
|
236
|
+
* own flags and the application never sees it.
|
|
237
|
+
*/
|
|
238
|
+
export function mountArgv(archive: string): string[] {
|
|
239
|
+
return [
|
|
240
|
+
'--no-warnings', '--experimental-vfs',
|
|
241
|
+
'-r', registerPath(),
|
|
242
|
+
'--vfs-load', archive,
|
|
243
|
+
'--',
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The absolute path of the preload that registers the verifying provider, as a
|
|
249
|
+
* real path `node -r` can resolve.
|
|
250
|
+
*/
|
|
251
|
+
export function registerPath(): string {
|
|
252
|
+
const ext = import.meta.filename.endsWith('.ts') ? '.ts' : '.js';
|
|
253
|
+
return PATH.join(PATH.dirname(import.meta.filename), `register${ext}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// `roots` may name PEM files or carry PEM text; `verifySync` wants text.
|
|
257
|
+
function withRoots(options: VerifyBundleOptions | undefined): VerifyOptions {
|
|
258
|
+
if (!options) return {};
|
|
259
|
+
const { roots, ...rest } = options;
|
|
260
|
+
if (!roots?.length) return rest;
|
|
261
|
+
const loaded = roots.map((root) => (root.includes('-----BEGIN') ? root : FS.readFileSync(root, 'utf-8')));
|
|
262
|
+
return { ...rest, extraRoots: [...(rest.extraRoots ?? []), ...loaded] };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Open the destination, run the build into it, wait for the bytes to land, and
|
|
266
|
+
// report what was written. A stream the caller supplied is left open — its
|
|
267
|
+
// lifetime is theirs — while one opened here is closed and waited on. stdout is
|
|
268
|
+
// ended (so a redirect sees EOF) but not awaited for 'finish', which never
|
|
269
|
+
// fires for a TTY or a pipe.
|
|
270
|
+
async function produce(
|
|
271
|
+
{ output, stream }: Destination,
|
|
272
|
+
executable: boolean,
|
|
273
|
+
build: (out: Writable) => Promise<EmitResult>,
|
|
274
|
+
): Promise<BuildResult> {
|
|
275
|
+
const out = stream ?? (output ? FS.createWriteStream(output) : process.stdout);
|
|
276
|
+
const res = await build(out);
|
|
277
|
+
if (!stream) await close(out);
|
|
278
|
+
|
|
279
|
+
if (output && executable) FS.chmodSync(output, 0o755);
|
|
280
|
+
return {
|
|
281
|
+
...res,
|
|
282
|
+
output: output ?? null,
|
|
283
|
+
members: output ? members(output) : [],
|
|
284
|
+
size: output ? FS.statSync(output).size : undefined,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function close(out: Writable): Promise<void> {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
if (out === process.stdout) return void out.end(() => resolve());
|
|
291
|
+
out.on('error', reject).on('finish', () => resolve()).end();
|
|
292
|
+
});
|
|
293
|
+
}
|