#!/bin/sh # # bolt-network-check.sh - watch every network connection Bolt makes, and name it. # # Sparcle publishes a list of what Bolt contacts (https://sparcle.app/trust/network-allowlist/). # This script does not ask you to believe that list. It samples the real sockets held by Bolt's # own processes on your machine and prints them, classified against the published list, so # anything we did not disclose stands out. # # It is read-only. It changes nothing, installs nothing, and uploads nothing. The only network # traffic it can generate is reverse-DNS lookups to name the addresses it finds, and --no-dns # turns even that off. Read it before you run it; it is deliberately short. # # sh bolt-network-check.sh # watch for 60 seconds # sh bolt-network-check.sh -d 300 # watch for 5 minutes # sh bolt-network-check.sh --no-dns # do not resolve names # # Requires lsof, which ships with macOS and is packaged on every Linux distribution. # On Windows, use the PowerShell version: https://sparcle.app/scripts/bolt-network-check.ps1 set -eu DURATION=60 INTERVAL=2 RESOLVE=1 while [ $# -gt 0 ]; do case "$1" in -d|--duration) DURATION="$2"; shift 2 ;; -i|--interval) INTERVAL="$2"; shift 2 ;; --no-dns) RESOLVE=0; shift ;; -h|--help) sed -n '2,20p' "$0"; exit 0 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac done command -v lsof >/dev/null 2>&1 || { echo "This script needs lsof. Install it and run again." >&2; exit 1; } TMP=$(mktemp -d "${TMPDIR:-/tmp}/bolt-netcheck.XXXXXX") trap 'rm -rf "$TMP"' EXIT INT TERM : > "$TMP/remote" : > "$TMP/local" # Bolt's own processes: the app shell, the local API sidecar, and the embedded Postgres it # runs for you on your own machine. Nothing else on your system is examined. bolt_pids() { pgrep -f 'Bolt\.app/Contents/MacOS/bolt' 2>/dev/null || true pgrep -x 'bolt' 2>/dev/null || true pgrep -x 'bolt-api' 2>/dev/null || true pgrep -f 'sparcle.*embedded-postgres' 2>/dev/null || true } sample() { for pid in $(bolt_pids | sort -u); do lsof -nP -a -p "$pid" -i 2>/dev/null | tail -n +2 | while read -r cmd _rest; do set -- $_rest # the socket description is the second-to-last or last field depending on state conn=$(echo "$_rest" | awk '{for(i=1;i<=NF;i++) if ($i ~ /->/ || $i ~ /:[0-9]+$/) last=$i; print last}') [ -n "${conn:-}" ] || continue case "$conn" in *"->"*) remote=${conn#*->} ;; *) echo "$cmd $conn" >> "$TMP/local"; continue ;; esac case "$remote" in 127.0.0.1:*|\[::1\]:*|localhost:*) echo "$cmd $conn" >> "$TMP/local"; continue ;; esac echo "$cmd $remote" >> "$TMP/remote" done done } # Classify a remote address against what we publish. Anything that falls through to UNDISCLOSED # is, by definition, something we did not tell you about. That is the case worth reporting. classify() { host="$1" case "$host" in *github.com|*githubusercontent.com) echo "Update check. The desktop updater fetches a release manifest from GitHub. No Sparcle server is involved." ;; *sparcle.app) echo "Sparcle. Bolt does not need us at runtime, so this is worth a look. Tell us at security@sparcle.app." ;; *1e100.net|*google.com|*googleapis.com|*googleusercontent.com|*gstatic.com) echo "Google, direct from your machine. Present only if you connected a Google account. Your data goes to Google, not through Sparcle." ;; *microsoft.com|*microsoftonline.com|*office.com|*office365.com|*outlook.com|*azure.com|*windows.net) echo "Microsoft 365, direct from your machine. Present only if you connected a Microsoft account." ;; *huggingface.co|*hf.co|*cdn-lfs*) echo "Hugging Face. A one-time download of an open model you enabled. It runs on-device afterwards." ;; *anthropic.com|*openai.com|*openai.azure.com) echo "The LLM provider you configured. Bolt ships no default endpoint; this is the one you chose." ;; *apple.com|*icloud.com|*aaplimg.com) echo "Apple. Notarization and OS-level checks made by macOS itself." ;; *) classify_by_address "$host" ;; esac } # Reverse DNS is not always available. Google, in particular, leaves most of its serving # addresses unnamed, and an honest tool must not report that as a finding. So fall back to the # address blocks the large providers publish, and say plainly when we cannot name something # rather than calling it suspicious. classify_by_address() { case "$1" in 2001:4860:*|2607:f8b0:*|142.250.*|142.251.*|172.217.*|172.253.*|216.58.*|74.125.*|64.233.*|35.190.*) echo "Google address block, direct from your machine. Present only if you connected a Google account." ;; 2603:*|2620:1ec:*|13.107.*|20.190.*|40.126.*|52.96.*|52.98.*|52.108.*) echo "Microsoft address block, direct from your machine. Present only if you connected a Microsoft account." ;; 140.82.*|185.199.*|192.30.*|2606:50c0:*) echo "GitHub address block. This is the update check." ;; 17.*|2620:149:*) echo "Apple address block. Checks macOS makes on its own." ;; *) echo "Not named by reverse DNS, and not in a block we recognise. Identify it with: whois $1" ;; esac } pids=$(bolt_pids | sort -u | tr '\n' ' ') if [ -z "$(echo "$pids" | tr -d ' ')" ]; then echo "Bolt does not appear to be running. Start it, then run this again." exit 1 fi echo "Watching Bolt for ${DURATION}s. Use Bolt normally while this runs; the more you do, the better the test." echo "Processes: $pids" echo elapsed=0 while [ "$elapsed" -lt "$DURATION" ]; do sample sleep "$INTERVAL" elapsed=$((elapsed + INTERVAL)) printf '.' done echo echo echo "=== Connections that left this machine ===" if [ ! -s "$TMP/remote" ]; then echo "None. Over ${DURATION}s, Bolt's processes opened no connection to anything outside your own machine." else sort -u "$TMP/remote" | while read -r cmd remote; do addr=${remote%:*} addr=${addr#[}; addr=${addr%]} name="$addr" if [ "$RESOLVE" = "1" ] && command -v host >/dev/null 2>&1; then resolved=$(host "$addr" 2>/dev/null | awk '/domain name pointer/ {print $NF}' | head -1) [ -n "$resolved" ] && name="${resolved%.}" fi printf ' %-9s %-45s %s\n' "$cmd" "$name" "$(classify "$name")" done fi echo echo "=== What stayed on this machine ===" echo " Bolt runs its own services on loopback. This is where your data actually is." echo # A loopback line names two ports: one is the service, the other is an ephemeral client port # the OS picked. Keep the one we can name, drop the pair when neither is ours. sort -u "$TMP/local" | while read -r cmd conn; do a=$(echo "$conn" | sed 's/->.*//' | sed 's/.*://') b=$(echo "$conn" | sed 's/.*->//' | sed 's/.*://') for port in "$a" "$b"; do case "$port" in 13017|13018|13020|15499|6379) echo "$cmd $port" ;; esac done done | sort -u | while read -r cmd port; do case "$port" in 13017) label="the app shell's local bridge" ;; 13018) label="the local Bolt API. Every request the app makes goes here, on 127.0.0.1" ;; 13020) label="the local clipboard and hotkey bridge" ;; 15499) label="the Postgres database Bolt runs for you, bound to loopback only" ;; 6379) label="the local cache" ;; esac printf ' %-9s 127.0.0.1:%-6s %s\n' "$cmd" "$port" "$label" done echo printf ' %s loopback sockets seen in total. None of them can leave this machine.\n' \ "$(sort -u "$TMP/local" | wc -l | tr -d ' ')" echo echo "Published list of what Bolt contacts: https://sparcle.app/trust/network-allowlist/" echo "Anything that leaves and does not match that list is something we failed to disclose. Tell us at security@sparcle.app."