mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): upgrade and update notice use the detected install method
* fix(cli): upgrade + update-notice use the detected install method
hyperframes upgrade hardcoded 'npm install -g', so bun/pnpm/brew users either
saw it fail or silently got a shadowed npm copy while their real (older) binary
kept running. Route the install through detectInstaller() via a new
installInvocation() argv helper; for skip kinds (ephemeral npx/bunx,
project-local, unknown) print 'npx hyperframes@latest' instead of guessing.
The passive update notice now shows the detected manager's command too. Semver
safety guard consolidated into a shared isSafeVersion(). Suppression gates and
the background auto-update flow are unchanged.
* test(cli): pin the shell:false contract of the --yes install path
Export runDetectedInstall and add a mocked-execFileSync test asserting the
detected manager binary is spawned with the exact installInvocation argv,
{stdio:inherit, shell:false}, and that an install failure sets a non-zero exit
code without throwing. Addresses review nit on the untested --yes path.
* fix(cli): guard the registry version at the boundary; execFile the auto-installer
Security (addresses review): a poisoned registry data.version (e.g.
'1.2.3; rm -rf /') was cached unvalidated and flowed into the background
auto-updater, which ran it via exec() -- a shell -- so a registry compromise
meant RCE on the next CLI run. isSafeVersion only covered the two touched
consumers (upgrade, notice), not this third sibling (scheduleBackgroundInstall).
- Guard at the registry boundary in checkForUpdate: only a strict-semver STRING
is trusted; a non-string or metachar-bearing data.version is never cached and
falls back to the last known-good version. The cache-read and fallback paths
re-validate too, so a pre-existing poisoned cache can't leak through. One gate
closes all three consumers and any future one; per-consumer checks stay as
defense in depth.
- The detached auto-installer now runs via execFile(bin, args, shell:false),
reusing installInvocation, matching the interactive runDetectedInstall path --
the shell is gone from that path entirely.
Tests: reject poisoned / non-string registry version (never cached); accept a
valid semver.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Pins the security-relevant contract of the `--yes` install path: the detected
|
||||
* manager binary is spawned via execFileSync with `shell: false` and the exact
|
||||
* argv from installInvocation — no shell, so a version can never be re-parsed
|
||||
* as shell syntax. installInvocation's argv correctness is covered separately
|
||||
* in installerDetection.test.ts; this locks how it's executed.
|
||||
*/
|
||||
describe("runDetectedInstall", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("spawns the manager binary with shell:false and inherited stdio", async () => {
|
||||
const execSpy = vi.fn();
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", () => ({ execFileSync: execSpy }));
|
||||
|
||||
const { runDetectedInstall } = await import("./upgrade.js");
|
||||
runDetectedInstall(
|
||||
{ bin: "bun", args: ["add", "-g", "hyperframes@1.2.3"] },
|
||||
"bun add -g hyperframes@1.2.3",
|
||||
"1.2.3",
|
||||
);
|
||||
|
||||
expect(execSpy).toHaveBeenCalledTimes(1);
|
||||
expect(execSpy).toHaveBeenCalledWith("bun", ["add", "-g", "hyperframes@1.2.3"], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets a non-zero exit code when the install fails, without throwing", async () => {
|
||||
const execSpy = vi.fn(() => {
|
||||
throw new Error("install boom");
|
||||
});
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", () => ({ execFileSync: execSpy }));
|
||||
|
||||
const { runDetectedInstall } = await import("./upgrade.js");
|
||||
const original = process.exitCode;
|
||||
try {
|
||||
expect(() =>
|
||||
runDetectedInstall(
|
||||
{ bin: "npm", args: ["install", "-g", "hyperframes@1.2.3"] },
|
||||
"npm install -g hyperframes@1.2.3",
|
||||
"1.2.3",
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
process.exitCode = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,13 @@ export const examples: Example[] = [
|
||||
["Upgrade non-interactively", "hyperframes upgrade --yes"],
|
||||
];
|
||||
import { VERSION } from "../version.js";
|
||||
import { checkForUpdate, withMeta } from "../utils/updateCheck.js";
|
||||
import {
|
||||
checkForUpdate,
|
||||
withMeta,
|
||||
isSafeVersion,
|
||||
type UpdateCheckResult,
|
||||
} from "../utils/updateCheck.js";
|
||||
import { detectInstaller, installInvocation } from "../utils/installerDetection.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "upgrade", description: "Check for updates and show upgrade instructions" },
|
||||
@@ -19,6 +25,7 @@ export default defineCommand({
|
||||
check: { type: "boolean", description: "Check for updates and exit (no prompt)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
const useJson = args.json === true;
|
||||
const checkOnly = args.check === true;
|
||||
@@ -56,53 +63,96 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoYes) {
|
||||
const shouldUpgrade = await clack.confirm({
|
||||
message: "Upgrade now?",
|
||||
});
|
||||
|
||||
if (clack.isCancel(shouldUpgrade) || !shouldUpgrade) {
|
||||
clack.outro(c.dim("Skipped."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject anything that isn't a strict semver-shaped string before it reaches
|
||||
// the install command. A poisoned npm registry response could otherwise put
|
||||
// shell metacharacters into `result.latest`; rejecting up front means the
|
||||
// version flows through execFile (and the displayed command) as an opaque
|
||||
// token, not something the shell might re-parse.
|
||||
const SAFE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
if (!SAFE_VERSION.test(result.latest)) {
|
||||
clack.outro(c.dim("Refusing to install: unexpected version string from npm registry."));
|
||||
process.exitCode = 1;
|
||||
if (!autoYes && !(await confirmUpgrade())) {
|
||||
clack.outro(c.dim("Skipped."));
|
||||
return;
|
||||
}
|
||||
|
||||
const installArgs = ["install", "-g", `hyperframes@${result.latest}`];
|
||||
const installCmd = `npm ${installArgs.join(" ")}`;
|
||||
if (autoYes) {
|
||||
console.log();
|
||||
console.log(` ${c.dim("Running:")} ${c.accent(installCmd)}`);
|
||||
console.log();
|
||||
try {
|
||||
// execFileSync with shell:false — the version is now provably safe per
|
||||
// SAFE_VERSION above, but keep the no-shell call so future edits can't
|
||||
// regress the shell-injection surface area.
|
||||
execFileSync("npm", installArgs, { stdio: "inherit", shell: false });
|
||||
clack.outro(c.success(`Upgraded to v${result.latest}`));
|
||||
} catch {
|
||||
clack.outro(c.dim("Install failed. Try running manually:"));
|
||||
console.log(` ${c.accent(installCmd)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
console.log();
|
||||
console.log(` ${c.accent(installCmd)}`);
|
||||
console.log(` ${c.dim("or")}`);
|
||||
console.log(` ${c.accent("npx hyperframes@" + result.latest + " --version")}`);
|
||||
console.log();
|
||||
clack.outro(c.success("Run one of the commands above to upgrade."));
|
||||
}
|
||||
applyUpgrade(result, autoYes);
|
||||
},
|
||||
});
|
||||
|
||||
/** Interactive "Upgrade now?" prompt; false on decline or cancel. */
|
||||
async function confirmUpgrade(): Promise<boolean> {
|
||||
const shouldUpgrade = await clack.confirm({ message: "Upgrade now?" });
|
||||
return !clack.isCancel(shouldUpgrade) && shouldUpgrade === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show (or, with `autoYes`, run) the upgrade for the user's ACTUAL install
|
||||
* method — not a hardcoded `npm install -g`, which fails or silently shadows a
|
||||
* bun/pnpm/brew install. Extracted from `run` to keep that handler simple.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function applyUpgrade(result: UpdateCheckResult, autoYes: boolean): void {
|
||||
// Reject anything that isn't a strict semver before it reaches a command. A
|
||||
// poisoned npm registry response could otherwise put shell metacharacters
|
||||
// into `result.latest`; the guard means the version flows through execFile
|
||||
// (and the displayed command) as an opaque token. Shared with the update
|
||||
// notice via isSafeVersion.
|
||||
if (!isSafeVersion(result.latest)) {
|
||||
clack.outro(c.dim("Refusing to install: unexpected version string from npm registry."));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const installer = detectInstaller();
|
||||
const invocation = installInvocation(installer.kind, result.latest);
|
||||
const displayCmd = installer.installCommand(result.latest);
|
||||
const npxFallback = `npx hyperframes@${result.latest}`;
|
||||
|
||||
// Undetectable / ephemeral (npx, bunx) / project-local / workspace: don't
|
||||
// guess a manager command; point at the universal npx fallback instead.
|
||||
if (!invocation || !displayCmd) {
|
||||
printNpxFallback(installer.reason, npxFallback, autoYes);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoYes) {
|
||||
printManualCommands(displayCmd, npxFallback);
|
||||
return;
|
||||
}
|
||||
|
||||
runDetectedInstall(invocation, displayCmd, result.latest);
|
||||
}
|
||||
|
||||
function printNpxFallback(reason: string, npxFallback: string, autoYes: boolean): void {
|
||||
console.log();
|
||||
if (autoYes) {
|
||||
console.log(
|
||||
` ${c.dim("Couldn't detect a global install to upgrade")} ${c.dim("(" + reason + ")")}`,
|
||||
);
|
||||
}
|
||||
console.log(` ${c.accent(npxFallback)}`);
|
||||
console.log();
|
||||
clack.outro(c.success("Run the command above to use the latest version."));
|
||||
}
|
||||
|
||||
function printManualCommands(displayCmd: string, npxFallback: string): void {
|
||||
console.log();
|
||||
console.log(` ${c.accent(displayCmd)}`);
|
||||
console.log(` ${c.dim("or")}`);
|
||||
console.log(` ${c.accent(npxFallback)}`);
|
||||
console.log();
|
||||
clack.outro(c.success("Run one of the commands above to upgrade."));
|
||||
}
|
||||
|
||||
export function runDetectedInstall(
|
||||
invocation: { bin: string; args: string[] },
|
||||
displayCmd: string,
|
||||
version: string,
|
||||
): void {
|
||||
console.log();
|
||||
console.log(` ${c.dim("Running:")} ${c.accent(displayCmd)}`);
|
||||
console.log();
|
||||
try {
|
||||
// shell:false — version is provably safe per isSafeVersion above; keep the
|
||||
// no-shell call so future edits can't regress the injection surface.
|
||||
execFileSync(invocation.bin, invocation.args, { stdio: "inherit", shell: false });
|
||||
clack.outro(c.success(`Upgraded to v${version}`));
|
||||
} catch {
|
||||
clack.outro(c.dim("Install failed. Try running manually:"));
|
||||
console.log(` ${c.accent(displayCmd)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ function setupMocks(opts: {
|
||||
installCommand: () => opts.installer.command,
|
||||
reason: "test",
|
||||
}),
|
||||
// scheduleBackgroundInstall now also derives the argv form; mirror the real
|
||||
// helper (null for skip, a {bin,args} pair otherwise).
|
||||
installInvocation: (kind: string, version: string) =>
|
||||
kind === "skip" ? null : { bin: kind, args: ["add", "-g", `hyperframes@${version}`] },
|
||||
}));
|
||||
|
||||
const spawnSpy = vi.fn(() => ({
|
||||
|
||||
@@ -30,7 +30,11 @@ import { join } from "node:path";
|
||||
import { compareVersions } from "compare-versions";
|
||||
import { readConfig, writeConfig } from "../telemetry/config.js";
|
||||
import { isDevMode } from "./env.js";
|
||||
import { detectInstaller } from "./installerDetection.js";
|
||||
import {
|
||||
detectInstaller,
|
||||
installInvocation,
|
||||
type InstallInvocation,
|
||||
} from "./installerDetection.js";
|
||||
|
||||
const CONFIG_DIR = join(homedir(), ".hyperframes");
|
||||
const LOG_FILE = join(CONFIG_DIR, "auto-update.log");
|
||||
@@ -74,22 +78,30 @@ function log(line: string): void {
|
||||
* the install that edits the config file in place. Keeps the whole thing to
|
||||
* one spawned process with no extra binary to distribute.
|
||||
*/
|
||||
function launchDetachedInstall(installCommand: string, version: string): void {
|
||||
function launchDetachedInstall(
|
||||
invocation: InstallInvocation,
|
||||
displayCommand: string,
|
||||
version: string,
|
||||
): void {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
const configFile = join(CONFIG_DIR, "config.json");
|
||||
|
||||
// The child script:
|
||||
// 1. Runs the install command, capturing exit code + stderr tail.
|
||||
// 1. Runs the install via execFile (bin + argv, NO shell) so a version
|
||||
// string can never be re-interpreted as shell syntax — structural
|
||||
// symmetry with the interactive `runDetectedInstall` path.
|
||||
// 2. Rewrites the config file with completedUpdate, clears pendingUpdate.
|
||||
// We shell out to `node -e` so we don't need to ship a separate file.
|
||||
// We run it through `node -e` so we don't need to ship a separate file. Bin
|
||||
// and args are embedded as JSON literals (data, not code).
|
||||
const nodeScript = `
|
||||
const { exec } = require("node:child_process");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { readFileSync, renameSync, writeFileSync } = require("node:fs");
|
||||
const CFG = ${JSON.stringify(configFile)};
|
||||
const TMP = \`\${CFG}.tmp\`;
|
||||
const VERSION = ${JSON.stringify(version)};
|
||||
const CMD = ${JSON.stringify(installCommand)};
|
||||
exec(CMD, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
||||
const BIN = ${JSON.stringify(invocation.bin)};
|
||||
const ARGS = ${JSON.stringify(invocation.args)};
|
||||
execFile(BIN, ARGS, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(readFileSync(CFG, "utf-8")); } catch (e) {}
|
||||
cfg.completedUpdate = {
|
||||
@@ -114,7 +126,7 @@ function launchDetachedInstall(installCommand: string, version: string): void {
|
||||
env: { ...process.env, HYPERFRAMES_NO_UPDATE_CHECK: "1", HYPERFRAMES_NO_AUTO_INSTALL: "1" },
|
||||
});
|
||||
child.unref();
|
||||
log(`[launch] pid=${child.pid ?? "?"} cmd=${installCommand} version=${version}`);
|
||||
log(`[launch] pid=${child.pid ?? "?"} cmd=${displayCommand} version=${version}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +161,8 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion:
|
||||
return false;
|
||||
}
|
||||
const installCommand = installer.installCommand(latestVersion);
|
||||
if (!installCommand) return false;
|
||||
const invocation = installInvocation(installer.kind, latestVersion);
|
||||
if (!installCommand || !invocation) return false;
|
||||
|
||||
const config = readConfig();
|
||||
|
||||
@@ -177,7 +190,7 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion:
|
||||
writeConfig(config);
|
||||
|
||||
try {
|
||||
launchDetachedInstall(installCommand, latestVersion);
|
||||
launchDetachedInstall(invocation, installCommand, latestVersion);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`[error] spawn failed: ${String(err)}`);
|
||||
|
||||
@@ -123,3 +123,36 @@ describe("detectInstaller", () => {
|
||||
expect(info.reason).toMatch(/Unknown install layout/);
|
||||
});
|
||||
});
|
||||
|
||||
import { installInvocation } from "./installerDetection.js";
|
||||
|
||||
describe("installInvocation", () => {
|
||||
it("returns the npm global argv for kind npm", () => {
|
||||
expect(installInvocation("npm", "1.2.3")).toEqual({
|
||||
bin: "npm",
|
||||
args: ["install", "-g", "hyperframes@1.2.3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns bun/pnpm add -g argv for those managers", () => {
|
||||
expect(installInvocation("bun", "1.2.3")).toEqual({
|
||||
bin: "bun",
|
||||
args: ["add", "-g", "hyperframes@1.2.3"],
|
||||
});
|
||||
expect(installInvocation("pnpm", "1.2.3")).toEqual({
|
||||
bin: "pnpm",
|
||||
args: ["add", "-g", "hyperframes@1.2.3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a version-less brew upgrade for kind brew", () => {
|
||||
expect(installInvocation("brew", "1.2.3")).toEqual({
|
||||
bin: "brew",
|
||||
args: ["upgrade", "hyperframes"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for kind skip (ephemeral / project-local / unknown)", () => {
|
||||
expect(installInvocation("skip", "1.2.3")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,3 +156,34 @@ export function detectInstaller(): InstallerInfo {
|
||||
reason: `Unknown install layout at ${realEntry}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Argv-shaped install command for a no-shell `execFile`. */
|
||||
export interface InstallInvocation {
|
||||
bin: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The argv form of {@link InstallerInfo.installCommand}, kept next to the
|
||||
* detector so the command we *run* (execFile, no shell) and the command we
|
||||
* *display* (installCommand string) can never drift. Returns `null` for `skip`
|
||||
* kinds (ephemeral npx/bunx, workspace links, project-local, unknown layouts):
|
||||
* the caller must print a manual instruction rather than run a guessed command
|
||||
* (running the wrong manager is worse than running nothing).
|
||||
*/
|
||||
export function installInvocation(kind: InstallerKind, version: string): InstallInvocation | null {
|
||||
switch (kind) {
|
||||
case "npm":
|
||||
return { bin: "npm", args: ["install", "-g", `hyperframes@${version}`] };
|
||||
case "bun":
|
||||
return { bin: "bun", args: ["add", "-g", `hyperframes@${version}`] };
|
||||
case "pnpm":
|
||||
return { bin: "pnpm", args: ["add", "-g", `hyperframes@${version}`] };
|
||||
case "brew":
|
||||
// brew has no per-version install; `brew upgrade` moves to the tap's
|
||||
// current formula (a no-op if the tap hasn't caught up).
|
||||
return { bin: "brew", args: ["upgrade", "hyperframes"] };
|
||||
case "skip":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isSafeVersion } from "./updateCheck.js";
|
||||
|
||||
describe("isSafeVersion", () => {
|
||||
it("accepts strict semver, incl. prerelease/build metadata", () => {
|
||||
expect(isSafeVersion("1.2.3")).toBe(true);
|
||||
expect(isSafeVersion("0.7.28")).toBe(true);
|
||||
expect(isSafeVersion("1.2.3-beta.1")).toBe(true);
|
||||
expect(isSafeVersion("1.2.3+build.5")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects anything that could carry shell metacharacters or isn't semver", () => {
|
||||
expect(isSafeVersion("")).toBe(false);
|
||||
expect(isSafeVersion("latest")).toBe(false);
|
||||
expect(isSafeVersion("1.2")).toBe(false);
|
||||
expect(isSafeVersion("1.2.3; rm -rf /")).toBe(false);
|
||||
expect(isSafeVersion("1.2.3 && curl evil")).toBe(false);
|
||||
expect(isSafeVersion("$(whoami)")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Drive printUpdateNotice under controlled mocks. isDevMode() is true under
|
||||
* vitest (the module path ends in .ts), which would suppress the notice, so we
|
||||
* mock ./env.js. detectInstaller and readConfig are mocked to pick the branch.
|
||||
*/
|
||||
async function noticeWith(opts: {
|
||||
installerCommand: string | null;
|
||||
latestVersion?: string;
|
||||
isTTY?: boolean;
|
||||
env?: Record<string, string | undefined>;
|
||||
}): Promise<string> {
|
||||
vi.resetModules();
|
||||
vi.doMock("./env.js", () => ({ isDevMode: () => false }));
|
||||
vi.doMock("./installerDetection.js", () => ({
|
||||
detectInstaller: () => ({
|
||||
kind: opts.installerCommand ? "npm" : "skip",
|
||||
installCommand: () => opts.installerCommand,
|
||||
reason: "test",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../telemetry/config.js", () => ({
|
||||
readConfig: () => ({ latestVersion: opts.latestVersion ?? "9.9.9" }),
|
||||
writeConfig: () => {},
|
||||
}));
|
||||
|
||||
const origEnv = { ...process.env };
|
||||
for (const [k, v] of Object.entries(opts.env ?? {})) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
// Default to a non-CI interactive terminal unless the test overrides env.
|
||||
if (!("CI" in (opts.env ?? {}))) delete process.env["CI"];
|
||||
|
||||
const origTTY = process.stderr.isTTY;
|
||||
Object.defineProperty(process.stderr, "isTTY", {
|
||||
value: opts.isTTY ?? true,
|
||||
configurable: true,
|
||||
});
|
||||
const writes: string[] = [];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = ((chunk: unknown) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
try {
|
||||
const mod = await import("./updateCheck.js");
|
||||
mod.printUpdateNotice();
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
Object.defineProperty(process.stderr, "isTTY", { value: origTTY, configurable: true });
|
||||
process.env = origEnv;
|
||||
}
|
||||
return writes.join("");
|
||||
}
|
||||
|
||||
describe("printUpdateNotice — install-method-aware command", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("./env.js");
|
||||
vi.doUnmock("./installerDetection.js");
|
||||
vi.doUnmock("../telemetry/config.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("shows the detected manager's command for an owned global install", async () => {
|
||||
const out = await noticeWith({ installerCommand: "brew upgrade hyperframes" });
|
||||
expect(out).toContain("Update available");
|
||||
expect(out).toContain("brew upgrade hyperframes");
|
||||
expect(out).not.toContain("npx hyperframes@latest");
|
||||
});
|
||||
|
||||
it("falls back to npx hyperframes@latest when the install method is skip/unknown", async () => {
|
||||
const out = await noticeWith({ installerCommand: null });
|
||||
expect(out).toContain("npx hyperframes@latest");
|
||||
});
|
||||
|
||||
it("is suppressed on a non-TTY stderr", async () => {
|
||||
const out = await noticeWith({ installerCommand: "brew upgrade hyperframes", isTTY: false });
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
it("is suppressed in CI", async () => {
|
||||
const out = await noticeWith({
|
||||
installerCommand: "brew upgrade hyperframes",
|
||||
env: { CI: "true" },
|
||||
});
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
it("is suppressed by the HYPERFRAMES_NO_UPDATE_CHECK opt-out", async () => {
|
||||
const out = await noticeWith({
|
||||
installerCommand: "brew upgrade hyperframes",
|
||||
env: { HYPERFRAMES_NO_UPDATE_CHECK: "1" },
|
||||
});
|
||||
expect(out).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The registry-boundary guard: a poisoned or non-string data.version must
|
||||
* never be cached, because it flows into the auto-updater's install command.
|
||||
* This closes the injection class for every downstream consumer at one point.
|
||||
*/
|
||||
async function checkWith(registryVersion: unknown): Promise<{
|
||||
latest: string;
|
||||
wroteVersion: string | undefined;
|
||||
}> {
|
||||
vi.resetModules();
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
vi.doMock("../telemetry/config.js", () => ({
|
||||
readConfig: () => ({}),
|
||||
writeConfig: (c: Record<string, unknown>) => writes.push({ ...c }),
|
||||
}));
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ version: registryVersion }),
|
||||
})) as unknown as typeof fetch;
|
||||
try {
|
||||
const mod = await import("./updateCheck.js");
|
||||
const result = await mod.checkForUpdate(true);
|
||||
const lastWrite = writes.at(-1);
|
||||
return {
|
||||
latest: result.latest,
|
||||
wroteVersion: lastWrite ? (lastWrite["latestVersion"] as string | undefined) : undefined,
|
||||
};
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
}
|
||||
|
||||
describe("checkForUpdate — registry boundary guard", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../telemetry/config.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("caches and returns a valid semver from the registry", async () => {
|
||||
const { latest, wroteVersion } = await checkWith("9.9.9");
|
||||
expect(latest).toBe("9.9.9");
|
||||
expect(wroteVersion).toBe("9.9.9");
|
||||
});
|
||||
|
||||
it("rejects a version carrying shell metacharacters (no cache, no surface)", async () => {
|
||||
const { latest, wroteVersion } = await checkWith("1.2.3; rm -rf /");
|
||||
expect(latest).not.toContain(";");
|
||||
expect(latest).not.toBe("1.2.3; rm -rf /");
|
||||
expect(wroteVersion).toBeUndefined(); // never written to config
|
||||
});
|
||||
|
||||
it("rejects a non-string data.version", async () => {
|
||||
const { latest, wroteVersion } = await checkWith({ evil: true });
|
||||
expect(typeof latest).toBe("string");
|
||||
expect(wroteVersion).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,20 @@ import { compareVersions } from "compare-versions";
|
||||
import { readConfig, writeConfig } from "../telemetry/config.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { isDevMode } from "./env.js";
|
||||
import { detectInstaller } from "./installerDetection.js";
|
||||
|
||||
/**
|
||||
* True when `v` is a strict semver-shaped string. Registry-supplied versions
|
||||
* flow into commands that are displayed AND executed (the `upgrade` command and
|
||||
* the background auto-installer both run them), so a poisoned `latest` carrying
|
||||
* shell metacharacters must never reach them. This is enforced at the registry
|
||||
* boundary in `checkForUpdate` — an unsafe `data.version` is never cached — so
|
||||
* every consumer (notice, upgrade, background auto-install, and any future one)
|
||||
* is covered by this single gate; the per-consumer checks are defense in depth.
|
||||
*/
|
||||
export function isSafeVersion(v: string): boolean {
|
||||
return /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v);
|
||||
}
|
||||
|
||||
const NPM_REGISTRY_URL = "https://registry.npmjs.org/hyperframes/latest";
|
||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -38,7 +52,14 @@ export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult
|
||||
const config = readConfig();
|
||||
const now = Date.now();
|
||||
|
||||
if (!force && config.lastUpdateCheck && config.latestVersion) {
|
||||
// Also guard the cache read: a cache written before this boundary guard
|
||||
// existed could hold an unsafe latestVersion — re-validate before trusting it.
|
||||
if (
|
||||
!force &&
|
||||
config.lastUpdateCheck &&
|
||||
config.latestVersion &&
|
||||
isSafeVersion(config.latestVersion)
|
||||
) {
|
||||
const lastCheck = new Date(config.lastUpdateCheck).getTime();
|
||||
if (now - lastCheck < CHECK_INTERVAL_MS) {
|
||||
return {
|
||||
@@ -60,8 +81,17 @@ export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult
|
||||
|
||||
if (!res.ok) return fallbackResult(config.latestVersion);
|
||||
|
||||
const data = (await res.json()) as { version?: string };
|
||||
const latest = data.version ?? VERSION;
|
||||
const data = (await res.json()) as { version?: unknown };
|
||||
// Registry boundary guard: only a strict-semver STRING is trusted. This
|
||||
// value is cached and later flows into an install command that the
|
||||
// background auto-updater executes, so a poisoned or non-string
|
||||
// data.version (e.g. "1.2.3; rm -rf /") must never be persisted. Reject it
|
||||
// and fall back to the last known-good version. Closes the injection class
|
||||
// for every consumer at one point.
|
||||
if (typeof data.version !== "string" || !isSafeVersion(data.version)) {
|
||||
return fallbackResult(config.latestVersion);
|
||||
}
|
||||
const latest = data.version;
|
||||
|
||||
config.lastUpdateCheck = new Date().toISOString();
|
||||
config.latestVersion = latest;
|
||||
@@ -74,10 +104,13 @@ export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult
|
||||
}
|
||||
|
||||
function fallbackResult(cachedLatest?: string): UpdateCheckResult {
|
||||
// Only surface a cached version we can prove is safe — a pre-existing
|
||||
// poisoned cache must not leak through the fallback path either.
|
||||
const safeCached = cachedLatest && isSafeVersion(cachedLatest) ? cachedLatest : undefined;
|
||||
return {
|
||||
current: VERSION,
|
||||
latest: cachedLatest ?? VERSION,
|
||||
updateAvailable: cachedLatest ? isNewerSemver(cachedLatest, VERSION) : false,
|
||||
latest: safeCached ?? VERSION,
|
||||
updateAvailable: safeCached ? isNewerSemver(safeCached, VERSION) : false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,8 +158,17 @@ export function printUpdateNotice(): void {
|
||||
const meta = getUpdateMeta();
|
||||
if (!meta.updateAvailable || !meta.latestVersion) return;
|
||||
|
||||
// Show the command that updates *this* install: the detected package
|
||||
// manager's upgrade for owned global installs (npm/bun/pnpm/brew), and the
|
||||
// universal `npx hyperframes@latest` for ephemeral/unknown installs (where a
|
||||
// manager command wouldn't apply). detectInstaller() only runs here, after
|
||||
// the suppression + update-available gates, so it adds no cost to normal runs.
|
||||
const safeLatest = isSafeVersion(meta.latestVersion);
|
||||
const managerCommand = safeLatest ? detectInstaller().installCommand(meta.latestVersion) : null;
|
||||
const command = managerCommand ?? "npx hyperframes@latest";
|
||||
|
||||
process.stderr.write(
|
||||
`\n Update available: ${meta.version} \u2192 ${meta.latestVersion}\n` +
|
||||
` Run: npx hyperframes@latest\n\n`,
|
||||
` Run: ${command}\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user