diff --git a/README.md b/README.md index 1505406..2338730 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,13 @@ This is a fork of the code-push-standalone CLI tool open-sourced by Microsoft, m ## Installation -To install and run the DeployPulse CLI, follow these steps: +Install the DeployPulse CLI from npm. It requires Node.js 20.19 or later, or 22.12 or later. + +```shell +npm install -g @deploypulseio/dpctl +``` + +To build it from source instead: 1. Clone this repository. 2. Install the necessary dependencies by running `npm install`. diff --git a/package.json b/package.json index d4c139d..58fc2c1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,20 @@ { "name": "@deploypulseio/dpctl", - "version": "1.1.1", + "version": "1.2.0", "description": "Management CLI for the DeployPulse CodePush service", + "keywords": [ + "deploypulse", + "codepush", + "code-push", + "ota", + "over-the-air", + "react-native", + "expo", + "expo-updates", + "hot-update", + "mobile", + "cli" + ], "main": "./bin/script/cli.js", "scripts": { "start": "node ./bin/script/cli.js", @@ -11,7 +24,7 @@ "prettier": "prettier --write \"./**/*.ts\"", "lint": "npx eslint ./script/**/*.ts", "lint:fix": "npx eslint ./script/**/*.ts --fix", - "test": "mocha --require ts-node/register ./test/cli.ts ./test/hash-utils.ts ./test/acquisition-sdk.ts ./test/management-sdk.ts ./test/command-parser.ts --reporter mochawesome --exit" + "test": "mocha --require ts-node/register ./test/cli.ts ./test/hash-utils.ts ./test/acquisition-sdk.ts ./test/management-sdk.ts ./test/command-parser.ts ./test/config-file.ts --reporter mochawesome --exit" }, "bin": { "dpctl": "./bin/script/cli.js" @@ -26,7 +39,7 @@ "email": "support@deploypulse.io" }, "files": [ - "bin/**", + "bin/script/**", "README.md", "LICENSE*", "NOTICE" diff --git a/script/command-executor.ts b/script/command-executor.ts index c7b40a5..a5fd65e 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -861,6 +861,10 @@ export const deserializeConnectionInfo = (): ILoginConnectionInfo => { const savedConnection: string = fs.readFileSync(configFilePath, { encoding: "utf8", }); + + // Repaired on read, not only on write: someone who was already logged in before this change never + // takes a write path again, so their key would stay world-readable forever. + tightenConfigFilePermissions(); let connectionInfo: ILegacyLoginConnectionInfo | ILoginConnectionInfo = JSON.parse(savedConnection); // If the connection info is in the legacy format, convert it to the modern format @@ -2244,9 +2248,33 @@ function orgClear(command: cli.ICommand): Promise { // `export const`, not `export function`: TypeScript compiles calls to an exported const through the // exports object, which is what lets the tests stub this and keep the real session file untouched. export const writeConnectionInfo = (connectionInfo: ILoginConnectionInfo): void => { - fs.writeFileSync(configFilePath, JSON.stringify(connectionInfo), { encoding: "utf8" }); + // Written to a temp file and renamed so an interrupted write cannot leave a half-written session + // file behind. The mode is set on creation rather than fixed afterwards, because the contents are a + // live access key and there should be no moment where the file exists and anyone can read it. + const tempPath = `${configFilePath}.${process.pid}.tmp`; + try { + fs.writeFileSync(tempPath, JSON.stringify(connectionInfo), { encoding: "utf8", mode: 0o600 }); + fs.renameSync(tempPath, configFilePath); + } catch (error) { + try { + fs.unlinkSync(tempPath); + } catch { + /* the temp file may never have been created */ + } + throw error; + } }; +// Best effort: Windows and some network filesystems do not support the mode, and a session that +// cannot be locked down is still better than no session. +function tightenConfigFilePermissions(): void { + try { + fs.chmodSync(configFilePath, 0o600); + } catch { + /* nothing to tighten, or the filesystem will not say */ + } +} + function serializeConnectionInfo(accessKey: string, preserveAccessKeyOnLogout: boolean, org?: OrgContext): void { const connectionInfo: ILoginConnectionInfo = { accessKey: accessKey, diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 80e35af..3e9e9b8 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -850,6 +850,9 @@ class AccountManager { request.set("Accept", `application/vnd.code-push.v${AccountManager.API_VERSION}+json`); request.set("Authorization", `Bearer ${this._accessKey}`); request.set("X-CodePush-SDK-Version", packageJson.version); + // Node's superagent sends no User-Agent of its own. Without this the server has only the version + // header to go on, and a login shows up as a nameless CLI in Recent Logins. + request.set("User-Agent", `dpctl/${packageJson.version}`); if (this._orgId) { request.set("x-org-id", this._orgId); } diff --git a/test/config-file.ts b/test/config-file.ts new file mode 100644 index 0000000..75aedf5 --- /dev/null +++ b/test/config-file.ts @@ -0,0 +1,84 @@ +// The session file holds a live access key, so these drive the real read and write paths rather than +// the stub the CLI tests use. `configFilePath` is resolved at import time from HOME, so each test +// points HOME at a temp directory and loads a fresh copy of the module. + +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +function withTempHome(body: (home: string, cmdexec: any) => T): T { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-test-home-")); + const previous = { HOME: process.env.HOME, LOCALAPPDATA: process.env.LOCALAPPDATA }; + process.env.HOME = home; + process.env.LOCALAPPDATA = home; + const modulePath = require.resolve("../script/command-executor"); + delete require.cache[modulePath]; + try { + return body(home, require("../script/command-executor")); + } finally { + delete require.cache[modulePath]; + process.env.HOME = previous.HOME; + process.env.LOCALAPPDATA = previous.LOCALAPPDATA; + fs.rmSync(home, { recursive: true, force: true }); + } +} + +function mode(file: string): number { + return fs.statSync(file).mode & 0o777; +} + +describe("the session file", () => { + it("is written so that only its owner can read it", () => { + withTempHome((home: string, cmdexec: any) => { + cmdexec.writeConnectionInfo({ accessKey: "secret-key", preserveAccessKeyOnLogout: false }); + const file = path.join(home, ".dpctl.config"); + assert.strictEqual(mode(file), 0o600, `mode was ${mode(file).toString(8)}`); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(file, "utf8")).accessKey, "secret-key"); + }); + }); + + it("is tightened on read, for anyone who logged in before it was written this way", () => { + withTempHome((home: string, cmdexec: any) => { + const file = path.join(home, ".dpctl.config"); + fs.writeFileSync(file, JSON.stringify({ accessKey: "secret-key" }), { mode: 0o644 }); + assert.strictEqual(mode(file), 0o644, "precondition: the file starts world-readable"); + + assert.strictEqual(cmdexec.deserializeConnectionInfo().accessKey, "secret-key"); + assert.strictEqual(mode(file), 0o600, `mode was ${mode(file).toString(8)}`); + }); + }); + + it("leaves no temp file behind", () => { + withTempHome((home: string, cmdexec: any) => { + cmdexec.writeConnectionInfo({ accessKey: "secret-key", preserveAccessKeyOnLogout: false }); + const strays = fs.readdirSync(home).filter((name: string) => name.indexOf(".tmp") >= 0); + assert.deepStrictEqual(strays, []); + }); + }); + + it("leaves no temp file behind when the rename fails", () => { + withTempHome((home: string, cmdexec: any) => { + // A directory in the session file's place: the temp file is written, then the rename fails. + fs.mkdirSync(path.join(home, ".dpctl.config")); + assert.throws(() => cmdexec.writeConnectionInfo({ accessKey: "secret-key", preserveAccessKeyOnLogout: false })); + + const strays = fs.readdirSync(home).filter((name: string) => name.indexOf(".tmp") >= 0); + assert.deepStrictEqual(strays, []); + }); + }); + + it("does not replace the old session when the write fails", () => { + withTempHome((home: string, cmdexec: any) => { + const file = path.join(home, ".dpctl.config"); + cmdexec.writeConnectionInfo({ accessKey: "first-key", preserveAccessKeyOnLogout: false }); + + // A directory where the temp file wants to go: writeFileSync fails, and the live file must survive. + fs.mkdirSync(`${file}.${process.pid}.tmp`); + assert.throws(() => cmdexec.writeConnectionInfo({ accessKey: "second-key", preserveAccessKeyOnLogout: false })); + + assert.strictEqual(JSON.parse(fs.readFileSync(file, "utf8")).accessKey, "first-key"); + assert.strictEqual(mode(file), 0o600); + }); + }); +}); diff --git a/test/management-sdk.ts b/test/management-sdk.ts index 53eafea..31dc4f9 100644 --- a/test/management-sdk.ts +++ b/test/management-sdk.ts @@ -420,6 +420,15 @@ describe("Management SDK", () => { }, rejectHandler); }); + it("identifies itself as dpctl, so a login is not a nameless CLI row", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ apps: [] }), 200); + manager.getApps().done(() => { + const version = require("../package.json").version; + assert.strictEqual(lastRequestHeaders["User-Agent"], `dpctl/${version}`); + done(); + }, rejectHandler); + }); + it("addAccessKey sends scopes and appIds when they are set", (done: Mocha.Done) => { mockReturn(JSON.stringify({ accessKey: { name: "k", friendlyName: "CI key", createdTime: 0, expires: 1 } }), 201); manager.addAccessKey("CI key", undefined, ["read"], ["app-a-id"]).done(() => {