diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a971b5af..9b663c817 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,32 @@ jobs: node-version: '20' registry-url: 'https://registry.npmjs.org' + - name: Fetch checksums.txt from GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release download "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern checksums.txt \ + --dir . + + - name: Verify checksums.txt is present and matches current version + run: | + set -euo pipefail + test -s checksums.txt + VERSION="${GITHUB_REF_NAME#v}" + for plat in \ + "linux-amd64.tar.gz" \ + "linux-arm64.tar.gz" \ + "darwin-amd64.tar.gz" \ + "darwin-arm64.tar.gz" \ + "windows-amd64.zip" \ + "windows-arm64.zip" + do + grep -qE '^[0-9a-fA-F]{64}[[:space:]]+\*?lark-cli-'"${VERSION}"'-'"${plat}"'$' checksums.txt \ + || { echo "checksums.txt missing valid entry for lark-cli-${VERSION}-${plat}"; exit 1; } + done + - name: Publish to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package.json b/package.json index 4fd7cab98..3a9edcb84 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "files": [ "scripts/install.js", "scripts/run.js", + "checksums.txt", "CHANGELOG.md" ] } diff --git a/scripts/install.js b/scripts/install.js index 3a3b1ecc4..175db7455 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -3,8 +3,13 @@ const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); +const { execFileSync } = require("child_process"); const os = require("os"); +const crypto = require("crypto"); + +class ChecksumError extends Error {} +class NetworkError extends Error {} +class PackageIntegrityError extends Error {} const VERSION = require("../package.json").version; const REPO = "larksuite/cli"; @@ -21,78 +26,266 @@ const ARCH_MAP = { arm64: "arm64", }; -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}`; -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 ALLOWED_INITIAL_HOSTS = new Set([ + "github.com", + "registry.npmmirror.com", +]); + +const CURL_CONNECT_TIMEOUT_SEC = 10; +const CURL_MAX_TIME_SEC = 120; +const CURL_MAX_REDIRS = 5; + +const DEFAULT_CHECKSUM_PATH = path.join(__dirname, "..", "checksums.txt"); + +// Defensive: escape single quotes for PowerShell literal-string embedding. +// tmpDir comes from mkdtempSync so is controlled, but this hardens against +// future refactors that route external input into the script. +function escapeSingleQuotes(s) { + return s.replace(/'/g, "''"); +} const binDir = path.join(__dirname, "..", "bin"); const dest = path.join(binDir, NAME + (isWindows ? ".exe" : "")); -fs.mkdirSync(binDir, { recursive: true }); - function download(url, destPath) { - // --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE - // errors when the certificate revocation list server is unreachable - const sslFlag = isWindows ? "--ssl-revoke-best-effort " : ""; - execSync( - `curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`, - { stdio: ["ignore", "ignore", "pipe"] } - ); -} + // JS-layer pre-check: initial URL must be https and in allowlist. + // Redirect targets are NOT host-checked; we rely on curl's + // --proto-redir =https + --max-redirs + SHA256 verify for safety. + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new NetworkError(`Non-HTTPS URL rejected: ${url}`); + } + if (!ALLOWED_INITIAL_HOSTS.has(parsed.hostname)) { + throw new NetworkError(`Untrusted initial host: ${parsed.hostname}`); + } -function install() { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-")); - const archivePath = path.join(tmpDir, archiveName); + const args = [ + "--fail", // HTTP 4xx/5xx -> non-zero exit + "--location", // follow redirects + "--proto", "=https", // initial URL: https only + "--proto-redir", "=https", // redirect targets: https only + "--max-redirs", String(CURL_MAX_REDIRS), + "--tlsv1.2", // minimum TLS 1.2 + "--connect-timeout", String(CURL_CONNECT_TIMEOUT_SEC), + "--max-time", String(CURL_MAX_TIME_SEC), + "--silent", "--show-error", + "--output", destPath, + ]; + + if (isWindows) { + // Schannel CRL check hard-fails when the CRL server is unreachable; + // this flag was in the original install.js and is preserved to + // avoid regression for users in corporate networks. + args.unshift("--ssl-revoke-best-effort"); + } + + // URL is always the last positional arg. + args.push(url); try { + execFileSync("curl", args, { + stdio: ["ignore", "ignore", "pipe"], + }); + } catch (err) { + if (err.code === "ENOENT") { + // ENOENT is NOT a NetworkError: another source won't help (curl + // is missing). Throw plain Error so the fallback loop re-raises + // instead of silently trying the next URL. + throw new Error( + "curl is required for installation but was not found in PATH. " + + "Install curl or manually download the binary from " + + `https://github.com/${REPO}/releases/tag/v${VERSION}` + ); + } + const stderr = err.stderr ? err.stderr.toString().trim() : ""; + const exitCode = err.status != null ? err.status : "unknown"; + throw new NetworkError( + `curl exited with code ${exitCode}${stderr ? ": " + stderr : ""}` + ); + } +} + +function downloadWithFallback(urls, destPath) { + const attempts = []; + for (const url of urls) { try { - download(GITHUB_URL, archivePath); + download(url, destPath); + return url; } catch (err) { - download(MIRROR_URL, archivePath); + if (err instanceof NetworkError) { + attempts.push({ url, error: err.message }); + continue; + } + // ChecksumError, plain Error (ENOENT), or any other type: + // re-raise immediately without trying the next source. + throw err; } + } + const detail = attempts + .map((a) => ` - ${a.url}\n ${a.error}`) + .join("\n"); + throw new NetworkError(`All download sources failed:\n${detail}`); +} - if (isWindows) { - execSync( - `powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`, - { stdio: "ignore" } - ); - } else { - execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { - stdio: "ignore", - }); +function extract(archivePath, tmpDir) { + if (isWindows) { + const script = + `$ErrorActionPreference = 'Stop'\n` + + `Expand-Archive -LiteralPath '${escapeSingleQuotes(archivePath)}' ` + + `-DestinationPath '${escapeSingleQuotes(tmpDir)}' -Force\n`; + + const scriptPath = path.join(tmpDir, "extract.ps1"); + fs.writeFileSync(scriptPath, script, { encoding: "utf-8" }); + + execFileSync("powershell", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-File", scriptPath, + ], { stdio: "ignore" }); + } else { + execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], { + stdio: "ignore", + }); + } +} + +function verifyChecksum(filePath, expectedHash) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash("sha256"); + const stream = fs.createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => { + const actual = hash.digest("hex"); + const expected = expectedHash.toLowerCase(); + if (actual !== expected) { + reject(new ChecksumError( + `SHA256 mismatch for ${path.basename(filePath)}\n` + + ` expected: ${expected}\n` + + ` actual: ${actual}` + )); + return; + } + resolve(); + }); + }); +} + +function getExpectedChecksum(archiveFilename, checksumPath = DEFAULT_CHECKSUM_PATH) { + if (!fs.existsSync(checksumPath)) { + // Packaging bug, not a tamper signal — routed separately. + throw new PackageIntegrityError("checksums.txt missing from package"); + } + + const contents = fs.readFileSync(checksumPath, "utf-8"); + const lineRegex = /^([0-9a-fA-F]{64})\s+\*?(.+)$/; + + for (const rawLine of contents.split("\n")) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("#")) continue; + + const match = line.match(lineRegex); + if (!match) continue; + + const [, hash, filename] = match; + if (filename.trim() === archiveFilename) { + return hash.toLowerCase(); } + } + + throw new ChecksumError(`No checksum entry for ${archiveFilename}`); +} + +async function install() { + const platform = PLATFORM_MAP[process.platform]; + const arch = ARCH_MAP[process.arch]; + if (!platform || !arch) { + throw new Error( + `Unsupported platform: ${process.platform}-${process.arch}. ` + + `Download manually from ` + + `https://github.com/${REPO}/releases/tag/v${VERSION}` + ); + } + const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`; + const sources = [ + `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`, + `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`, + ]; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-")); + const archivePath = path.join(tmpDir, archiveName); + + try { + // 1. Early fail: if the bundled checksums.txt is broken, + // report now before spending bandwidth. + const expectedHash = getExpectedChecksum(archiveName); + + // 2. Multi-source download; only NetworkError triggers fallback. + const sourceUrl = downloadWithFallback(sources, archivePath); + // 3. Integrity check outside the fallback loop. Mismatch aborts + // the entire install, does NOT try the next source. + await verifyChecksum(archivePath, expectedHash); + + // 4. Extract (safe: bytes match the official release). + extract(archivePath, tmpDir); + + // 5. Copy binary into place and chmod. const binaryName = NAME + (isWindows ? ".exe" : ""); const extractedBinary = path.join(tmpDir, binaryName); - + fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(extractedBinary, dest); fs.chmodSync(dest, 0o755); - console.log(`${NAME} v${VERSION} installed successfully`); + + console.log( + `${NAME} v${VERSION} installed successfully ` + + `(from ${new URL(sourceUrl).hostname})` + ); } finally { + // 6. Always clean up the temp directory. fs.rmSync(tmpDir, { recursive: true, force: true }); } } -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); +if (require.main === module) { + install().catch((err) => { + if (err instanceof PackageIntegrityError) { + console.error(`\n${NAME} install aborted: the installed package looks broken.\n`); + console.error(err.message); + console.error( + `\nRe-install the package; if the issue persists, please report it:\n` + + ` https://github.com/${REPO}/issues\n` + ); + } else if (err instanceof ChecksumError) { + console.error(`\n[SECURITY] ${NAME} install aborted due to integrity check failure:\n`); + console.error(err.message); + console.error( + `\nRetry the install; if it persists, report it and download manually:\n` + + ` https://github.com/${REPO}/releases/tag/v${VERSION}\n` + ); + } else if (err instanceof NetworkError) { + console.error(`\n${NAME} install failed due to network errors:\n`); + console.error(err.message); + console.error( + `\nIf you are behind a firewall or on a restricted network, try configuring a proxy:\n` + + ` export https_proxy=http://your-proxy:port\n` + + ` npm install -g @larksuite/cli\n` + ); + } else { + console.error(`\n${NAME} install failed:\n${err.stack || err.message}`); + } + process.exit(1); + }); } + +module.exports = { + verifyChecksum, + getExpectedChecksum, + ChecksumError, + NetworkError, + PackageIntegrityError, +}; diff --git a/scripts/install.test.js b/scripts/install.test.js new file mode 100644 index 000000000..4b881cb7f --- /dev/null +++ b/scripts/install.test.js @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const crypto = require("crypto"); +const { + verifyChecksum, + getExpectedChecksum, + ChecksumError, + PackageIntegrityError, +} = require("./install.js"); + +function mktmpdir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "install-test-")); +} + +test("verifyChecksum: correct hash resolves", async () => { + const dir = mktmpdir(); + try { + const filePath = path.join(dir, "data.bin"); + const bytes = Buffer.from("hello world"); + fs.writeFileSync(filePath, bytes); + const correctHash = crypto.createHash("sha256").update(bytes).digest("hex"); + + await verifyChecksum(filePath, correctHash); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("verifyChecksum: mismatched hash throws ChecksumError", async () => { + const dir = mktmpdir(); + try { + const filePath = path.join(dir, "data.bin"); + fs.writeFileSync(filePath, "hello world"); + const wrongHash = "0".repeat(64); + + await assert.rejects( + () => verifyChecksum(filePath, wrongHash), + (err) => err instanceof ChecksumError, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("getExpectedChecksum: returns hash for listed archive", () => { + const dir = mktmpdir(); + try { + const checksumsPath = path.join(dir, "checksums.txt"); + const knownHash = "a".repeat(64); + fs.writeFileSync( + checksumsPath, + `${knownHash} lark-cli-1.0.0-linux-amd64.tar.gz\n` + ); + + const result = getExpectedChecksum( + "lark-cli-1.0.0-linux-amd64.tar.gz", + checksumsPath, + ); + assert.strictEqual(result, knownHash); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("getExpectedChecksum: throws PackageIntegrityError (not ChecksumError) when checksums.txt file is absent", () => { + const dir = mktmpdir(); + try { + const missingPath = path.join(dir, "does-not-exist.txt"); + + assert.throws( + () => getExpectedChecksum("lark-cli-1.0.0-linux-amd64.tar.gz", missingPath), + (err) => + err instanceof PackageIntegrityError && + !(err instanceof ChecksumError), + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("getExpectedChecksum: throws ChecksumError when entry missing", () => { + const dir = mktmpdir(); + try { + const checksumsPath = path.join(dir, "checksums.txt"); + fs.writeFileSync( + checksumsPath, + `${"a".repeat(64)} some-other-archive.tar.gz\n` + ); + + assert.throws( + () => getExpectedChecksum("nonexistent-archive.tar.gz", checksumsPath), + (err) => err instanceof ChecksumError, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +});