1919 * script validates every zip entry before extracting it and rejects
2020 * absolute, UNC, and parent-traversal entries.
2121 *
22+ * Verification chain (in order, each gate must pass to proceed):
23+ * 1. TLS - https.get() pins the GitHub CA chain at the OS level.
24+ * 2. SHA-256 sidecar - `<asset>.sha256` fetched from the same release and
25+ * verified against the downloaded bytes. Closes basic tampering.
26+ * 3. SLSA build provenance (optional / required) - `gh attestation verify`
27+ * checks the Sigstore-signed attestation that agent-analyzer's release
28+ * workflow publishes via `actions/attest-build-provenance`. This closes
29+ * the "stolen release token uploads attacker binary + attacker sha256"
30+ * hole that steps 1 and 2 cannot see.
31+ *
32+ * SLSA verification is SOFT by default: if `gh` is not on PATH we log
33+ * a warning and proceed with just SHA-256. Set env var
34+ * `AGENT_ANALYZER_REQUIRE_ATTESTATION=1` to make a missing `gh` a hard
35+ * failure (recommended for CI). A present `gh` that reports a failed
36+ * verification is ALWAYS a hard failure regardless of the env var.
37+ *
2238 * @module lib/binary
2339 */
2440
@@ -572,6 +588,117 @@ function findBinaryInScratch(scratch, binaryBaseName) {
572588 return null ;
573589}
574590
591+ // ---------------------------------------------------------------------------
592+ // SLSA build provenance verification
593+ // ---------------------------------------------------------------------------
594+
595+ /**
596+ * Result of an attempted SLSA attestation verification.
597+ * @typedef {Object } SlsaResult
598+ * @property {'verified'|'skipped'|'failed' } status
599+ * @property {string } [reason] human-readable detail (for skipped/failed)
600+ * @property {string } [stderr] captured stderr from `gh` (failed only)
601+ */
602+
603+ /**
604+ * Default runner: spawn `gh attestation verify` and return the captured
605+ * exit code, stdout, and stderr. Injectable for tests.
606+ * @param {string } filePath
607+ * @param {string } repo e.g. `agent-sh/agent-analyzer`
608+ * @returns {{ status: number|null, stdout: string, stderr: string } }
609+ */
610+ function defaultGhRunner ( filePath , repo ) {
611+ try {
612+ const stdout = cp . execFileSync (
613+ 'gh' ,
614+ [ 'attestation' , 'verify' , filePath , '--repo' , repo , '--format' , 'json' ] ,
615+ {
616+ encoding : 'utf8' ,
617+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
618+ timeout : 60000 ,
619+ windowsHide : true
620+ }
621+ ) ;
622+ return { status : 0 , stdout : stdout || '' , stderr : '' } ;
623+ } catch ( err ) {
624+ return {
625+ status : typeof err . status === 'number' ? err . status : null ,
626+ stdout : err . stdout ? String ( err . stdout ) : '' ,
627+ stderr : err . stderr ? String ( err . stderr ) : ( err . message || '' )
628+ } ;
629+ }
630+ }
631+
632+ /**
633+ * Returns true if the `gh` CLI is on PATH. Uses a short, non-privileged probe.
634+ * @param {function } [runner] optional probe; defaults to real `gh --version`
635+ * @returns {boolean }
636+ */
637+ function isGhAvailable ( runner ) {
638+ if ( typeof runner === 'function' ) {
639+ try { return ! ! runner ( ) ; } catch ( e ) { return false ; }
640+ }
641+ try {
642+ cp . execFileSync ( 'gh' , [ '--version' ] , {
643+ stdio : 'ignore' ,
644+ timeout : 5000 ,
645+ windowsHide : true
646+ } ) ;
647+ return true ;
648+ } catch ( e ) {
649+ return false ;
650+ }
651+ }
652+
653+ /**
654+ * Verify a downloaded asset's SLSA build provenance attestation via the
655+ * GitHub CLI. The check is SOFT by default: if `gh` is not installed the
656+ * function returns { status: 'skipped' } and the caller logs a warning. Set
657+ * `requireAttestation` (or the env var) to make a missing `gh` a failure.
658+ *
659+ * A present `gh` that reports verification failure ALWAYS returns
660+ * { status: 'failed' } regardless of `requireAttestation`; the caller is
661+ * expected to abort in that case.
662+ *
663+ * @param {string } filePath absolute path to the downloaded archive
664+ * @param {Object } [options]
665+ * @param {string } [options.repo] e.g. `agent-sh/agent-analyzer`
666+ * @param {boolean } [options.requireAttestation] defaults to env
667+ * `AGENT_ANALYZER_REQUIRE_ATTESTATION === '1'`
668+ * @param {function } [options.ghRunner] injectable runner for tests. Receives
669+ * (filePath, repo), returns { status, stdout, stderr }.
670+ * @param {function } [options.ghProbe] injectable gh-on-PATH probe for tests.
671+ * @returns {SlsaResult }
672+ */
673+ function verifySlsaAttestation ( filePath , options ) {
674+ const opts = options || { } ;
675+ const repo = opts . repo || GITHUB_REPO ;
676+ const runner = typeof opts . ghRunner === 'function' ? opts . ghRunner : defaultGhRunner ;
677+ const require_ = typeof opts . requireAttestation === 'boolean'
678+ ? opts . requireAttestation
679+ : process . env . AGENT_ANALYZER_REQUIRE_ATTESTATION === '1' ;
680+
681+ const ghPresent = isGhAvailable ( opts . ghProbe ) ;
682+ if ( ! ghPresent ) {
683+ const reason = '`gh` CLI not found on PATH' ;
684+ if ( require_ ) {
685+ return { status : 'failed' , reason : reason + ' (AGENT_ANALYZER_REQUIRE_ATTESTATION=1)' } ;
686+ }
687+ return { status : 'skipped' , reason : reason } ;
688+ }
689+
690+ const result = runner ( filePath , repo ) ;
691+ if ( result && result . status === 0 ) {
692+ return { status : 'verified' } ;
693+ }
694+ return {
695+ status : 'failed' ,
696+ reason : 'gh attestation verify exited with status ' +
697+ ( result && result . status !== null ? result . status : 'unknown' ) ,
698+ stderr : ( result && result . stderr ) || ''
699+ } ;
700+ }
701+
575702// ---------------------------------------------------------------------------
576703// Download + install
577704// ---------------------------------------------------------------------------
@@ -582,11 +709,19 @@ function findBinaryInScratch(scratch, binaryBaseName) {
582709 * @param {Object } [options]
583710 * @param {boolean } [options.skipChecksum=false] LOCAL DEV ONLY. Skips the
584711 * `.sha256` sidecar fetch and verification. NEVER set this in production.
712+ * @param {boolean } [options.skipAttestation=false] LOCAL DEV ONLY. Skips the
713+ * SLSA attestation check entirely.
714+ * @param {boolean } [options.requireAttestation] when true, a missing `gh`
715+ * CLI becomes a hard failure. Defaults to
716+ * `process.env.AGENT_ANALYZER_REQUIRE_ATTESTATION === '1'`.
717+ * @param {function } [options.ghRunner] injectable runner for tests.
718+ * @param {function } [options.ghProbe] injectable gh-on-PATH probe for tests.
585719 * @returns {Promise<string> } path to the installed binary
586720 */
587721async function downloadBinary ( ver , options ) {
588722 const opts = options || { } ;
589723 const skipChecksum = opts . skipChecksum === true ;
724+ const skipAttestation = opts . skipAttestation === true ;
590725
591726 const platformKey = getPlatformKey ( ) ;
592727 if ( ! platformKey ) {
@@ -643,6 +778,47 @@ async function downloadBinary(ver, options) {
643778 verifySha256 ( buf , expected , filename ) ;
644779 }
645780
781+ // --- 2b. Verify SLSA build provenance (optional / required) ------------
782+ if ( skipAttestation ) {
783+ process . stderr . write (
784+ '[WARN] skipAttestation=true - SLSA verification disabled. ' +
785+ 'This is LOCAL DEV ONLY and MUST NOT be used in production.\n'
786+ ) ;
787+ } else {
788+ // `gh attestation verify` needs a real file. Persist buf to a tmp path,
789+ // verify, then drop it. Extraction continues from the in-memory buf so
790+ // we don't need the tmp file beyond the verify call.
791+ const attestDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'agent-analyzer-slsa-' ) ) ;
792+ const attestFile = path . join ( attestDir , filename ) ;
793+ try {
794+ fs . writeFileSync ( attestFile , buf ) ;
795+ const result = verifySlsaAttestation ( attestFile , {
796+ repo : GITHUB_REPO ,
797+ requireAttestation : opts . requireAttestation ,
798+ ghRunner : opts . ghRunner ,
799+ ghProbe : opts . ghProbe
800+ } ) ;
801+ if ( result . status === 'verified' ) {
802+ process . stderr . write ( '[OK] SLSA attestation verified for ' + filename + '\n' ) ;
803+ } else if ( result . status === 'skipped' ) {
804+ process . stderr . write (
805+ '[WARN] SLSA attestation check skipped: ' + result . reason + '. ' +
806+ 'Install the GitHub CLI (`gh`) to enable provenance verification. ' +
807+ 'Set AGENT_ANALYZER_REQUIRE_ATTESTATION=1 to require it.\n'
808+ ) ;
809+ } else {
810+ // 'failed'
811+ throw new Error (
812+ 'SLSA attestation verification failed for ' + filename + ': ' +
813+ result . reason + '. Refusing to execute binary.' +
814+ ( result . stderr ? '\n--- gh stderr ---\n' + result . stderr : '' )
815+ ) ;
816+ }
817+ } finally {
818+ rmrf ( attestDir ) ;
819+ }
820+ }
821+
646822 // --- 3. Extract to isolated scratch dir + validate entries -------------
647823 const binaryBaseName = path . basename ( binPath ) ;
648824 let scratch ;
@@ -707,7 +883,13 @@ async function ensureBinary(options) {
707883 }
708884 }
709885
710- return downloadBinary ( targetVer , { skipChecksum : opts . skipChecksum === true } ) ;
886+ return downloadBinary ( targetVer , {
887+ skipChecksum : opts . skipChecksum === true ,
888+ skipAttestation : opts . skipAttestation === true ,
889+ requireAttestation : opts . requireAttestation ,
890+ ghRunner : opts . ghRunner ,
891+ ghProbe : opts . ghProbe
892+ } ) ;
711893}
712894
713895/**
@@ -730,11 +912,27 @@ function ensureBinarySync(options) {
730912
731913 const targetVer = ( options && options . version ) || ANALYZER_MIN_VERSION ;
732914 const skipChecksum = ! ! ( options && options . skipChecksum ) ;
915+ const skipAttestation = ! ! ( options && options . skipAttestation ) ;
916+ // Forward requireAttestation when explicitly set (tri-state: undefined
917+ // lets the child fall back to the AGENT_ANALYZER_REQUIRE_ATTESTATION
918+ // env var, matching ensureBinary()). Without this forwarding, a sync
919+ // caller with requireAttestation:true would silently lose the hard-fail
920+ // intent when gh is missing.
921+ const requireAttestation = options && typeof options . requireAttestation === 'boolean'
922+ ? options . requireAttestation
923+ : undefined ;
733924 const selfPath = __filename ;
925+ const ensureOpts = {
926+ version : targetVer ,
927+ skipChecksum : skipChecksum ,
928+ skipAttestation : skipAttestation
929+ } ;
930+ if ( requireAttestation !== undefined ) {
931+ ensureOpts . requireAttestation = requireAttestation ;
932+ }
734933 const helperLines = [
735934 'var b = require(' + JSON . stringify ( selfPath ) + ');' ,
736- 'b.ensureBinary({ version: ' + JSON . stringify ( targetVer ) +
737- ', skipChecksum: ' + JSON . stringify ( skipChecksum ) + ' })' ,
935+ 'b.ensureBinary(' + JSON . stringify ( ensureOpts ) + ')' ,
738936 ' .then(function(p) { process.stdout.write(p); })' ,
739937 ' .catch(function(e) { process.stderr.write(e.message); process.exit(1); });'
740938 ] ;
@@ -798,6 +996,8 @@ module.exports = {
798996 assertSafeArchiveEntry,
799997 assertInsideRoot,
800998 downloadBinary,
999+ verifySlsaAttestation,
1000+ isGhAvailable,
8011001 // Exported for tests only
8021002 extractTarGzToScratch,
8031003 extractZipToScratch,
0 commit comments