diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a971b5af..02e2274d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,15 @@ jobs: node-version: '20' registry-url: 'https://registry.npmjs.org' + - name: Download checksums from release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + gh release download "${TAG}" --pattern checksums.txt --dir . + test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; } + - name: Publish to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package.json b/package.json index b7d5c903b..8c82e3629 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "scripts/install.js", "scripts/install-wizard.js", "scripts/run.js", + "checksums.txt", "CHANGELOG.md" ], "dependencies": { diff --git a/scripts/install.js b/scripts/install.js index 17903ba67..4ef9e50ab 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -5,10 +5,16 @@ 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"; +const ALLOWED_HOSTS = [ + "github.com", + "objects.githubusercontent.com", + "registry.npmmirror.com", +]; const PLATFORM_MAP = { darwin: "darwin", @@ -24,13 +30,6 @@ const ARCH_MAP = { const platform = PLATFORM_MAP[process.platform]; const arch = ARCH_MAP[process.arch]; -if (!platform || !arch) { - console.error( - `Unsupported platform: ${process.platform}-${process.arch}` - ); - process.exit(1); -} - const isWindows = process.platform === "win32"; const ext = isWindows ? ".zip" : ".tar.gz"; const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`; @@ -40,12 +39,19 @@ const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION} const binDir = path.join(__dirname, "..", "bin"); const dest = path.join(binDir, NAME + (isWindows ? ".exe" : "")); -fs.mkdirSync(binDir, { recursive: true }); +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 @@ -56,6 +62,8 @@ function download(url, destPath) { } function install() { + fs.mkdirSync(binDir, { recursive: true }); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-")); const archivePath = path.join(tmpDir, archiveName); @@ -66,6 +74,9 @@ function install() { download(MIRROR_URL, archivePath); } + const expectedHash = getExpectedChecksum(archiveName); + verifyChecksum(archivePath, expectedHash); + if (isWindows) { execFileSync("powershell", [ "-Command", @@ -88,24 +99,73 @@ function install() { } } -// 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; +function getExpectedChecksum(archiveName, checksumsDir) { + const dir = checksumsDir || path.join(__dirname, ".."); + const checksumsPath = path.join(dir, "checksums.txt"); -if (isNpxPostinstall) { - process.exit(0); + 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}`); } -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); +function verifyChecksum(archivePath, expectedHash) { + if (expectedHash === null) return; + + const content = fs.readFileSync(archivePath); + const actual = crypto.createHash("sha256").update(content).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 }; diff --git a/scripts/install.test.js b/scripts/install.test.js new file mode 100644 index 000000000..d700f9f37 --- /dev/null +++ b/scripts/install.test.js @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const crypto = require("crypto"); + +const { getExpectedChecksum, verifyChecksum } = require("./install.js"); + +describe("getExpectedChecksum", () => { + function makeTmpChecksums(content) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-")); + fs.writeFileSync(path.join(dir, "checksums.txt"), content, "utf8"); + return dir; + } + + it("returns correct hash from standard-format checksums.txt", () => { + const dir = makeTmpChecksums( + "abc123def456 lark-cli-1.0.0-darwin-arm64.tar.gz\n" + ); + const hash = getExpectedChecksum( + "lark-cli-1.0.0-darwin-arm64.tar.gz", + dir + ); + assert.equal(hash, "abc123def456"); + }); + + it("returns correct entry when multiple entries exist", () => { + const dir = makeTmpChecksums( + "aaaa lark-cli-1.0.0-linux-amd64.tar.gz\n" + + "bbbb lark-cli-1.0.0-darwin-arm64.tar.gz\n" + + "cccc lark-cli-1.0.0-windows-amd64.zip\n" + ); + const hash = getExpectedChecksum( + "lark-cli-1.0.0-darwin-arm64.tar.gz", + dir + ); + assert.equal(hash, "bbbb"); + }); + + it("throws Error when archiveName is not found", () => { + const dir = makeTmpChecksums( + "aaaa lark-cli-1.0.0-linux-amd64.tar.gz\n" + ); + assert.throws( + () => getExpectedChecksum("nonexistent.tar.gz", dir), + { message: /Checksum entry not found for nonexistent\.tar\.gz/ } + ); + }); + + it("returns null when checksums.txt does not exist", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-")); + // No checksums.txt in dir + const result = getExpectedChecksum("anything.tar.gz", dir); + assert.equal(result, null); + }); +}); + +describe("verifyChecksum", () => { + function makeTmpFile(content) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-")); + const filePath = path.join(dir, "archive.tar.gz"); + fs.writeFileSync(filePath, content); + return filePath; + } + + function sha256(content) { + return crypto.createHash("sha256").update(content).digest("hex"); + } + + it("returns normally when hash matches", () => { + const content = "binary content here"; + const filePath = makeTmpFile(content); + const hash = sha256(content); + // Should not throw + verifyChecksum(filePath, hash); + }); + + it("matches case-insensitively", () => { + const content = "case test"; + const filePath = makeTmpFile(content); + const hash = sha256(content).toUpperCase(); + // Should not throw + verifyChecksum(filePath, hash); + }); + + it("throws [SECURITY]-prefixed Error on mismatch", () => { + const filePath = makeTmpFile("real content"); + assert.throws( + () => verifyChecksum(filePath, "0000000000000000000000000000000000000000000000000000000000000000"), + (err) => { + assert.match(err.message, /^\[SECURITY\]/); + assert.match(err.message, /Checksum mismatch/); + return true; + } + ); + }); +});