diff --git a/lib/binary/index.js b/lib/binary/index.js index ce8e72c..1aaeae4 100644 --- a/lib/binary/index.js +++ b/lib/binary/index.js @@ -19,6 +19,22 @@ * script validates every zip entry before extracting it and rejects * absolute, UNC, and parent-traversal entries. * + * Verification chain (in order, each gate must pass to proceed): + * 1. TLS - https.get() pins the GitHub CA chain at the OS level. + * 2. SHA-256 sidecar - `.sha256` fetched from the same release and + * verified against the downloaded bytes. Closes basic tampering. + * 3. SLSA build provenance (optional / required) - `gh attestation verify` + * checks the Sigstore-signed attestation that agent-analyzer's release + * workflow publishes via `actions/attest-build-provenance`. This closes + * the "stolen release token uploads attacker binary + attacker sha256" + * hole that steps 1 and 2 cannot see. + * + * SLSA verification is SOFT by default: if `gh` is not on PATH we log + * a warning and proceed with just SHA-256. Set env var + * `AGENT_ANALYZER_REQUIRE_ATTESTATION=1` to make a missing `gh` a hard + * failure (recommended for CI). A present `gh` that reports a failed + * verification is ALWAYS a hard failure regardless of the env var. + * * @module lib/binary */ @@ -572,6 +588,117 @@ function findBinaryInScratch(scratch, binaryBaseName) { return null; } +// --------------------------------------------------------------------------- +// SLSA build provenance verification +// --------------------------------------------------------------------------- + +/** + * Result of an attempted SLSA attestation verification. + * @typedef {Object} SlsaResult + * @property {'verified'|'skipped'|'failed'} status + * @property {string} [reason] human-readable detail (for skipped/failed) + * @property {string} [stderr] captured stderr from `gh` (failed only) + */ + +/** + * Default runner: spawn `gh attestation verify` and return the captured + * exit code, stdout, and stderr. Injectable for tests. + * @param {string} filePath + * @param {string} repo e.g. `agent-sh/agent-analyzer` + * @returns {{ status: number|null, stdout: string, stderr: string }} + */ +function defaultGhRunner(filePath, repo) { + try { + const stdout = cp.execFileSync( + 'gh', + ['attestation', 'verify', filePath, '--repo', repo, '--format', 'json'], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 60000, + windowsHide: true + } + ); + return { status: 0, stdout: stdout || '', stderr: '' }; + } catch (err) { + return { + status: typeof err.status === 'number' ? err.status : null, + stdout: err.stdout ? String(err.stdout) : '', + stderr: err.stderr ? String(err.stderr) : (err.message || '') + }; + } +} + +/** + * Returns true if the `gh` CLI is on PATH. Uses a short, non-privileged probe. + * @param {function} [runner] optional probe; defaults to real `gh --version` + * @returns {boolean} + */ +function isGhAvailable(runner) { + if (typeof runner === 'function') { + try { return !!runner(); } catch (e) { return false; } + } + try { + cp.execFileSync('gh', ['--version'], { + stdio: 'ignore', + timeout: 5000, + windowsHide: true + }); + return true; + } catch (e) { + return false; + } +} + +/** + * Verify a downloaded asset's SLSA build provenance attestation via the + * GitHub CLI. The check is SOFT by default: if `gh` is not installed the + * function returns { status: 'skipped' } and the caller logs a warning. Set + * `requireAttestation` (or the env var) to make a missing `gh` a failure. + * + * A present `gh` that reports verification failure ALWAYS returns + * { status: 'failed' } regardless of `requireAttestation`; the caller is + * expected to abort in that case. + * + * @param {string} filePath absolute path to the downloaded archive + * @param {Object} [options] + * @param {string} [options.repo] e.g. `agent-sh/agent-analyzer` + * @param {boolean} [options.requireAttestation] defaults to env + * `AGENT_ANALYZER_REQUIRE_ATTESTATION === '1'` + * @param {function} [options.ghRunner] injectable runner for tests. Receives + * (filePath, repo), returns { status, stdout, stderr }. + * @param {function} [options.ghProbe] injectable gh-on-PATH probe for tests. + * @returns {SlsaResult} + */ +function verifySlsaAttestation(filePath, options) { + const opts = options || {}; + const repo = opts.repo || GITHUB_REPO; + const runner = typeof opts.ghRunner === 'function' ? opts.ghRunner : defaultGhRunner; + const require_ = typeof opts.requireAttestation === 'boolean' + ? opts.requireAttestation + : process.env.AGENT_ANALYZER_REQUIRE_ATTESTATION === '1'; + + const ghPresent = isGhAvailable(opts.ghProbe); + if (!ghPresent) { + const reason = '`gh` CLI not found on PATH'; + if (require_) { + return { status: 'failed', reason: reason + ' (AGENT_ANALYZER_REQUIRE_ATTESTATION=1)' }; + } + return { status: 'skipped', reason: reason }; + } + + const result = runner(filePath, repo); + if (result && result.status === 0) { + return { status: 'verified' }; + } + return { + status: 'failed', + reason: 'gh attestation verify exited with status ' + + (result && result.status !== null ? result.status : 'unknown'), + stderr: (result && result.stderr) || '' + }; +} + // --------------------------------------------------------------------------- // Download + install // --------------------------------------------------------------------------- @@ -582,11 +709,19 @@ function findBinaryInScratch(scratch, binaryBaseName) { * @param {Object} [options] * @param {boolean} [options.skipChecksum=false] LOCAL DEV ONLY. Skips the * `.sha256` sidecar fetch and verification. NEVER set this in production. + * @param {boolean} [options.skipAttestation=false] LOCAL DEV ONLY. Skips the + * SLSA attestation check entirely. + * @param {boolean} [options.requireAttestation] when true, a missing `gh` + * CLI becomes a hard failure. Defaults to + * `process.env.AGENT_ANALYZER_REQUIRE_ATTESTATION === '1'`. + * @param {function} [options.ghRunner] injectable runner for tests. + * @param {function} [options.ghProbe] injectable gh-on-PATH probe for tests. * @returns {Promise} path to the installed binary */ async function downloadBinary(ver, options) { const opts = options || {}; const skipChecksum = opts.skipChecksum === true; + const skipAttestation = opts.skipAttestation === true; const platformKey = getPlatformKey(); if (!platformKey) { @@ -643,6 +778,47 @@ async function downloadBinary(ver, options) { verifySha256(buf, expected, filename); } + // --- 2b. Verify SLSA build provenance (optional / required) ------------ + if (skipAttestation) { + process.stderr.write( + '[WARN] skipAttestation=true - SLSA verification disabled. ' + + 'This is LOCAL DEV ONLY and MUST NOT be used in production.\n' + ); + } else { + // `gh attestation verify` needs a real file. Persist buf to a tmp path, + // verify, then drop it. Extraction continues from the in-memory buf so + // we don't need the tmp file beyond the verify call. + const attestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-analyzer-slsa-')); + const attestFile = path.join(attestDir, filename); + try { + fs.writeFileSync(attestFile, buf); + const result = verifySlsaAttestation(attestFile, { + repo: GITHUB_REPO, + requireAttestation: opts.requireAttestation, + ghRunner: opts.ghRunner, + ghProbe: opts.ghProbe + }); + if (result.status === 'verified') { + process.stderr.write('[OK] SLSA attestation verified for ' + filename + '\n'); + } else if (result.status === 'skipped') { + process.stderr.write( + '[WARN] SLSA attestation check skipped: ' + result.reason + '. ' + + 'Install the GitHub CLI (`gh`) to enable provenance verification. ' + + 'Set AGENT_ANALYZER_REQUIRE_ATTESTATION=1 to require it.\n' + ); + } else { + // 'failed' + throw new Error( + 'SLSA attestation verification failed for ' + filename + ': ' + + result.reason + '. Refusing to execute binary.' + + (result.stderr ? '\n--- gh stderr ---\n' + result.stderr : '') + ); + } + } finally { + rmrf(attestDir); + } + } + // --- 3. Extract to isolated scratch dir + validate entries ------------- const binaryBaseName = path.basename(binPath); let scratch; @@ -707,7 +883,13 @@ async function ensureBinary(options) { } } - return downloadBinary(targetVer, { skipChecksum: opts.skipChecksum === true }); + return downloadBinary(targetVer, { + skipChecksum: opts.skipChecksum === true, + skipAttestation: opts.skipAttestation === true, + requireAttestation: opts.requireAttestation, + ghRunner: opts.ghRunner, + ghProbe: opts.ghProbe + }); } /** @@ -730,11 +912,27 @@ function ensureBinarySync(options) { const targetVer = (options && options.version) || ANALYZER_MIN_VERSION; const skipChecksum = !!(options && options.skipChecksum); + const skipAttestation = !!(options && options.skipAttestation); + // Forward requireAttestation when explicitly set (tri-state: undefined + // lets the child fall back to the AGENT_ANALYZER_REQUIRE_ATTESTATION + // env var, matching ensureBinary()). Without this forwarding, a sync + // caller with requireAttestation:true would silently lose the hard-fail + // intent when gh is missing. + const requireAttestation = options && typeof options.requireAttestation === 'boolean' + ? options.requireAttestation + : undefined; const selfPath = __filename; + const ensureOpts = { + version: targetVer, + skipChecksum: skipChecksum, + skipAttestation: skipAttestation + }; + if (requireAttestation !== undefined) { + ensureOpts.requireAttestation = requireAttestation; + } const helperLines = [ 'var b = require(' + JSON.stringify(selfPath) + ');', - 'b.ensureBinary({ version: ' + JSON.stringify(targetVer) + - ', skipChecksum: ' + JSON.stringify(skipChecksum) + ' })', + 'b.ensureBinary(' + JSON.stringify(ensureOpts) + ')', ' .then(function(p) { process.stdout.write(p); })', ' .catch(function(e) { process.stderr.write(e.message); process.exit(1); });' ]; @@ -798,6 +996,8 @@ module.exports = { assertSafeArchiveEntry, assertInsideRoot, downloadBinary, + verifySlsaAttestation, + isGhAvailable, // Exported for tests only extractTarGzToScratch, extractZipToScratch,