Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 203 additions & 3 deletions lib/binary/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 - `<asset>.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
*/

Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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<string>} 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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
});
}

/**
Expand All @@ -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); });'
];
Expand Down Expand Up @@ -798,6 +996,8 @@ module.exports = {
assertSafeArchiveEntry,
assertInsideRoot,
downloadBinary,
verifySlsaAttestation,
isGhAvailable,
// Exported for tests only
extractTarGzToScratch,
extractZipToScratch,
Expand Down
Loading