preinstall-check.sh 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. #!/usr/bin/env bash
  2. # Release-age pre-check for dependencies — enforce the cooldown policy.
  3. #
  4. # Flags any package whose target version was published inside the cooldown window
  5. # (default 7 days), because the 2026 worm campaign poisons brand-new releases that
  6. # are removed within hours. Routes to `socket` for a behavioural verdict when the
  7. # CLI is installed. Queries public registries (npm registry / PyPI JSON API) — no
  8. # auth, no install, read-only.
  9. #
  10. # Usage: preinstall-check.sh [--npm|--pip|--composer|--cargo|--go] [--json] [-q] <pkg>[@version] ...
  11. # Input: one or more package specs as positionals; a flag picks the ecosystem
  12. # (default npm; Composer specs are vendor/pkg[@version])
  13. # Output: stdout = per-package records (tab-separated, or JSON with --json)
  14. # Stderr: headers, socket suggestions, progress, errors
  15. # Exit: 0 all outside cooldown, 2 usage, 5 missing-dep, 7 registry-unavailable,
  16. # 10 at-least-one-inside-cooldown
  17. #
  18. # Examples:
  19. # preinstall-check.sh axios react@19.0.0
  20. # preinstall-check.sh --pip requests fastapi@0.110.0
  21. # preinstall-check.sh --composer laravel-lang/lang craftcms/cms@4.5.0
  22. # preinstall-check.sh --cargo serde ; preinstall-check.sh --go github.com/gin-gonic/gin
  23. # preinstall-check.sh --json axios | jq '.data[] | select(.inside_cooldown)'
  24. # COOLDOWN_DAYS=14 preinstall-check.sh left-pad
  25. set -uo pipefail
  26. EXIT_OK=0; EXIT_USAGE=2; EXIT_MISSING_DEP=5; EXIT_UNAVAILABLE=7; EXIT_INSIDE=10
  27. ECOSYSTEM="npm"; COOLDOWN_DAYS="${COOLDOWN_DAYS:-7}"; JSON=0; QUIET=0; PKGS=()
  28. while [[ $# -gt 0 ]]; do
  29. case "$1" in
  30. --pip|--pypi) ECOSYSTEM="pypi" ;;
  31. --npm) ECOSYSTEM="npm" ;;
  32. --composer) ECOSYSTEM="composer" ;;
  33. --cargo) ECOSYSTEM="cargo" ;;
  34. --go) ECOSYSTEM="go" ;;
  35. --json) JSON=1 ;;
  36. -q|--quiet) QUIET=1 ;;
  37. -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
  38. -*) echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
  39. *) PKGS+=("$1") ;;
  40. esac
  41. shift
  42. done
  43. [[ ${#PKGS[@]} -eq 0 ]] && { echo "ERROR: no package specs given (try --help)" >&2; exit "$EXIT_USAGE"; }
  44. command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit "$EXIT_MISSING_DEP"; }
  45. HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
  46. [[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]] && {
  47. echo '{"error":{"code":"MISSING_DEPENDENCY","message":"jq required for --json"}}'
  48. echo "ERROR: jq required for --json" >&2; exit "$EXIT_MISSING_DEP"; }
  49. HAS_SOCKET=0; command -v socket >/dev/null 2>&1 && HAS_SOCKET=1
  50. emit() { [[ "$QUIET" -eq 1 ]] && return; printf '%s\n' "$1" >&2; }
  51. now_epoch=$(date +%s); inside=0; unavailable=0
  52. JSON_OBJS=()
  53. iso_to_epoch() {
  54. local ts=$1
  55. date -d "$ts" +%s 2>/dev/null && return 0
  56. ts="${ts%%.*}"; ts="${ts%Z}"
  57. date -j -f "%Y-%m-%dT%H:%M:%S" "$ts" +%s 2>/dev/null && return 0
  58. echo ""
  59. }
  60. result() { # name version published
  61. local name=$1 version=$2 published=$3 days=-1 ic=false
  62. if [[ -n "$version" && -n "$published" ]]; then
  63. local epoch; epoch=$(iso_to_epoch "$published")
  64. if [[ -n "$epoch" ]]; then
  65. days=$(( (now_epoch - epoch) / 86400 ))
  66. if [[ "$days" -lt "$COOLDOWN_DAYS" ]]; then ic=true; inside=1; fi
  67. fi
  68. fi
  69. # data record → stdout (non-json mode)
  70. if [[ "$JSON" -eq 0 ]]; then
  71. printf '%s\t%s\t%s\t%s\t%s\n' "$ECOSYSTEM" "$name" "${version:-?}" "${days}" "$ic"
  72. fi
  73. [[ "$HAS_JQ" -eq 1 ]] && JSON_OBJS+=("$(jq -cn \
  74. --arg e "$ECOSYSTEM" --arg n "$name" --arg v "$version" \
  75. --arg p "$published" --argjson d "$days" --argjson ic "$ic" \
  76. '{ecosystem:$e, name:$n, version:($v|select(length>0)), published:($p|select(length>0)), age_days:(if $d<0 then null else $d end), inside_cooldown:$ic}')")
  77. # human framing → stderr
  78. if [[ "$ic" == "true" ]]; then
  79. emit " [INSIDE COOLDOWN] ${name}@${version} — ${days}d ago (< ${COOLDOWN_DAYS}d). Hold off."
  80. elif [[ "$days" -ge 0 ]]; then
  81. emit " [ok] ${name}@${version} — ${days}d ago (>= ${COOLDOWN_DAYS}d)."
  82. else
  83. emit " [?] ${name} — version/publish time not found or registry unreachable."
  84. fi
  85. }
  86. fetch() { curl -fsSL -A "supply-chain-defense/preinstall-check" "$1" 2>/dev/null || { unavailable=1; echo ""; }; }
  87. check_npm() {
  88. local spec=$1 name version json
  89. name="${spec%@*}"; version=""
  90. [[ "$spec" == *"@"* && "$spec" != @*/* ]] && version="${spec#*@}"
  91. json=$(fetch "https://registry.npmjs.org/${name}")
  92. [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
  93. [[ -z "$version" ]] && version=$(jq -r '."dist-tags".latest // empty' <<<"$json")
  94. result "$name" "$version" "$(jq -r --arg v "$version" '.time[$v] // empty' <<<"$json")"
  95. }
  96. check_pypi() {
  97. local spec=$1 name version url json
  98. name="${spec%==*}"; version=""
  99. [[ "$spec" == *"=="* ]] && version="${spec#*==}"
  100. [[ "$spec" == *"@"* ]] && { name="${spec%@*}"; version="${spec#*@}"; }
  101. url="https://pypi.org/pypi/${name}/json"; [[ -n "$version" ]] && url="https://pypi.org/pypi/${name}/${version}/json"
  102. json=$(fetch "$url")
  103. [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
  104. [[ -z "$version" ]] && version=$(jq -r '.info.version // empty' <<<"$json")
  105. result "$name" "$version" "$(jq -r --arg v "$version" \
  106. '(.releases[$v]//[])[0].upload_time_iso_8601 // .urls[0].upload_time_iso_8601 // empty' <<<"$json")"
  107. }
  108. check_composer() { # Packagist: repo.packagist.org/p2/<vendor>/<pkg>.json
  109. local spec=$1 name version json published
  110. name="${spec%@*}"; version=""
  111. [[ "$spec" == *"@"* ]] && version="${spec#*@}"
  112. json=$(fetch "https://repo.packagist.org/p2/${name}.json")
  113. [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
  114. [[ -z "$version" ]] && version=$(jq -r --arg n "$name" '(.packages[$n][0].version) // empty' <<<"$json")
  115. published=$(jq -r --arg n "$name" --arg v "$version" 'first(.packages[$n][] | select(.version==$v) | .time) // empty' <<<"$json")
  116. result "$name" "$version" "$published"
  117. }
  118. check_cargo() { # crates.io API (requires User-Agent — fetch sets one)
  119. local spec=$1 name version json published
  120. name="${spec%@*}"; version=""
  121. [[ "$spec" == *"@"* ]] && version="${spec#*@}"
  122. json=$(fetch "https://crates.io/api/v1/crates/${name}")
  123. [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$name" "" ""; return; }
  124. [[ -z "$version" ]] && version=$(jq -r '.crate.max_stable_version // .crate.newest_version // empty' <<<"$json")
  125. published=$(jq -r --arg v "$version" 'first(.versions[] | select(.num==$v) | .created_at) // empty' <<<"$json")
  126. result "$name" "$version" "$published"
  127. }
  128. check_go() { # proxy.golang.org/<module>/@v/<version>.info (or /@latest)
  129. local spec=$1 mod version json
  130. mod="${spec%@*}"; version=""
  131. [[ "$spec" == *"@"* ]] && version="${spec#*@}"
  132. if [[ -z "$version" ]]; then json=$(fetch "https://proxy.golang.org/${mod}/@latest")
  133. else json=$(fetch "https://proxy.golang.org/${mod}/@v/${version}.info"); fi
  134. [[ -z "$json" || "$HAS_JQ" -eq 0 ]] && { result "$mod" "" ""; return; }
  135. result "$mod" "$(jq -r '.Version // empty' <<<"$json")" "$(jq -r '.Time // empty' <<<"$json")"
  136. }
  137. emit "=== Pre-install check (${ECOSYSTEM}, cooldown ${COOLDOWN_DAYS}d) ==="
  138. for spec in "${PKGS[@]}"; do
  139. case "$ECOSYSTEM" in
  140. npm) check_npm "$spec" ;; pypi) check_pypi "$spec" ;;
  141. composer) check_composer "$spec" ;; cargo) check_cargo "$spec" ;; go) check_go "$spec" ;;
  142. esac
  143. done
  144. if [[ "$JSON" -eq 1 ]]; then
  145. printf '%s\n' "${JSON_OBJS[@]:-}" | jq -s \
  146. --argjson cd "$COOLDOWN_DAYS" --arg eco "$ECOSYSTEM" \
  147. '{data: map(select(length>0)), meta:{ecosystem:$eco, cooldown_days:$cd, count:(map(select(length>0))|length), schema:"axiom.tool.preinstall-check.report/v1"}}'
  148. fi
  149. if [[ "$QUIET" -eq 0 ]]; then
  150. if [[ "$HAS_SOCKET" -eq 1 ]]; then
  151. emit ""; emit "Behavioural verdict:"
  152. for spec in "${PKGS[@]}"; do n="${spec%@*}"; n="${n%==*}"; emit " socket package score ${ECOSYSTEM} ${n}"; done
  153. else
  154. emit ""; emit "Behavioural scan (free): npm install -g socket # then: socket package score ${ECOSYSTEM} <pkg>"
  155. emit "Or depscore MCP (no key): claude mcp add --transport http socket-mcp https://mcp.socket.dev/"
  156. fi
  157. fi
  158. [[ "$inside" -eq 1 ]] && exit "$EXIT_INSIDE"
  159. [[ "$unavailable" -eq 1 ]] && exit "$EXIT_UNAVAILABLE"
  160. exit "$EXIT_OK"