#!/usr/bin/env bash # StackPatch agent installer — paid customers only. # Source: https://mindsparkstack.com/install.sh # # What it does: # - Asks the StackPatch API to register this server under your token # - Drops a small inventory script at /usr/local/bin/stackpatch-inventory.sh # - Drops your auth config at /etc/stackpatch/agent.conf (mode 600) # - Adds an hourly cron entry (no systemd dependency) # - Runs first inventory + prints your audit URL # # What it does NOT do: # - Modify your packages # - Send hostnames, IPs, public keys, or env vars # - Persist anything other than what you can see in the inventory output set -eu API_BASE="https://mindsparkstack.com" TOKEN="" DISPLAY_NAME="" MODE="install" while [ $# -gt 0 ]; do case "$1" in --token) TOKEN="${2:-}"; shift 2 ;; --token=*) TOKEN="${1#--token=}"; shift ;; --name) DISPLAY_NAME="${2:-}"; shift 2 ;; --name=*) DISPLAY_NAME="${1#--name=}"; shift ;; --uninstall) MODE="uninstall"; shift ;; *) echo "stackpatch-install: unknown arg: $1" >&2; exit 1 ;; esac done if [ "$(id -u)" -ne 0 ]; then echo "stackpatch-install: must run as root (sudo bash)" >&2 exit 1 fi if [ "${MODE}" = "uninstall" ]; then echo "=== StackPatch agent uninstall ===" rm -f /usr/local/bin/stackpatch-inventory.sh rm -rf /etc/stackpatch ( crontab -l 2>/dev/null | grep -v 'stackpatch-inventory.sh' ) | crontab - || true echo " removed: /usr/local/bin/stackpatch-inventory.sh" echo " removed: /etc/stackpatch/" echo " removed: cron entry" echo echo "Inventory data on the StackPatch side stays for ~14 days then auto-purges." echo "Email agents@mindsparkstack.com to delete immediately." exit 0 fi if [ -z "${TOKEN}" ]; then echo "stackpatch-install: --token required (get yours at $API_BASE/patch/onboarding/success after checkout)" >&2 exit 1 fi for cmd in curl python3 uname; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "stackpatch-install: missing dependency: $cmd" >&2 exit 1 fi done # Need ONE of: dpkg-query (Debian/Ubuntu), apk (Alpine), rpm (RHEL family) if ! command -v dpkg-query >/dev/null 2>&1 && ! command -v apk >/dev/null 2>&1 && ! command -v rpm >/dev/null 2>&1; then echo "stackpatch-install: need one of dpkg-query / apk / rpm" >&2 exit 1 fi # 1. Gather minimal facts for enrollment DISTRO="" CODENAME="" if [ -r /etc/os-release ]; then . /etc/os-release DISTRO="${ID:-}" CODENAME="${VERSION_CODENAME:-}" fi KERNEL="$(uname -r)" HOSTNAME_SAFE="$(hostname 2>/dev/null | head -c 64)" [ -z "${DISPLAY_NAME}" ] && DISPLAY_NAME="${HOSTNAME_SAFE:-server}" echo echo "=== StackPatch agent install ===" echo " distro: ${DISTRO}" echo " codename: ${CODENAME}" echo " kernel: ${KERNEL}" echo " display: ${DISPLAY_NAME}" echo # 2. Enroll this server with the API ENROLL_PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'display_name': sys.argv[1], 'hostname': sys.argv[2], 'distro': sys.argv[3], 'codename': sys.argv[4], 'kernel': sys.argv[5]}))" "${DISPLAY_NAME}" "${HOSTNAME_SAFE}" "${DISTRO}" "${CODENAME}" "${KERNEL}") ENROLL_RESP=$(curl -fsS -X POST -H "Content-Type: application/json" -H "X-StackPatch-Token: ${TOKEN}" -d "${ENROLL_PAYLOAD}" "${API_BASE}/api/stackpatch/enroll") SERVER_ID=$(echo "${ENROLL_RESP}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('server_id',''))") AUDIT_URL=$(echo "${ENROLL_RESP}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('audit_url',''))") if [ -z "${SERVER_ID}" ] || [ -z "${AUDIT_URL}" ]; then echo "stackpatch-install: enroll failed: ${ENROLL_RESP}" >&2 exit 2 fi echo " server_id: ${SERVER_ID}" echo " audit_url: ${AUDIT_URL}" echo # 3. Persist auth config (mode 600 — only root reads) mkdir -p /etc/stackpatch umask 077 cat > /etc/stackpatch/agent.conf < /usr/local/bin/stackpatch-inventory.sh <<'AGENT' #!/usr/bin/env bash # StackPatch hourly inventory agent — installed by curl https://mindsparkstack.com/install.sh set -eu . /etc/stackpatch/agent.conf DISTRO=""; CODENAME=""; VERSION_ID="" if [ -r /etc/os-release ]; then . /etc/os-release DISTRO="${ID:-}" CODENAME="${VERSION_CODENAME:-}" VERSION_ID="${VERSION_ID:-}" fi # Alpine has no VERSION_CODENAME; pass the major.minor as codename if [ "$DISTRO" = "alpine" ] && [ -z "$CODENAME" ]; then CODENAME="v${VERSION_ID%.*}" # 3.18.5 → v3.18 fi # Rocky / AlmaLinux / RHEL: codename is the major version (matches OSV ecosystem keys) case "$DISTRO" in rocky|rockylinux|almalinux|alma|rhel|centos) if [ -z "$CODENAME" ]; then CODENAME="${VERSION_ID%%.*}" # 9.3 → 9 fi ;; esac KERNEL="$(uname -r)" # Distro-aware package list collection. All formats normalize to {name: version}. if command -v dpkg-query >/dev/null 2>&1; then PKG_JSON=$(dpkg-query -W -f='${Package}\t${Version}\n' 2>/dev/null | head -200 | python3 -c " import sys, json out = {} for line in sys.stdin: parts = line.rstrip().split('\t') if len(parts) == 2 and parts[0]: out[parts[0]] = parts[1] print(json.dumps(out))") elif command -v rpm >/dev/null 2>&1; then PKG_JSON=$(rpm -qa --qf '%{NAME}\t%{EPOCH}:%{VERSION}-%{RELEASE}\n' 2>/dev/null | head -200 | python3 -c " import sys, json out = {} for line in sys.stdin: parts = line.rstrip().split('\t') if len(parts) == 2 and parts[0]: v = parts[1] # Strip '(none):' epoch prefix that rpm uses for unset epochs if v.startswith('(none):'): v = v[7:] out[parts[0]] = v print(json.dumps(out))") elif command -v apk >/dev/null 2>&1; then PKG_JSON=$(apk info -v 2>/dev/null | head -200 | python3 -c " import sys, json, re out = {} # Alpine 'apk info -v' returns lines like 'openssl-3.0.11-r0', need to split name/version for line in sys.stdin: line = line.strip() m = re.match(r'^(.+?)-(\d.*)$', line) if m: out[m.group(1)] = m.group(2) print(json.dumps(out))") else PKG_JSON='{}' fi PAYLOAD=$(python3 -c " import json, sys, os, subprocess def safe_run(cmd): try: return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip() except: return '' images = [] if os.path.exists('/usr/bin/docker'): out = safe_run(['docker', 'images', '--format', '{{.Repository}}:{{.Tag}}']) images = [l for l in out.splitlines() if l and ':' in l][:50] ports = [] out = safe_run(['ss', '-tlnH']) for line in out.splitlines()[:100]: cols = line.split() if len(cols) >= 4: ports.append(cols[3]) mitig = [] import glob for f in glob.glob('/etc/modprobe.d/*.conf'): try: with open(f) as fh: for line in fh: line = line.strip() if line.startswith('blacklist '): mitig.append({'file': os.path.basename(f), 'line': line}) except: pass # /var/run/reboot-required is dropped by unattended-upgrades / needrestart # whenever a kernel or libc upgrade requires a restart to take effect. reboot_pending = os.path.exists('/var/run/reboot-required') # Best-effort: latest installed kernel (different from running) tells us if # an apt upgrade already shipped a fix that just needs a reboot. installed_kernels = safe_run(['dpkg-query', '-W', '-f=${Package}\t${Version}\n', 'linux-image-*-generic']) latest_kernel = '' for line in installed_kernels.splitlines(): parts = line.split('\t') if len(parts) == 2 and parts[0].startswith('linux-image-'): ver = parts[0].replace('linux-image-', '').replace('-generic', '') if not latest_kernel or ver > latest_kernel: latest_kernel = ver print(json.dumps({ 'distro': sys.argv[1], 'codename': sys.argv[2], 'kernel': sys.argv[3], 'kernel_installed_latest': latest_kernel or sys.argv[3], 'reboot_pending': reboot_pending, 'packages': json.loads(sys.argv[4]), 'docker_images': images, 'open_ports': ports[:50], 'modprobe_blacklists': mitig, }))" "${DISTRO}" "${CODENAME}" "${KERNEL}" "${PKG_JSON}") curl -fsS -X POST -H "Content-Type: application/json" -H "X-StackPatch-Token: ${STACKPATCH_TOKEN}" -H "X-StackPatch-Server: ${STACKPATCH_SERVER}" -d "${PAYLOAD}" "${STACKPATCH_API}/api/stackpatch/inventory" > /dev/null AGENT chmod +x /usr/local/bin/stackpatch-inventory.sh # 5. Schedule via cron (every hour at :07 to avoid feed-fetch contention) ( crontab -l 2>/dev/null | grep -v 'stackpatch-inventory.sh' ; echo "7 * * * * /usr/local/bin/stackpatch-inventory.sh >> /var/log/stackpatch-inventory.log 2>&1" ) | crontab - # 6. First run echo "Running first inventory..." /usr/local/bin/stackpatch-inventory.sh && echo " ok" echo echo "=== StackPatch agent installed ===" echo " Audit URL: ${AUDIT_URL}" echo " Inventory: /usr/local/bin/stackpatch-inventory.sh (cron @ :07 hourly)" echo " Config: /etc/stackpatch/agent.conf (mode 600)" echo " Logs: /var/log/stackpatch-inventory.log" echo " Uninstall: curl -fsSL https://mindsparkstack.com/install.sh | sudo bash -s -- --uninstall" echo echo "First CVE match runs within ~30 min on the StackPatch matcher cycle." echo "You will receive an email if any active findings are detected." echo