wuphf 0.34.3 → 0.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wuphf",
3
- "version": "0.34.3",
3
+ "version": "0.35.1",
4
4
  "description": "Open source Slack for AI agents. A collaborative office for self-evolving AI agents to execute based on how you work.",
5
5
  "bin": {
6
6
  "wuphf": "bin/wuphf.js"
@@ -4,14 +4,42 @@
4
4
  // from the corresponding GitHub release and extracts it into bin/.
5
5
  // GoReleaser archive name: wuphf_<version>_<os>_<arch>.tar.gz
6
6
  // where <version> is the tag without the leading 'v'.
7
+ //
8
+ // ---------------------------------------------------------------------------
9
+ // Integrity verification contract
10
+ // ---------------------------------------------------------------------------
11
+ // Every download is verified against the `checksums.txt` file published as a
12
+ // sibling asset on the same GitHub release. That file is produced by
13
+ // `goreleaser release` (see `.goreleaser.yml` `checksum.name_template`) and
14
+ // contains one line per archive in the format:
15
+ //
16
+ // <sha256-hex> <archive-filename>
17
+ //
18
+ // Verification flow:
19
+ // 1. Download the per-platform archive (wuphf_<ver>_<os>_<arch>.tar.gz).
20
+ // 2. Download checksums.txt from the same release.
21
+ // 3. Compute SHA256 of the downloaded archive locally.
22
+ // 4. Compare against the hash listed for that archive in checksums.txt.
23
+ // 5. If they differ, or checksums.txt is unreachable, or the archive is not
24
+ // listed in it: delete the archive and abort with a non-zero exit.
25
+ //
26
+ // This guards against release-asset tampering: even if a compromised release
27
+ // token replaces the tarball, the mismatch causes `npm install wuphf` to fail
28
+ // loudly rather than silently install a backdoored binary.
29
+ //
30
+ // To regenerate checksums.txt, run `goreleaser release` (or `goreleaser
31
+ // release --snapshot` for a dry run). Never hand-edit the published file.
32
+ // ---------------------------------------------------------------------------
7
33
 
8
34
  const fs = require("node:fs");
9
35
  const fsp = require("node:fs/promises");
10
36
  const path = require("node:path");
11
37
  const os = require("node:os");
38
+ const crypto = require("node:crypto");
12
39
  const { execFileSync } = require("node:child_process");
13
40
 
14
41
  const REPO = "nex-crm/wuphf";
42
+ const CHECKSUMS_FILENAME = "checksums.txt";
15
43
 
16
44
  function detectPlatform() {
17
45
  const platform = process.platform;
@@ -40,10 +68,13 @@ function packageVersion() {
40
68
  return pkg.version;
41
69
  }
42
70
 
43
- function archiveUrl(version) {
71
+ function archiveName(version) {
44
72
  const { os: goOs, arch: goArch } = detectPlatform();
45
- const archive = `wuphf_${version}_${goOs}_${goArch}.tar.gz`;
46
- return `https://github.com/${REPO}/releases/download/v${version}/${archive}`;
73
+ return `wuphf_${version}_${goOs}_${goArch}.tar.gz`;
74
+ }
75
+
76
+ function releaseAssetUrl(version, filename) {
77
+ return `https://github.com/${REPO}/releases/download/v${version}/${filename}`;
47
78
  }
48
79
 
49
80
  async function fetchToFile(url, dest) {
@@ -55,16 +86,93 @@ async function fetchToFile(url, dest) {
55
86
  await fsp.writeFile(dest, buf);
56
87
  }
57
88
 
89
+ async function fetchText(url) {
90
+ const res = await fetch(url, { redirect: "follow" });
91
+ if (!res.ok) {
92
+ throw new Error(`Download failed: ${res.status} ${res.statusText} (${url})`);
93
+ }
94
+ return res.text();
95
+ }
96
+
97
+ async function sha256OfFile(filePath) {
98
+ const hash = crypto.createHash("sha256");
99
+ const stream = fs.createReadStream(filePath);
100
+ for await (const chunk of stream) {
101
+ hash.update(chunk);
102
+ }
103
+ return hash.digest("hex");
104
+ }
105
+
106
+ // Parse a GoReleaser-style checksums.txt.
107
+ // Each non-empty line is: <sha256hex><whitespace><filename>
108
+ // We look up the filename (basename) and return the hex hash, or null.
109
+ function expectedHashFor(checksumsText, filename) {
110
+ const lines = checksumsText.split(/\r?\n/);
111
+ for (const line of lines) {
112
+ const trimmed = line.trim();
113
+ if (!trimmed || trimmed.startsWith("#")) continue;
114
+ // Split on first whitespace run. GoReleaser uses two spaces; be lenient.
115
+ const match = trimmed.match(/^([0-9a-fA-F]{64})\s+(?:\*)?(.+)$/);
116
+ if (!match) continue;
117
+ const [, hex, name] = match;
118
+ // Match on basename to tolerate optional path prefixes.
119
+ if (path.basename(name) === filename) {
120
+ return hex.toLowerCase();
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ async function verifyArchive({ version, archivePath, archiveBasename, silent }) {
127
+ const checksumsUrl = releaseAssetUrl(version, CHECKSUMS_FILENAME);
128
+ if (!silent) {
129
+ process.stderr.write(`wuphf: verifying ${archiveBasename} against ${CHECKSUMS_FILENAME}\n`);
130
+ }
131
+
132
+ let checksumsText;
133
+ try {
134
+ checksumsText = await fetchText(checksumsUrl);
135
+ } catch (err) {
136
+ throw new Error(
137
+ `Cannot verify download integrity: failed to fetch ${CHECKSUMS_FILENAME} ` +
138
+ `(${err.message}). Refusing to install an unverified binary.`,
139
+ );
140
+ }
141
+
142
+ const expected = expectedHashFor(checksumsText, archiveBasename);
143
+ if (!expected) {
144
+ throw new Error(
145
+ `Cannot verify download integrity: ${archiveBasename} not listed in ` +
146
+ `${checksumsUrl}. Refusing to install an unverified binary.`,
147
+ );
148
+ }
149
+
150
+ const actual = await sha256OfFile(archivePath);
151
+ if (actual.toLowerCase() !== expected) {
152
+ // Scrub the tampered/corrupt archive before aborting.
153
+ await fsp.rm(archivePath, { force: true });
154
+ throw new Error(
155
+ `SHA256 mismatch for ${archiveBasename}.\n` +
156
+ ` expected: ${expected}\n` +
157
+ ` actual: ${actual}\n` +
158
+ `Refusing to install. This may indicate a tampered release asset or ` +
159
+ `a corrupted download. Re-run the install on a clean network; if the ` +
160
+ `mismatch persists, file an issue at https://github.com/${REPO}/issues.`,
161
+ );
162
+ }
163
+ }
164
+
58
165
  async function downloadBinary({ silent = false } = {}) {
59
166
  const version = packageVersion();
60
- const url = archiveUrl(version);
167
+ const archiveBasename = archiveName(version);
168
+ const url = releaseAssetUrl(version, archiveBasename);
61
169
  const binDir = path.join(__dirname, "..", "bin");
62
170
  const binaryPath = path.join(binDir, "wuphf");
63
171
 
64
172
  await fsp.mkdir(binDir, { recursive: true });
65
173
 
66
174
  const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "wuphf-"));
67
- const archivePath = path.join(tmpDir, "wuphf.tar.gz");
175
+ const archivePath = path.join(tmpDir, archiveBasename);
68
176
 
69
177
  try {
70
178
  if (!silent) {
@@ -72,6 +180,9 @@ async function downloadBinary({ silent = false } = {}) {
72
180
  }
73
181
  await fetchToFile(url, archivePath);
74
182
 
183
+ // Integrity check BEFORE we extract or execute anything.
184
+ await verifyArchive({ version, archivePath, archiveBasename, silent });
185
+
75
186
  // Extract using system tar (available on darwin + linux).
76
187
  execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
77
188
  stdio: silent ? "ignore" : "inherit",
@@ -99,4 +210,10 @@ async function downloadBinary({ silent = false } = {}) {
99
210
  }
100
211
  }
101
212
 
102
- module.exports = { downloadBinary, packageVersion };
213
+ module.exports = {
214
+ downloadBinary,
215
+ packageVersion,
216
+ // Exported for tests.
217
+ expectedHashFor,
218
+ sha256OfFile,
219
+ };
@@ -1,19 +1,62 @@
1
1
  "use strict";
2
2
 
3
- // Best-effort: fetch the binary at install time. Failures are non-fatal —
4
- // the bin/wuphf.js shim will retry on first invocation. This keeps
5
- // `npm install` from failing behind flaky networks or corporate proxies.
3
+ // Postinstall: fetch and cryptographically verify the wuphf binary.
4
+ //
5
+ // Security model: the download is verified against the SHA256 listed in the
6
+ // release's checksums.txt. If the archive is tampered with, or the hash file
7
+ // is unreachable, the install MUST fail — silently continuing would allow a
8
+ // compromised release token to plant a backdoored binary on every machine
9
+ // that runs `npm install wuphf`.
10
+ //
11
+ // Escape hatches (opt-in only):
12
+ // WUPHF_SKIP_POSTINSTALL=1
13
+ // Skip the download entirely. The bin/wuphf.js shim will attempt an
14
+ // (also-verified) download on first invocation. Use this for packaging
15
+ // builds, offline mirrors, or CI images that restore a prebuilt bin/.
16
+ //
17
+ // WUPHF_POSTINSTALL_SOFT_FAIL=1
18
+ // Downgrade a *network* failure (e.g., GitHub unreachable behind a
19
+ // corporate proxy) from fatal to a warning. SHA256 mismatches are ALWAYS
20
+ // fatal and cannot be soft-failed — that path exists to catch tampering.
6
21
 
7
22
  const { downloadBinary } = require("./download-binary");
8
23
 
9
24
  if (process.env.WUPHF_SKIP_POSTINSTALL === "1") {
25
+ process.stderr.write(
26
+ "wuphf: postinstall skipped via WUPHF_SKIP_POSTINSTALL=1\n",
27
+ );
10
28
  process.exit(0);
11
29
  }
12
30
 
13
31
  downloadBinary().catch((err) => {
32
+ const message = err && err.message ? err.message : String(err);
33
+ const isIntegrityFailure =
34
+ message.includes("SHA256 mismatch") ||
35
+ message.includes("Cannot verify download integrity");
36
+
37
+ // Integrity failures are ALWAYS fatal. No soft-fail, no retry-on-first-run.
38
+ if (isIntegrityFailure) {
39
+ process.stderr.write(
40
+ `\nwuphf: SECURITY: ${message}\n` +
41
+ `wuphf: aborting install. No binary has been placed in bin/.\n\n`,
42
+ );
43
+ process.exit(1);
44
+ }
45
+
46
+ // Non-integrity failures (network, DNS, disk, unsupported platform).
47
+ if (process.env.WUPHF_POSTINSTALL_SOFT_FAIL === "1") {
48
+ process.stderr.write(
49
+ `wuphf: postinstall download failed (${message}).\n` +
50
+ `wuphf: continuing because WUPHF_POSTINSTALL_SOFT_FAIL=1 is set. ` +
51
+ `The binary will be fetched (and verified) on first run.\n`,
52
+ );
53
+ process.exit(0);
54
+ }
55
+
14
56
  process.stderr.write(
15
- `wuphf: postinstall download failed (${err.message}). ` +
16
- `The binary will be fetched on first run.\n`,
57
+ `\nwuphf: postinstall download failed: ${message}\n` +
58
+ `wuphf: set WUPHF_POSTINSTALL_SOFT_FAIL=1 to downgrade this to a ` +
59
+ `warning, or WUPHF_SKIP_POSTINSTALL=1 to skip the download entirely.\n\n`,
17
60
  );
18
- process.exit(0);
61
+ process.exit(1);
19
62
  });