wuphf 0.35.0 → 0.35.2
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 +1 -1
- package/scripts/download-binary.js +123 -6
- package/scripts/postinstall.js +49 -6
package/package.json
CHANGED
|
@@ -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
|
|
71
|
+
function archiveName(version) {
|
|
44
72
|
const { os: goOs, arch: goArch } = detectPlatform();
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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,
|
|
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 = {
|
|
213
|
+
module.exports = {
|
|
214
|
+
downloadBinary,
|
|
215
|
+
packageVersion,
|
|
216
|
+
// Exported for tests.
|
|
217
|
+
expectedHashFor,
|
|
218
|
+
sha256OfFile,
|
|
219
|
+
};
|
package/scripts/postinstall.js
CHANGED
|
@@ -1,19 +1,62 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
16
|
-
`
|
|
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(
|
|
61
|
+
process.exit(1);
|
|
19
62
|
});
|