From dedc48e668e8e9c0ef67045827675ba1b383be4a Mon Sep 17 00:00:00 2001 From: jdalton Date: Thu, 9 Apr 2026 22:29:20 -0400 Subject: [PATCH 1/3] feat(updating): add automatic version checks for security tools --- .claude/hooks/setup-security-tools/update.mts | 512 ++++++++++++++++++ .claude/skills/updating/SKILL.md | 3 + .gitignore | 2 + 3 files changed, 517 insertions(+) create mode 100644 .claude/hooks/setup-security-tools/update.mts diff --git a/.claude/hooks/setup-security-tools/update.mts b/.claude/hooks/setup-security-tools/update.mts new file mode 100644 index 00000000..e9e6d390 --- /dev/null +++ b/.claude/hooks/setup-security-tools/update.mts @@ -0,0 +1,512 @@ +#!/usr/bin/env node +// Update script for Socket security tools. +// +// Checks for new releases of zizmor and sfw, respecting the pnpm +// minimumReleaseAge cooldown (read from pnpm-workspace.yaml) for third-party tools. +// Socket-owned tools (sfw) are excluded from cooldown. +// +// Updates embedded checksums in index.mts when new versions are found. + +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { httpDownload, httpRequest } from '@socketsecurity/lib/http-request' +import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { spawn } from '@socketsecurity/lib/spawn' + +const logger = getDefaultLogger() + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const INDEX_FILE = path.join(__dirname, 'index.mts') + +const MS_PER_MINUTE = 60_000 +const DEFAULT_COOLDOWN_MINUTES = 10_080 + +// Read minimumReleaseAge from pnpm-workspace.yaml (minutes → ms). +function readCooldownMs(): number { + let dir = __dirname + for (let i = 0; i < 10; i += 1) { + const candidate = path.join(dir, 'pnpm-workspace.yaml') + if (existsSync(candidate)) { + try { + const content = readFileSync(candidate, 'utf8') + const match = /^minimumReleaseAge:\s*(\d+)/m.exec(content) + if (match) return Number(match[1]) * MS_PER_MINUTE + } catch { + // Read error. + } + logger.warn(`Could not read minimumReleaseAge from ${candidate}, defaulting to ${DEFAULT_COOLDOWN_MINUTES} minutes`) + return DEFAULT_COOLDOWN_MINUTES * MS_PER_MINUTE + } + const parent = path.dirname(dir) + if (parent === dir) break + dir = parent + } + logger.warn(`pnpm-workspace.yaml not found, defaulting cooldown to ${DEFAULT_COOLDOWN_MINUTES} minutes`) + return DEFAULT_COOLDOWN_MINUTES * MS_PER_MINUTE +} + +const COOLDOWN_MS = readCooldownMs() + +// ── GitHub API helpers ── + +interface GhRelease { + assets: GhAsset[] + published_at: string + tag_name: string +} + +interface GhAsset { + browser_download_url: string + name: string +} + +async function ghApiLatestRelease(repo: string): Promise { + const result = await spawn( + 'gh', + ['api', `repos/${repo}/releases/latest`, '--cache', '1h'], + { stdio: 'pipe' }, + ) + const stdout = + typeof result.stdout === 'string' + ? result.stdout + : result.stdout.toString() + return JSON.parse(stdout) as GhRelease +} + +function isOlderThanCooldown(publishedAt: string): boolean { + const published = new Date(publishedAt).getTime() + return Date.now() - published >= COOLDOWN_MS +} + +function versionFromTag(tag: string): string { + return tag.replace(/^v/, '') +} + +// ── Checksum computation ── + +async function computeSha256(filePath: string): Promise { + const content = await fs.readFile(filePath) + return createHash('sha256').update(content).digest('hex') +} + +async function downloadAndHash(url: string): Promise { + const tmpFile = path.join(tmpdir(), `security-tools-update-${Date.now()}-${Math.random().toString(36).slice(2)}`) + try { + await httpDownload(url, tmpFile, { retries: 2 }) + return await computeSha256(tmpFile) + } finally { + await fs.unlink(tmpFile).catch(() => {}) + } +} + +// ── Index file manipulation ── + +function readIndexFile(): string { + return readFileSync(INDEX_FILE, 'utf8') +} + +async function writeIndexFile(content: string): Promise { + await fs.writeFile(INDEX_FILE, content, 'utf8') +} + +function replaceConstant( + source: string, + name: string, + oldValue: string, + newValue: string, +): string { + const escaped = oldValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp(`(const ${name}\\s*=\\s*')${escaped}'`) + return source.replace(pattern, `$1${newValue}'`) +} + +function replaceChecksumValue( + source: string, + assetName: string, + oldHash: string, + newHash: string, +): string { + // Match the specific asset line in a checksums object. + const escaped = assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp( + `('${escaped}':\\s*\\n\\s*')${oldHash}'`, + ) + if (pattern.test(source)) { + return source.replace(pattern, `$1${newHash}'`) + } + // Single-line format: 'asset-name': 'hash', + const singleLine = new RegExp( + `('${escaped}':\\s*')${oldHash}'`, + ) + return source.replace(singleLine, `$1${newHash}'`) +} + +// ── Zizmor update ── + +interface UpdateResult { + reason: string + skipped: boolean + tool: string + updated: boolean +} + +// Map from index.mts asset names to zizmor release asset names. +const ZIZMOR_ASSETS: Record = { + __proto__: null as unknown as string, + 'zizmor-aarch64-apple-darwin.tar.gz': + 'zizmor-aarch64-apple-darwin.tar.gz', + 'zizmor-aarch64-unknown-linux-gnu.tar.gz': + 'zizmor-aarch64-unknown-linux-gnu.tar.gz', + 'zizmor-x86_64-apple-darwin.tar.gz': + 'zizmor-x86_64-apple-darwin.tar.gz', + 'zizmor-x86_64-pc-windows-msvc.zip': + 'zizmor-x86_64-pc-windows-msvc.zip', + 'zizmor-x86_64-unknown-linux-gnu.tar.gz': + 'zizmor-x86_64-unknown-linux-gnu.tar.gz', +} + +async function updateZizmor(source: string): Promise<{ + result: UpdateResult + source: string +}> { + const tool = 'zizmor' + logger.log(`=== Checking ${tool} ===`) + + let release: GhRelease + try { + release = await ghApiLatestRelease('woodruffw/zizmor') + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + logger.warn(`Failed to fetch zizmor releases: ${msg}`) + return { + result: { tool, skipped: true, updated: false, reason: `API error: ${msg}` }, + source, + } + } + + const latestVersion = versionFromTag(release.tag_name) + // Extract current version from source. + const currentMatch = /const ZIZMOR_VERSION = '([^']+)'/.exec(source) + const currentVersion = currentMatch ? currentMatch[1] : '' + + logger.log(`Current: v${currentVersion}, Latest: v${latestVersion}`) + + if (latestVersion === currentVersion) { + logger.log('Already current.') + return { + result: { tool, skipped: false, updated: false, reason: 'already current' }, + source, + } + } + + // Respect cooldown for third-party tools. + if (!isOlderThanCooldown(release.published_at)) { + const daysOld = ((Date.now() - new Date(release.published_at).getTime()) / 86_400_000).toFixed(1) + const cooldownDays = (COOLDOWN_MS / 86_400_000).toFixed(0) + logger.log(`v${latestVersion} is only ${daysOld} days old (need ${cooldownDays}). Skipping.`) + return { + result: { tool, skipped: true, updated: false, reason: `too new (${daysOld} days, need ${cooldownDays})` }, + source, + } + } + + logger.log(`Updating to v${latestVersion}...`) + + // Try to get checksums from the release's checksums.txt asset first. + let checksumMap: Record | undefined + const checksumsAsset = release.assets.find(a => a.name === 'checksums.txt') + if (checksumsAsset) { + try { + const resp = await httpRequest(checksumsAsset.browser_download_url) + if (resp.ok) { + checksumMap = { __proto__: null } as unknown as Record + for (const line of resp.text().split('\n')) { + const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line.trim()) + if (match) { + checksumMap[match[2]!] = match[1]! + } + } + } + } catch { + // Fall through to per-asset download. + } + } + + // Compute checksums for each platform asset. + let updated = source + let allFound = true + for (const assetName of Object.keys(ZIZMOR_ASSETS)) { + let newHash: string | undefined + + // Try checksums.txt first. + if (checksumMap && checksumMap[assetName]) { + newHash = checksumMap[assetName] + } else { + // Download and compute. + const asset = release.assets.find(a => a.name === assetName) + if (!asset) { + logger.warn(` Asset not found in release: ${assetName}`) + allFound = false + continue + } + logger.log(` Computing checksum for ${assetName}...`) + try { + newHash = await downloadAndHash(asset.browser_download_url) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + logger.warn(` Failed to download ${assetName}: ${msg}`) + allFound = false + continue + } + } + + if (!newHash) { + allFound = false + continue + } + + // Find and replace the old hash. + const oldHashMatch = new RegExp( + `'${assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}':\\s*\\n\\s*'([a-f0-9]{64})'`, + ).exec(updated) + const oldHashSingle = new RegExp( + `'${assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}':\\s*'([a-f0-9]{64})'`, + ).exec(updated) + const oldHash = oldHashMatch?.[1] ?? oldHashSingle?.[1] + if (oldHash && oldHash !== newHash) { + updated = replaceChecksumValue(updated, assetName, oldHash, newHash) + logger.log(` ${assetName}: ${oldHash.slice(0, 12)}... -> ${newHash.slice(0, 12)}...`) + } else if (oldHash === newHash) { + logger.log(` ${assetName}: unchanged`) + } + } + + if (!allFound) { + logger.warn('Some assets could not be verified. Skipping version bump.') + return { + result: { tool, skipped: true, updated: false, reason: 'incomplete asset checksums' }, + source, + } + } + + // Update version constant. + updated = replaceConstant(updated, 'ZIZMOR_VERSION', currentVersion!, latestVersion) + logger.log(`Updated ZIZMOR_VERSION: ${currentVersion} -> ${latestVersion}`) + + return { + result: { tool, skipped: false, updated: true, reason: `${currentVersion} -> ${latestVersion}` }, + source: updated, + } +} + +// ── SFW update ── + +const SFW_FREE_ASSET_NAMES: Record = { + __proto__: null as unknown as string, + 'linux-arm64': 'sfw-free-linux-arm64', + 'linux-x86_64': 'sfw-free-linux-x86_64', + 'macos-arm64': 'sfw-free-macos-arm64', + 'macos-x86_64': 'sfw-free-macos-x86_64', + 'windows-x86_64': 'sfw-free-windows-x86_64.exe', +} + +const SFW_ENTERPRISE_ASSET_NAMES: Record = { + __proto__: null as unknown as string, + 'linux-arm64': 'sfw-linux-arm64', + 'linux-x86_64': 'sfw-linux-x86_64', + 'macos-arm64': 'sfw-macos-arm64', + 'macos-x86_64': 'sfw-macos-x86_64', + 'windows-x86_64': 'sfw-windows-x86_64.exe', +} + +async function fetchSfwChecksums( + repo: string, + label: string, + assetNames: Record, + currentChecksums: Record, +): Promise<{ + checksums: Record + changed: boolean +}> { + let release: GhRelease + try { + release = await ghApiLatestRelease(repo) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + logger.warn(`Failed to fetch ${label} releases: ${msg}`) + return { checksums: currentChecksums, changed: false } + } + + logger.log(` ${label}: latest ${release.tag_name} (published ${release.published_at.slice(0, 10)})`) + + const newChecksums: Record = { __proto__: null } as unknown as Record + let changed = false + + for (const { 0: platform, 1: assetName } of Object.entries(assetNames)) { + const asset = release.assets.find(a => a.name === assetName) + if (!asset) { + // Use latest/download URL pattern for sfw (uses /releases/latest/download/). + const url = `https://github.com/${repo}/releases/latest/download/${assetName}` + logger.log(` Computing checksum for ${assetName}...`) + try { + const hash = await downloadAndHash(url) + newChecksums[platform] = hash + if (currentChecksums[platform] !== hash) { + logger.log(` ${platform}: ${(currentChecksums[platform] ?? '').slice(0, 12)}... -> ${hash.slice(0, 12)}...`) + changed = true + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + logger.warn(` Failed to download ${assetName}: ${msg}`) + newChecksums[platform] = currentChecksums[platform] ?? '' + } + } else { + logger.log(` Computing checksum for ${assetName}...`) + try { + const hash = await downloadAndHash(asset.browser_download_url) + newChecksums[platform] = hash + if (currentChecksums[platform] !== hash) { + logger.log(` ${platform}: ${(currentChecksums[platform] ?? '').slice(0, 12)}... -> ${hash.slice(0, 12)}...`) + changed = true + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + logger.warn(` Failed to download ${assetName}: ${msg}`) + newChecksums[platform] = currentChecksums[platform] ?? '' + } + } + } + + return { checksums: newChecksums, changed } +} + +function extractChecksums( + source: string, + objectName: string, +): Record { + const result: Record = { __proto__: null } as unknown as Record + // Find the object in source. + const objPattern = new RegExp( + `const ${objectName}[^{]*\\{[^}]*?(?:'([^']+)':\\s*'([a-f0-9]{64})'[,\\s]*)+`, + 's', + ) + const objMatch = objPattern.exec(source) + if (!objMatch) return result + + const block = objMatch[0] + const entryPattern = /'([^']+)':\s*\n?\s*'([a-f0-9]{64})'/g + let match: RegExpExecArray | null + while ((match = entryPattern.exec(block)) !== null) { + if (match[1] !== '__proto__') { + result[match[1]!] = match[2]! + } + } + return result +} + +async function updateSfw(source: string): Promise<{ + results: UpdateResult[] + source: string +}> { + logger.log('=== Checking SFW ===') + // Socket-owned tools: no cooldown. + logger.log('Socket-owned tool: cooldown excluded.') + + const results: UpdateResult[] = [] + + // Extract current checksums from source. + const currentFree = extractChecksums(source, 'SFW_FREE_CHECKSUMS') + const currentEnterprise = extractChecksums(source, 'SFW_ENTERPRISE_CHECKSUMS') + + // Check sfw-free. + logger.log('') + const free = await fetchSfwChecksums( + 'SocketDev/sfw-free', + 'sfw-free', + SFW_FREE_ASSET_NAMES, + currentFree, + ) + + let updated = source + if (free.changed) { + for (const { 0: platform, 1: hash } of Object.entries(free.checksums)) { + if (currentFree[platform] && currentFree[platform] !== hash) { + updated = replaceChecksumValue(updated, platform, currentFree[platform]!, hash) + } + } + results.push({ tool: 'sfw-free', skipped: false, updated: true, reason: 'checksums updated' }) + } else { + results.push({ tool: 'sfw-free', skipped: false, updated: false, reason: 'already current' }) + } + + // Check sfw enterprise. + logger.log('') + const enterprise = await fetchSfwChecksums( + 'SocketDev/firewall-release', + 'sfw-enterprise', + SFW_ENTERPRISE_ASSET_NAMES, + currentEnterprise, + ) + + if (enterprise.changed) { + for (const { 0: platform, 1: hash } of Object.entries(enterprise.checksums)) { + if (currentEnterprise[platform] && currentEnterprise[platform] !== hash) { + updated = replaceChecksumValue(updated, platform, currentEnterprise[platform]!, hash) + } + } + results.push({ tool: 'sfw-enterprise', skipped: false, updated: true, reason: 'checksums updated' }) + } else { + results.push({ tool: 'sfw-enterprise', skipped: false, updated: false, reason: 'already current' }) + } + + return { results, source: updated } +} + +// ── Main ── + +async function main(): Promise { + logger.log('Checking for security tool updates...\n') + + let source = readIndexFile() + const allResults: UpdateResult[] = [] + + // 1. Check zizmor (third-party, respects cooldown). + const zizmor = await updateZizmor(source) + source = zizmor.source + allResults.push(zizmor.result) + logger.log('') + + // 2. Check sfw (Socket-owned, no cooldown). + const sfw = await updateSfw(source) + source = sfw.source + allResults.push(...sfw.results) + logger.log('') + + // Write updated index.mts if anything changed. + const anyUpdated = allResults.some(r => r.updated) + if (anyUpdated) { + await writeIndexFile(source) + logger.log('Updated index.mts with new checksums.\n') + } + + // Report. + logger.log('=== Summary ===') + for (const r of allResults) { + const status = r.updated ? 'UPDATED' : r.skipped ? 'SKIPPED' : 'CURRENT' + logger.log(` ${r.tool}: ${status} (${r.reason})`) + } + + if (!anyUpdated) { + logger.log('\nNo updates needed.') + } +} + +main().catch((e: unknown) => { + logger.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 +}) diff --git a/.claude/skills/updating/SKILL.md b/.claude/skills/updating/SKILL.md index f1c47a73..48ec9f96 100644 --- a/.claude/skills/updating/SKILL.md +++ b/.claude/skills/updating/SKILL.md @@ -98,6 +98,9 @@ fi If stale, inform the user and offer to run the `updating-workflows` skill. +3b. **Update Security Tools** - Run `node .claude/hooks/setup-security-tools/update.mts` to check for new zizmor/sfw releases. Respects pnpm `minimumReleaseAge` cooldown for third-party tools (zizmor) but updates Socket tools (sfw) immediately. Updates embedded checksums in the setup hook. +3c. **Sync Claude Code version** - Run `claude --version` to get the installed version. If it's newer than the `@anthropic-ai/claude-code` entry in `pnpm-workspace.yaml` catalog, update both the catalog entry AND the `minimumReleaseAgeExclude` pinned version. This bypasses cooldown since we're the ones running it. Then run `pnpm install` to update the lockfile. + --- ### Phase 4: Final Validation diff --git a/.gitignore b/.gitignore index 3f298222..14ec50ac 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,9 @@ WIP /.claude/* !/.claude/agents/ !/.claude/commands/ +!/.claude/hooks/ !/.claude/ops/ +!/.claude/settings.json !/.claude/skills/ # Environment files From db0fc519a4f47401b3345e1ca4b7ba9789c99aa9 Mon Sep 17 00:00:00 2001 From: jdalton Date: Sat, 11 Apr 2026 11:51:02 -0400 Subject: [PATCH 2/3] fix(hooks): sync update.mts with socket-cli, fix zizmor org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Copy canonical update.mts from socket-cli (adds objectName scoping for replaceChecksumValue) - Fix woodruffw/zizmor → zizmorcore/zizmor in update.mts and checkout action --- .claude/hooks/setup-security-tools/update.mts | 103 +++++++++++------- .github/actions/checkout/action.yml | 2 +- 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/.claude/hooks/setup-security-tools/update.mts b/.claude/hooks/setup-security-tools/update.mts index e9e6d390..4702a7cc 100644 --- a/.claude/hooks/setup-security-tools/update.mts +++ b/.claude/hooks/setup-security-tools/update.mts @@ -130,19 +130,53 @@ function replaceChecksumValue( assetName: string, oldHash: string, newHash: string, + objectName?: string, ): string { // Match the specific asset line in a checksums object. const escaped = assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = new RegExp( + const multiLine = new RegExp( `('${escaped}':\\s*\\n\\s*')${oldHash}'`, ) - if (pattern.test(source)) { - return source.replace(pattern, `$1${newHash}'`) - } - // Single-line format: 'asset-name': 'hash', const singleLine = new RegExp( `('${escaped}':\\s*')${oldHash}'`, ) + // When objectName is provided, scope the replacement to that object block + // to avoid ambiguity when multiple objects share the same platform keys + // (e.g. SFW_FREE_CHECKSUMS and SFW_ENTERPRISE_CHECKSUMS both use 'linux-arm64'). + if (objectName) { + const objStart = source.indexOf(`const ${objectName}`) + if (objStart !== -1) { + const braceStart = source.indexOf('{', objStart) + if (braceStart !== -1) { + // Find the matching closing brace. + let depth = 0 + let braceEnd = -1 + for (let i = braceStart; i < source.length; i += 1) { + if (source[i] === '{') depth += 1 + else if (source[i] === '}') { + depth -= 1 + if (!depth) { + braceEnd = i + 1 + break + } + } + } + if (braceEnd !== -1) { + let block = source.slice(objStart, braceEnd) + if (multiLine.test(block)) { + block = block.replace(multiLine, `$1${newHash}'`) + } else { + block = block.replace(singleLine, `$1${newHash}'`) + } + return source.slice(0, objStart) + block + source.slice(braceEnd) + } + } + } + } + // Unscoped fallback: replace first match in entire source. + if (multiLine.test(source)) { + return source.replace(multiLine, `$1${newHash}'`) + } return source.replace(singleLine, `$1${newHash}'`) } @@ -179,7 +213,7 @@ async function updateZizmor(source: string): Promise<{ let release: GhRelease try { - release = await ghApiLatestRelease('woodruffw/zizmor') + release = await ghApiLatestRelease('zizmorcore/zizmor') } catch (e) { const msg = e instanceof Error ? e.message : String(e) logger.warn(`Failed to fetch zizmor releases: ${msg}`) @@ -283,6 +317,9 @@ async function updateZizmor(source: string): Promise<{ logger.log(` ${assetName}: ${oldHash.slice(0, 12)}... -> ${newHash.slice(0, 12)}...`) } else if (oldHash === newHash) { logger.log(` ${assetName}: unchanged`) + } else { + logger.warn(` ${assetName}: no existing checksum entry found in source`) + allFound = false } } @@ -346,42 +383,34 @@ async function fetchSfwChecksums( const newChecksums: Record = { __proto__: null } as unknown as Record let changed = false + let allFound = true for (const { 0: platform, 1: assetName } of Object.entries(assetNames)) { const asset = release.assets.find(a => a.name === assetName) - if (!asset) { - // Use latest/download URL pattern for sfw (uses /releases/latest/download/). - const url = `https://github.com/${repo}/releases/latest/download/${assetName}` - logger.log(` Computing checksum for ${assetName}...`) - try { - const hash = await downloadAndHash(url) - newChecksums[platform] = hash - if (currentChecksums[platform] !== hash) { - logger.log(` ${platform}: ${(currentChecksums[platform] ?? '').slice(0, 12)}... -> ${hash.slice(0, 12)}...`) - changed = true - } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(` Failed to download ${assetName}: ${msg}`) - newChecksums[platform] = currentChecksums[platform] ?? '' - } - } else { - logger.log(` Computing checksum for ${assetName}...`) - try { - const hash = await downloadAndHash(asset.browser_download_url) - newChecksums[platform] = hash - if (currentChecksums[platform] !== hash) { - logger.log(` ${platform}: ${(currentChecksums[platform] ?? '').slice(0, 12)}... -> ${hash.slice(0, 12)}...`) - changed = true - } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(` Failed to download ${assetName}: ${msg}`) - newChecksums[platform] = currentChecksums[platform] ?? '' + const url = asset + ? asset.browser_download_url + : `https://github.com/${repo}/releases/latest/download/${assetName}` + logger.log(` Computing checksum for ${assetName}...`) + try { + const hash = await downloadAndHash(url) + newChecksums[platform] = hash + if (currentChecksums[platform] !== hash) { + logger.log(` ${platform}: ${(currentChecksums[platform] ?? '').slice(0, 12)}... -> ${hash.slice(0, 12)}...`) + changed = true } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + logger.warn(` Failed to download ${assetName}: ${msg}`) + newChecksums[platform] = currentChecksums[platform] ?? '' + allFound = false } } + if (!allFound) { + logger.warn(` Some ${label} assets could not be downloaded. Skipping update.`) + return { checksums: currentChecksums, changed: false } + } + return { checksums: newChecksums, changed } } @@ -436,7 +465,7 @@ async function updateSfw(source: string): Promise<{ if (free.changed) { for (const { 0: platform, 1: hash } of Object.entries(free.checksums)) { if (currentFree[platform] && currentFree[platform] !== hash) { - updated = replaceChecksumValue(updated, platform, currentFree[platform]!, hash) + updated = replaceChecksumValue(updated, platform, currentFree[platform]!, hash, 'SFW_FREE_CHECKSUMS') } } results.push({ tool: 'sfw-free', skipped: false, updated: true, reason: 'checksums updated' }) @@ -456,7 +485,7 @@ async function updateSfw(source: string): Promise<{ if (enterprise.changed) { for (const { 0: platform, 1: hash } of Object.entries(enterprise.checksums)) { if (currentEnterprise[platform] && currentEnterprise[platform] !== hash) { - updated = replaceChecksumValue(updated, platform, currentEnterprise[platform]!, hash) + updated = replaceChecksumValue(updated, platform, currentEnterprise[platform]!, hash, 'SFW_ENTERPRISE_CHECKSUMS') } } results.push({ tool: 'sfw-enterprise', skipped: false, updated: true, reason: 'checksums updated' }) diff --git a/.github/actions/checkout/action.yml b/.github/actions/checkout/action.yml index 50b94f32..5dfa9431 100644 --- a/.github/actions/checkout/action.yml +++ b/.github/actions/checkout/action.yml @@ -51,7 +51,7 @@ runs: [[ "$ASSET" == *.zip ]] && ZIZMOR_BIN="$ZIZMOR_DIR/zizmor.exe" if [ ! -x "$ZIZMOR_BIN" ]; then mkdir -p "$ZIZMOR_DIR" - DOWNLOAD_URL="https://github.com/woodruffw/zizmor/releases/download/v${ZIZMOR_VERSION}/${ASSET}" + DOWNLOAD_URL="https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/${ASSET}" DOWNLOAD_FILE="${ZIZMOR_DIR}/${ASSET}" curl -fsSL -o "$DOWNLOAD_FILE" "$DOWNLOAD_URL" ACTUAL_SHA256="$( (sha256sum "$DOWNLOAD_FILE" 2>/dev/null || shasum -a 256 "$DOWNLOAD_FILE") | cut -d' ' -f1 | tr -d '\\')" From 81eedf29bb82c0ce41bdf7a75d447ac8c536d54a Mon Sep 17 00:00:00 2001 From: jdalton Date: Sat, 11 Apr 2026 12:58:19 -0400 Subject: [PATCH 3/3] fix(hooks): rewrite update.mts to modify external-tools.json The updater was regex-replacing constants in index.mts (ZIZMOR_VERSION, SFW_FREE_CHECKSUMS, etc.) but those constants no longer exist after the migration to external-tools.json + zod validation. The updater now reads and writes external-tools.json directly via JSON manipulation. --- .claude/hooks/setup-security-tools/update.mts | 358 +++++------------- 1 file changed, 99 insertions(+), 259 deletions(-) diff --git a/.claude/hooks/setup-security-tools/update.mts b/.claude/hooks/setup-security-tools/update.mts index 4702a7cc..e2517415 100644 --- a/.claude/hooks/setup-security-tools/update.mts +++ b/.claude/hooks/setup-security-tools/update.mts @@ -5,7 +5,7 @@ // minimumReleaseAge cooldown (read from pnpm-workspace.yaml) for third-party tools. // Socket-owned tools (sfw) are excluded from cooldown. // -// Updates embedded checksums in index.mts when new versions are found. +// Updates external-tools.json when new versions or checksums are found. import { createHash } from 'node:crypto' import { existsSync, readFileSync, promises as fs } from 'node:fs' @@ -19,9 +19,8 @@ import { spawn } from '@socketsecurity/lib/spawn' const logger = getDefaultLogger() -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const INDEX_FILE = path.join(__dirname, 'index.mts') +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const CONFIG_FILE = path.join(__dirname, 'external-tools.json') const MS_PER_MINUTE = 60_000 const DEFAULT_COOLDOWN_MINUTES = 10_080 @@ -87,6 +86,31 @@ function versionFromTag(tag: string): string { return tag.replace(/^v/, '') } +// ── Config file I/O ── + +interface ToolConfig { + description?: string + version: string + repository?: string + assets?: Record + platforms?: Record + checksums?: Record + ecosystems?: string[] +} + +interface Config { + description?: string + tools: Record +} + +function readConfig(): Config { + return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) as Config +} + +async function writeConfig(config: Config): Promise { + await fs.writeFile(CONFIG_FILE, JSON.stringify(config, undefined, 2) + '\n', 'utf8') +} + // ── Checksum computation ── async function computeSha256(filePath: string): Promise { @@ -104,82 +128,6 @@ async function downloadAndHash(url: string): Promise { } } -// ── Index file manipulation ── - -function readIndexFile(): string { - return readFileSync(INDEX_FILE, 'utf8') -} - -async function writeIndexFile(content: string): Promise { - await fs.writeFile(INDEX_FILE, content, 'utf8') -} - -function replaceConstant( - source: string, - name: string, - oldValue: string, - newValue: string, -): string { - const escaped = oldValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = new RegExp(`(const ${name}\\s*=\\s*')${escaped}'`) - return source.replace(pattern, `$1${newValue}'`) -} - -function replaceChecksumValue( - source: string, - assetName: string, - oldHash: string, - newHash: string, - objectName?: string, -): string { - // Match the specific asset line in a checksums object. - const escaped = assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const multiLine = new RegExp( - `('${escaped}':\\s*\\n\\s*')${oldHash}'`, - ) - const singleLine = new RegExp( - `('${escaped}':\\s*')${oldHash}'`, - ) - // When objectName is provided, scope the replacement to that object block - // to avoid ambiguity when multiple objects share the same platform keys - // (e.g. SFW_FREE_CHECKSUMS and SFW_ENTERPRISE_CHECKSUMS both use 'linux-arm64'). - if (objectName) { - const objStart = source.indexOf(`const ${objectName}`) - if (objStart !== -1) { - const braceStart = source.indexOf('{', objStart) - if (braceStart !== -1) { - // Find the matching closing brace. - let depth = 0 - let braceEnd = -1 - for (let i = braceStart; i < source.length; i += 1) { - if (source[i] === '{') depth += 1 - else if (source[i] === '}') { - depth -= 1 - if (!depth) { - braceEnd = i + 1 - break - } - } - } - if (braceEnd !== -1) { - let block = source.slice(objStart, braceEnd) - if (multiLine.test(block)) { - block = block.replace(multiLine, `$1${newHash}'`) - } else { - block = block.replace(singleLine, `$1${newHash}'`) - } - return source.slice(0, objStart) + block + source.slice(braceEnd) - } - } - } - } - // Unscoped fallback: replace first match in entire source. - if (multiLine.test(source)) { - return source.replace(multiLine, `$1${newHash}'`) - } - return source.replace(singleLine, `$1${newHash}'`) -} - // ── Zizmor update ── interface UpdateResult { @@ -189,53 +137,34 @@ interface UpdateResult { updated: boolean } -// Map from index.mts asset names to zizmor release asset names. -const ZIZMOR_ASSETS: Record = { - __proto__: null as unknown as string, - 'zizmor-aarch64-apple-darwin.tar.gz': - 'zizmor-aarch64-apple-darwin.tar.gz', - 'zizmor-aarch64-unknown-linux-gnu.tar.gz': - 'zizmor-aarch64-unknown-linux-gnu.tar.gz', - 'zizmor-x86_64-apple-darwin.tar.gz': - 'zizmor-x86_64-apple-darwin.tar.gz', - 'zizmor-x86_64-pc-windows-msvc.zip': - 'zizmor-x86_64-pc-windows-msvc.zip', - 'zizmor-x86_64-unknown-linux-gnu.tar.gz': - 'zizmor-x86_64-unknown-linux-gnu.tar.gz', -} - -async function updateZizmor(source: string): Promise<{ - result: UpdateResult - source: string -}> { +async function updateZizmor(config: Config): Promise { const tool = 'zizmor' logger.log(`=== Checking ${tool} ===`) + const toolConfig = config.tools[tool] + if (!toolConfig) { + return { tool, skipped: true, updated: false, reason: 'not in config' } + } + + const repo = toolConfig.repository ?? 'zizmorcore/zizmor' + let release: GhRelease try { - release = await ghApiLatestRelease('zizmorcore/zizmor') + release = await ghApiLatestRelease(repo) } catch (e) { const msg = e instanceof Error ? e.message : String(e) logger.warn(`Failed to fetch zizmor releases: ${msg}`) - return { - result: { tool, skipped: true, updated: false, reason: `API error: ${msg}` }, - source, - } + return { tool, skipped: true, updated: false, reason: `API error: ${msg}` } } const latestVersion = versionFromTag(release.tag_name) - // Extract current version from source. - const currentMatch = /const ZIZMOR_VERSION = '([^']+)'/.exec(source) - const currentVersion = currentMatch ? currentMatch[1] : '' + const currentVersion = toolConfig.version logger.log(`Current: v${currentVersion}, Latest: v${latestVersion}`) if (latestVersion === currentVersion) { logger.log('Already current.') - return { - result: { tool, skipped: false, updated: false, reason: 'already current' }, - source, - } + return { tool, skipped: false, updated: false, reason: 'already current' } } // Respect cooldown for third-party tools. @@ -243,10 +172,7 @@ async function updateZizmor(source: string): Promise<{ const daysOld = ((Date.now() - new Date(release.published_at).getTime()) / 86_400_000).toFixed(1) const cooldownDays = (COOLDOWN_MS / 86_400_000).toFixed(0) logger.log(`v${latestVersion} is only ${daysOld} days old (need ${cooldownDays}). Skipping.`) - return { - result: { tool, skipped: true, updated: false, reason: `too new (${daysOld} days, need ${cooldownDays})` }, - source, - } + return { tool, skipped: true, updated: false, reason: `too new (${daysOld} days, need ${cooldownDays})` } } logger.log(`Updating to v${latestVersion}...`) @@ -271,14 +197,16 @@ async function updateZizmor(source: string): Promise<{ } } - // Compute checksums for each platform asset. - let updated = source + // Compute checksums for each asset in the config. + const currentChecksums = toolConfig.checksums ?? {} + const newChecksums: Record = { __proto__: null } as unknown as Record let allFound = true - for (const assetName of Object.keys(ZIZMOR_ASSETS)) { + + for (const assetName of Object.keys(currentChecksums)) { let newHash: string | undefined // Try checksums.txt first. - if (checksumMap && checksumMap[assetName]) { + if (checksumMap?.[assetName]) { newHash = checksumMap[assetName] } else { // Download and compute. @@ -304,196 +232,111 @@ async function updateZizmor(source: string): Promise<{ continue } - // Find and replace the old hash. - const oldHashMatch = new RegExp( - `'${assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}':\\s*\\n\\s*'([a-f0-9]{64})'`, - ).exec(updated) - const oldHashSingle = new RegExp( - `'${assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}':\\s*'([a-f0-9]{64})'`, - ).exec(updated) - const oldHash = oldHashMatch?.[1] ?? oldHashSingle?.[1] + newChecksums[assetName] = newHash + const oldHash = currentChecksums[assetName] if (oldHash && oldHash !== newHash) { - updated = replaceChecksumValue(updated, assetName, oldHash, newHash) logger.log(` ${assetName}: ${oldHash.slice(0, 12)}... -> ${newHash.slice(0, 12)}...`) } else if (oldHash === newHash) { logger.log(` ${assetName}: unchanged`) - } else { - logger.warn(` ${assetName}: no existing checksum entry found in source`) - allFound = false } } if (!allFound) { logger.warn('Some assets could not be verified. Skipping version bump.') - return { - result: { tool, skipped: true, updated: false, reason: 'incomplete asset checksums' }, - source, - } + return { tool, skipped: true, updated: false, reason: 'incomplete asset checksums' } } - // Update version constant. - updated = replaceConstant(updated, 'ZIZMOR_VERSION', currentVersion!, latestVersion) - logger.log(`Updated ZIZMOR_VERSION: ${currentVersion} -> ${latestVersion}`) + // Update config. + toolConfig.version = latestVersion + toolConfig.checksums = newChecksums + logger.log(`Updated zizmor: ${currentVersion} -> ${latestVersion}`) - return { - result: { tool, skipped: false, updated: true, reason: `${currentVersion} -> ${latestVersion}` }, - source: updated, - } + return { tool, skipped: false, updated: true, reason: `${currentVersion} -> ${latestVersion}` } } // ── SFW update ── -const SFW_FREE_ASSET_NAMES: Record = { - __proto__: null as unknown as string, - 'linux-arm64': 'sfw-free-linux-arm64', - 'linux-x86_64': 'sfw-free-linux-x86_64', - 'macos-arm64': 'sfw-free-macos-arm64', - 'macos-x86_64': 'sfw-free-macos-x86_64', - 'windows-x86_64': 'sfw-free-windows-x86_64.exe', -} +async function updateSfwTool( + config: Config, + toolName: string, +): Promise { + const toolConfig = config.tools[toolName] + if (!toolConfig) { + return { tool: toolName, skipped: true, updated: false, reason: 'not in config' } + } -const SFW_ENTERPRISE_ASSET_NAMES: Record = { - __proto__: null as unknown as string, - 'linux-arm64': 'sfw-linux-arm64', - 'linux-x86_64': 'sfw-linux-x86_64', - 'macos-arm64': 'sfw-macos-arm64', - 'macos-x86_64': 'sfw-macos-x86_64', - 'windows-x86_64': 'sfw-windows-x86_64.exe', -} + const repo = toolConfig.repository + if (!repo) { + return { tool: toolName, skipped: true, updated: false, reason: 'no repository' } + } -async function fetchSfwChecksums( - repo: string, - label: string, - assetNames: Record, - currentChecksums: Record, -): Promise<{ - checksums: Record - changed: boolean -}> { let release: GhRelease try { release = await ghApiLatestRelease(repo) } catch (e) { const msg = e instanceof Error ? e.message : String(e) - logger.warn(`Failed to fetch ${label} releases: ${msg}`) - return { checksums: currentChecksums, changed: false } + logger.warn(`Failed to fetch ${toolName} releases: ${msg}`) + return { tool: toolName, skipped: true, updated: false, reason: `API error: ${msg}` } } - logger.log(` ${label}: latest ${release.tag_name} (published ${release.published_at.slice(0, 10)})`) + logger.log(` ${toolName}: latest ${release.tag_name} (published ${release.published_at.slice(0, 10)})`) + const currentChecksums = toolConfig.checksums ?? {} + const platforms = toolConfig.platforms ?? {} + const prefix = toolName === 'sfw-enterprise' ? 'sfw' : 'sfw-free' const newChecksums: Record = { __proto__: null } as unknown as Record let changed = false let allFound = true - for (const { 0: platform, 1: assetName } of Object.entries(assetNames)) { + for (const { 0: _, 1: sfwPlatform } of Object.entries(platforms)) { + const suffix = sfwPlatform.startsWith('windows') ? '.exe' : '' + const assetName = `${prefix}-${sfwPlatform}${suffix}` const asset = release.assets.find(a => a.name === assetName) const url = asset ? asset.browser_download_url - : `https://github.com/${repo}/releases/latest/download/${assetName}` + : `https://github.com/${repo}/releases/download/${release.tag_name}/${assetName}` logger.log(` Computing checksum for ${assetName}...`) try { const hash = await downloadAndHash(url) - newChecksums[platform] = hash - if (currentChecksums[platform] !== hash) { - logger.log(` ${platform}: ${(currentChecksums[platform] ?? '').slice(0, 12)}... -> ${hash.slice(0, 12)}...`) + newChecksums[sfwPlatform] = hash + if (currentChecksums[sfwPlatform] !== hash) { + logger.log(` ${sfwPlatform}: ${(currentChecksums[sfwPlatform] ?? '').slice(0, 12)}... -> ${hash.slice(0, 12)}...`) changed = true } } catch (e) { const msg = e instanceof Error ? e.message : String(e) logger.warn(` Failed to download ${assetName}: ${msg}`) - newChecksums[platform] = currentChecksums[platform] ?? '' allFound = false } } if (!allFound) { - logger.warn(` Some ${label} assets could not be downloaded. Skipping update.`) - return { checksums: currentChecksums, changed: false } + logger.warn(` Some ${toolName} assets could not be downloaded. Skipping update.`) + return { tool: toolName, skipped: true, updated: false, reason: 'incomplete downloads' } } - return { checksums: newChecksums, changed } -} - -function extractChecksums( - source: string, - objectName: string, -): Record { - const result: Record = { __proto__: null } as unknown as Record - // Find the object in source. - const objPattern = new RegExp( - `const ${objectName}[^{]*\\{[^}]*?(?:'([^']+)':\\s*'([a-f0-9]{64})'[,\\s]*)+`, - 's', - ) - const objMatch = objPattern.exec(source) - if (!objMatch) return result - - const block = objMatch[0] - const entryPattern = /'([^']+)':\s*\n?\s*'([a-f0-9]{64})'/g - let match: RegExpExecArray | null - while ((match = entryPattern.exec(block)) !== null) { - if (match[1] !== '__proto__') { - result[match[1]!] = match[2]! - } + if (changed) { + toolConfig.version = release.tag_name + toolConfig.checksums = newChecksums + return { tool: toolName, skipped: false, updated: true, reason: 'checksums updated' } } - return result + + return { tool: toolName, skipped: false, updated: false, reason: 'already current' } } -async function updateSfw(source: string): Promise<{ - results: UpdateResult[] - source: string -}> { +async function updateSfw(config: Config): Promise { logger.log('=== Checking SFW ===') - // Socket-owned tools: no cooldown. logger.log('Socket-owned tool: cooldown excluded.') const results: UpdateResult[] = [] - // Extract current checksums from source. - const currentFree = extractChecksums(source, 'SFW_FREE_CHECKSUMS') - const currentEnterprise = extractChecksums(source, 'SFW_ENTERPRISE_CHECKSUMS') - - // Check sfw-free. logger.log('') - const free = await fetchSfwChecksums( - 'SocketDev/sfw-free', - 'sfw-free', - SFW_FREE_ASSET_NAMES, - currentFree, - ) + results.push(await updateSfwTool(config, 'sfw-free')) - let updated = source - if (free.changed) { - for (const { 0: platform, 1: hash } of Object.entries(free.checksums)) { - if (currentFree[platform] && currentFree[platform] !== hash) { - updated = replaceChecksumValue(updated, platform, currentFree[platform]!, hash, 'SFW_FREE_CHECKSUMS') - } - } - results.push({ tool: 'sfw-free', skipped: false, updated: true, reason: 'checksums updated' }) - } else { - results.push({ tool: 'sfw-free', skipped: false, updated: false, reason: 'already current' }) - } - - // Check sfw enterprise. logger.log('') - const enterprise = await fetchSfwChecksums( - 'SocketDev/firewall-release', - 'sfw-enterprise', - SFW_ENTERPRISE_ASSET_NAMES, - currentEnterprise, - ) - - if (enterprise.changed) { - for (const { 0: platform, 1: hash } of Object.entries(enterprise.checksums)) { - if (currentEnterprise[platform] && currentEnterprise[platform] !== hash) { - updated = replaceChecksumValue(updated, platform, currentEnterprise[platform]!, hash, 'SFW_ENTERPRISE_CHECKSUMS') - } - } - results.push({ tool: 'sfw-enterprise', skipped: false, updated: true, reason: 'checksums updated' }) - } else { - results.push({ tool: 'sfw-enterprise', skipped: false, updated: false, reason: 'already current' }) - } + results.push(await updateSfwTool(config, 'sfw-enterprise')) - return { results, source: updated } + return results } // ── Main ── @@ -501,26 +344,23 @@ async function updateSfw(source: string): Promise<{ async function main(): Promise { logger.log('Checking for security tool updates...\n') - let source = readIndexFile() + const config = readConfig() const allResults: UpdateResult[] = [] // 1. Check zizmor (third-party, respects cooldown). - const zizmor = await updateZizmor(source) - source = zizmor.source - allResults.push(zizmor.result) + allResults.push(await updateZizmor(config)) logger.log('') // 2. Check sfw (Socket-owned, no cooldown). - const sfw = await updateSfw(source) - source = sfw.source - allResults.push(...sfw.results) + const sfwResults = await updateSfw(config) + allResults.push(...sfwResults) logger.log('') - // Write updated index.mts if anything changed. + // Write updated config if anything changed. const anyUpdated = allResults.some(r => r.updated) if (anyUpdated) { - await writeIndexFile(source) - logger.log('Updated index.mts with new checksums.\n') + await writeConfig(config) + logger.log('Updated external-tools.json.\n') } // Report.