diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 2010cfeb..d31c83dd 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -238,16 +238,27 @@ function uninstall(args) { exitWithSpawnResult(result); } + // Download to file before execution — prevents partial-download execution. + // Upstream URL is a rolling release so SHA-256 pinning isn't practical. console.log(` Local uninstall script not found; falling back to ${REMOTE_UNINSTALL_URL}`); - const forwardedArgs = args.map(shellQuote).join(" "); - const command = forwardedArgs.length > 0 - ? `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash -s -- ${forwardedArgs}` - : `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash`; - const result = spawnSync("bash", ["-c", command], { - stdio: "inherit", - cwd: ROOT, - env: process.env, - }); + const uninstallDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-")); + const uninstallScript = path.join(uninstallDir, "uninstall.sh"); + let result; + try { + try { + execFileSync("curl", ["-fsSL", REMOTE_UNINSTALL_URL, "-o", uninstallScript], { stdio: "inherit" }); + } catch { + console.error(` Failed to download uninstall script from ${REMOTE_UNINSTALL_URL}`); + process.exit(1); + } + result = spawnSync("bash", [uninstallScript, ...args], { + stdio: "inherit", + cwd: ROOT, + env: process.env, + }); + } finally { + fs.rmSync(uninstallDir, { recursive: true, force: true }); + } exitWithSpawnResult(result); } diff --git a/install.sh b/install.sh index 0452d282..ead3e952 100755 --- a/install.sh +++ b/install.sh @@ -367,14 +367,28 @@ install_or_upgrade_ollama() { info "Ollama v${current} meets minimum requirement (>= v${OLLAMA_MIN_VERSION})" else info "Ollama v${current:-unknown} is below v${OLLAMA_MIN_VERSION} — upgrading…" - curl -fsSL https://ollama.com/install.sh | sh + # Upstream URL is a rolling release so SHA-256 pinning isn't practical, + # but download-then-execute allows inspection and prevents partial-download execution. + ( + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" + sh "$tmpdir/install_ollama.sh" + ) info "Ollama upgraded to $(get_ollama_version)" fi else # No ollama — only install if a GPU is present if detect_gpu; then info "GPU detected — installing Ollama…" - curl -fsSL https://ollama.com/install.sh | sh + # Upstream URL is a rolling release so SHA-256 pinning isn't practical, + # but download-then-execute allows inspection and prevents partial-download execution. + ( + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" + sh "$tmpdir/install_ollama.sh" + ) info "Ollama installed: v$(get_ollama_version)" else warn "No GPU detected — skipping Ollama installation." diff --git a/scripts/brev-setup.sh b/scripts/brev-setup.sh index cc8701ba..2f123a61 100755 --- a/scripts/brev-setup.sh +++ b/scripts/brev-setup.sh @@ -39,7 +39,14 @@ export DEBIAN_FRONTEND=noninteractive # --- 0. Node.js (needed for services) --- if ! command -v node >/dev/null 2>&1; then info "Installing Node.js..." - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >/dev/null 2>&1 + # Upstream URL is a rolling release so SHA-256 pinning isn't practical, + # but download-then-execute allows inspection and prevents partial-download execution. + ( + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL https://deb.nodesource.com/setup_22.x -o "$tmpdir/setup_node.sh" + sudo -E bash "$tmpdir/setup_node.sh" >/dev/null 2>&1 + ) sudo apt-get install -y -qq nodejs >/dev/null 2>&1 info "Node.js $(node --version) installed" else diff --git a/scripts/install.sh b/scripts/install.sh index b00de5e8..1b56c6f4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -235,7 +235,14 @@ install_node() { brew link --overwrite node@22 2>/dev/null || true ;; nodesource) - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >/dev/null 2>&1 + # Upstream URL is a rolling release so SHA-256 pinning isn't practical, + # but download-then-execute allows inspection and prevents partial-download execution. + ( + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL https://deb.nodesource.com/setup_22.x -o "$tmpdir/setup_node.sh" + sudo -E bash "$tmpdir/setup_node.sh" >/dev/null 2>&1 + ) sudo apt-get install -y -qq nodejs >/dev/null 2>&1 ;; none) diff --git a/test/runner.test.js b/test/runner.test.js index f35c8825..15fe3b00 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -208,4 +208,61 @@ describe("runner helpers", () => { expect(!src.includes("execSync")).toBeTruthy(); }); }); + + describe("curl-pipe-to-shell guards (#574, #583)", () => { + // Strip comment lines, then join line continuations so multiline + // curl ... |\n bash patterns are caught by the single-line regex. + const stripComments = (src, commentPrefix) => + src.split("\n").filter((l) => !l.trim().startsWith(commentPrefix)).join("\n"); + + const joinContinuations = (src) => + src.replace(/\\\n\s*/g, " "); + + const collapseMultilinePipes = (src) => + src.replace(/\|\s*\n\s*/g, "| "); + + const normalize = (src, commentPrefix) => + collapseMultilinePipes(joinContinuations(stripComments(src, commentPrefix))); + + const shellViolationRe = /curl\s[^|]*\|\s*(sh|bash|sudo\s+(-\S+\s+)*(sh|bash))\b/; + const jsViolationRe = /curl.*\|\s*(sh|bash|sudo\s+(-\S+\s+)*(sh|bash))\b/; + + const findShellViolations = (src) => { + const normalized = normalize(src, "#"); + return normalized.split("\n").filter((line) => { + const t = line.trim(); + if (t.startsWith("printf") || t.startsWith("echo")) return false; + return shellViolationRe.test(t); + }); + }; + + const findJsViolations = (src) => { + const normalized = normalize(src, "//"); + return normalized.split("\n").filter((line) => { + const t = line.trim(); + if (t.startsWith("*")) return false; + return jsViolationRe.test(t); + }); + }; + + it("install.sh does not pipe curl to shell", () => { + const src = fs.readFileSync(path.join(import.meta.dirname, "..", "install.sh"), "utf-8"); + expect(findShellViolations(src)).toEqual([]); + }); + + it("scripts/install.sh does not pipe curl to shell", () => { + const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "install.sh"), "utf-8"); + expect(findShellViolations(src)).toEqual([]); + }); + + it("scripts/brev-setup.sh does not pipe curl to shell", () => { + const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "brev-setup.sh"), "utf-8"); + expect(findShellViolations(src)).toEqual([]); + }); + + it("bin/nemoclaw.js does not pipe curl to shell", () => { + const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); + expect(findJsViolations(src)).toEqual([]); + }); + }); });