Files
larksuite-cli/scripts/install.js
MaxHuang22 593025d298 feat: add SHA-256 checksum verification to install.js (#592)
* refactor: make install.js side-effect-free on require

Change-Id: I5444e3f34642d7c0740b6422a70ca6921a85e363

* feat: add getExpectedChecksum with unit tests

Change-Id: I87548be25d30c384e743da17b1d161b9d9f0ea87

* feat: add verifyChecksum with unit tests

Change-Id: Ifc2067bf1b824b02257dba7b53716fbe18d0f6b6

* feat: harden download with host allowlist and checksum verification

Change-Id: I2580782866049f1f62a2597e86b7bf59d0e50925

* ci: bundle checksums.txt in npm package for install verification

Change-Id: I2d7c44d9d5b9075158f63c0f8cf66c1e0abe3d8d

* ci: use triggering tag and verify checksums.txt presence in release workflow

Address CodeRabbit review: use GITHUB_REF_NAME instead of parsing
package.json to avoid version drift, and add explicit file check to
fail loudly if checksums.txt is missing or empty.

Change-Id: I8a5658412b6afc338ad2a642baba146cceafd0fc

* feat: streaming hash, allowlist tests, and malformed-line coverage

- verifyChecksum: switch from readFileSync to streaming 64KB chunks
  to avoid loading entire archive (10-100MB) into memory
- Export and test assertAllowedHost: 7 cases covering allowed hosts,
  rejection, case normalization, port handling, invalid URL
- Add ALLOWED_HOSTS comment clarifying it only gates initial URL
- Add getExpectedChecksum tests for malformed/tab-separated lines

Change-Id: Ida639def89c242b3b261a76effae08fd414a10dc
2026-04-23 19:40:27 +08:00

188 lines
5.7 KiB
JavaScript

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const os = require("os");
const crypto = require("crypto");
const VERSION = require("../package.json").version.replace(/-.*$/, "");
const REPO = "larksuite/cli";
const NAME = "lark-cli";
// Allowlist gates the *initial* request URL only. curl --location follows
// redirects (capped by --max-redirs 3) without re-checking the target host.
// This is acceptable because checksum verification is the primary integrity
// control; the allowlist is defense-in-depth to reject obviously wrong URLs.
const ALLOWED_HOSTS = [
"github.com",
"objects.githubusercontent.com",
"registry.npmmirror.com",
];
const PLATFORM_MAP = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const ARCH_MAP = {
x64: "amd64",
arm64: "arm64",
};
const platform = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
function assertAllowedHost(url) {
const { hostname } = new URL(url);
if (!ALLOWED_HOSTS.includes(hostname)) {
throw new Error(`Download host not allowed: ${hostname}`);
}
}
function download(url, destPath) {
assertAllowedHost(url);
const args = [
"--fail", "--location", "--silent", "--show-error",
"--connect-timeout", "10", "--max-time", "120",
"--max-redirs", "3",
"--output", destPath,
];
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable
if (isWindows) args.unshift("--ssl-revoke-best-effort");
args.push(url);
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
function install() {
fs.mkdirSync(binDir, { recursive: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
try {
download(GITHUB_URL, archivePath);
} catch (err) {
download(MIRROR_URL, archivePath);
}
const expectedHash = getExpectedChecksum(archiveName);
verifyChecksum(archivePath, expectedHash);
if (isWindows) {
execFileSync("powershell", [
"-Command",
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
], { stdio: "ignore" });
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
stdio: "ignore",
});
}
const binaryName = NAME + (isWindows ? ".exe" : "");
const extractedBinary = path.join(tmpDir, binaryName);
fs.copyFileSync(extractedBinary, dest);
fs.chmodSync(dest, 0o755);
console.log(`${NAME} v${VERSION} installed successfully`);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
function getExpectedChecksum(archiveName, checksumsDir) {
const dir = checksumsDir || path.join(__dirname, "..");
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
}
const content = fs.readFileSync(checksumsPath, "utf8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf(" ");
if (idx === -1) continue;
const hash = trimmed.slice(0, idx);
const name = trimmed.slice(idx + 2);
if (name === archiveName) return hash;
}
throw new Error(`Checksum entry not found for ${archiveName}`);
}
function verifyChecksum(archivePath, expectedHash) {
if (expectedHash === null) return;
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.
const hash = crypto.createHash("sha256");
const fd = fs.openSync(archivePath, "r");
try {
const buf = Buffer.alloc(64 * 1024);
let bytesRead;
while ((bytesRead = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
hash.update(buf.subarray(0, bytesRead));
}
} finally {
fs.closeSync(fd);
}
const actual = hash.digest("hex");
if (actual.toLowerCase() !== expectedHash.toLowerCase()) {
throw new Error(
`[SECURITY] Checksum mismatch for ${path.basename(archivePath)}: expected ${expectedHash} but got ${actual}`
);
}
}
if (require.main === module) {
if (!platform || !arch) {
console.error(
`Unsupported platform: ${process.platform}-${process.arch}`
);
process.exit(1);
}
// When triggered as a postinstall hook under npx, skip the binary download.
// The "install" wizard doesn't need it, and run.js calls install.js directly
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
const isNpxPostinstall =
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
if (isNpxPostinstall) {
process.exit(0);
}
try {
install();
} catch (err) {
console.error(`Failed to install ${NAME}:`, err.message);
console.error(
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli`
);
process.exit(1);
}
}
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost };