From a07e1ca7c648fb23d072ad169398756b4b039833 Mon Sep 17 00:00:00 2001 From: agent-core-bot Date: Sun, 26 Apr 2026 12:35:55 +0000 Subject: [PATCH] chore: sync core lib and CLAUDE.md from agent-core --- lib/binary/index.js | 499 ++++++++++++++++++++++++++++++++++---- lib/binary/index.test.js | 511 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 964 insertions(+), 46 deletions(-) create mode 100644 lib/binary/index.test.js diff --git a/lib/binary/index.js b/lib/binary/index.js index 8b4d785..ce8e72c 100644 --- a/lib/binary/index.js +++ b/lib/binary/index.js @@ -6,6 +6,19 @@ * Handles lazy downloading and execution. Since Claude Code plugins have no * postinstall hooks, the binary is downloaded at runtime on first use. * + * Security hardening (2026-04-26 audit): + * - Every release asset is verified against its `.sha256` sidecar + * before extraction. A mismatch aborts with a clear message. + * - Archives are extracted into an isolated tmpdir. Each entry path is + * validated to reject absolute paths, Windows drive letters, and parent + * traversal (`..`). The expected binary is then moved to the final + * destination; everything else is discarded. + * - The Windows zip path runs a PowerShell helper script via `-File` and + * passes paths through environment variables, so PowerShell never + * re-parses a command string (which broke on spaces/brackets). The + * script validates every zip entry before extracting it and rejects + * absolute, UNC, and parent-traversal entries. + * * @module lib/binary */ @@ -14,6 +27,7 @@ const path = require('path'); const os = require('os'); const https = require('https'); const cp = require('child_process'); +const crypto = require('crypto'); const { promisify } = require('util'); const execFileAsync = promisify(cp.execFile); @@ -120,7 +134,7 @@ async function isAvailableAsync() { } // --------------------------------------------------------------------------- -// Download +// Download + checksum verification // --------------------------------------------------------------------------- /** @@ -180,67 +194,400 @@ function downloadToBuffer(url) { } /** - * Extract a tar.gz buffer into a directory using the system tar command. + * Parse the leading 64-hex digest from a `.sha256` sidecar body. + * Tolerant of these formats (GNU coreutils + BSD `shasum`): + * "<64-hex>\n" + * "<64-hex> \n" (text mode, two-space separator) + * "<64-hex> *\n" (binary mode, leading asterisk) + * "<64-hex> \n" (single-space variants) + * Throws if no valid digest is found. + * @param {string} body + * @returns {string} lower-cased 64-char hex digest + */ +function parseSha256Sidecar(body) { + if (typeof body !== 'string') body = String(body || ''); + const match = body.trim().match(/^([A-Fa-f0-9]{64})\b/); + if (!match) { + throw new Error('Could not parse SHA-256 digest from sidecar body'); + } + return match[1].toLowerCase(); +} + +/** + * Fetch and parse a `.sha256` sidecar next to an asset URL. + * @param {string} assetUrl full URL of the archive (not the sidecar) + * @returns {Promise} lower-cased hex digest + */ +async function downloadSha256(assetUrl) { + const sidecarUrl = assetUrl + '.sha256'; + const buf = await downloadToBuffer(sidecarUrl); + return parseSha256Sidecar(buf.toString('utf8')); +} + +/** + * Compute the lower-case hex SHA-256 of a Buffer. + * @param {Buffer} buf + * @returns {string} + */ +function sha256Hex(buf) { + return crypto.createHash('sha256').update(buf).digest('hex'); +} + +/** + * Verify a downloaded buffer against an expected hex digest. + * Throws with a security-focused message on mismatch. * @param {Buffer} buf - * @param {string} destDir - * @returns {Promise} + * @param {string} expectedHex + * @param {string} filename user-facing name for the error message */ -function extractTarGz(buf, destDir) { +function verifySha256(buf, expectedHex, filename) { + const expected = String(expectedHex || '').toLowerCase(); + const actual = sha256Hex(buf); + if (expected !== actual) { + throw new Error( + 'SHA-256 verification failed for ' + filename + ': ' + + 'expected ' + expected + ', got ' + actual + '. ' + + 'This could indicate a tampered release. Do not extract.' + ); + } +} + +// --------------------------------------------------------------------------- +// Archive entry validation +// --------------------------------------------------------------------------- + +/** + * Reject archive entries with paths that could escape the extract directory. + * Rules: + * - No absolute POSIX paths (leading `/`) + * - No Windows absolute paths (drive letter like `C:\` or `C:/`) + * - No UNC paths (`\\server\share`) + * - No `..` as a path component + * - No empty entry names + * @param {string} entry + * @throws {Error} on unsafe entry + */ +function assertSafeArchiveEntry(entry) { + if (!entry || typeof entry !== 'string') { + throw new Error('Refusing to extract archive with empty entry name'); + } + const name = entry.replace(/\\/g, '/').trim(); + if (name.length === 0) { + throw new Error('Refusing to extract archive with empty entry name'); + } + if (name.startsWith('//')) { + throw new Error('Refusing to extract archive with UNC entry: ' + entry); + } + if (name.startsWith('/')) { + throw new Error('Refusing to extract archive with absolute entry: ' + entry); + } + if (/^[A-Za-z]:[\\/]/.test(entry)) { + throw new Error('Refusing to extract archive with Windows absolute entry: ' + entry); + } + const parts = name.split('/').filter(function(p) { return p.length > 0; }); + for (let i = 0; i < parts.length; i++) { + if (parts[i] === '..') { + throw new Error('Refusing to extract archive with parent-traversal entry: ' + entry); + } + } +} + +/** + * List the entries inside a tar.gz buffer by running `tar -tz` over stdin. + * Returns the raw list; caller is responsible for validating each entry. + * @param {Buffer} buf + * @returns {Promise} + */ +function listTarGzEntries(buf) { return new Promise(function(resolve, reject) { - const tarDest = process.platform === 'win32' ? destDir.replace(/\\/g, '/') : destDir; - const tar = cp.spawn('tar', ['xz', '-C', tarDest], { - stdio: ['pipe', 'pipe', 'pipe'] - }); + const tar = cp.spawn('tar', ['-tz'], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; let stderr = ''; + tar.stdout.on('data', function(d) { stdout += d; }); tar.stderr.on('data', function(d) { stderr += d; }); - tar.stdin.write(buf); - tar.stdin.end(); + tar.on('error', reject); tar.on('close', function(code) { if (code !== 0) { - reject(new Error('tar extraction failed (code ' + code + '): ' + stderr)); - } else { - resolve(); + reject(new Error('tar -tz listing failed (code ' + code + '): ' + stderr)); + return; } + const entries = stdout.split(/\r?\n/).filter(function(l) { return l.length > 0; }); + resolve(entries); }); - tar.on('error', reject); + tar.stdin.write(buf); + tar.stdin.end(); }); } /** - * Extract a zip buffer into a directory using PowerShell Expand-Archive (Windows). + * Verify that a path resolved from extraction lies inside a known root. + * Guards against symlinks and any surprise introduced by the OS extractor. + * @param {string} root + * @param {string} candidate + */ +function assertInsideRoot(root, candidate) { + const rootResolved = path.resolve(root) + path.sep; + const candResolved = path.resolve(candidate); + if (candResolved !== path.resolve(root) && !candResolved.startsWith(rootResolved)) { + throw new Error('Extracted path escapes extract root: ' + candidate); + } +} + +/** + * Recursively walk a directory and return all file paths (not dirs). + * Throws if any symlink is encountered (defense in depth: no surprise escapes). + * @param {string} dir + * @returns {string[]} + */ +function walkFiles(dir) { + const out = []; + const stack = [dir]; + while (stack.length > 0) { + const cur = stack.pop(); + const st = fs.lstatSync(cur); + if (st.isSymbolicLink()) { + throw new Error('Refusing to follow symlink produced by extractor: ' + cur); + } + if (st.isDirectory()) { + const names = fs.readdirSync(cur); + for (let i = 0; i < names.length; i++) { + stack.push(path.join(cur, names[i])); + } + } else if (st.isFile()) { + out.push(cur); + } + } + return out; +} + +/** + * Remove a directory tree, tolerating already-missing paths. + * @param {string} dir + */ +function rmrf(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch (e) { + /* ignore */ + } +} + +// --------------------------------------------------------------------------- +// Extraction +// --------------------------------------------------------------------------- + +/** + * Extract a tar.gz buffer into a scratch directory, validating entries first. + * Returns the scratch directory; caller is responsible for moving files out + * and calling rmrf() on it. * @param {Buffer} buf - * @param {string} destDir - * @param {string} binaryName - * @returns {Promise} + * @returns {Promise} scratch dir */ -function extractZip(buf, destDir, binaryName) { - return new Promise(function(resolve, reject) { - const tmpZip = path.join(os.tmpdir(), binaryName + '-' + Date.now() + '.zip'); - fs.writeFileSync(tmpZip, buf); - const cmd = 'Expand-Archive -Path \'' + tmpZip + '\' -DestinationPath \'' + destDir + '\' -Force'; - const ps = cp.spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', cmd], { - stdio: ['ignore', 'pipe', 'pipe'] +async function extractTarGzToScratch(buf) { + const entries = await listTarGzEntries(buf); + for (let i = 0; i < entries.length; i++) { + assertSafeArchiveEntry(entries[i]); + } + + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-analyzer-tar-')); + + try { + await new Promise(function(resolve, reject) { + const tar = cp.spawn('tar', ['xz', '-C', scratch], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stderr = ''; + tar.stderr.on('data', function(d) { stderr += d; }); + tar.on('error', reject); + tar.on('close', function(code) { + if (code !== 0) { + reject(new Error('tar extraction failed (code ' + code + '): ' + stderr)); + } else { + resolve(); + } + }); + tar.stdin.write(buf); + tar.stdin.end(); }); - let stderr = ''; - ps.stderr.on('data', function(d) { stderr += d; }); - ps.on('close', function(code) { - try { fs.unlinkSync(tmpZip); } catch (e) { /* ignore */ } - if (code !== 0) { - reject(new Error('zip extraction failed (code ' + code + '): ' + stderr)); - } else { - resolve(); - } + + // Defense in depth: reject any symlink or non-regular entry the OS + // extractor may have created, and confirm every file resolves inside + // scratch. + const files = walkFiles(scratch); + for (let i = 0; i < files.length; i++) { + assertInsideRoot(scratch, files[i]); + } + } catch (err) { + rmrf(scratch); + throw err; + } + + return scratch; +} + +/** + * PowerShell script body that validates and extracts a zip entry-by-entry + * using .NET's System.IO.Compression.ZipFile. Paths and output dir are read + * from environment variables (`SRC_ZIP`, `DEST_DIR`) so no argument parsing + * can split on spaces, wildcards, or quotes. + * + * Rejects: + * - Absolute entry names (POSIX `/`, Windows `C:\`) + * - UNC entry names (`\\server\share`) + * - Any `..` path component + * - Resolved paths that escape the destination directory + * + * On any validation failure the script writes to stderr and exits with a + * non-zero status; nothing is extracted. + */ +const EXTRACT_ZIP_PS1 = [ + '$ErrorActionPreference = "Stop"', + '$src = $env:SRC_ZIP', + '$dest = $env:DEST_DIR', + 'if ([string]::IsNullOrEmpty($src) -or [string]::IsNullOrEmpty($dest)) {', + ' [Console]::Error.WriteLine("SRC_ZIP and DEST_DIR must both be set"); exit 2', + '}', + 'Add-Type -AssemblyName System.IO.Compression.FileSystem', + '$destFull = [System.IO.Path]::GetFullPath($dest)', + 'if (-not $destFull.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {', + ' $destFull = $destFull + [System.IO.Path]::DirectorySeparatorChar', + '}', + '$zip = [System.IO.Compression.ZipFile]::OpenRead($src)', + 'try {', + ' foreach ($entry in $zip.Entries) {', + ' $name = $entry.FullName', + ' if ([string]::IsNullOrEmpty($name)) { continue }', + ' $norm = $name -replace "\\\\","/"', + ' if ($norm.StartsWith("/") -or $norm.StartsWith("//")) {', + ' [Console]::Error.WriteLine("Refusing absolute/UNC entry: " + $name); exit 3', + ' }', + ' if ($name -match "^[A-Za-z]:[\\\\/]") {', + ' [Console]::Error.WriteLine("Refusing Windows-absolute entry: " + $name); exit 3', + ' }', + ' foreach ($part in ($norm -split "/")) {', + ' if ($part -eq "..") {', + ' [Console]::Error.WriteLine("Refusing parent-traversal entry: " + $name); exit 3', + ' }', + ' }', + ' $target = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($destFull, $norm))', + ' if (-not $target.StartsWith($destFull, [System.StringComparison]::OrdinalIgnoreCase)) {', + ' [Console]::Error.WriteLine("Entry escapes destination: " + $name); exit 3', + ' }', + ' if ($entry.FullName.EndsWith("/")) {', + ' [System.IO.Directory]::CreateDirectory($target) | Out-Null', + ' } else {', + ' $parent = [System.IO.Path]::GetDirectoryName($target)', + ' if ($parent) { [System.IO.Directory]::CreateDirectory($parent) | Out-Null }', + ' [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true)', + ' }', + ' }', + '} finally {', + ' $zip.Dispose()', + '}' +].join('\r\n'); + +/** + * Extract a zip buffer into a scratch directory. + * + * The extraction runs a PowerShell helper script via `-File` so PowerShell + * never re-parses a command string (which would break on paths containing + * spaces or brackets). The script reads the zip and destination paths from + * environment variables, validates every entry's path before extracting, and + * writes files individually using .NET's ZipFile APIs. + * + * After extraction, `walkFiles` re-checks the tree and rejects any symlink + * or junction that might have been created. + * + * @param {Buffer} buf + * @returns {Promise} scratch dir + */ +async function extractZipToScratch(buf) { + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-analyzer-zip-')); + const tmpZip = path.join(scratch, '__archive.zip'); + const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-analyzer-ps-')); + const scriptPath = path.join(scriptDir, 'extract.ps1'); + + try { + fs.writeFileSync(tmpZip, buf); + fs.writeFileSync(scriptPath, EXTRACT_ZIP_PS1, 'utf8'); + + await new Promise(function(resolve, reject) { + const child = cp.execFile( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', 'Bypass', + '-File', scriptPath + ], + { + windowsHide: true, + env: Object.assign({}, process.env, { + SRC_ZIP: tmpZip, + DEST_DIR: scratch + }) + }, + function(err, _stdout, stderr) { + if (err) { + reject(new Error('zip extraction failed: ' + (stderr || err.message))); + } else { + resolve(); + } + } + ); + // Do not write to stdin; the script reads from env. + if (child.stdin) child.stdin.end(); }); - ps.on('error', reject); - }); + + try { fs.unlinkSync(tmpZip); } catch (e) { /* ignore */ } + + // Defense in depth: walkFiles() throws on any symlink/junction. Also + // confirm every file resolves inside scratch. + const files = walkFiles(scratch); + for (let i = 0; i < files.length; i++) { + assertInsideRoot(scratch, files[i]); + } + } catch (err) { + rmrf(scratch); + throw err; + } finally { + rmrf(scriptDir); + } + + return scratch; +} + +/** + * Find the expected binary inside a scratch directory (recursive search). + * @param {string} scratch + * @param {string} binaryBaseName e.g. `agent-analyzer` or `agent-analyzer.exe` + * @returns {string|null} absolute path, or null if not found + */ +function findBinaryInScratch(scratch, binaryBaseName) { + const files = walkFiles(scratch); + for (let i = 0; i < files.length; i++) { + if (path.basename(files[i]) === binaryBaseName) { + assertInsideRoot(scratch, files[i]); + return files[i]; + } + } + return null; } +// --------------------------------------------------------------------------- +// Download + install +// --------------------------------------------------------------------------- + /** * Download and install the binary for the current platform into ~/.agent-sh/bin/. * @param {string} ver - * @returns {Promise} + * @param {Object} [options] + * @param {boolean} [options.skipChecksum=false] LOCAL DEV ONLY. Skips the + * `.sha256` sidecar fetch and verification. NEVER set this in production. + * @returns {Promise} path to the installed binary */ -async function downloadBinary(ver) { +async function downloadBinary(ver, options) { + const opts = options || {}; + const skipChecksum = opts.skipChecksum === true; + const platformKey = getPlatformKey(); if (!platformKey) { throw new Error( @@ -250,12 +597,14 @@ async function downloadBinary(ver) { } const url = buildDownloadUrl(ver, platformKey); - process.stderr.write('Downloading ' + BINARY_NAME + ' v' + ver + ' for ' + platformKey + '...' + '\n'); + const filename = url.substring(url.lastIndexOf('/') + 1); + process.stderr.write('Downloading ' + BINARY_NAME + ' v' + ver + ' for ' + platformKey + '...\n'); const binPath = getBinaryPath(); const binDir = path.dirname(binPath); fs.mkdirSync(binDir, { recursive: true }); + // --- 1. Fetch archive bytes -------------------------------------------- let buf; try { buf = await downloadToBuffer(url); @@ -271,10 +620,53 @@ async function downloadBinary(ver) { ); } - if (process.platform === 'win32') { - await extractZip(buf, binDir, path.basename(binPath)); + // --- 2. Verify SHA-256 sidecar ----------------------------------------- + if (skipChecksum) { + process.stderr.write( + '[WARN] skipChecksum=true - SHA-256 verification disabled. ' + + 'This is LOCAL DEV ONLY and MUST NOT be used in production.\n' + ); } else { - await extractTarGz(buf, binDir); + let expected; + try { + expected = await downloadSha256(url); + } catch (err) { + throw new Error( + 'Failed to fetch SHA-256 sidecar for ' + filename + ':\n' + + ' URL: ' + url + '.sha256\n' + + ' Error: ' + err.message + '\n\n' + + 'The release may be missing its checksum file. Refusing to install ' + + 'an unverified binary. If this is a legacy release without sidecars, ' + + 'pass { skipChecksum: true } to downloadBinary() (LOCAL DEV ONLY).' + ); + } + verifySha256(buf, expected, filename); + } + + // --- 3. Extract to isolated scratch dir + validate entries ------------- + const binaryBaseName = path.basename(binPath); + let scratch; + try { + if (process.platform === 'win32') { + scratch = await extractZipToScratch(buf); + } else { + scratch = await extractTarGzToScratch(buf); + } + + // --- 4. Locate the expected binary inside scratch -------------------- + const extractedBin = findBinaryInScratch(scratch, binaryBaseName); + if (!extractedBin) { + throw new Error( + 'Expected binary "' + binaryBaseName + '" not found inside archive ' + + filename + '. Archive layout may have changed.' + ); + } + + // --- 5. Move ONLY the expected binary to its final location ---------- + // copyFileSync so cross-device moves work. scratch is rmrf'd in finally. + fs.copyFileSync(extractedBin, binPath); + } finally { + if (scratch) rmrf(scratch); } if (process.platform !== 'win32') { @@ -300,6 +692,7 @@ async function downloadBinary(ver) { * Ensure the binary exists and meets the minimum version. Downloads if needed. * @param {Object} [options] * @param {string} [options.version] + * @param {boolean} [options.skipChecksum=false] LOCAL DEV ONLY. * @returns {Promise} */ async function ensureBinary(options) { @@ -314,7 +707,7 @@ async function ensureBinary(options) { } } - return downloadBinary(targetVer); + return downloadBinary(targetVer, { skipChecksum: opts.skipChecksum === true }); } /** @@ -322,6 +715,7 @@ async function ensureBinary(options) { * Prefer ensureBinary() unless a sync API is strictly required. * @param {Object} [options] * @param {string} [options.version] + * @param {boolean} [options.skipChecksum=false] LOCAL DEV ONLY. * @returns {string} */ function ensureBinarySync(options) { @@ -335,10 +729,12 @@ function ensureBinarySync(options) { } const targetVer = (options && options.version) || ANALYZER_MIN_VERSION; + const skipChecksum = !!(options && options.skipChecksum); const selfPath = __filename; const helperLines = [ 'var b = require(' + JSON.stringify(selfPath) + ');', - 'b.ensureBinary({ version: ' + JSON.stringify(targetVer) + ' })', + 'b.ensureBinary({ version: ' + JSON.stringify(targetVer) + + ', skipChecksum: ' + JSON.stringify(skipChecksum) + ' })', ' .then(function(p) { process.stdout.write(p); })', ' .catch(function(e) { process.stderr.write(e.message); process.exit(1); });' ]; @@ -394,5 +790,16 @@ module.exports = { isAvailableAsync, meetsMinimumVersion, buildDownloadUrl, - PLATFORM_MAP + PLATFORM_MAP, + // Exported for tests + advanced consumers + parseSha256Sidecar, + verifySha256, + sha256Hex, + assertSafeArchiveEntry, + assertInsideRoot, + downloadBinary, + // Exported for tests only + extractTarGzToScratch, + extractZipToScratch, + _EXTRACT_ZIP_PS1: EXTRACT_ZIP_PS1 }; diff --git a/lib/binary/index.test.js b/lib/binary/index.test.js new file mode 100644 index 0000000..0138fab --- /dev/null +++ b/lib/binary/index.test.js @@ -0,0 +1,511 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Tests for the security-hardened binary downloader. + * + * Focus: + * - SHA-256 sidecar parsing and verification + * - Archive entry path validation (absolute, drive letter, parent traversal) + * - Scratch-root escape detection + * - Happy-path extraction flow via a real gzipped tar built in-test + * + * These tests do NOT hit the network or install any binary. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const zlib = require('node:zlib'); +const cp = require('node:child_process'); + +const mod = require('./index.js'); + +// --------------------------------------------------------------------------- +// Helpers: build a minimal gzipped USTAR archive in memory +// --------------------------------------------------------------------------- + +/** + * Build a single USTAR tar block for a regular file with the given name and + * contents. Returns 512-byte header + padded data blocks. + */ +function tarBlock(name, contents) { + const data = Buffer.from(contents, 'utf8'); + const header = Buffer.alloc(512, 0); + + // Name field (0-99): ASCII, NUL-terminated if shorter + header.write(name, 0, Math.min(name.length, 100), 'utf8'); + // Mode (100-107): "0000644\0" + header.write('0000644\0', 100, 8, 'ascii'); + // UID (108-115), GID (116-123): "0000000\0" + header.write('0000000\0', 108, 8, 'ascii'); + header.write('0000000\0', 116, 8, 'ascii'); + // Size (124-135): octal, 11 digits + NUL + const sizeOctal = data.length.toString(8).padStart(11, '0') + '\0'; + header.write(sizeOctal, 124, 12, 'ascii'); + // Mtime (136-147): "00000000000\0" + header.write('00000000000\0', 136, 12, 'ascii'); + // Checksum field (148-155): fill with spaces for checksum calculation + header.write(' ', 148, 8, 'ascii'); + // Typeflag (156): '0' = regular file + header.write('0', 156, 1, 'ascii'); + // Magic + version (257-264): "ustar\0" + "00" + header.write('ustar\0', 257, 6, 'ascii'); + header.write('00', 263, 2, 'ascii'); + + // Compute checksum: unsigned sum of all 512 header bytes + let sum = 0; + for (let i = 0; i < 512; i++) sum += header[i]; + const chkOctal = sum.toString(8).padStart(6, '0') + '\0 '; + header.write(chkOctal, 148, 8, 'ascii'); + + // Pad data to 512-byte multiple + const padLen = (512 - (data.length % 512)) % 512; + const padded = Buffer.concat([data, Buffer.alloc(padLen, 0)]); + + return Buffer.concat([header, padded]); +} + +function buildTarGz(files) { + const blocks = files.map(function(f) { return tarBlock(f.name, f.contents); }); + // Two empty 512-byte blocks terminate the archive + const terminator = Buffer.alloc(1024, 0); + const tar = Buffer.concat(blocks.concat([terminator])); + return zlib.gzipSync(tar); +} + +function hasSystemTar() { + try { + cp.execFileSync('tar', ['--version'], { stdio: 'ignore', timeout: 3000 }); + return true; + } catch (e) { + return false; + } +} + +// --------------------------------------------------------------------------- +// parseSha256Sidecar +// --------------------------------------------------------------------------- + +describe('parseSha256Sidecar', function() { + it('parses a bare 64-hex digest', function() { + const hex = 'a'.repeat(64); + assert.equal(mod.parseSha256Sidecar(hex + '\n'), hex); + }); + + it('parses GNU coreutils format " "', function() { + const hex = '0123456789abcdef'.repeat(4); + const body = hex + ' agent-analyzer-x86_64-unknown-linux-gnu.tar.gz\n'; + assert.equal(mod.parseSha256Sidecar(body), hex); + }); + + it('parses BSD binary-mode format " *"', function() { + const hex = 'f'.repeat(64); + const body = hex + ' *agent-analyzer.zip\n'; + assert.equal(mod.parseSha256Sidecar(body), hex); + }); + + it('lower-cases an upper-case digest', function() { + const hex = 'A'.repeat(64); + assert.equal(mod.parseSha256Sidecar(hex), 'a'.repeat(64)); + }); + + it('throws on missing digest', function() { + assert.throws(function() { mod.parseSha256Sidecar('not a digest'); }, + /Could not parse SHA-256 digest/); + }); + + it('throws on short hex', function() { + assert.throws(function() { mod.parseSha256Sidecar('abc123'); }, + /Could not parse SHA-256 digest/); + }); +}); + +// --------------------------------------------------------------------------- +// verifySha256 +// --------------------------------------------------------------------------- + +describe('verifySha256', function() { + it('accepts a matching digest', function() { + const buf = Buffer.from('hello world', 'utf8'); + const hex = crypto.createHash('sha256').update(buf).digest('hex'); + assert.doesNotThrow(function() { mod.verifySha256(buf, hex, 'test.bin'); }); + }); + + it('accepts a matching digest case-insensitively', function() { + const buf = Buffer.from('hello world', 'utf8'); + const hex = crypto.createHash('sha256').update(buf).digest('hex').toUpperCase(); + assert.doesNotThrow(function() { mod.verifySha256(buf, hex, 'test.bin'); }); + }); + + it('throws with tamper-framed message on mismatch', function() { + const buf = Buffer.from('hello world', 'utf8'); + const wrong = '0'.repeat(64); + assert.throws( + function() { mod.verifySha256(buf, wrong, 'agent-analyzer.tar.gz'); }, + function(err) { + assert.match(err.message, /SHA-256 verification failed for agent-analyzer\.tar\.gz/); + assert.match(err.message, /expected 0{64}/); + assert.match(err.message, /tampered release/); + assert.match(err.message, /Do not extract/); + return true; + } + ); + }); +}); + +// --------------------------------------------------------------------------- +// assertSafeArchiveEntry +// --------------------------------------------------------------------------- + +describe('assertSafeArchiveEntry', function() { + it('accepts a normal relative entry', function() { + assert.doesNotThrow(function() { mod.assertSafeArchiveEntry('agent-analyzer'); }); + assert.doesNotThrow(function() { mod.assertSafeArchiveEntry('bin/agent-analyzer'); }); + assert.doesNotThrow(function() { mod.assertSafeArchiveEntry('dir/sub/file.txt'); }); + }); + + it('rejects parent-traversal entry', function() { + assert.throws(function() { mod.assertSafeArchiveEntry('../evil.exe'); }, + /parent-traversal/); + assert.throws(function() { mod.assertSafeArchiveEntry('foo/../../evil'); }, + /parent-traversal/); + assert.throws(function() { mod.assertSafeArchiveEntry('a/b/..'); }, + /parent-traversal/); + }); + + it('rejects POSIX absolute path', function() { + assert.throws(function() { mod.assertSafeArchiveEntry('/etc/passwd'); }, + /absolute entry/); + }); + + it('rejects Windows drive-letter path', function() { + assert.throws(function() { mod.assertSafeArchiveEntry('C:\\Windows\\evil.exe'); }, + /Windows absolute entry/); + assert.throws(function() { mod.assertSafeArchiveEntry('D:/foo'); }, + /Windows absolute entry/); + }); + + it('rejects UNC path', function() { + assert.throws(function() { mod.assertSafeArchiveEntry('//server/share/evil'); }, + /UNC entry/); + }); + + it('rejects empty entry', function() { + assert.throws(function() { mod.assertSafeArchiveEntry(''); }, + /empty entry/); + assert.throws(function() { mod.assertSafeArchiveEntry(null); }, + /empty entry/); + }); + + it('rejects backslash-form parent traversal', function() { + // Windows-style separator in tar entries should still be caught + assert.throws(function() { mod.assertSafeArchiveEntry('foo\\..\\bar'); }, + /parent-traversal/); + }); +}); + +// --------------------------------------------------------------------------- +// assertInsideRoot +// --------------------------------------------------------------------------- + +describe('assertInsideRoot', function() { + it('accepts a path inside root', function() { + const root = os.tmpdir(); + const inside = path.join(root, 'a', 'b', 'c'); + assert.doesNotThrow(function() { mod.assertInsideRoot(root, inside); }); + }); + + it('rejects a path that escapes root via ..', function() { + const root = path.join(os.tmpdir(), 'root'); + const escape = path.join(root, '..', 'outside'); + assert.throws(function() { mod.assertInsideRoot(root, escape); }, + /escapes extract root/); + }); + + it('rejects a sibling directory that shares a prefix', function() { + // /tmp/rootX should not be considered inside /tmp/root + const root = path.join(os.tmpdir(), 'root'); + const sibling = path.join(os.tmpdir(), 'rootX', 'file'); + assert.throws(function() { mod.assertInsideRoot(root, sibling); }, + /escapes extract root/); + }); +}); + +// --------------------------------------------------------------------------- +// Happy-path: synthetic tar.gz extracts and yields the expected binary +// --------------------------------------------------------------------------- + +describe('synthetic tar.gz extraction (integration-lite)', function() { + it('extracts a simple tar containing agent-analyzer', { skip: !hasSystemTar() }, async function() { + const targz = buildTarGz([ + { name: 'agent-analyzer', contents: '#!/bin/sh\necho ok\n' }, + { name: 'README.md', contents: 'hi\n' } + ]); + + // We test the internal flow by re-requiring and poking at the private + // helpers we export for tests. Use the exported downloadBinary? That + // would hit the network. Instead, build a scratch dir using the tar + // helpers directly. + // + // The safest public-surface test: assert that assertSafeArchiveEntry + // passes for each entry in a clean archive, AND that a synthetic tar + // built with only "agent-analyzer" + "README.md" round-trips via the + // system tar into a scratch dir. + + // List entries via the same mechanism downloadBinary uses: + const tar = cp.spawnSync('tar', ['-tz'], { input: targz }); + assert.equal(tar.status, 0, 'tar -tz should succeed on synthetic archive'); + const entries = tar.stdout.toString('utf8').split(/\r?\n/).filter(Boolean); + assert.deepEqual(entries.sort(), ['README.md', 'agent-analyzer']); + for (const e of entries) { + assert.doesNotThrow(function() { mod.assertSafeArchiveEntry(e); }); + } + + // Now actually extract into a scratch dir and confirm the binary lands. + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-analyzer-test-')); + try { + const tarDest = process.platform === 'win32' ? scratch.replace(/\\/g, '/') : scratch; + const ex = cp.spawnSync('tar', ['xz', '-C', tarDest], { input: targz }); + assert.equal(ex.status, 0, 'tar extract stderr: ' + (ex.stderr && ex.stderr.toString())); + const landed = path.join(scratch, 'agent-analyzer'); + assert.ok(fs.existsSync(landed), 'extracted binary should exist'); + assert.doesNotThrow(function() { mod.assertInsideRoot(scratch, landed); }); + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Malicious archives: traversal + absolute paths are rejected BEFORE extract +// --------------------------------------------------------------------------- + +describe('malicious tar.gz entries are rejected', function() { + it('rejects ../evil.exe before extraction', { skip: !hasSystemTar() }, function() { + const targz = buildTarGz([ + { name: '../evil.exe', contents: 'pwned\n' } + ]); + const tar = cp.spawnSync('tar', ['-tz'], { input: targz }); + assert.equal(tar.status, 0); + const entries = tar.stdout.toString('utf8').split(/\r?\n/).filter(Boolean); + assert.ok(entries.length > 0); + // The downloader loops entries and calls assertSafeArchiveEntry on each. + assert.throws(function() { + for (const e of entries) mod.assertSafeArchiveEntry(e); + }, /parent-traversal/); + }); + + it('rejects an absolute-path entry before extraction', { skip: !hasSystemTar() }, function() { + const targz = buildTarGz([ + { name: '/etc/cron.d/backdoor', contents: 'pwn\n' } + ]); + const tar = cp.spawnSync('tar', ['-tz'], { input: targz }); + assert.equal(tar.status, 0); + const entries = tar.stdout.toString('utf8').split(/\r?\n/).filter(Boolean); + assert.ok(entries.length > 0); + assert.throws(function() { + for (const e of entries) mod.assertSafeArchiveEntry(e); + }, /absolute entry/); + }); +}); + +// --------------------------------------------------------------------------- +// Public API surface has not regressed +// --------------------------------------------------------------------------- + +describe('public API surface', function() { + it('exports the same top-level functions as before, plus new helpers', function() { + const expected = [ + 'ensureBinary', 'ensureBinarySync', 'runAnalyzer', 'runAnalyzerAsync', + 'getBinaryPath', 'getVersion', 'getPlatformKey', 'isAvailable', + 'isAvailableAsync', 'meetsMinimumVersion', 'buildDownloadUrl', + 'PLATFORM_MAP', + // New (non-breaking additions) + 'parseSha256Sidecar', 'verifySha256', 'sha256Hex', + 'assertSafeArchiveEntry', 'assertInsideRoot', 'downloadBinary' + ]; + for (const name of expected) { + assert.ok(name in mod, 'expected export: ' + name); + } + }); + + it('ensureBinary still accepts { version } without breaking', function() { + // Smoke-check: the function is callable without exploding on typecheck. + assert.equal(typeof mod.ensureBinary, 'function'); + assert.equal(mod.ensureBinary.length, 1); // single optional [options] + }); +}); + +// --------------------------------------------------------------------------- +// Scratch cleanup: extractors must not leak scratch dirs on failure +// --------------------------------------------------------------------------- + +describe('extractTarGzToScratch cleans up scratch on failure', function() { + it('rmrfs scratch when tar exits non-zero', { skip: !hasSystemTar() || process.platform === 'win32' }, async function() { + // Build a tar.gz whose entries look clean to the pre-validator but whose + // payload is corrupt. The quickest way: gzip random bytes. `tar -tz` + // will fail to list, so we instead pass a valid listing by monkey-patching. + // Simpler path: build a valid tar.gz, then snapshot tmpdir, invoke + // extractTarGzToScratch with a buffer whose listing succeeds but whose + // extraction fails because we truncate it mid-block. + const full = buildTarGz([{ name: 'agent-analyzer', contents: 'x'.repeat(2048) }]); + // Truncate to 512 bytes - `tar -tz` will fail BEFORE scratch is created, + // so this doesn't test the cleanup path. Instead, make listing succeed + // but extraction fail: keep the gzip header + first block, drop the rest. + // We take: full gzip of [header + small data + truncated]. + // Simpler: run listing on `full`, then pass a corrupt buffer to the + // extractor after capturing entries. Since we can't intercept listing, + // we instead wrap extractTarGzToScratch in a controlled failure by + // giving it a buffer that lists but fails on extract. Practical trick: + // pass `full` concatenated with garbage, then check that tar rejects. + // + // Realistic failure: empty buffer. Both listing and extract will fail; + // extractTarGzToScratch should throw during listing (before scratch is + // made), so scratch is never created. That already proves no leak. + const before = fs.readdirSync(os.tmpdir()).filter(function(n) { + return n.startsWith('agent-analyzer-tar-'); + }); + await assert.rejects(function() { + return mod.extractTarGzToScratch(Buffer.alloc(0)); + }); + const after = fs.readdirSync(os.tmpdir()).filter(function(n) { + return n.startsWith('agent-analyzer-tar-'); + }); + // No new scratch dirs should have been left behind. + assert.deepEqual(after.sort(), before.sort(), + 'extractTarGzToScratch must not leak scratch dirs on failure'); + void full; // keep reference so test helper isn't tree-shaken conceptually + }); + + it('rejects symlinks produced by extractor via walkFiles', { skip: !hasSystemTar() || process.platform === 'win32' }, async function() { + // Build a tar that contains a symlink entry. Use the USTAR typeflag '2'. + function tarSymlinkBlock(name, linkname) { + const header = Buffer.alloc(512, 0); + header.write(name, 0, Math.min(name.length, 100), 'utf8'); + header.write('0000777\0', 100, 8, 'ascii'); + header.write('0000000\0', 108, 8, 'ascii'); + header.write('0000000\0', 116, 8, 'ascii'); + header.write('00000000000\0', 124, 12, 'ascii'); // size 0 + header.write('00000000000\0', 136, 12, 'ascii'); + header.write(' ', 148, 8, 'ascii'); + header.write('2', 156, 1, 'ascii'); // symlink + header.write(linkname, 157, Math.min(linkname.length, 100), 'utf8'); + header.write('ustar\0', 257, 6, 'ascii'); + header.write('00', 263, 2, 'ascii'); + let sum = 0; + for (let i = 0; i < 512; i++) sum += header[i]; + const chkOctal = sum.toString(8).padStart(6, '0') + '\0 '; + header.write(chkOctal, 148, 8, 'ascii'); + return header; + } + const terminator = Buffer.alloc(1024, 0); + const tar = Buffer.concat([ + tarSymlinkBlock('agent-analyzer', '/etc/passwd'), + terminator + ]); + const targz = zlib.gzipSync(tar); + + const before = fs.readdirSync(os.tmpdir()).filter(function(n) { + return n.startsWith('agent-analyzer-tar-'); + }); + await assert.rejects(function() { + return mod.extractTarGzToScratch(targz); + }, /symlink/); + const after = fs.readdirSync(os.tmpdir()).filter(function(n) { + return n.startsWith('agent-analyzer-tar-'); + }); + assert.deepEqual(after.sort(), before.sort(), + 'scratch dir must be cleaned up after symlink rejection'); + }); +}); + +// --------------------------------------------------------------------------- +// PowerShell helper script is structurally safe +// --------------------------------------------------------------------------- + +describe('PowerShell extract script', function() { + it('reads paths from environment, not from argv', function() { + const src = mod._EXTRACT_ZIP_PS1; + assert.match(src, /\$env:SRC_ZIP/, + 'script must read zip path from SRC_ZIP env var'); + assert.match(src, /\$env:DEST_DIR/, + 'script must read destination from DEST_DIR env var'); + }); + + it('uses ZipFile.OpenRead for entry validation before extraction', function() { + const src = mod._EXTRACT_ZIP_PS1; + assert.match(src, /ZipFile\]::OpenRead/, + 'script must enumerate entries via System.IO.Compression.ZipFile'); + assert.match(src, /parent-traversal/, + 'script must reject parent-traversal entries'); + assert.match(src, /absolute/i, + 'script must reject absolute entries'); + }); + + it('does not use Expand-Archive (which is permissive)', function() { + const src = mod._EXTRACT_ZIP_PS1; + assert.doesNotMatch(src, /Expand-Archive/, + 'script must not fall back to Expand-Archive'); + }); +}); + +// --------------------------------------------------------------------------- +// ExtractZipToScratch (Windows-only): path with spaces in tmpdir +// --------------------------------------------------------------------------- + +describe('extractZipToScratch handles paths with spaces', function() { + it('extracts a zip when tmpdir contains spaces', { skip: process.platform !== 'win32' }, async function() { + // Create a tmpdir whose NAME has spaces, and point os.tmpdir() at it. + const spacedParent = fs.mkdtempSync(path.join(os.tmpdir(), 'agnx spaced ')); + const origTmpdir = os.tmpdir; + os.tmpdir = function() { return spacedParent; }; + try { + // Build a minimal zip in memory. Use Node's zlib? There's no stdlib + // zip builder. Fall back to creating a zip via PowerShell itself. + const seed = fs.mkdtempSync(path.join(spacedParent, 'seed-')); + const payload = path.join(seed, 'agent-analyzer.exe'); + fs.writeFileSync(payload, 'fake binary\r\n'); + const zipOut = path.join(spacedParent, 'in put.zip'); + const r = cp.spawnSync('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-Command', + 'Compress-Archive -LiteralPath $env:SRC -DestinationPath $env:DST -Force' + ], { + env: Object.assign({}, process.env, { SRC: payload, DST: zipOut }), + windowsHide: true + }); + assert.equal(r.status, 0, 'zip builder failed: ' + (r.stderr && r.stderr.toString())); + + const buf = fs.readFileSync(zipOut); + const scratch = await mod.extractZipToScratch(buf); + try { + const extracted = path.join(scratch, 'agent-analyzer.exe'); + assert.ok(fs.existsSync(extracted), 'binary should extract despite spaces in tmpdir'); + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } + } finally { + os.tmpdir = origTmpdir; + fs.rmSync(spacedParent, { recursive: true, force: true }); + } + }); + + it('cleans up scratch when extraction fails', { skip: process.platform !== 'win32' }, async function() { + const before = fs.readdirSync(os.tmpdir()).filter(function(n) { + return n.startsWith('agent-analyzer-zip-'); + }); + await assert.rejects(function() { + // A buffer that is not a zip will make PowerShell exit non-zero. + return mod.extractZipToScratch(Buffer.from('not a zip file')); + }); + const after = fs.readdirSync(os.tmpdir()).filter(function(n) { + return n.startsWith('agent-analyzer-zip-'); + }); + assert.deepEqual(after.sort(), before.sort(), + 'extractZipToScratch must not leak scratch dirs on failure'); + }); +});