diff --git a/updater.go b/updater.go index 01b7585..f8dea06 100644 --- a/updater.go +++ b/updater.go @@ -51,6 +51,12 @@ type Config struct { // behaviour. Set to an empty string to un-pin and resume // auto-updating to the latest stable. PinnedVersion string + + // SkipAttestation disables SLSA attestation verification of + // checksums.txt. Intended for test environments where the test + // repos do not have real attestations. Default (false) enables + // verification in production. + SkipAttestation bool } // Updater periodically checks GitHub Releases for new versions and optionally applies them. @@ -326,11 +332,11 @@ func (u *Updater) applyUpdate(release *GitHubRelease) error { // would auto-install it unverified. A network MITM dropping just // the checksums.txt fetch had the same effect. // - // Note: the checksums.txt file itself is not yet signed — task - // #63 tracks adding minisign/Ed25519 signatures on the release - // workflow side. Until that lands, a maintainer with GitHub - // write access can still publish matched fake binary + fake - // checksums. But this change closes the trivial bypass. + // The checksums.txt file itself is now attested via SLSA + // (actions/attest-build-provenance@v2 in release.yml, PILOT-120 + // PR #166). verifyChecksumsAttestation (below) checks provenance + // before trusting the checksums file, closing the "matched fake + // binary + fake checksums" gap. if checksumsURL == "" { return fmt.Errorf("release %s has no checksums.txt asset; refusing to install unverified binary", release.TagName) } @@ -338,6 +344,18 @@ func (u *Updater) applyUpdate(release *GitHubRelease) error { if err := u.downloadFile(checksumsURL, checksumsPath); err != nil { return fmt.Errorf("download checksums: %w", err) } + + // Verify checksums.txt provenance via GitHub SLSA attestation. + // The release workflow attests checksums.txt via + // actions/attest-build-provenance@v2 (PILOT-120, PR #166). + // This closes the "attacker publishes matched fake binary + + // fake checksums.txt" gap — the attestation ties checksums.txt + // to the trusted CI workflow. Graceful skip when gh CLI is + // unavailable (operator directive: not every environment has it). + if err := u.verifyChecksumsAttestation(checksumsPath); err != nil { + return fmt.Errorf("checksums attestation verification failed: %w", err) + } + if err := VerifyChecksum(archivePath, archiveName, checksumsPath); err != nil { return fmt.Errorf("checksum verification failed: %w", err) } @@ -677,3 +695,42 @@ func (u *Updater) signalDaemonRestartLinux() { } slog.Warn("daemon process not found — restart daemon manually") } + +// verifyChecksumsAttestation verifies the SLSA provenance of checksums.txt +// via the GitHub CLI's attestation verify command. The release workflow +// (release.yml) attests checksums.txt via actions/attest-build-provenance@v2 +// (PILOT-120, PR #166). This closes the "attacker publishes matched fake +// binary + fake checksums.txt" gap — the attestation ties checksums.txt to +// the trusted CI workflow identity. +// +// If gh is not on PATH, we log a warning and return nil (graceful skip per +// operator directive: not every environment has gh installed). If gh is +// present but verification fails, we return an error — the checksums file +// cannot be trusted. +func (u *Updater) verifyChecksumsAttestation(checksumsPath string) error { + if u.config.SkipAttestation { + slog.Debug("skipping attestation verification (SkipAttestation=true)") + return nil + } + return verifyChecksumsAttestationFn(u.config.Repo, checksumsPath) +} + +// verifyChecksumsAttestationFn is the default attestation verification +// implementation. Tests may replace it to avoid requiring a real GitHub +// repo with SLSA attestations. +var verifyChecksumsAttestationFn = func(repo, checksumsPath string) error { + ghPath, err := exec.LookPath("gh") + if err != nil { + slog.Warn("gh CLI not found — skipping attestation verification; install gh for full provenance guarantee", + "install_url", "https://cli.github.com/") + return nil + } + + cmd := exec.Command(ghPath, "attestation", "verify", checksumsPath, "--repo", repo) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("gh attestation verify: %s: %w", strings.TrimSpace(string(output)), err) + } + slog.Info("checksums provenance verified via SLSA attestation") + return nil +} diff --git a/zz_coverage_test.go b/zz_coverage_test.go index 57a433b..d7e1f3c 100644 --- a/zz_coverage_test.go +++ b/zz_coverage_test.go @@ -786,3 +786,35 @@ func TestArchiveToInstallMapping(t *testing.T) { } } } + +// TestVerifyChecksumsAttestation_GhNotInstalled verifies graceful +// skip when gh CLI is not on PATH. +func TestVerifyChecksumsAttestation_GhNotInstalled(t *testing.T) { + t.Parallel() + + // Temporarily unset PATH so LookPath fails. + origPath := os.Getenv("PATH") + os.Setenv("PATH", "") + defer os.Setenv("PATH", origPath) + + err := verifyChecksumsAttestationFn("test/repo", "/nonexistent/checksums.txt") + if err != nil { + t.Errorf("expected graceful skip when gh not installed, got error: %v", err) + } +} + +// TestVerifyChecksumsAttestation_SkipConfig verifies the config-driven skip. +func TestVerifyChecksumsAttestation_SkipConfig(t *testing.T) { + t.Parallel() + + u := New(Config{ + InstallDir: t.TempDir(), + Repo: "test/repo", + SkipAttestation: true, + }) + + err := u.verifyChecksumsAttestation("/nonexistent/checksums.txt") + if err != nil { + t.Errorf("SkipAttestation=true should return nil, got: %v", err) + } +} diff --git a/zz_test.go b/zz_test.go index 4bb69c8..007dafd 100644 --- a/zz_test.go +++ b/zz_test.go @@ -18,6 +18,17 @@ import ( "time" ) +// TestMain disables attestation verification for all tests — test repos +// don't have real GitHub SLSA attestations, and the test environment may +// not have the gh CLI installed. A dedicated TestVerifyChecksumsAttestation +// test exercises the real function in isolation. +func TestMain(m *testing.M) { + verifyChecksumsAttestationFn = func(repo, checksumsPath string) error { + return nil + } + os.Exit(m.Run()) +} + func TestParseSemver(t *testing.T) { t.Parallel() tests := []struct {