spawn/cli/src/update-check.ts
A ea39c8bf28
fix: prevent command injection in update-check reExecWithArgs (#951)
Replace execSync with execFileSync in reExecWithArgs() to prevent shell
metacharacter injection via binary path. execFileSync bypasses the shell
entirely, executing the binary directly with an argv array.

The performAutoUpdate() call retains execSync since it legitimately needs
a shell for piping (curl | bash).

Fixes #950

Agent: security-auditor

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-13 08:34:04 -08:00

161 lines
5.5 KiB
TypeScript

import "./unicode-detect.js"; // Ensure TERM is set before using symbols
import { execSync as nodeExecSync, execFileSync as nodeExecFileSync } from "child_process";
import pc from "picocolors";
import pkg from "../package.json" with { type: "json" };
import { RAW_BASE } from "./manifest.js";
const VERSION = pkg.version;
// Internal executor for testability - can be replaced in tests
export const executor = {
execSync: (cmd: string, options?: any) => nodeExecSync(cmd, options),
execFileSync: (file: string, args: string[], options?: any) => nodeExecFileSync(file, args, options),
};
// ── Constants ──────────────────────────────────────────────────────────────────
const FETCH_TIMEOUT = 5000; // 5 seconds
// Use ASCII-safe symbols when unicode is disabled (SSH, dumb terminals)
const isAscii = process.env.TERM === "linux";
const CHECK_MARK = isAscii ? "*" : "\u2713";
const CROSS_MARK = isAscii ? "x" : "\u2717";
// ── Helpers ────────────────────────────────────────────────────────────────────
async function fetchLatestVersion(): Promise<string | null> {
try {
const res = await fetch(`${RAW_BASE}/cli/package.json`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (!res.ok) return null;
const pkg = (await res.json()) as { version: string };
return pkg.version;
} catch {
return null;
}
}
function compareVersions(current: string, latest: string): boolean {
// Simple semantic version comparison (assumes format: major.minor.patch)
const parseSemver = (v: string): number[] =>
v.split(".").map((n) => parseInt(n, 10) || 0);
const currentParts = parseSemver(current);
const latestParts = parseSemver(latest);
for (let i = 0; i < 3; i++) {
if ((latestParts[i] || 0) > (currentParts[i] || 0)) return true;
if ((latestParts[i] || 0) < (currentParts[i] || 0)) return false;
}
return false; // Versions are equal
}
/** Print boxed update banner to stderr */
function printUpdateBanner(latestVersion: string): void {
const line1 = `Update available: v${VERSION} -> v${latestVersion}`;
const line2 = "Updating automatically...";
const width = Math.max(line1.length, line2.length) + 4;
const border = "+" + "-".repeat(width) + "+";
console.error(); // Use stderr so it doesn't interfere with parseable output
console.error(pc.yellow(border));
console.error(
pc.yellow("| ") +
pc.bold(`Update available: v${VERSION} -> `) +
pc.green(pc.bold(`v${latestVersion}`)) +
" ".repeat(width - 2 - line1.length) +
pc.yellow(" |")
);
console.error(
pc.yellow("| ") +
pc.bold(line2) +
" ".repeat(width - 2 - line2.length) +
pc.yellow(" |")
);
console.error(pc.yellow(border));
console.error();
}
/** Re-exec the updated binary with the original CLI arguments, forwarding the exit code */
function reExecWithArgs(): void {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error(pc.dim(" Run your spawn command again to use the new version."));
console.error();
process.exit(0);
return; // unreachable in production, but needed when process.exit is mocked in tests
}
const binPath = process.argv[1] || "spawn";
console.error(pc.dim(` Rerunning: spawn ${args.join(" ")}`));
console.error();
try {
executor.execFileSync(binPath, args, {
stdio: "inherit",
env: { ...process.env, SPAWN_NO_UPDATE_CHECK: "1" },
});
process.exit(0);
} catch (reexecErr) {
const code = reexecErr && typeof reexecErr === "object" && "status" in reexecErr
? (reexecErr as { status: number }).status
: 1;
process.exit(code);
}
}
function performAutoUpdate(latestVersion: string): void {
printUpdateBanner(latestVersion);
try {
executor.execSync(`curl -fsSL ${RAW_BASE}/cli/install.sh | bash`, {
stdio: "inherit",
shell: "/bin/bash",
});
console.error();
console.error(pc.green(pc.bold(`${CHECK_MARK} Updated successfully!`)));
reExecWithArgs();
} catch {
console.error();
console.error(pc.red(pc.bold(`${CROSS_MARK} Auto-update failed`)));
console.error(pc.dim(" Please update manually:"));
console.error();
console.error(pc.cyan(` curl -fsSL ${RAW_BASE}/cli/install.sh | bash`));
console.error();
// Continue with original command despite update failure
}
}
// ── Public API ─────────────────────────────────────────────────────────────────
/**
* Check for updates on every run and auto-update if available.
* Uses a 5-second timeout to avoid blocking for too long.
*/
export async function checkForUpdates(): Promise<void> {
// Skip in test environment
if (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test") {
return;
}
// Skip if SPAWN_NO_UPDATE_CHECK is set
if (process.env.SPAWN_NO_UPDATE_CHECK === "1") {
return;
}
// Always fetch the latest version on every run
try {
const latestVersion = await fetchLatestVersion();
if (!latestVersion) return;
// Auto-update if newer version is available
if (compareVersions(VERSION, latestVersion)) {
performAutoUpdate(latestVersion);
}
} catch {
// Silently fail - update check is non-critical
}
}