diff --git a/.github/workflows/installer-hash-check.yaml b/.github/workflows/installer-hash-check.yaml new file mode 100644 index 0000000000..626bb97aa9 --- /dev/null +++ b/.github/workflows/installer-hash-check.yaml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Verifies pinned installer SHA-256 hashes still match upstream scripts. +# Checked: Ollama installer. +# Runs on every PR and push to main, plus a weekly scheduled check. + +name: installer-hash-check + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + schedule: + # Weekly fallback in case upstream changes between PRs + - cron: "30 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-hash: + if: github.repository == 'NVIDIA/NemoClaw' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Verify installer hashes are current + run: bash scripts/check-installer-hash.sh diff --git a/install.sh b/install.sh index bf33782e45..356f856f06 100755 --- a/install.sh +++ b/install.sh @@ -19,7 +19,7 @@ resolve_release_tag() { } verify_downloaded_script() { - local file="$1" label="${2:-installer}" + local file="$1" label="${2:-installer}" expected_hash="${3:-}" if [[ ! -s "$file" ]]; then printf "[ERROR] %s download is empty or missing\n" "$label" >&2 exit 1 @@ -28,6 +28,23 @@ verify_downloaded_script() { printf "[ERROR] %s does not start with a shell shebang\n" "$label" >&2 exit 1 fi + if [[ -n "$expected_hash" ]]; then + local actual_hash="" + if command -v sha256sum >/dev/null 2>&1; then + actual_hash="$(sha256sum "$file" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual_hash="$(shasum -a 256 "$file" | awk '{print $1}')" + fi + if [[ -z "$actual_hash" ]]; then + printf "[ERROR] No SHA-256 tool available — cannot verify %s integrity\n" "$label" >&2 + exit 1 + fi + if [[ "$actual_hash" != "$expected_hash" ]]; then + rm -f "$file" + printf "[ERROR] %s integrity check failed\n Expected: %s\n Actual: %s\n" "$label" "$expected_hash" "$actual_hash" >&2 + exit 1 + fi + fi } has_payload_marker() { diff --git a/scripts/check-installer-hash.sh b/scripts/check-installer-hash.sh new file mode 100755 index 0000000000..362c086c4b --- /dev/null +++ b/scripts/check-installer-hash.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Verifies that pinned SHA-256 hashes for downloaded installers still match +# the current upstream scripts. +# +# Checked installers: +# 1. Ollama installer — scripts/install.sh (OLLAMA_INSTALL_SHA256) +# +# Usage: +# scripts/check-installer-hash.sh # exit 0 if current, 1 if stale +# scripts/check-installer-hash.sh --update # rewrite stale hashes in-place + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +case "${1:-}" in + "" | --update) ;; + *) + echo "Usage: scripts/check-installer-hash.sh [--update]" >&2 + exit 2 + ;; +esac + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +fetch_hash() { + local url="$1" tmpfile + tmpfile=$(mktemp) + trap 'rm -f "$tmpfile"' RETURN + + curl --proto '=https' --tlsv1.2 -fsSL \ + --connect-timeout 10 --max-time 30 \ + --retry 3 --retry-delay 1 --retry-all-errors \ + -o "$tmpfile" "$url" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$tmpfile" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$tmpfile" | awk '{print $1}' + else + echo "ERROR: No SHA-256 tool available (sha256sum/shasum)." >&2 + return 1 + fi +} + +extract_pinned() { + local file="$1" var_name="$2" + sed -n "s/.*${var_name}=\"\\([a-f0-9]\\{64\\}\\)\".*/\\1/p" "$file" | head -1 +} + +update_pinned() { + local file="$1" old_hash="$2" new_hash="$3" + sed -i.bak "s/${old_hash}/${new_hash}/" "$file" + rm -f "${file}.bak" +} + +# --------------------------------------------------------------------------- +# Registry of pinned hashes: (label, file, variable, upstream URL) +# --------------------------------------------------------------------------- +LABELS=() +FILES=() +VARS=() +URLS=() + +register() { + LABELS+=("$1") + FILES+=("$2") + VARS+=("$3") + URLS+=("$4") +} + +register "Ollama installer" \ + "${REPO_ROOT}/scripts/install.sh" \ + "OLLAMA_INSTALL_SHA256" \ + "https://ollama.com/install.sh" + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +failures=0 + +for i in "${!LABELS[@]}"; do + label="${LABELS[$i]}" + file="${FILES[$i]}" + var="${VARS[$i]}" + url="${URLS[$i]}" + + pinned=$(extract_pinned "$file" "$var") + + if [[ -z "$pinned" ]]; then + echo " SKIP: ${var} not found in ${file} (not yet merged?)" + continue + fi + + echo "Checking ${label} (${var})..." + echo " Fetching ${url}..." + upstream=$(fetch_hash "$url") + + if [[ "$pinned" == "$upstream" ]]; then + echo " OK: hash is up-to-date (${pinned})" + continue + fi + + if [[ "${1:-}" == "--update" ]]; then + update_pinned "$file" "$pinned" "$upstream" + echo " UPDATED ${file}: ${var}" + echo " old: ${pinned}" + echo " new: ${upstream}" + else + echo " STALE: pinned hash does not match upstream." + echo " pinned: ${pinned}" + echo " upstream: ${upstream}" + failures=$((failures + 1)) + fi +done + +if ((failures > 0)); then + echo "" + echo "${failures} hash(es) are stale. To update, run:" + echo "" + echo " scripts/check-installer-hash.sh --update" + echo "" + exit 1 +fi + +echo "" +echo "All installer hashes are current." diff --git a/scripts/install.sh b/scripts/install.sh index 64c0738bae..2035303108 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -134,21 +134,30 @@ error() { ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$*"; } verify_downloaded_script() { - local file="$1" label="${2:-script}" + local file="$1" label="${2:-script}" expected_hash="${3:-}" if [ ! -s "$file" ]; then - error "$label installer download is empty or missing" + error "$label download is empty or missing" fi if ! head -1 "$file" | grep -qE '^#!.*(sh|bash)'; then - error "$label installer does not start with a shell shebang — possible download corruption" + error "$label does not start with a shell shebang — possible download corruption" fi - local hash + local actual_hash="" if command -v sha256sum >/dev/null 2>&1; then - hash="$(sha256sum "$file" | awk '{print $1}')" + actual_hash="$(sha256sum "$file" | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then - hash="$(shasum -a 256 "$file" | awk '{print $1}')" + actual_hash="$(shasum -a 256 "$file" | awk '{print $1}')" fi - if [ -n "${hash:-}" ]; then - info "$label installer SHA-256: $hash" + if [ -n "$expected_hash" ]; then + if [ -z "$actual_hash" ]; then + error "No SHA-256 tool available — cannot verify $label integrity" + fi + if [ "$actual_hash" != "$expected_hash" ]; then + rm -f "$file" + error "$label integrity check failed\n Expected: $expected_hash\n Actual: $actual_hash" + fi + info "$label integrity verified (SHA-256: ${actual_hash:0:16}…)" + elif [ -n "$actual_hash" ]; then + info "$label SHA-256: $actual_hash" fi } @@ -689,6 +698,9 @@ install_nodejs() { # 2. Ollama # --------------------------------------------------------------------------- OLLAMA_MIN_VERSION="0.18.0" +# IMPORTANT: update OLLAMA_INSTALL_SHA256 when changing OLLAMA_MIN_VERSION +# Pattern: pin hash and verify, same as NVM_SHA256 above (line ~656). +OLLAMA_INSTALL_SHA256="25f64b810b947145095956533e1bdf56eacea2673c55a7e586be4515fc882c9f" get_ollama_version() { # `ollama --version` outputs something like "ollama version 0.18.0" @@ -732,7 +744,7 @@ install_or_upgrade_ollama() { tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" - verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" + verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" "$OLLAMA_INSTALL_SHA256" sh "$tmpdir/install_ollama.sh" ) info "Ollama upgraded to $(get_ollama_version)" @@ -745,7 +757,7 @@ install_or_upgrade_ollama() { tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT curl -fsSL https://ollama.com/install.sh -o "$tmpdir/install_ollama.sh" - verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" + verify_downloaded_script "$tmpdir/install_ollama.sh" "Ollama" "$OLLAMA_INSTALL_SHA256" sh "$tmpdir/install_ollama.sh" ) info "Ollama installed: v$(get_ollama_version)"