|
| 1 | +/** |
| 2 | + * @file Single source of truth for "is this package manager's auto-update |
| 3 | + * disabled on this machine?" — shared by the pkg-auto-update-guard hook |
| 4 | + * (point-of-use block), the audit-pkg-auto-update.mts script (drift report in |
| 5 | + * `check --all`), and setup-security-tools (which sets the knobs). A package |
| 6 | + * manager that auto-updates mid-task can change a tool's version underneath a |
| 7 | + * build/scan, add latency, or pull an unsoaked package — a reproducibility + |
| 8 | + * supply-chain hazard. The knob lives OUTSIDE the repo (env vars, npmrc, |
| 9 | + * chocolatey.config, winget settings) so it drifts per machine; this module |
| 10 | + * centralizes the knob + how to read it so the three consumers never diverge. |
| 11 | + */ |
| 12 | + |
| 13 | +// oxlint-disable-next-line socket/prefer-async-spawn -- detection runs in a sync hook + sync audit script; needs typed string stdout, no async. |
| 14 | +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' |
| 15 | +import { existsSync, readFileSync } from 'node:fs' |
| 16 | +import os from 'node:os' |
| 17 | +import path from 'node:path' |
| 18 | +import process from 'node:process' |
| 19 | + |
| 20 | +import { findInvocation } from './shell-command.mts' |
| 21 | + |
| 22 | +export type PkgManagerPlatform = 'darwin' | 'linux' | 'win32' | 'all' |
| 23 | + |
| 24 | +export interface AutoUpdateStatus { |
| 25 | + // The manager id (matches AutoUpdateCheck.id). |
| 26 | + id: string |
| 27 | + // 'disabled' = auto-update is off (good); 'enabled' = on (drift, blockable); |
| 28 | + // 'absent' = the manager isn't installed/configured on this machine, so the |
| 29 | + // check is not applicable (never blocks, never fails CI). |
| 30 | + state: 'disabled' | 'enabled' | 'absent' |
| 31 | + // One-line explanation of what was read. |
| 32 | + reason: string |
| 33 | + // Imperative fix the operator runs to disable auto-update. |
| 34 | + fix: string |
| 35 | +} |
| 36 | + |
| 37 | +export interface AutoUpdateCheck { |
| 38 | + // Stable id, e.g. 'homebrew'. |
| 39 | + id: string |
| 40 | + // Binary names whose Bash invocation should be guarded. |
| 41 | + binaries: readonly string[] |
| 42 | + // Platforms this manager runs on; 'all' = every platform. |
| 43 | + platform: PkgManagerPlatform |
| 44 | + // Imperative fix string surfaced to the operator. |
| 45 | + fix: string |
| 46 | + // Read current machine state. Pure-ish: only reads env / files / `<mgr> |
| 47 | + // config`. Never mutates. |
| 48 | + detect: () => AutoUpdateStatus |
| 49 | +} |
| 50 | + |
| 51 | +// Resolve an env var to its trimmed value, treating empty as unset. |
| 52 | +export function envValue(name: string): string | undefined { |
| 53 | + const v = process.env[name] |
| 54 | + return v === undefined || v === '' ? undefined : v |
| 55 | +} |
| 56 | + |
| 57 | +// True when an env var is set to a truthy "on" value (1 / true / yes). |
| 58 | +export function envIsOn(name: string): boolean { |
| 59 | + const v = envValue(name)?.toLowerCase() |
| 60 | + return v === '1' || v === 'true' || v === 'yes' || v === 'on' |
| 61 | +} |
| 62 | + |
| 63 | +// Run a binary with args and return trimmed stdout, or undefined when the |
| 64 | +// binary is missing / the call exits non-zero (manager absent). Never throws. |
| 65 | +// Takes an arg array (not a shell string) so no shell parsing / injection. |
| 66 | +export function readCommand( |
| 67 | + binary: string, |
| 68 | + args: readonly string[], |
| 69 | +): string | undefined { |
| 70 | + try { |
| 71 | + const result = spawnSync(binary, args as string[], { stdio: 'pipe' }) |
| 72 | + if (result.status !== 0) { |
| 73 | + return undefined |
| 74 | + } |
| 75 | + const { stdout } = result |
| 76 | + return typeof stdout === 'string' ? stdout.trim() : String(stdout).trim() |
| 77 | + } catch { |
| 78 | + return undefined |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +// True when `binary` resolves on PATH (manager installed). `command -v` is a |
| 83 | +// shell builtin (not spawnable directly), so probe with the platform's PATH |
| 84 | +// resolver binary: `where` on Windows, `which` elsewhere. |
| 85 | +export function hasBinary(binary: string): boolean { |
| 86 | + return os.platform() === 'win32' |
| 87 | + ? readCommand('where', [binary]) !== undefined |
| 88 | + : readCommand('which', [binary]) !== undefined |
| 89 | +} |
| 90 | + |
| 91 | +export const AUTO_UPDATE_CHECKS: readonly AutoUpdateCheck[] = [ |
| 92 | + { |
| 93 | + id: 'homebrew', |
| 94 | + binaries: ['brew'], |
| 95 | + platform: 'darwin', |
| 96 | + fix: 'export HOMEBREW_NO_AUTO_UPDATE=1 (run setup-security-tools to persist it to ~/.zshenv)', |
| 97 | + detect(): AutoUpdateStatus { |
| 98 | + if (!hasBinary('brew')) { |
| 99 | + return { |
| 100 | + id: 'homebrew', |
| 101 | + state: 'absent', |
| 102 | + reason: 'brew not on PATH', |
| 103 | + fix: this.fix, |
| 104 | + } |
| 105 | + } |
| 106 | + const on = envIsOn('HOMEBREW_NO_AUTO_UPDATE') |
| 107 | + return { |
| 108 | + id: 'homebrew', |
| 109 | + state: on ? 'disabled' : 'enabled', |
| 110 | + reason: on |
| 111 | + ? 'HOMEBREW_NO_AUTO_UPDATE is set' |
| 112 | + : 'HOMEBREW_NO_AUTO_UPDATE is unset — `brew install` triggers `brew update`', |
| 113 | + fix: this.fix, |
| 114 | + } |
| 115 | + }, |
| 116 | + }, |
| 117 | + { |
| 118 | + id: 'chocolatey', |
| 119 | + binaries: ['choco'], |
| 120 | + platform: 'win32', |
| 121 | + fix: 'choco feature disable -n autoUpdate', |
| 122 | + detect(): AutoUpdateStatus { |
| 123 | + if (!hasBinary('choco')) { |
| 124 | + return { |
| 125 | + id: 'chocolatey', |
| 126 | + state: 'absent', |
| 127 | + reason: 'choco not on PATH', |
| 128 | + fix: this.fix, |
| 129 | + } |
| 130 | + } |
| 131 | + // `choco feature list` prints e.g. "autoUpdate - [Disabled] ...". |
| 132 | + const out = readCommand('choco', ['feature', 'list', '-r']) ?? '' |
| 133 | + const line = out |
| 134 | + .split(/\r?\n/u) |
| 135 | + .find(l => l.toLowerCase().startsWith('autoupdate')) |
| 136 | + const disabled = line ? /disabled/iu.test(line) : false |
| 137 | + return { |
| 138 | + id: 'chocolatey', |
| 139 | + state: disabled ? 'disabled' : 'enabled', |
| 140 | + reason: disabled |
| 141 | + ? 'choco autoUpdate feature is disabled' |
| 142 | + : 'choco autoUpdate feature is enabled', |
| 143 | + fix: this.fix, |
| 144 | + } |
| 145 | + }, |
| 146 | + }, |
| 147 | + { |
| 148 | + id: 'winget', |
| 149 | + binaries: ['winget'], |
| 150 | + platform: 'win32', |
| 151 | + fix: 'set winget settings.json `"network": { "downloader": "wininet" }` and disable source auto-update (autoUpdateIntervalInMinutes: 0)', |
| 152 | + detect(): AutoUpdateStatus { |
| 153 | + if (!hasBinary('winget')) { |
| 154 | + return { |
| 155 | + id: 'winget', |
| 156 | + state: 'absent', |
| 157 | + reason: 'winget not on PATH', |
| 158 | + fix: this.fix, |
| 159 | + } |
| 160 | + } |
| 161 | + const localAppData = process.env['LOCALAPPDATA'] ?? '' |
| 162 | + const settingsPath = path.join( |
| 163 | + localAppData, |
| 164 | + 'Packages', |
| 165 | + 'Microsoft.DesktopAppInstaller_8wekyb3d8bbwe', |
| 166 | + 'LocalState', |
| 167 | + 'settings.json', |
| 168 | + ) |
| 169 | + let interval: number | undefined |
| 170 | + if (localAppData && existsSync(settingsPath)) { |
| 171 | + try { |
| 172 | + const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')) as { |
| 173 | + source?: { autoUpdateIntervalInMinutes?: number } | undefined |
| 174 | + } |
| 175 | + interval = parsed.source?.autoUpdateIntervalInMinutes |
| 176 | + } catch {} |
| 177 | + } |
| 178 | + const disabled = interval === 0 |
| 179 | + return { |
| 180 | + id: 'winget', |
| 181 | + state: disabled ? 'disabled' : 'enabled', |
| 182 | + reason: disabled |
| 183 | + ? 'winget source auto-update interval is 0' |
| 184 | + : 'winget source auto-update interval is non-zero or unset', |
| 185 | + fix: this.fix, |
| 186 | + } |
| 187 | + }, |
| 188 | + }, |
| 189 | + { |
| 190 | + id: 'scoop', |
| 191 | + binaries: ['scoop'], |
| 192 | + platform: 'win32', |
| 193 | + fix: 'remove any scheduled `scoop update` task (Task Scheduler) and avoid `scoop update` in cron/CI', |
| 194 | + detect(): AutoUpdateStatus { |
| 195 | + if (!hasBinary('scoop')) { |
| 196 | + return { |
| 197 | + id: 'scoop', |
| 198 | + state: 'absent', |
| 199 | + reason: 'scoop not on PATH', |
| 200 | + fix: this.fix, |
| 201 | + } |
| 202 | + } |
| 203 | + // Scoop has no install-time auto-update; the drift is a scheduled |
| 204 | + // `scoop update` task. Look for one; absence = disabled. |
| 205 | + const tasks = readCommand('schtasks', ['/query', '/fo', 'csv']) ?? '' |
| 206 | + const hasTask = /scoop\s+update/iu.test(tasks) |
| 207 | + return { |
| 208 | + id: 'scoop', |
| 209 | + state: hasTask ? 'enabled' : 'disabled', |
| 210 | + reason: hasTask |
| 211 | + ? 'a scheduled `scoop update` task exists' |
| 212 | + : 'no scheduled `scoop update` task', |
| 213 | + fix: this.fix, |
| 214 | + } |
| 215 | + }, |
| 216 | + }, |
| 217 | + { |
| 218 | + id: 'npm', |
| 219 | + binaries: ['npm'], |
| 220 | + platform: 'all', |
| 221 | + fix: 'npm config set update-notifier false (or export NO_UPDATE_NOTIFIER=1)', |
| 222 | + detect(): AutoUpdateStatus { |
| 223 | + if (!hasBinary('npm')) { |
| 224 | + return { id: 'npm', state: 'absent', reason: 'npm not on PATH', fix: this.fix } |
| 225 | + } |
| 226 | + if (envIsOn('NO_UPDATE_NOTIFIER')) { |
| 227 | + return { |
| 228 | + id: 'npm', |
| 229 | + state: 'disabled', |
| 230 | + reason: 'NO_UPDATE_NOTIFIER is set', |
| 231 | + fix: this.fix, |
| 232 | + } |
| 233 | + } |
| 234 | + const cfg = readCommand('npm', ['config', 'get', 'update-notifier']) |
| 235 | + const disabled = cfg === 'false' |
| 236 | + return { |
| 237 | + id: 'npm', |
| 238 | + state: disabled ? 'disabled' : 'enabled', |
| 239 | + reason: disabled |
| 240 | + ? 'npm update-notifier is false' |
| 241 | + : 'npm update-notifier is not false', |
| 242 | + fix: this.fix, |
| 243 | + } |
| 244 | + }, |
| 245 | + }, |
| 246 | + { |
| 247 | + id: 'pnpm', |
| 248 | + binaries: ['pnpm'], |
| 249 | + platform: 'all', |
| 250 | + fix: 'export NO_UPDATE_NOTIFIER=1 (pnpm honors it)', |
| 251 | + detect(): AutoUpdateStatus { |
| 252 | + if (!hasBinary('pnpm')) { |
| 253 | + return { id: 'pnpm', state: 'absent', reason: 'pnpm not on PATH', fix: this.fix } |
| 254 | + } |
| 255 | + const disabled = envIsOn('NO_UPDATE_NOTIFIER') |
| 256 | + return { |
| 257 | + id: 'pnpm', |
| 258 | + state: disabled ? 'disabled' : 'enabled', |
| 259 | + reason: disabled |
| 260 | + ? 'NO_UPDATE_NOTIFIER is set' |
| 261 | + : 'NO_UPDATE_NOTIFIER is unset', |
| 262 | + fix: this.fix, |
| 263 | + } |
| 264 | + }, |
| 265 | + }, |
| 266 | +] |
| 267 | + |
| 268 | +// True when `name` (a platform string) applies to the current OS. |
| 269 | +export function platformApplies(platform: PkgManagerPlatform): boolean { |
| 270 | + return platform === 'all' || platform === os.platform() |
| 271 | +} |
| 272 | + |
| 273 | +// The blanket bypass phrase that suppresses the guard for ALL managers. |
| 274 | +export const BLANKET_BYPASS_PHRASE = 'Allow package-manager-auto-update bypass' |
| 275 | + |
| 276 | +// The bypass phrases that authorize skipping the check for one manager: the |
| 277 | +// blanket phrase OR a per-manager phrase `Allow <noun> auto-update bypass` |
| 278 | +// (e.g. `Allow brew auto-update bypass`, `Allow homebrew auto-update bypass`). |
| 279 | +// Per-manager lets an operator green one manager without disabling the guard |
| 280 | +// for the rest. Both the id and the binary names are accepted nouns. |
| 281 | +export function bypassPhrasesFor(check: AutoUpdateCheck): string[] { |
| 282 | + const nouns = [check.id, ...check.binaries] |
| 283 | + const phrases = [BLANKET_BYPASS_PHRASE] |
| 284 | + const seen = new Set<string>() |
| 285 | + for (let i = 0, { length } = nouns; i < length; i += 1) { |
| 286 | + const noun = nouns[i]! |
| 287 | + if (!seen.has(noun)) { |
| 288 | + seen.add(noun) |
| 289 | + phrases.push(`Allow ${noun} auto-update bypass`) |
| 290 | + } |
| 291 | + } |
| 292 | + return phrases |
| 293 | +} |
| 294 | + |
| 295 | +// The check whose binary the command invokes, if any (AST-matched, no regex). |
| 296 | +// Used by the guard to map a Bash command → the manager to verify. |
| 297 | +export function matchInvokedManager( |
| 298 | + command: string, |
| 299 | +): AutoUpdateCheck | undefined { |
| 300 | + for (let i = 0, { length } = AUTO_UPDATE_CHECKS; i < length; i += 1) { |
| 301 | + const check = AUTO_UPDATE_CHECKS[i]! |
| 302 | + for (let j = 0, blen = check.binaries.length; j < blen; j += 1) { |
| 303 | + if (findInvocation(command, { binary: check.binaries[j]! })) { |
| 304 | + return check |
| 305 | + } |
| 306 | + } |
| 307 | + } |
| 308 | + return undefined |
| 309 | +} |
| 310 | + |
| 311 | +// Run every check that applies to the current platform. Used by the audit |
| 312 | +// script; 'absent' results are informational (never a drift failure). |
| 313 | +export function auditCurrentPlatform(): AutoUpdateStatus[] { |
| 314 | + const results: AutoUpdateStatus[] = [] |
| 315 | + for (let i = 0, { length } = AUTO_UPDATE_CHECKS; i < length; i += 1) { |
| 316 | + const check = AUTO_UPDATE_CHECKS[i]! |
| 317 | + if (platformApplies(check.platform)) { |
| 318 | + results.push(check.detect()) |
| 319 | + } |
| 320 | + } |
| 321 | + return results |
| 322 | +} |
0 commit comments