diff --git a/CHANGELOG.md b/CHANGELOG.md index 967d1eb..4e788a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,17 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). credentials create` now rejects an empty `--scoped-role` or `--allow-cidr` before it can create an unrestricted credential, and preserves fractional seconds in `--expires`. +- **`c1i upgrade`** (alias `update`) — check for and install a newer release + from the C1 distribution center. Reads the `stable` channel by default + (`--channel latest|preview` to opt into newer builds), verifies the download + against the release manifest's SHA-256, and replaces a standalone binary in + place. For a Homebrew, `go install`, or container-image install it prints the + matching upgrade command instead of self-replacing. `--check` reports whether + a newer release exists without changing anything. Before installing, `upgrade` + verifies the release manifest's **Sigstore signature** (keyless / Fulcio) + against the pinned ConductorOne release-workflow identity, in addition to the + per-artifact SHA-256 — so a tampered or unsigned manifest is rejected before + anything is replaced. ## [0.7.0] - 2026-09-03 diff --git a/README.md b/README.md index 174a969..d25d545 100644 --- a/README.md +++ b/README.md @@ -1281,6 +1281,42 @@ emit a script that completes names only, without the per-command help text. c1i version # or: c1i --version ``` +## Upgrading + +```sh +c1i upgrade # upgrade to the latest stable release (prompts first) +c1i upgrade --check # report whether a newer release is available; change nothing +c1i upgrade --channel latest -y # take the newest release without prompting +``` + +`upgrade` reads the release channels published by the C1 distribution center +(`dist.conductorone.com`) — `stable` by default, or `latest`/`preview` via +`--channel` — and, for a standalone downloaded binary, replaces the running +binary in place. `--yes`/`-y` skips the confirmation prompt (and is required when +stdin is not a terminal). + +Before anything is installed, `upgrade` verifies the release's authenticity in +two layers. First it checks the release manifest's **Sigstore signature** +(keyless / Fulcio) against the pinned C1.ai reusable release workflow, GitHub +Actions OIDC issuer, and `ConductorOne/c1i` source repository. It also verifies +the published Rekor signed entry timestamp, which binds the exact manifest, +signature, and certificate to a transparency-log time while the certificate was +valid. Then the manifest's per-artifact **SHA-256** authenticates the downloaded +binary. A failure at either layer aborts the upgrade without touching the +installed binary. + +Only the per-release manifests are signed; the channel catalog (`index.json`) +that names which version each channel points at, and its `yanked` flags, are +not. So a compromised distribution origin could steer you to a *different but +authentic, ConductorOne-signed* release — an older one (down to your current +version, no further) or one marked yanked — but never to an unsigned or +third-party binary. Treat the channel and yank status as best-effort, not a hard +security boundary. + +If c1i was installed with **Homebrew**, **`go install`**, a system package +manager, or is running as a **container image**, `upgrade` does not self-replace +— it prints the appropriate remediation instead. + ## License Apache 2.0 diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 0000000..ccdc314 --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,245 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "runtime" + "strings" + + "github.com/ConductorOne/c1i/internal/selfupdate" + "github.com/ConductorOne/c1i/internal/transport" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var upgradeChannels = map[string]bool{"stable": true, "latest": true, "preview": true} + +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Aliases: []string{"update"}, + Short: "Upgrade c1i to the latest release from the C1 distribution center", + Long: `Check for and install a newer c1i release. + +Release metadata comes from the C1 distribution center +(dist.conductorone.com): the "stable" channel by default, with "latest" and +"preview" available via --channel. The downloaded artifact is verified against +the release manifest's SHA-256 before anything is replaced. + +Only a standalone downloaded binary is replaced in place. If c1i was installed +with Homebrew, "go install", or is running as a container image, upgrade prints +the right command for that install method instead of self-replacing. + + c1i upgrade # upgrade to the latest stable release (asks first) + c1i upgrade --check # report whether a newer release is available; change nothing + c1i upgrade --channel latest -y # take the newest release without prompting`, + RunE: func(cmd *cobra.Command, args []string) error { + channel, _ := cmd.Flags().GetString("channel") + if !upgradeChannels[channel] { + return &usageError{fmt.Errorf("unknown --channel %q: expected stable, latest, or preview", channel)} + } + checkOnly, _ := cmd.Flags().GetBool("check") + assumeYes, _ := cmd.Flags().GetBool("yes") + out := cmd.OutOrStdout() + + client := &selfupdate.Client{HTTP: newUpgradeDoer(), Download: newUpgradeDownloadDoer()} + + idx, err := client.Index(cmd.Context()) + if err != nil { + return &upstreamError{fmt.Errorf("reading release channels: %w", err)} + } + target := idx.Channels[channel] + if target == "" { + return &upstreamError{fmt.Errorf("the distribution center lists no %q channel", channel)} + } + // index.json (channel + yank status) is NOT signed — only the per-release + // manifest is. So this yank check, and channel resolution, are best-effort + // against a compromised distribution origin: it could re-point a channel + // to a different but authentic ConductorOne-signed release, or un-yank one. + // The signature + monotonicity below bound that to authentic, not-older + // binaries; closing it fully needs a signed index (a dist-side change). + if e, ok := idx.Semvers[target]; ok && e.Yanked { + return &upstreamError{fmt.Errorf("the %q channel points at %s, which has been yanked; try again later", channel, target)} + } + + current := Version + if !isReleaseVersion(current) { + _, _ = fmt.Fprintf(out, "c1i is a development build (version %q); `c1i upgrade` works on released binaries.\n", current) + _, _ = fmt.Fprintf(out, "The current %s release is %s.\n", channel, target) + return nil + } + + cmp, ok := selfupdate.CompareVersions(current, target) + switch { + case !ok: + return &upstreamError{fmt.Errorf("cannot compare current version %q with %s", current, target)} + case cmp == 0: + _, _ = fmt.Fprintf(out, "c1i %s is already the latest %s release.\n", current, channel) + return nil + case cmp > 0: + _, _ = fmt.Fprintf(out, "c1i %s is newer than the %s channel (%s); nothing to do.\n", current, channel, target) + if channel == "stable" { + _, _ = fmt.Fprintln(out, "(Pass --channel latest to track the newest release.)") + } + return nil + } + + // cmp < 0: an upgrade is available. + if checkOnly { + _, _ = fmt.Fprintf(out, "A newer %s release is available: %s -> %s.\n", channel, current, target) + return nil + } + + execPath, err := selfupdate.ExecutablePath() + if err != nil { + return fmt.Errorf("locating the running binary: %w", err) + } + method, hint := selfupdate.Detect(execPath, runtime.GOOS) + if method != selfupdate.Standalone { + _, _ = fmt.Fprintf(out, "Not upgrading in place: %s\n", hint) + return nil + } + + entry, ok := idx.Semvers[target] + if !ok || entry.Manifest == "" { + return &upstreamError{fmt.Errorf("no manifest listed for %s", target)} + } + manifest, manifestBytes, err := client.ManifestRaw(cmd.Context(), entry.Manifest) + if err != nil { + return &upstreamError{fmt.Errorf("reading the %s manifest: %w", target, err)} + } + // The manifest must describe the version the channel points at; a + // mismatch means the index and manifest disagree about what this is. + // Compare as semver so a formatting skew (v-prefix) isn't a false reject. + if cmp, ok := selfupdate.CompareVersions(manifest.Semver, target); !ok || cmp != 0 { + return &upstreamError{fmt.Errorf("manifest for %s reports version %q; refusing the mismatch", target, manifest.Semver)} + } + + // Authenticate the manifest itself before trusting anything in it: the + // signature (pinned release-workflow identity, keyless/Fulcio) covers + // the exact manifest bytes; the per-asset sha256 inside then covers the + // downloaded artifact. + if entry.Signature == "" || entry.Certificate == "" || manifest.SignatureBundleHref == "" { + return &upstreamError{fmt.Errorf("release %s carries incomplete manifest verification material", target)} + } + sig, err := client.GetBytes(cmd.Context(), entry.Signature) + if err != nil { + return &upstreamError{fmt.Errorf("fetching the %s manifest signature: %w", target, err)} + } + cert, err := client.GetBytes(cmd.Context(), entry.Certificate) + if err != nil { + return &upstreamError{fmt.Errorf("fetching the %s manifest certificate: %w", target, err)} + } + rekorBundle, err := client.GetBytes(cmd.Context(), manifest.SignatureBundleHref) + if err != nil { + return &upstreamError{fmt.Errorf("fetching the %s manifest Rekor bundle: %w", target, err)} + } + if err := selfupdate.VerifyManifest(cmd.Context(), manifestBytes, sig, cert, rekorBundle); err != nil { + return &upstreamError{fmt.Errorf("verifying the %s release signature: %w", target, err)} + } + + asset, ok := manifest.Assets[selfupdate.PlatformKey()] + if !ok { + return &upstreamError{fmt.Errorf("%s has no build for %s", target, selfupdate.PlatformKey())} + } + + if dryRunActive() { + _, _ = fmt.Fprintf(out, "[dry-run] manifest signature verified; would download %s\n", asset.Href) + _, _ = fmt.Fprintf(out, "[dry-run] would verify sha256 %s and replace %s\n", asset.SHA256, execPath) + return nil + } + unlock, err := selfupdate.LockExecutable(execPath) + if err != nil { + return fmt.Errorf("locking %s for upgrade: %w", execPath, err) + } + defer unlock() + + current, err = selfupdate.InstalledVersion(execPath) + if err != nil { + return fmt.Errorf("reading installed c1i version: %w", err) + } + cmp, ok = selfupdate.CompareVersions(current, target) + switch { + case !ok: + return &upstreamError{fmt.Errorf("cannot compare installed version %q with %s", current, target)} + case cmp == 0: + _, _ = fmt.Fprintf(out, "c1i %s is already the latest %s release.\n", current, channel) + return nil + case cmp > 0: + _, _ = fmt.Fprintf(out, "c1i %s is newer than the %s channel (%s); nothing to do.\n", current, channel, target) + return nil + } + _, _ = fmt.Fprintf(out, "A newer %s release is available: %s -> %s.\n", channel, current, target) + + if !assumeYes { + ok, err := confirm(cmd, fmt.Sprintf("Upgrade c1i %s -> %s, replacing %s?", current, target, execPath)) + if err != nil { + return err + } + if !ok { + _, _ = fmt.Fprintln(out, "Upgrade cancelled.") + return nil + } + } + + _, _ = fmt.Fprintf(out, "Downloading %s...\n", asset.Filename) + if err := client.Apply(cmd.Context(), asset, execPath); err != nil { + return &upstreamError{fmt.Errorf("applying upgrade: %w", err)} + } + _, _ = fmt.Fprintf(out, "Upgraded c1i %s -> %s.\n", current, target) + return nil + }, +} + +// newUpgradeDoer builds the transport the self-updater fetches metadata +// (index.json, manifest.json, signature, certificate, and Rekor bundle) through, +// bounded to MaxMetadataBytes. A var so a test can inject a fake dist server; +// production threads --max-retries and --debug like every other network path. +var newUpgradeDoer = func() selfupdate.Doer { + return transport.New(nil, + transport.WithMaxRetries(viper.GetInt("max_retries")), + transport.WithDebug(viper.GetBool("debug")), + transport.WithMaxResponseBytes(selfupdate.MaxMetadataBytes), + ) +} + +// newUpgradeDownloadDoer builds the transport for the (larger) release archive, +// bounded to MaxArtifactBytes. Separate from newUpgradeDoer so the two fetch +// paths carry different size ceilings. +var newUpgradeDownloadDoer = func() selfupdate.Doer { + return transport.New(nil, + transport.WithMaxRetries(viper.GetInt("max_retries")), + transport.WithDebug(viper.GetBool("debug")), + transport.WithMaxResponseBytes(selfupdate.MaxArtifactBytes), + ) +} + +func init() { + upgradeCmd.Flags().Bool("check", false, "Report whether a newer release is available; change nothing") + upgradeCmd.Flags().String("channel", "stable", "Release channel: stable, latest, or preview") + upgradeCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt") + rootCmd.AddCommand(upgradeCmd) +} + +// isReleaseVersion reports whether Version looks like a real release tag +// (vMAJOR.MINOR.PATCH). A `go run`/source build reports "dev" (or "(devel)"), +// which cannot be compared or upgraded from. +func isReleaseVersion(v string) bool { + _, ok := selfupdate.CompareVersions(v, v) + return ok +} + +// confirm asks a yes/no question. It requires --yes when stdin is not a +// terminal, so a non-interactive run never blocks or silently proceeds. +func confirm(cmd *cobra.Command, prompt string) (bool, error) { + if !isTerminal() { + return false, &usageError{fmt.Errorf("re-run with --yes to upgrade without a prompt (stdin is not a terminal)")} + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s [y/N] ", prompt) + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return false, nil + } + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "y" || answer == "yes", nil +} diff --git a/cmd/upgrade_test.go b/cmd/upgrade_test.go new file mode 100644 index 0000000..0a43e95 --- /dev/null +++ b/cmd/upgrade_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "bytes" + "net/http" + "strings" + "testing" + + "github.com/ConductorOne/c1i/internal/selfupdate" + "github.com/ConductorOne/c1i/internal/transport" +) + +// fakeUpgradeDoer serves a canned index.json for any /index.json request, so +// the upgrade decision tree can be exercised without the network. +type fakeUpgradeDoer struct{ index string } + +func (f fakeUpgradeDoer) Do(req *http.Request) (*transport.Response, error) { + if strings.HasSuffix(req.URL.Path, "/index.json") { + return &transport.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: []byte(f.index), + }, nil + } + return &transport.Response{StatusCode: http.StatusNotFound}, nil +} + +func withUpgradeIndex(t *testing.T, index string) { + t.Helper() + orig := newUpgradeDoer + newUpgradeDoer = func() selfupdate.Doer { return fakeUpgradeDoer{index: index} } + t.Cleanup(func() { newUpgradeDoer = orig }) +} + +func withVersion(t *testing.T, v string) { + t.Helper() + orig := Version + Version = v + t.Cleanup(func() { Version = orig }) +} + +func runUpgrade(t *testing.T, args ...string) (string, error) { + t.Helper() + resetCmds(t, upgradeCmd) + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + t.Cleanup(func() { rootCmd.SetOut(nil); rootCmd.SetErr(nil) }) + rootCmd.SetArgs(append([]string{"upgrade"}, args...)) + err := rootCmd.ExecuteContext(t.Context()) + return out.String(), err +} + +const idxStable06Latest07 = `{"channels":{"stable":"v0.6.0","latest":"v0.7.0"},"semvers":{"v0.6.0":{"yanked":false,"manifest":"m"},"v0.7.0":{"yanked":false,"manifest":"m"}}}` + +func TestUpgradeAlreadyLatest(t *testing.T) { + withUpgradeIndex(t, idxStable06Latest07) + withVersion(t, "v0.6.0") + out, err := runUpgrade(t) + if err != nil { + t.Fatalf("err = %v", err) + } + if !strings.Contains(out, "already the latest stable") { + t.Errorf("output = %q", out) + } +} + +func TestUpgradeNewerThanChannel(t *testing.T) { + withUpgradeIndex(t, idxStable06Latest07) + withVersion(t, "v0.7.0") // ahead of stable (v0.6.0) + out, err := runUpgrade(t) + if err != nil { + t.Fatalf("err = %v", err) + } + if !strings.Contains(out, "newer than the stable channel") { + t.Errorf("output = %q", out) + } +} + +func TestUpgradeCheckReportsAvailable(t *testing.T) { + withUpgradeIndex(t, idxStable06Latest07) + withVersion(t, "v0.5.0") // behind stable + out, err := runUpgrade(t, "--check") + if err != nil { + t.Fatalf("err = %v", err) + } + if !strings.Contains(out, "newer stable release is available: v0.5.0 -> v0.6.0") { + t.Errorf("output = %q", out) + } +} + +func TestUpgradeChannelLatest(t *testing.T) { + withUpgradeIndex(t, idxStable06Latest07) + withVersion(t, "v0.6.0") + out, err := runUpgrade(t, "--check", "--channel", "latest") + if err != nil { + t.Fatalf("err = %v", err) + } + if !strings.Contains(out, "available: v0.6.0 -> v0.7.0") { + t.Errorf("output = %q", out) + } +} + +func TestUpgradeUnknownChannelIsUsageError(t *testing.T) { + withUpgradeIndex(t, idxStable06Latest07) + withVersion(t, "v0.6.0") + _, err := runUpgrade(t, "--channel", "nightly") + if err == nil { + t.Fatal("expected an error for an unknown channel") + } + if got := exitCode(err); got != exitUsage { + t.Errorf("exit = %d, want %d (usage)", got, exitUsage) + } +} + +func TestUpgradeYankedTargetErrors(t *testing.T) { + withUpgradeIndex(t, `{"channels":{"stable":"v0.6.0"},"semvers":{"v0.6.0":{"yanked":true,"manifest":"m"}}}`) + withVersion(t, "v0.5.0") + _, err := runUpgrade(t) + if err == nil { + t.Fatal("expected an error when the channel points at a yanked version") + } + if got := exitCode(err); got != exitUpstream { + t.Errorf("exit = %d, want %d (upstream)", got, exitUpstream) + } +} + +func TestUpgradeDevBuildDoesNotAttempt(t *testing.T) { + withUpgradeIndex(t, idxStable06Latest07) + withVersion(t, "dev") + out, err := runUpgrade(t) + if err != nil { + t.Fatalf("err = %v", err) + } + if !strings.Contains(out, "development build") { + t.Errorf("output = %q", out) + } +} diff --git a/go.mod b/go.mod index 6e5d85e..bb76e2d 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,13 @@ go 1.27.1 require ( github.com/go-jose/go-jose/v4 v4.1.5 + github.com/sigstore/protobuf-specs v0.5.1 + github.com/sigstore/sigstore v1.10.8 + github.com/sigstore/sigstore-go v1.2.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 + github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef github.com/zalando/go-keyring v0.2.8 golang.org/x/oauth2 v0.37.0 golang.org/x/term v0.46.0 @@ -14,18 +18,78 @@ require ( ) require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.25.2 // indirect + github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/loads v0.23.3 // indirect + github.com/go-openapi/runtime v0.32.3 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect + github.com/go-openapi/spec v0.22.5 // indirect + github.com/go-openapi/strfmt v0.26.3 // indirect + github.com/go-openapi/swag v0.26.0 // indirect + github.com/go-openapi/swag/cmdutils v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/go-openapi/validate v0.25.3 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect + github.com/google/go-containerregistry v0.21.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/in-toto/attestation v1.2.0 // indirect + github.com/in-toto/in-toto-golang v0.11.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/rekor v1.5.2 // indirect + github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/transparency-dev/formats v0.1.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + k8s.io/klog/v2 v2.140.0 // indirect ) diff --git a/go.sum b/go.sum index 50c08a3..16c2a7c 100644 --- a/go.sum +++ b/go.sum @@ -1,35 +1,282 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA= github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= +github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= +github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= +github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= +github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= +github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= +github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= +github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= +github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= +github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= +github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= +github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= +github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= +github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= +github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= +github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= +github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.2 h1:k6pX4o1zFAzAvDbXiVIp5IHj1b0wcDaxsbsbNpuRO8o= +github.com/sigstore/rekor v1.5.2/go.mod h1:WkMnITBccOFauPkT6yte74tF5gC83pefKRGZvNOsbjI= +github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 h1:/CO8F6m3Bo/f59bZo5dv1sTIfUnQqVnepIdDV24KoDw= +github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443/go.mod h1:w1h8wF8vq9lHjmtRdwJiEaoVxhP+WHIMpj4M39pkzp0= +github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= +github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.2.1 h1:YWP/rDbBaEBvtbkj6xtwsSj38ZCFEhTVVadNOXjVe3A= +github.com/sigstore/sigstore-go v1.2.1/go.mod h1:I8BqVwAb/SaQJ5pBu5IDFY+ksq8O/1/kCag8XUgrsko= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= +github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -47,20 +294,96 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef h1:jJac5InhEfD0Z46/d5RayZjoavf/se7bPZpOgg8GLrM= +github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef/go.mod h1:cLUSJ2cgR194lNWfp+TJT4P8PX7qGleCXdudqlCMtOE= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= +github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= +github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= +go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= +google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/selfupdate/apply.go b/internal/selfupdate/apply.go new file mode 100644 index 0000000..c6c54dc --- /dev/null +++ b/internal/selfupdate/apply.go @@ -0,0 +1,206 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +// Apply downloads asset, verifies its sha256, extracts the c1i binary, and +// atomically replaces the running executable at execPath. It never overwrites +// execPath unless the replacement is fully staged and verified, so a failed or +// interrupted upgrade leaves the current binary intact. +func (c *Client) Apply(ctx context.Context, asset Asset, execPath string) error { + raw, err := c.download(ctx, asset.Href) + if err != nil { + return err + } + if err := verifySHA256(raw, asset.SHA256); err != nil { + return err + } + bin, err := extractBinary(raw, asset.Filename) + if err != nil { + return err + } + return replaceExecutable(execPath, bin) +} + +func (c *Client) download(ctx context.Context, url string) ([]byte, error) { + if err := c.validateURL(url); err != nil { + return nil, err + } + body, err := c.get(ctx, c.downloadDoer(), url) + if err != nil { + return nil, err + } + // Belt-and-suspenders: the backing transport is bounded to MaxArtifactBytes, + // but a test Doer or an unbounded transport is not, so re-check here. + if len(body) > MaxArtifactBytes { + return nil, fmt.Errorf("downloading %s: artifact exceeds %d bytes", url, MaxArtifactBytes) + } + return body, nil +} + +func verifySHA256(data []byte, want string) error { + if want == "" { + return fmt.Errorf("manifest asset has no sha256; refusing to install unverified binary") + } + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if !strings.EqualFold(got, want) { + return fmt.Errorf("checksum mismatch: downloaded %s, manifest says %s", got, want) + } + return nil +} + +// extractBinary pulls the c1i executable out of a release archive. filename +// names the archive (…-linux-amd64.tar.gz or …-darwin-arm64.zip); the binary +// inside is named "c1i". +func extractBinary(archive []byte, filename string) ([]byte, error) { + switch { + case strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz"): + return fromTarGz(archive) + case strings.HasSuffix(filename, ".zip"): + return fromZip(archive) + default: + return nil, fmt.Errorf("unsupported archive %q", filename) + } +} + +func fromTarGz(archive []byte) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("gunzip: %w", err) + } + defer func() { _ = gz.Close() }() + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("reading tar: %w", err) + } + if isC1iEntry(hdr.Name) && hdr.Typeflag == tar.TypeReg { + return readBinary(tr) + } + } + return nil, fmt.Errorf("no c1i binary found in archive") +} + +func fromZip(archive []byte) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("reading zip: %w", err) + } + for _, f := range zr.File { + // IsRegular() skips symlink/dir/device entries (mirrors fromTarGz's + // tar.TypeReg check), so a crafted archive can't smuggle a symlink + // named c1i in place of the real binary. + if isC1iEntry(f.Name) && f.Mode().IsRegular() { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("opening %s in zip: %w", f.Name, err) + } + defer func() { _ = rc.Close() }() + return readBinary(rc) + } + } + return nil, fmt.Errorf("no c1i binary found in archive") +} + +func readBinary(reader io.Reader) ([]byte, error) { + return readBounded(reader, MaxArtifactBytes) +} + +func readBounded(reader io.Reader, limit int) ([]byte, error) { + binary, err := io.ReadAll(io.LimitReader(reader, int64(limit)+1)) + if err != nil { + return nil, err + } + if len(binary) > limit { + return nil, fmt.Errorf("archive c1i binary exceeds %d bytes", limit) + } + return binary, nil +} + +// isC1iEntry matches the c1i executable whether it sits at the archive root or +// under a directory, on either archive style. +func isC1iEntry(name string) bool { + base := path.Base(filepath.ToSlash(name)) + return base == "c1i" || base == "c1i.exe" +} + +// replaceExecutable atomically swaps newBinary in for the file at execPath. The +// new binary is written to a temp file in the SAME directory (so the final +// rename stays on one filesystem and is atomic), made executable, then renamed +// over execPath — which POSIX permits even while the old binary is running. +func replaceExecutable(execPath string, newBinary []byte) error { + dir := filepath.Dir(execPath) + tmp, err := os.CreateTemp(dir, ".c1i-upgrade-*") + if err != nil { + return fmt.Errorf("staging upgrade in %s: %w (is the install directory writable?)", dir, err) + } + tmpName := tmp.Name() + cleanup := func() { _ = os.Remove(tmpName) } + + // Match a normal executable's mode; preserve the existing binary's mode if + // we can read it, else fall back to 0755. + mode := os.FileMode(0o755) + if fi, err := os.Stat(execPath); err == nil { + mode = fi.Mode().Perm() + } + if _, err := tmp.Write(newBinary); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("writing staged binary: %w", err) + } + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("setting mode on staged binary: %w", err) + } + // Flush bytes and mode together before rename so a crash cannot leave a + // renamed binary that is empty or not executable. + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("syncing staged binary: %w", err) + } + if err := tmp.Close(); err != nil { + cleanup() + return fmt.Errorf("closing staged binary: %w", err) + } + if err := os.Rename(tmpName, execPath); err != nil { + cleanup() + return fmt.Errorf("replacing %s: %w", execPath, err) + } + // Best-effort: fsync the containing directory so the rename itself is + // durable. A failure here doesn't undo a successful rename, so don't fail + // the upgrade over it. + syncDir(dir) + return nil +} + +// syncDir flushes a directory's own metadata (the rename entry) to disk. Errors +// are ignored: some platforms/filesystems don't permit opening a directory for +// sync, and the rename has already succeeded. +func syncDir(dir string) { + d, err := os.Open(dir) // #nosec G304 -- dir is filepath.Dir(execPath), the install directory, not attacker input + if err != nil { + return + } + _ = d.Sync() + _ = d.Close() +} diff --git a/internal/selfupdate/dist.go b/internal/selfupdate/dist.go new file mode 100644 index 0000000..2b70f62 --- /dev/null +++ b/internal/selfupdate/dist.go @@ -0,0 +1,331 @@ +// Package selfupdate resolves and applies c1i upgrades from the C1 +// distribution center (dist.conductorone.com). It consumes the public release +// interface documented in ConductorOne/baton-admin's dist-release RFC: a +// per-CLI index.json (release channels + version list) and a per-release +// manifest.json (per-GOOS-GOARCH assets with sha256). The canonical schema is +// the protobuf in ConductorOne/github-workflows/pb/artifacts/v1; this package +// hand-rolls the small stable subset it needs rather than importing that +// workflow-tooling module. +package selfupdate + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "runtime" + "strconv" + "strings" + + "github.com/ConductorOne/c1i/internal/transport" +) + +// DefaultBaseURL is the dist release path for c1i. Assets, index.json and +// per-version manifest.json all live under it. +const DefaultBaseURL = "https://dist.conductorone.com/releases/ConductorOne/c1i" + +// MaxMetadataBytes bounds a JSON metadata fetch (index.json, manifest.json) and +// the small detached .sig/.cert, so a hostile endpoint can't stream an +// unbounded body into memory before it is parsed. MaxArtifactBytes bounds the +// release archive download. Callers wire these into the transport that backs +// the Doer(s) below. +const ( + MaxMetadataBytes = 8 << 20 // 8 MiB + MaxArtifactBytes = 200 << 20 // 200 MiB +) + +// Doer sends a request through the shared transport, so upgrade inherits the +// same retries, --max-retries, --debug tracing and user-agent as every other +// network path. *transport.Client satisfies it; tests fake it. +type Doer interface { + Do(*http.Request) (*transport.Response, error) +} + +// Index is the subset of dist's index.json the updater reads. +type Index struct { + // Channels maps a channel name ("stable", "latest", "preview") to a + // version tag; a released version is "latest" immediately but stays out of + // "stable" until it is promoted. + Channels map[string]string `json:"channels"` + Semvers map[string]SemverEntry `json:"semvers"` +} + +// SemverEntry is one version's entry in index.json. +type SemverEntry struct { + Yanked bool `json:"yanked"` + Hidden bool `json:"hidden"` + Manifest string `json:"manifest"` // absolute URL of this version's manifest.json + // Signature and Certificate are absolute URLs of the manifest's detached + // Sigstore signature (.sig) and signing certificate (.cert), each + // base64-encoded on the wire. They authenticate manifest.json. + Signature string `json:"signature"` + Certificate string `json:"certificate"` +} + +// Manifest is the subset of a dist /manifest.json the updater reads. +type Manifest struct { + Semver string `json:"semver"` + Assets map[string]Asset `json:"assets"` // keyed by "-", plus "checksums" + SignatureBundleHref string `json:"signatureBundleHref"` +} + +// Asset is one downloadable artifact in a manifest. +type Asset struct { + Filename string `json:"filename"` + SHA256 string `json:"sha256"` + Href string `json:"href"` // absolute download URL +} + +// Client fetches the dist index and manifests. +type Client struct { + // HTTP fetches metadata (index.json, manifest.json, .sig, .cert). Its + // backing transport should be bounded to MaxMetadataBytes. + HTTP Doer + // Download fetches the (larger) release archive; its transport should be + // bounded to MaxArtifactBytes. When nil, HTTP is used. + Download Doer + BaseURL string +} + +func (c *Client) downloadDoer() Doer { + if c.Download != nil { + return c.Download + } + return c.HTTP +} + +// validateURL rejects any URL that is not https or whose host differs from the +// configured dist base host, before it is fetched. In production baseURL() is +// DefaultBaseURL, so this pins every fetched URL (manifest, .sig, .cert, asset +// href) to dist.conductorone.com over TLS; a test that points BaseURL at its +// own host pins to that host instead. +func (c *Client) validateURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", raw, err) + } + if !strings.EqualFold(u.Scheme, "https") { + return fmt.Errorf("refusing to fetch non-https URL %q", raw) + } + base, err := url.Parse(c.baseURL()) + if err != nil { + return fmt.Errorf("invalid base URL %q: %w", c.baseURL(), err) + } + if !strings.EqualFold(u.Host, base.Host) { + return fmt.Errorf("refusing to fetch off-host URL %q (expected host %q)", raw, base.Host) + } + return nil +} + +// PlatformKey is the manifest asset key for the running binary, e.g. +// "linux-amd64" or "darwin-arm64". +func PlatformKey() string { return runtime.GOOS + "-" + runtime.GOARCH } + +func (c *Client) baseURL() string { + if c.BaseURL != "" { + return c.BaseURL + } + return DefaultBaseURL +} + +// Index fetches and decodes index.json. +func (c *Client) Index(ctx context.Context) (*Index, error) { + var idx Index + if err := c.getJSON(ctx, c.baseURL()+"/index.json", &idx); err != nil { + return nil, err + } + return &idx, nil +} + +// Manifest fetches and decodes a version's manifest.json from the URL the +// index entry names. +func (c *Client) Manifest(ctx context.Context, url string) (*Manifest, error) { + m, _, err := c.ManifestRaw(ctx, url) + return m, err +} + +// ManifestRaw fetches a version's manifest.json and returns both the decoded +// Manifest and the exact bytes it was decoded from. The Sigstore signature is +// over those raw bytes, so the caller must verify the same bytes it parsed. +func (c *Client) ManifestRaw(ctx context.Context, url string) (*Manifest, []byte, error) { + raw, err := c.getJSONBytes(ctx, url) + if err != nil { + return nil, nil, err + } + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, nil, fmt.Errorf("parsing %s: %w", url, err) + } + return &m, raw, nil +} + +// GetBytes fetches url and returns the response body for a 200, with no +// JSON/content-type check. Used for the detached .sig/.cert, which are +// base64 text rather than JSON. +func (c *Client) GetBytes(ctx context.Context, url string) ([]byte, error) { + if err := c.validateURL(url); err != nil { + return nil, err + } + return c.get(ctx, c.HTTP, url) +} + +func (c *Client) getJSON(ctx context.Context, url string, out any) error { + raw, err := c.getJSONBytes(ctx, url) + if err != nil { + return err + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("parsing %s: %w", url, err) + } + return nil +} + +func (c *Client) getJSONBytes(ctx context.Context, url string) ([]byte, error) { + if err := c.validateURL(url); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching %s: %w", url, err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetching %s: HTTP %d", url, resp.StatusCode) + } + // dist serves the SPA HTML shell (text/html) with 200 for a path that has + // no object; a real API response is application/json. Guard against + // decoding the shell as an empty struct. + if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "json") { + return nil, fmt.Errorf("fetching %s: expected JSON, got Content-Type %q (no such release object?)", url, ct) + } + return resp.Body, nil +} + +// get issues a GET through the given Doer and returns the body for a 200. The +// URL must already have been validated by the caller. +func (c *Client) get(ctx context.Context, doer Doer, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := doer.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching %s: %w", url, err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetching %s: HTTP %d", url, resp.StatusCode) + } + return resp.Body, nil +} + +// CompareVersions orders two "vMAJOR.MINOR.PATCH[-prerelease]" tags: it returns +// -1 if a < b, 0 if equal, 1 if a > b. A release outranks its own prerelease +// (v1.0.0 > v1.0.0-rc.1). ok is false if either side is not a parseable +// version (e.g. "dev"), and the caller must handle that rather than trust 0. +func CompareVersions(a, b string) (cmp int, ok bool) { + am, ap, aok := parseVersion(a) + bm, bp, bok := parseVersion(b) + if !aok || !bok { + return 0, false + } + for i := 0; i < 3; i++ { + if am[i] != bm[i] { + if am[i] < bm[i] { + return -1, true + } + return 1, true + } + } + // Equal core: absent prerelease outranks a present one; otherwise compare + // the prerelease per semver §11. + switch { + case ap == "" && bp == "": + return 0, true + case ap == "": + return 1, true + case bp == "": + return -1, true + default: + return comparePrerelease(ap, bp), true + } +} + +// comparePrerelease orders two prerelease strings per semver §11.4: compare +// dot-separated identifiers left to right; two numeric identifiers compare +// numerically (so rc.10 > rc.2), a numeric identifier ranks below a +// non-numeric one, non-numeric identifiers compare lexically (ASCII), and a +// longer identifier list outranks a shorter prefix of it. +func comparePrerelease(a, b string) int { + as := strings.Split(a, ".") + bs := strings.Split(b, ".") + for i := 0; i < len(as) && i < len(bs); i++ { + if c := compareIdentifier(as[i], bs[i]); c != 0 { + return c + } + } + switch { + case len(as) < len(bs): + return -1 + case len(as) > len(bs): + return 1 + default: + return 0 + } +} + +// compareIdentifier orders one prerelease identifier against another per the +// numeric/alphanumeric rules of semver §11.4. +func compareIdentifier(a, b string) int { + an, aErr := strconv.Atoi(a) + bn, bErr := strconv.Atoi(b) + aNum := aErr == nil + bNum := bErr == nil + switch { + case aNum && bNum: + switch { + case an < bn: + return -1 + case an > bn: + return 1 + default: + return 0 + } + case aNum: // numeric identifiers have lower precedence than non-numeric + return -1 + case bNum: + return 1 + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +func parseVersion(v string) (core [3]int, pre string, ok bool) { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + if v == "" { + return core, "", false + } + if i := strings.IndexByte(v, '-'); i != -1 { + pre = v[i+1:] + v = v[:i] + } + parts := strings.Split(v, ".") + if len(parts) != 3 { + return core, "", false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return core, "", false + } + core[i] = n + } + return core, pre, true +} diff --git a/internal/selfupdate/install.go b/internal/selfupdate/install.go new file mode 100644 index 0000000..55d1bb1 --- /dev/null +++ b/internal/selfupdate/install.go @@ -0,0 +1,182 @@ +package selfupdate + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Method is how the running c1i binary was installed. It decides whether a +// self-replace is appropriate or the user should upgrade through their package +// manager. +type Method int + +const ( + // Standalone is a plain downloaded binary — safe to replace in place. + Standalone Method = iota + // Homebrew binaries are symlinks into a Cellar; `brew upgrade` owns them. + Homebrew + // GoInstall binaries live in GOBIN/GOPATH/bin; `go install ...@latest` owns them. + GoInstall + // Docker means we are inside a container image — the image is replaced by re-pulling, not in place. + Docker + // SystemInstall is owned by a system package manager, not this updater. + SystemInstall + // Windows is handled separately: a running .exe cannot be overwritten in place, and the channel is an MSI. + Windows +) + +// Detect classifies how this binary was installed. execPath should be the +// resolved (symlink-followed) path of the running executable; goos is +// runtime.GOOS (a parameter so tests can exercise every branch). It returns +// the method and, for the non-self-replace methods, a one-line remediation the +// caller can print. +func Detect(execPath, goos string) (Method, string) { + if goos == "windows" { + return Windows, "download the Windows build (or MSI) from " + DefaultBaseURL + } + if goos == "linux" && inContainer() { + return Docker, "you are running the container image; re-pull it: docker pull public.ecr.aws/conductorone/c1i" + } + if isHomebrew(execPath) { + return Homebrew, "c1i was installed with Homebrew; upgrade it there: brew upgrade c1i" + } + if isGoInstall(execPath) { + return GoInstall, "c1i was installed with `go install`; upgrade it there: go install github.com/ConductorOne/c1i@latest" + } + if isSystemInstall(execPath) { + return SystemInstall, "c1i is installed in a system package directory; upgrade it with your system package manager" + } + return Standalone, "" +} + +// containerMarkerFiles are the runtime-dropped marker files whose presence +// signals a container. Overridable so a test can point them at a temp file. +// /.dockerenv is Docker's; /run/.containerenv is Podman's. +var containerMarkerFiles = []string{"/.dockerenv", "/run/.containerenv"} + +// containerCgroupFile is the cgroup path inspected for runtime markers. +// Overridable for the same reason. +var containerCgroupFile = "/proc/1/cgroup" + +// inContainer reports whether we are running inside a container image, where an +// in-place replace is pointless (the layer is ephemeral). Best-effort and +// Linux-shaped; false on macOS. +func inContainer() bool { + for _, marker := range containerMarkerFiles { + if _, err := os.Stat(marker); err == nil { + return true + } + } + // cgroup v1 names the controller path; container runtimes leave a marker. + if b, err := os.ReadFile(containerCgroupFile); err == nil { + s := string(b) + for _, marker := range []string{"docker", "containerd", "kubepods", "/lxc/"} { + if strings.Contains(s, marker) { + return true + } + } + } + return false +} + +// isHomebrew reports whether execPath resolves into a Homebrew Cellar. Brew +// installs the binary in /bin as a symlink to +// /Cellar///bin/, so the resolved path +// contains "/Cellar/". +func isHomebrew(execPath string) bool { + return strings.Contains(execPath, "/Cellar/") +} + +func isSystemInstall(execPath string) bool { + dir := filepath.Clean(filepath.Dir(execPath)) + return dir == "/bin" || dir == "/usr/bin" +} + +// goEnv contains Go's effective persisted install settings. +type goEnv struct { + GOBIN string + GOPATH string +} + +var readGoEnv = func() (goEnv, error) { + output, err := exec.Command("go", "env", "-json", "GOBIN", "GOPATH").Output() + if err != nil { + return goEnv{}, err + } + var env goEnv + if err := json.Unmarshal(output, &env); err != nil { + return goEnv{}, err + } + return env, nil +} + +// isGoInstall reports whether execPath is under any effective Go install target. +func isGoInstall(execPath string) bool { + dir := filepath.Dir(execPath) + for _, target := range goInstallTargets() { + if sameDir(dir, target) { + return true + } + } + return false +} + +func goInstallTargets() []string { + var targets []string + addGoPaths := func(gopath string) { + for _, gp := range filepath.SplitList(gopath) { + if gp != "" { + targets = append(targets, filepath.Join(gp, "bin")) + } + } + } + if gobin := os.Getenv("GOBIN"); gobin != "" { + targets = append(targets, gobin) + } + addGoPaths(os.Getenv("GOPATH")) + if persisted, err := readGoEnv(); err == nil { + if persisted.GOBIN != "" { + targets = append(targets, persisted.GOBIN) + } + addGoPaths(persisted.GOPATH) + } + if home, err := os.UserHomeDir(); err == nil { + targets = append(targets, filepath.Join(home, "go", "bin")) + } + return targets +} + +func sameDir(a, b string) bool { + ac := filepath.Clean(a) + bc := filepath.Clean(b) + if ac == bc { + return true + } + // Fall back to a resolved comparison so a symlinked GOPATH still matches. + if ra, err := filepath.EvalSymlinks(ac); err == nil { + if rb, err := filepath.EvalSymlinks(bc); err == nil { + return ra == rb + } + } + return false +} + +// ExecutablePath returns the resolved path of the running binary. +func ExecutablePath() (string, error) { + p, err := os.Executable() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(p); err == nil { + return resolved, nil + } + return p, nil +} + +// SelfReplaceGOOS reports whether the current OS supports replacing the running +// binary in place (POSIX rename over a running executable). Windows does not. +func SelfReplaceGOOS() bool { return runtime.GOOS != "windows" } diff --git a/internal/selfupdate/installed.go b/internal/selfupdate/installed.go new file mode 100644 index 0000000..6757205 --- /dev/null +++ b/internal/selfupdate/installed.go @@ -0,0 +1,18 @@ +package selfupdate + +import ( + "debug/buildinfo" + "fmt" +) + +// InstalledVersion reads the module version embedded in an installed c1i binary. +func InstalledVersion(execPath string) (string, error) { + info, err := buildinfo.ReadFile(execPath) + if err != nil { + return "", fmt.Errorf("reading build information for %s: %w", execPath, err) + } + if info.Main.Version == "" || info.Main.Version == "(devel)" { + return "", fmt.Errorf("installed binary %s has no release version", execPath) + } + return info.Main.Version, nil +} diff --git a/internal/selfupdate/lock_unix.go b/internal/selfupdate/lock_unix.go new file mode 100644 index 0000000..f621fa0 --- /dev/null +++ b/internal/selfupdate/lock_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package selfupdate + +import ( + "fmt" + "os" + "syscall" +) + +// LockExecutable acquires a non-blocking advisory lock for an in-place update. +func LockExecutable(execPath string) (func(), error) { + file, err := os.OpenFile(execPath+".upgrade-lock", os.O_CREATE|os.O_RDWR, 0o600) // #nosec G304 -- execPath comes from os.Executable + if err != nil { + return nil, fmt.Errorf("opening upgrade lock: %w", err) + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = file.Close() + return nil, fmt.Errorf("another c1i upgrade is already running: %w", err) + } + return func() { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() + }, nil +} diff --git a/internal/selfupdate/lock_windows.go b/internal/selfupdate/lock_windows.go new file mode 100644 index 0000000..4b27fe2 --- /dev/null +++ b/internal/selfupdate/lock_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package selfupdate + +// LockExecutable is a no-op because Windows upgrades never self-replace. +func LockExecutable(string) (func(), error) { + return func() {}, nil +} diff --git a/internal/selfupdate/rekor_bundle.go b/internal/selfupdate/rekor_bundle.go new file mode 100644 index 0000000..148fc78 --- /dev/null +++ b/internal/selfupdate/rekor_bundle.go @@ -0,0 +1,110 @@ +package selfupdate + +import ( + "bytes" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "time" + + bundlepb "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" + commonpb "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1" + rekorpb "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1" + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/verify" +) + +// legacyRekorBundle is the release bundle format currently published by dist. +type legacyRekorBundle struct { + Base64Signature string `json:"base64Signature"` + Cert string `json:"cert"` + RekorBundle struct { + SignedEntryTimestamp string `json:"SignedEntryTimestamp"` + Payload struct { + Body string `json:"body"` + IntegratedTime int64 `json:"integratedTime"` + LogIndex int64 `json:"logIndex"` + LogID string `json:"logID"` + } `json:"Payload"` + } `json:"rekorBundle"` +} + +func verifyRekorBundle(manifest, signature []byte, leaf *x509.Certificate, rawBundle []byte, trustedRoot root.TrustedMaterial) (time.Time, error) { + var legacy legacyRekorBundle + if err := json.Unmarshal(rawBundle, &legacy); err != nil { + return time.Time{}, fmt.Errorf("parsing Rekor bundle: %w", err) + } + + bundleSignature, err := base64.StdEncoding.DecodeString(legacy.Base64Signature) + if err != nil { + return time.Time{}, fmt.Errorf("decoding Rekor bundle signature: %w", err) + } + if !bytes.Equal(bundleSignature, signature) { + return time.Time{}, fmt.Errorf("rekor bundle signature does not match manifest signature") + } + bundleCertificatePEM, err := base64.StdEncoding.DecodeString(legacy.Cert) + if err != nil { + return time.Time{}, fmt.Errorf("decoding Rekor bundle certificate: %w", err) + } + block, _ := pem.Decode(bundleCertificatePEM) + if block == nil || block.Type != "CERTIFICATE" { + return time.Time{}, fmt.Errorf("rekor bundle certificate is not PEM-encoded") + } + bundleCertificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return time.Time{}, fmt.Errorf("parsing Rekor bundle certificate: %w", err) + } + if !bytes.Equal(bundleCertificate.Raw, leaf.Raw) { + return time.Time{}, fmt.Errorf("rekor bundle certificate does not match manifest certificate") + } + body, err := base64.StdEncoding.DecodeString(legacy.RekorBundle.Payload.Body) + if err != nil { + return time.Time{}, fmt.Errorf("decoding Rekor bundle entry: %w", err) + } + logID, err := hex.DecodeString(legacy.RekorBundle.Payload.LogID) + if err != nil { + return time.Time{}, fmt.Errorf("decoding Rekor log ID: %w", err) + } + set, err := base64.StdEncoding.DecodeString(legacy.RekorBundle.SignedEntryTimestamp) + if err != nil { + return time.Time{}, fmt.Errorf("decoding Rekor signed entry timestamp: %w", err) + } + + digest := sha256.Sum256(manifest) + entity, err := bundle.NewBundle(&bundlepb.Bundle{ + MediaType: "application/vnd.dev.sigstore.bundle+json;version=0.1", + Content: &bundlepb.Bundle_MessageSignature{MessageSignature: &commonpb.MessageSignature{ + MessageDigest: &commonpb.HashOutput{Algorithm: commonpb.HashAlgorithm_SHA2_256, Digest: digest[:]}, + Signature: signature, + }}, + VerificationMaterial: &bundlepb.VerificationMaterial{ + Content: &bundlepb.VerificationMaterial_Certificate{Certificate: &commonpb.X509Certificate{RawBytes: leaf.Raw}}, + TlogEntries: []*rekorpb.TransparencyLogEntry{{ + LogIndex: legacy.RekorBundle.Payload.LogIndex, + LogId: &commonpb.LogId{KeyId: logID}, + KindVersion: &rekorpb.KindVersion{Kind: "hashedrekord", Version: "0.0.1"}, + IntegratedTime: legacy.RekorBundle.Payload.IntegratedTime, + InclusionPromise: &rekorpb.InclusionPromise{ + SignedEntryTimestamp: set, + }, + CanonicalizedBody: body, + }}, + }, + }) + if err != nil { + return time.Time{}, fmt.Errorf("building Rekor bundle: %w", err) + } + timestamps, err := verify.VerifyTlogEntry(entity, trustedRoot, 1, true) + if err != nil { + return time.Time{}, fmt.Errorf("verifying Rekor bundle: %w", err) + } + if len(timestamps) != 1 { + return time.Time{}, fmt.Errorf("verifying Rekor bundle: expected one verified timestamp, got %d", len(timestamps)) + } + return timestamps[0].Time, nil +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..02d7b47 --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,400 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/ConductorOne/c1i/internal/transport" +) + +// fakeDoer serves canned transport.Responses keyed by URL, standing in for the +// distribution center so no test touches the network. +type fakeDoer struct { + resp map[string]*transport.Response + err error +} + +func (f *fakeDoer) Do(req *http.Request) (*transport.Response, error) { + if f.err != nil { + return nil, f.err + } + if r, ok := f.resp[req.URL.String()]; ok { + return r, nil + } + return &transport.Response{StatusCode: http.StatusNotFound}, nil +} + +func jsonResp(body string) *transport.Response { + return &transport.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: []byte(body), + } +} + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + want int + ok bool + }{ + {"v0.6.0", "v0.7.0", -1, true}, + {"v0.7.0", "v0.6.0", 1, true}, + {"v0.7.0", "v0.7.0", 0, true}, + {"v0.7.0", "0.7.0", 0, true}, // v-prefix optional + {"v0.10.0", "v0.9.0", 1, true}, + {"v1.0.0", "v1.0.0-rc.1", 1, true}, // release outranks its prerelease + {"v1.0.0-rc.1", "v1.0.0-rc.2", -1, true}, + {"v1.0.0-rc.10", "v1.0.0-rc.2", 1, true}, // numeric identifiers compare numerically, not lexically + {"v1.0.0-rc.2", "v1.0.0-rc.10", -1, true}, // symmetric + {"v1.0.0-rc.10", "v1.0.0-rc.10", 0, true}, + {"v1.0.0-alpha", "v1.0.0-alpha.1", -1, true}, // shorter prefix outranked by longer + {"v1.0.0-alpha.1", "v1.0.0-beta", -1, true}, // non-numeric lexical + {"v1.0.0-rc.1", "v1.0.0-rc.alpha", -1, true}, // numeric ranks below non-numeric + {"dev", "v0.7.0", 0, false}, + {"v0.7", "v0.7.0", 0, false}, // not three components + {"", "v0.7.0", 0, false}, + } + for _, c := range cases { + got, ok := CompareVersions(c.a, c.b) + if ok != c.ok || (ok && got != c.want) { + t.Errorf("CompareVersions(%q,%q) = (%d,%v), want (%d,%v)", c.a, c.b, got, ok, c.want, c.ok) + } + } +} + +func TestDetect(t *testing.T) { + if m, _ := Detect(`C:\Users\x\c1i.exe`, "windows"); m != Windows { + t.Errorf("windows -> %v, want Windows", m) + } + if m, _ := Detect("/opt/homebrew/Cellar/c1i/0.7.0/bin/c1i", "darwin"); m != Homebrew { + t.Errorf("Cellar path -> %v, want Homebrew", m) + } + if m, _ := Detect("/usr/local/bin/c1i", "linux"); m != Standalone { + t.Errorf("/usr/local/bin -> %v, want Standalone", m) + } + // go install: binary under GOBIN. + gobin := t.TempDir() + t.Setenv("GOBIN", gobin) + if m, hint := Detect(filepath.Join(gobin, "c1i"), "linux"); m != GoInstall { + t.Errorf("GOBIN path -> %v, want GoInstall", m) + } else if hint == "" { + t.Error("GoInstall returned no remediation hint") + } +} + +func TestDetectSystemAndPersistedGoInstall(t *testing.T) { + if m, hint := Detect("/usr/bin/c1i", "darwin"); m != SystemInstall || hint == "" { + t.Errorf("/usr/bin -> (%v, %q), want SystemInstall with a remediation", m, hint) + } + + original := readGoEnv + readGoEnv = func() (goEnv, error) { + return goEnv{GOBIN: "/opt/custom-go/bin", GOPATH: "/work/a:/work/b"}, nil + } + t.Cleanup(func() { readGoEnv = original }) + t.Setenv("GOBIN", "") + t.Setenv("GOPATH", "") + + for _, path := range []string{"/opt/custom-go/bin/c1i", "/work/b/bin/c1i"} { + if m, hint := Detect(path, "darwin"); m != GoInstall || hint == "" { + t.Errorf("%s -> (%v, %q), want GoInstall with a remediation", path, m, hint) + } + } +} + +func TestClientIndexAndManifest(t *testing.T) { + base := "https://dist.example/releases/ConductorOne/c1i" + index := `{"channels":{"stable":"v0.6.0","latest":"v0.7.0"},"semvers":{"v0.7.0":{"yanked":false,"manifest":"` + base + `/v0.7.0/manifest.json"}}}` + manifest := `{"semver":"v0.7.0","assets":{"linux-amd64":{"filename":"c1i-v0.7.0-linux-amd64.tar.gz","sha256":"abc","href":"` + base + `/v0.7.0/c1i-v0.7.0-linux-amd64.tar.gz"}}}` + c := &Client{BaseURL: base, HTTP: &fakeDoer{resp: map[string]*transport.Response{ + base + "/index.json": jsonResp(index), + base + "/v0.7.0/manifest.json": jsonResp(manifest), + }}} + + idx, err := c.Index(context.Background()) + if err != nil { + t.Fatalf("Index: %v", err) + } + if idx.Channels["stable"] != "v0.6.0" || idx.Channels["latest"] != "v0.7.0" { + t.Errorf("channels = %v", idx.Channels) + } + m, err := c.Manifest(context.Background(), idx.Semvers["v0.7.0"].Manifest) + if err != nil { + t.Fatalf("Manifest: %v", err) + } + if a := m.Assets["linux-amd64"]; a.SHA256 != "abc" || a.Filename == "" { + t.Errorf("asset = %+v", a) + } +} + +func TestClientRejectsHTMLShellAndNon200(t *testing.T) { + base := "https://dist.example/releases/ConductorOne/c1i" + shell := &transport.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/html; charset=utf-8"}}, Body: []byte("")} + c := &Client{BaseURL: base, HTTP: &fakeDoer{resp: map[string]*transport.Response{base + "/index.json": shell}}} + if _, err := c.Index(context.Background()); err == nil { + t.Error("expected an error decoding the SPA HTML shell, got nil") + } + + c2 := &Client{BaseURL: base, HTTP: &fakeDoer{}} // everything 404s + if _, err := c2.Index(context.Background()); err == nil { + t.Error("expected an error on a 404 index, got nil") + } +} + +func TestVerifySHA256(t *testing.T) { + data := []byte("hello") + sum := sha256.Sum256(data) + good := hex.EncodeToString(sum[:]) + if err := verifySHA256(data, good); err != nil { + t.Errorf("matching sha256 errored: %v", err) + } + if err := verifySHA256(data, "deadbeef"); err == nil { + t.Error("mismatched sha256 did not error") + } + if err := verifySHA256(data, ""); err == nil { + t.Error("empty sha256 did not error") + } +} + +func TestExtractBinary(t *testing.T) { + want := []byte("#!c1i-binary") + if got, err := extractBinary(makeTarGz(t, "c1i", want), "c1i-v0.7.0-linux-amd64.tar.gz"); err != nil || !bytes.Equal(got, want) { + t.Errorf("tar.gz extract = (%q,%v)", got, err) + } + if got, err := extractBinary(makeZip(t, "c1i", want), "c1i-v0.7.0-darwin-arm64.zip"); err != nil || !bytes.Equal(got, want) { + t.Errorf("zip extract = (%q,%v)", got, err) + } + // binary under a top-level dir is still found. + if got, err := extractBinary(makeTarGz(t, "c1i-v0.7.0/c1i", want), "x.tar.gz"); err != nil || !bytes.Equal(got, want) { + t.Errorf("nested tar.gz extract = (%q,%v)", got, err) + } + if _, err := extractBinary(makeTarGz(t, "README.md", want), "x.tar.gz"); err == nil { + t.Error("archive without a c1i entry did not error") + } + if _, err := extractBinary([]byte("x"), "x.rar"); err == nil { + t.Error("unsupported archive extension did not error") + } +} + +func TestReadBoundedRejectsOverflow(t *testing.T) { + if _, err := readBounded(bytes.NewReader([]byte("1234")), 3); err == nil { + t.Fatal("expected oversized binary to be rejected") + } + got, err := readBounded(bytes.NewReader([]byte("123")), 3) + if err != nil || string(got) != "123" { + t.Fatalf("readBounded exact limit = (%q, %v), want (123, nil)", got, err) + } +} + +func TestReplaceExecutable(t *testing.T) { + dir := t.TempDir() + execPath := filepath.Join(dir, "c1i") + if err := os.WriteFile(execPath, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + if err := replaceExecutable(execPath, []byte("NEW")); err != nil { + t.Fatalf("replace: %v", err) + } + got, _ := os.ReadFile(execPath) + if string(got) != "NEW" { + t.Errorf("content = %q, want NEW", got) + } + if fi, _ := os.Stat(execPath); fi.Mode().Perm()&0o100 == 0 { + t.Error("replaced binary is not executable") + } + // No leftover temp files in the dir. + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Errorf("dir has %d entries, want 1 (temp file leaked)", len(entries)) + } +} + +func TestLockExecutableRejectsConcurrentUpgrade(t *testing.T) { + execPath := filepath.Join(t.TempDir(), "c1i") + unlock, err := LockExecutable(execPath) + if err != nil { + t.Fatalf("first LockExecutable: %v", err) + } + t.Cleanup(unlock) + if _, err := LockExecutable(execPath); err == nil { + t.Fatal("second LockExecutable succeeded while the first lock was held") + } +} + +func TestApplyEndToEndAndChecksumGuard(t *testing.T) { + dir := t.TempDir() + execPath := filepath.Join(dir, "c1i") + if err := os.WriteFile(execPath, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + newBin := []byte("#!c1i-v0.7.0") + tgz := makeTarGz(t, "c1i", newBin) + sum := sha256.Sum256(tgz) + // Href must share the client's base host (URL pinning), so set BaseURL to + // match rather than relying on the production default. + href := "https://dist.example/c1i.tar.gz" + + client := &Client{BaseURL: "https://dist.example", HTTP: &fakeDoer{resp: map[string]*transport.Response{ + href: {StatusCode: 200, Body: tgz}, + }}} + + // Happy path: replaces the binary. + asset := Asset{Filename: "c1i-v0.7.0-linux-amd64.tar.gz", SHA256: hex.EncodeToString(sum[:]), Href: href} + if err := client.Apply(context.Background(), asset, execPath); err != nil { + t.Fatalf("Apply: %v", err) + } + if got, _ := os.ReadFile(execPath); !bytes.Equal(got, newBin) { + t.Errorf("binary not replaced: %q", got) + } + + // Checksum mismatch: aborts, leaves the (now-new) binary untouched. + _ = os.WriteFile(execPath, []byte("KEEP"), 0o755) + bad := Asset{Filename: asset.Filename, SHA256: "00", Href: href} + if err := client.Apply(context.Background(), bad, execPath); err == nil { + t.Error("Apply with a bad checksum did not error") + } + if got, _ := os.ReadFile(execPath); string(got) != "KEEP" { + t.Errorf("binary changed despite checksum mismatch: %q", got) + } +} + +func TestValidateURLRejectsOffHostAndNonHTTPS(t *testing.T) { + base := "https://dist.example/releases/ConductorOne/c1i" + c := &Client{BaseURL: base} + cases := []struct { + url string + wantErr bool + }{ + {"https://dist.example/releases/ConductorOne/c1i/v1/manifest.json", false}, + {"https://evil.example/manifest.json", true}, // off-host + {"http://dist.example/manifest.json", true}, // non-https + {"https://dist.example.evil.com/manifest.json", true}, // suffix trick, different host + {"ftp://dist.example/manifest.json", true}, // wrong scheme + } + for _, tc := range cases { + err := c.validateURL(tc.url) + if tc.wantErr && err == nil { + t.Errorf("validateURL(%q) = nil, want error", tc.url) + } + if !tc.wantErr && err != nil { + t.Errorf("validateURL(%q) = %v, want nil", tc.url, err) + } + } +} + +func TestApplyRejectsOffHostHref(t *testing.T) { + // An asset href on a host other than the client's base is refused before + // any download. + c := &Client{BaseURL: "https://dist.example", HTTP: &fakeDoer{}} + asset := Asset{Filename: "c1i.tar.gz", SHA256: "abc", Href: "https://evil.example/c1i.tar.gz"} + if err := c.Apply(context.Background(), asset, filepath.Join(t.TempDir(), "c1i")); err == nil { + t.Fatal("Apply followed an off-host href; expected a refusal") + } +} + +func TestFromZipSkipsSymlinkEntry(t *testing.T) { + // A zip whose "c1i" entry is a symlink must not be treated as the binary; + // only a regular-file c1i counts (mirrors fromTarGz's tar.TypeReg check). + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + // Symlink entry named c1i. + symHdr := &zip.FileHeader{Name: "c1i"} + symHdr.SetMode(os.ModeSymlink | 0o777) + w, err := zw.CreateHeader(symHdr) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("/etc/passwd")); err != nil { + t.Fatal(err) + } + _ = zw.Close() + + if _, err := fromZip(buf.Bytes()); err == nil { + t.Fatal("fromZip returned a binary for a symlink-only c1i entry; expected 'no c1i binary found'") + } + + // Now add a real regular-file c1i alongside the symlink: it must be found. + buf.Reset() + zw = zip.NewWriter(&buf) + w, _ = zw.CreateHeader(symHdr) + _, _ = w.Write([]byte("/etc/passwd")) + want := []byte("#!real-c1i") + rw, err := zw.Create("dir/c1i") + if err != nil { + t.Fatal(err) + } + if _, err := rw.Write(want); err != nil { + t.Fatal(err) + } + _ = zw.Close() + got, err := fromZip(buf.Bytes()) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("fromZip with a real c1i = (%q, %v), want (%q, nil)", got, err, want) + } +} + +func TestInContainerDetectsPodmanMarker(t *testing.T) { + // Point the marker list at a temp file (no /run/.containerenv on the host) + // to prove the Podman marker path is honored. + dir := t.TempDir() + marker := filepath.Join(dir, ".containerenv") + if err := os.WriteFile(marker, nil, 0o644); err != nil { + t.Fatal(err) + } + origMarkers, origCgroup := containerMarkerFiles, containerCgroupFile + t.Cleanup(func() { containerMarkerFiles, containerCgroupFile = origMarkers, origCgroup }) + + // No markers present, cgroup file absent: not a container. + containerMarkerFiles = []string{filepath.Join(dir, "absent")} + containerCgroupFile = filepath.Join(dir, "absent-cgroup") + if inContainer() { + t.Error("inContainer() = true with no markers present") + } + + // Podman marker present: container. + containerMarkerFiles = []string{filepath.Join(dir, "absent"), marker} + if !inContainer() { + t.Error("inContainer() = false despite the Podman marker file") + } +} + +func makeTarGz(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + _ = tw.Close() + _ = gz.Close() + return buf.Bytes() +} + +func makeZip(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(content); err != nil { + t.Fatal(err) + } + _ = zw.Close() + return buf.Bytes() +} diff --git a/internal/selfupdate/testdata/manifest.json b/internal/selfupdate/testdata/manifest.json new file mode 100644 index 0000000..d8cbb7f --- /dev/null +++ b/internal/selfupdate/testdata/manifest.json @@ -0,0 +1,181 @@ +{ + "version": "2", + "name": "c1i", + "org": "ConductorOne", + "semver": "v0.7.0", + "releasedAt": "2026-09-04T16:57:04Z", + "assets": { + "checksums": { + "filename": "c1i_0.7.0_checksums.txt", + "mediaType": "text/plain", + "sizeBytes": 570, + "sha256": "f4de219194d88b8c9783f99d0d6333961ca864bc6b83672e8a5f9bd877fd8f1f", + "href": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i_0.7.0_checksums.txt", + "signatureHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i_0.7.0_checksums.txt.sig", + "certificateHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i_0.7.0_checksums.txt.cert", + "sbomHref": null, + "attestations": [], + "updaterSignature": null + }, + "darwin-amd64": { + "filename": "c1i-v0.7.0-darwin-amd64.zip", + "mediaType": "application/zip", + "sizeBytes": "4837607", + "sha256": "78d3c3e2f2d3bbcd81bd2ea6c5f7dd10c31351a327a370b24e42373dfef84fdf", + "href": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-amd64.zip", + "signatureHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-amd64.zip.sig", + "certificateHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-amd64.zip.cert", + "sbomHref": null, + "attestations": [ + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-amd64.zip.provenance.sigstore.json" + }, + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://spdx.dev/Document", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-amd64.zip.sbom.sigstore.json" + } + ], + "updaterSignature": null + }, + "darwin-arm64": { + "filename": "c1i-v0.7.0-darwin-arm64.zip", + "mediaType": "application/zip", + "sizeBytes": "4322292", + "sha256": "ff6a98348975ae062ec87b4f05418f23da94175b59dba9edc206d51b864861ba", + "href": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-arm64.zip", + "signatureHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-arm64.zip.sig", + "certificateHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-arm64.zip.cert", + "sbomHref": null, + "attestations": [ + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-arm64.zip.provenance.sigstore.json" + }, + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://spdx.dev/Document", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-darwin-arm64.zip.sbom.sigstore.json" + } + ], + "updaterSignature": null + }, + "linux-amd64": { + "filename": "c1i-v0.7.0-linux-amd64.tar.gz", + "mediaType": "application/gzip", + "sizeBytes": "4834233", + "sha256": "b423c938e43ae600d1999eb8b66b9112c1b17a7deb6b8f384388b00b7c48409d", + "href": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-amd64.tar.gz", + "signatureHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-amd64.tar.gz.sig", + "certificateHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-amd64.tar.gz.cert", + "sbomHref": null, + "attestations": [ + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-amd64.tar.gz.provenance.sigstore.json" + }, + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://spdx.dev/Document", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-amd64.tar.gz.sbom.sigstore.json" + } + ], + "updaterSignature": null + }, + "linux-arm64": { + "filename": "c1i-v0.7.0-linux-arm64.tar.gz", + "mediaType": "application/gzip", + "sizeBytes": "4362752", + "sha256": "6fd8bcb60b0693eae30169cc6c3a9846cb678c79a08e7ddbf6fb111c5f0be0db", + "href": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-arm64.tar.gz", + "signatureHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-arm64.tar.gz.sig", + "certificateHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-arm64.tar.gz.cert", + "sbomHref": null, + "attestations": [ + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-arm64.tar.gz.provenance.sigstore.json" + }, + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://spdx.dev/Document", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-linux-arm64.tar.gz.sbom.sigstore.json" + } + ], + "updaterSignature": null + }, + "windows-amd64": { + "filename": "c1i-v0.7.0-windows-amd64.zip", + "mediaType": "application/zip", + "sizeBytes": "4766919", + "sha256": "f01184b4b6baa994b95229cf727d0ca0e4be712901f3d3b81f2044a274c7bfad", + "href": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-windows-amd64.zip", + "signatureHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-windows-amd64.zip.sig", + "certificateHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-windows-amd64.zip.cert", + "sbomHref": null, + "attestations": [ + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-windows-amd64.zip.provenance.sigstore.json" + }, + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://spdx.dev/Document", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i-v0.7.0-windows-amd64.zip.sbom.sigstore.json" + } + ], + "updaterSignature": null + }, + "windows-amd64-msi": { + "filename": "c1i_v0.7.0_windows_amd64.msi", + "mediaType": "application/x-msi", + "sizeBytes": "4706304", + "sha256": "4a26d68f1e2ad05d7714b2ef37af04c962065610b942167db46d72cf2a78fdcb", + "href": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i_v0.7.0_windows_amd64.msi", + "signatureHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i_v0.7.0_windows_amd64.msi.sig", + "certificateHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i_v0.7.0_windows_amd64.msi.cert", + "sbomHref": null, + "attestations": [ + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i_v0.7.0_windows_amd64.msi.provenance.sigstore.json" + }, + { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://spdx.dev/Document", + "bundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/c1i_v0.7.0_windows_amd64.msi.sbom.sigstore.json" + } + ], + "updaterSignature": null + } + }, + "images": { + "ecrPublic": { + "ref": "public.ecr.aws/conductorone/c1i:0.7.0", + "digest": "sha256:c546208d7357a59b8e0d8b324faae4d4ba8296fec1d60f5bc12e8f4a675fff13", + "tag": "0.7.0", + "uri": "public.ecr.aws/conductorone/c1i@sha256:c546208d7357a59b8e0d8b324faae4d4ba8296fec1d60f5bc12e8f4a675fff13", + "isIndex": true + } + }, + "signatureHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/manifest.json.sig", + "certificateHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/manifest.json.cert", + "imageAttestation": { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "bundleHref": null + }, + "assetAttestation": { + "attestationType": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "bundleHref": null + }, + "signatureBundleHref": "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0/manifest.json.sigstore.json" +} diff --git a/internal/selfupdate/testdata/manifest.json.cert b/internal/selfupdate/testdata/manifest.json.cert new file mode 100644 index 0000000..4bb1664 --- /dev/null +++ b/internal/selfupdate/testdata/manifest.json.cert @@ -0,0 +1 @@ +LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUcrVENDQm42Z0F3SUJBZ0lVZDN4Q21tOWJqcjdiS0xnc1dzRzlYT09DaWdjd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpZd09UQTBNVFkxT1RJeldoY05Nall3T1RBME1UY3dPVEl6V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVhSVZ2TkV4Sy9wTldrRnJGandvZjMwRkVldWpuUkxmeUhpdnMKeTlUWDhiSUVSaTdPY3UxQitTSFVwcTVmSXdZUUV0SFh4cnFaMWx4Vk5NVFJnMDJKVjZPQ0JaMHdnZ1daTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVtanY2Cjd4eldGTTJ5Q01ldXpWQWI4U3hZeUgwd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d2FnWURWUjBSQVFIL0JHQXdYb1pjYUhSMGNITTZMeTluYVhSb2RXSXVZMjl0TDBOdmJtUjFZM1J2Y2s5dQpaUzluYVhSb2RXSXRkMjl5YTJac2IzZHpMeTVuYVhSb2RXSXZkMjl5YTJac2IzZHpMM0psYkdWaGMyVXVlV0Z0CmJFQnlaV1p6TDNSaFozTXZkalF3T1FZS0t3WUJCQUdEdnpBQkFRUXJhSFIwY0hNNkx5OTBiMnRsYmk1aFkzUnAKYjI1ekxtZHBkR2gxWW5WelpYSmpiMjUwWlc1MExtTnZiVEFTQmdvckJnRUVBWU8vTUFFQ0JBUndkWE5vTURZRwpDaXNHQVFRQmc3OHdBUU1FS0dVd09UazBaV0poWTJZNFlURXlPRFppTXpaak5EQXdOVFF3WVRBek5HTmlOR1poCk5XUTBaVGt3RlFZS0t3WUJCQUdEdnpBQkJBUUhVbVZzWldGelpUQWVCZ29yQmdFRUFZTy9NQUVGQkJCRGIyNWsKZFdOMGIzSlBibVV2WXpGcE1CNEdDaXNHQVFRQmc3OHdBUVlFRUhKbFpuTXZkR0ZuY3k5Mk1DNDNMakF3T3dZSwpLd1lCQkFHRHZ6QUJDQVF0REN0b2RIUndjem92TDNSdmEyVnVMbUZqZEdsdmJuTXVaMmwwYUhWaWRYTmxjbU52CmJuUmxiblF1WTI5dE1Hd0dDaXNHQVFRQmc3OHdBUWtFWGd4Y2FIUjBjSE02THk5bmFYUm9kV0l1WTI5dEwwTnYKYm1SMVkzUnZjazl1WlM5bmFYUm9kV0l0ZDI5eWEyWnNiM2R6THk1bmFYUm9kV0l2ZDI5eWEyWnNiM2R6TDNKbApiR1ZoYzJVdWVXRnRiRUJ5WldaekwzUmhaM012ZGpRd09BWUtLd1lCQkFHRHZ6QUJDZ1FxRENnMk5HSm1NV015CllqSmpNbU5tWW1GaVl6RmhaRFF5TWpaa1pHTXlNbVJoWWpZMk5UTTVOV05sTUIwR0Npc0dBUVFCZzc4d0FRc0UKRHd3TloybDBhSFZpTFdodmMzUmxaREF6QmdvckJnRUVBWU8vTUFFTUJDVU1JMmgwZEhCek9pOHZaMmwwYUhWaQpMbU52YlM5RGIyNWtkV04wYjNKUGJtVXZZekZwTURnR0Npc0dBUVFCZzc4d0FRMEVLZ3dvWlRBNU9UUmxZbUZqClpqaGhNVEk0Tm1Jek5tTTBNREExTkRCaE1ETTBZMkkwWm1FMVpEUmxPVEFnQmdvckJnRUVBWU8vTUFFT0JCSU0KRUhKbFpuTXZkR0ZuY3k5Mk1DNDNMakF3R2dZS0t3WUJCQUdEdnpBQkR3UU1EQW94TVRnMU5qZzFNalkxTUM4RwpDaXNHQVFRQmc3OHdBUkFFSVF3ZmFIUjBjSE02THk5bmFYUm9kV0l1WTI5dEwwTnZibVIxWTNSdmNrOXVaVEFZCkJnb3JCZ0VFQVlPL01BRVJCQW9NQ0RjeE56TTFORGc0TUdNR0Npc0dBUVFCZzc4d0FSSUVWUXhUYUhSMGNITTYKTHk5bmFYUm9kV0l1WTI5dEwwTnZibVIxWTNSdmNrOXVaUzlqTVdrdkxtZHBkR2gxWWk5M2IzSnJabXh2ZDNNdgpjbVZzWldGelpTNTVZVzFzUUhKbFpuTXZkR0ZuY3k5Mk1DNDNMakF3T0FZS0t3WUJCQUdEdnpBQkV3UXFEQ2hsCk1EazVOR1ZpWVdObU9HRXhNamcyWWpNMll6UXdNRFUwTUdFd016UmpZalJtWVRWa05HVTVNQlFHQ2lzR0FRUUIKZzc4d0FSUUVCZ3dFY0hWemFEQlhCZ29yQmdFRUFZTy9NQUVWQkVrTVIyaDBkSEJ6T2k4dloybDBhSFZpTG1OdgpiUzlEYjI1a2RXTjBiM0pQYm1Vdll6RnBMMkZqZEdsdmJuTXZjblZ1Y3k4ek16ZzVOell5TmpjNE5DOWhkSFJsCmJYQjBjeTh4TUJZR0Npc0dBUVFCZzc4d0FSWUVDQXdHY0hWaWJHbGpNRG9HQ2lzR0FRUUJnNzh3QVJnRUxBd3EKY21Wd2J6cERiMjVrZFdOMGIzSlBibVV2WXpGcE9uSmxaanB5WldaekwzUmhaM012ZGpBdU55NHdNSUdKQmdvcgpCZ0VFQWRaNUFnUUNCSHNFZVFCM0FIVUEzVDB3YXNiSEVUSmpHUjRjbVdjM0FxSktYcmplUEszL2g0cHlnQzhwCjdvNEFBQUdnYlZ4V3RRQUFCQU1BUmpCRUFpQWFnTGVLbjdEeUI4ZzJwd0pING4vdjE0a2dMSDZCOVU2QWdZV1cKMWkxTWF3SWdkd01kVlcxUDdLSGorSDJ3SUhIKzI5MVFGOGhZMlBselJac28rVjZwNEdzd0NnWUlLb1pJemowRQpBd01EYVFBd1pnSXhBTXJsbERJdmhWSzE3UWdja3RhQ3g2eEVnTW1wZ0R4a1FaV3pLR2plVTd6YmVYaENUNE02CmRBaGEvSDNyRFZZenlRSXhBUEVla1ZqV3VNZG5YL1lrcjN0ZTF4eEVCU0k1YXhIaGtXQ3hBbU11cEp1ZFpYeFMKbXpwR3JXL0hPV1VBbTZZeGdnPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= \ No newline at end of file diff --git a/internal/selfupdate/testdata/manifest.json.sig b/internal/selfupdate/testdata/manifest.json.sig new file mode 100644 index 0000000..06545c5 --- /dev/null +++ b/internal/selfupdate/testdata/manifest.json.sig @@ -0,0 +1 @@ +MEQCIHRGTO6ccf5iM7krGNZSdW0yZCXfy81byssHAfHMorpQAiBEe2ec1pIDGnYlXXjkHGCADAa+ATERlNKbI+VVLEelyw== \ No newline at end of file diff --git a/internal/selfupdate/testdata/manifest.json.sigstore.json b/internal/selfupdate/testdata/manifest.json.sigstore.json new file mode 100644 index 0000000..806e4a8 --- /dev/null +++ b/internal/selfupdate/testdata/manifest.json.sigstore.json @@ -0,0 +1 @@ +{"base64Signature":"MEQCIHRGTO6ccf5iM7krGNZSdW0yZCXfy81byssHAfHMorpQAiBEe2ec1pIDGnYlXXjkHGCADAa+ATERlNKbI+VVLEelyw==","cert":"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUcrVENDQm42Z0F3SUJBZ0lVZDN4Q21tOWJqcjdiS0xnc1dzRzlYT09DaWdjd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpZd09UQTBNVFkxT1RJeldoY05Nall3T1RBME1UY3dPVEl6V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVhSVZ2TkV4Sy9wTldrRnJGandvZjMwRkVldWpuUkxmeUhpdnMKeTlUWDhiSUVSaTdPY3UxQitTSFVwcTVmSXdZUUV0SFh4cnFaMWx4Vk5NVFJnMDJKVjZPQ0JaMHdnZ1daTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVtanY2Cjd4eldGTTJ5Q01ldXpWQWI4U3hZeUgwd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d2FnWURWUjBSQVFIL0JHQXdYb1pjYUhSMGNITTZMeTluYVhSb2RXSXVZMjl0TDBOdmJtUjFZM1J2Y2s5dQpaUzluYVhSb2RXSXRkMjl5YTJac2IzZHpMeTVuYVhSb2RXSXZkMjl5YTJac2IzZHpMM0psYkdWaGMyVXVlV0Z0CmJFQnlaV1p6TDNSaFozTXZkalF3T1FZS0t3WUJCQUdEdnpBQkFRUXJhSFIwY0hNNkx5OTBiMnRsYmk1aFkzUnAKYjI1ekxtZHBkR2gxWW5WelpYSmpiMjUwWlc1MExtTnZiVEFTQmdvckJnRUVBWU8vTUFFQ0JBUndkWE5vTURZRwpDaXNHQVFRQmc3OHdBUU1FS0dVd09UazBaV0poWTJZNFlURXlPRFppTXpaak5EQXdOVFF3WVRBek5HTmlOR1poCk5XUTBaVGt3RlFZS0t3WUJCQUdEdnpBQkJBUUhVbVZzWldGelpUQWVCZ29yQmdFRUFZTy9NQUVGQkJCRGIyNWsKZFdOMGIzSlBibVV2WXpGcE1CNEdDaXNHQVFRQmc3OHdBUVlFRUhKbFpuTXZkR0ZuY3k5Mk1DNDNMakF3T3dZSwpLd1lCQkFHRHZ6QUJDQVF0REN0b2RIUndjem92TDNSdmEyVnVMbUZqZEdsdmJuTXVaMmwwYUhWaWRYTmxjbU52CmJuUmxiblF1WTI5dE1Hd0dDaXNHQVFRQmc3OHdBUWtFWGd4Y2FIUjBjSE02THk5bmFYUm9kV0l1WTI5dEwwTnYKYm1SMVkzUnZjazl1WlM5bmFYUm9kV0l0ZDI5eWEyWnNiM2R6THk1bmFYUm9kV0l2ZDI5eWEyWnNiM2R6TDNKbApiR1ZoYzJVdWVXRnRiRUJ5WldaekwzUmhaM012ZGpRd09BWUtLd1lCQkFHRHZ6QUJDZ1FxRENnMk5HSm1NV015CllqSmpNbU5tWW1GaVl6RmhaRFF5TWpaa1pHTXlNbVJoWWpZMk5UTTVOV05sTUIwR0Npc0dBUVFCZzc4d0FRc0UKRHd3TloybDBhSFZpTFdodmMzUmxaREF6QmdvckJnRUVBWU8vTUFFTUJDVU1JMmgwZEhCek9pOHZaMmwwYUhWaQpMbU52YlM5RGIyNWtkV04wYjNKUGJtVXZZekZwTURnR0Npc0dBUVFCZzc4d0FRMEVLZ3dvWlRBNU9UUmxZbUZqClpqaGhNVEk0Tm1Jek5tTTBNREExTkRCaE1ETTBZMkkwWm1FMVpEUmxPVEFnQmdvckJnRUVBWU8vTUFFT0JCSU0KRUhKbFpuTXZkR0ZuY3k5Mk1DNDNMakF3R2dZS0t3WUJCQUdEdnpBQkR3UU1EQW94TVRnMU5qZzFNalkxTUM4RwpDaXNHQVFRQmc3OHdBUkFFSVF3ZmFIUjBjSE02THk5bmFYUm9kV0l1WTI5dEwwTnZibVIxWTNSdmNrOXVaVEFZCkJnb3JCZ0VFQVlPL01BRVJCQW9NQ0RjeE56TTFORGc0TUdNR0Npc0dBUVFCZzc4d0FSSUVWUXhUYUhSMGNITTYKTHk5bmFYUm9kV0l1WTI5dEwwTnZibVIxWTNSdmNrOXVaUzlqTVdrdkxtZHBkR2gxWWk5M2IzSnJabXh2ZDNNdgpjbVZzWldGelpTNTVZVzFzUUhKbFpuTXZkR0ZuY3k5Mk1DNDNMakF3T0FZS0t3WUJCQUdEdnpBQkV3UXFEQ2hsCk1EazVOR1ZpWVdObU9HRXhNamcyWWpNMll6UXdNRFUwTUdFd016UmpZalJtWVRWa05HVTVNQlFHQ2lzR0FRUUIKZzc4d0FSUUVCZ3dFY0hWemFEQlhCZ29yQmdFRUFZTy9NQUVWQkVrTVIyaDBkSEJ6T2k4dloybDBhSFZpTG1OdgpiUzlEYjI1a2RXTjBiM0pQYm1Vdll6RnBMMkZqZEdsdmJuTXZjblZ1Y3k4ek16ZzVOell5TmpjNE5DOWhkSFJsCmJYQjBjeTh4TUJZR0Npc0dBUVFCZzc4d0FSWUVDQXdHY0hWaWJHbGpNRG9HQ2lzR0FRUUJnNzh3QVJnRUxBd3EKY21Wd2J6cERiMjVrZFdOMGIzSlBibVV2WXpGcE9uSmxaanB5WldaekwzUmhaM012ZGpBdU55NHdNSUdKQmdvcgpCZ0VFQWRaNUFnUUNCSHNFZVFCM0FIVUEzVDB3YXNiSEVUSmpHUjRjbVdjM0FxSktYcmplUEszL2g0cHlnQzhwCjdvNEFBQUdnYlZ4V3RRQUFCQU1BUmpCRUFpQWFnTGVLbjdEeUI4ZzJwd0pING4vdjE0a2dMSDZCOVU2QWdZV1cKMWkxTWF3SWdkd01kVlcxUDdLSGorSDJ3SUhIKzI5MVFGOGhZMlBselJac28rVjZwNEdzd0NnWUlLb1pJemowRQpBd01EYVFBd1pnSXhBTXJsbERJdmhWSzE3UWdja3RhQ3g2eEVnTW1wZ0R4a1FaV3pLR2plVTd6YmVYaENUNE02CmRBaGEvSDNyRFZZenlRSXhBUEVla1ZqV3VNZG5YL1lrcjN0ZTF4eEVCU0k1YXhIaGtXQ3hBbU11cEp1ZFpYeFMKbXpwR3JXL0hPV1VBbTZZeGdnPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=","rekorBundle":{"SignedEntryTimestamp":"MEYCIQCivCPZWrwQY5S7Q228dQVo3pWJ2utiLuntTl4WalKKnAIhAK3yp5RBlC1dzQhvlxXRr9WD9Cw0XN+M4K0JJQAtMRLN","Payload":{"body":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJlZTUzZDRiZTJiMjAyNzM5NDBjMzI5ZDRlYjI4ODg1ODExNzU0ZTAyYmJiNzBlZGExNDc0ZTgwMzA2ZTVmMzY0In19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FUUNJSFJHVE82Y2NmNWlNN2tyR05aU2RXMHlaQ1hmeTgxYnlzc0hBZkhNb3JwUUFpQkVlMmVjMXBJREduWWxYWGprSEdDQURBYStBVEVSbE5LYkkrVlZMRWVseXc9PSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVY3JWRU5EUW00MlowRjNTVUpCWjBsVlpETjRRMjF0T1dKcWNqZGlTMHhuYzFkelJ6bFlUMDlEYVdkamQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFpkMDlVUVRCTlZGa3hUMVJKZWxkb1kwNU5hbGwzVDFSQk1FMVVZM2RQVkVsNlYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZoU1ZaMlRrVjRTeTl3VGxkclJuSkdhbmR2WmpNd1JrVmxkV3B1VWt4bWVVaHBkbk1LZVRsVVdEaGlTVVZTYVRkUFkzVXhRaXRUU0ZWd2NUVm1TWGRaVVVWMFNGaDRjbkZhTVd4NFZrNU5WRkpuTURKS1ZqWlBRMEphTUhkbloxZGFUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZ0YW5ZMkNqZDRlbGRHVFRKNVEwMWxkWHBXUVdJNFUzaFplVWd3ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDJGbldVUldVakJTUVZGSUwwSkhRWGRZYjFwallVaFNNR05JVFRaTWVUbHVZVmhTYjJSWFNYVlpNamwwVERCT2RtSnRVakZaTTFKMlkyczVkUXBhVXpsdVlWaFNiMlJYU1hSa01qbDVZVEphYzJJelpIcE1lVFZ1WVZoU2IyUlhTWFprTWpsNVlUSmFjMkl6WkhwTU0wcHNZa2RXYUdNeVZYVmxWMFowQ21KRlFubGFWMXA2VEROU2FGb3pUWFprYWxGM1QxRlpTMHQzV1VKQ1FVZEVkbnBCUWtGUlVYSmhTRkl3WTBoTk5reDVPVEJpTW5Sc1ltazFhRmt6VW5BS1lqSTFla3h0WkhCa1IyZ3hXVzVXZWxwWVNtcGlNalV3V2xjMU1FeHRUblppVkVGVFFtZHZja0puUlVWQldVOHZUVUZGUTBKQlVuZGtXRTV2VFVSWlJ3cERhWE5IUVZGUlFtYzNPSGRCVVUxRlMwZFZkMDlVYXpCYVYwcG9XVEpaTkZsVVJYbFBSRnBwVFhwYWFrNUVRWGRPVkZGM1dWUkJlazVIVG1sT1IxcG9DazVYVVRCYVZHdDNSbEZaUzB0M1dVSkNRVWRFZG5wQlFrSkJVVWhWYlZaeldsZEdlbHBVUVdWQ1oyOXlRbWRGUlVGWlR5OU5RVVZHUWtKQ1JHSXlOV3NLWkZkT01HSXpTbEJpYlZWMldYcEdjRTFDTkVkRGFYTkhRVkZSUW1jM09IZEJVVmxGUlVoS2JGcHVUWFprUjBadVkzazVNazFETkROTWFrRjNUM2RaU3dwTGQxbENRa0ZIUkhaNlFVSkRRVkYwUkVOMGIyUklVbmRqZW05MlRETlNkbUV5Vm5WTWJVWnFaRWRzZG1KdVRYVmFNbXd3WVVoV2FXUllUbXhqYlU1MkNtSnVVbXhpYmxGMVdUSTVkRTFIZDBkRGFYTkhRVkZSUW1jM09IZEJVV3RGV0dkNFkyRklVakJqU0UwMlRIazVibUZZVW05a1YwbDFXVEk1ZEV3d1RuWUtZbTFTTVZrelVuWmphemwxV2xNNWJtRllVbTlrVjBsMFpESTVlV0V5V25OaU0yUjZUSGsxYm1GWVVtOWtWMGwyWkRJNWVXRXlXbk5pTTJSNlRETktiQXBpUjFab1l6SlZkV1ZYUm5SaVJVSjVXbGRhZWt3elVtaGFNMDEyWkdwUmQwOUJXVXRMZDFsQ1FrRkhSSFo2UVVKRFoxRnhSRU5uTWs1SFNtMU5WMDE1Q2xscVNtcE5iVTV0V1cxR2FWbDZSbWhhUkZGNVRXcGFhMXBIVFhsTmJWSm9XV3BaTWs1VVRUVk9WMDVzVFVJd1IwTnBjMGRCVVZGQ1p6YzRkMEZSYzBVS1JIZDNUbG95YkRCaFNGWnBURmRvZG1NelVteGFSRUY2UW1kdmNrSm5SVVZCV1U4dlRVRkZUVUpEVlUxSk1tZ3daRWhDZWs5cE9IWmFNbXd3WVVoV2FRcE1iVTUyWWxNNVJHSXlOV3RrVjA0d1lqTktVR0p0VlhaWmVrWndUVVJuUjBOcGMwZEJVVkZDWnpjNGQwRlJNRVZMWjNkdldsUkJOVTlVVW14WmJVWnFDbHBxYUdoTlZFazBUbTFKZWs1dFRUQk5SRUV4VGtSQ2FFMUVUVEJaTWtrd1dtMUZNVnBFVW14UFZFRm5RbWR2Y2tKblJVVkJXVTh2VFVGRlQwSkNTVTBLUlVoS2JGcHVUWFprUjBadVkzazVNazFETkROTWFrRjNSMmRaUzB0M1dVSkNRVWRFZG5wQlFrUjNVVTFFUVc5NFRWUm5NVTVxWnpGTmFsa3hUVU00UndwRGFYTkhRVkZSUW1jM09IZEJVa0ZGU1ZGM1ptRklVakJqU0UwMlRIazVibUZZVW05a1YwbDFXVEk1ZEV3d1RuWmliVkl4V1ROU2RtTnJPWFZhVkVGWkNrSm5iM0pDWjBWRlFWbFBMMDFCUlZKQ1FXOU5RMFJqZUU1NlRURk9SR2MwVFVkTlIwTnBjMGRCVVZGQ1p6YzRkMEZTU1VWV1VYaFVZVWhTTUdOSVRUWUtUSGs1Ym1GWVVtOWtWMGwxV1RJNWRFd3dUblppYlZJeFdUTlNkbU5yT1hWYVV6bHFUVmRyZGt4dFpIQmtSMmd4V1drNU0ySXpTbkphYlhoMlpETk5kZ3BqYlZaeldsZEdlbHBUTlRWWlZ6RnpVVWhLYkZwdVRYWmtSMFp1WTNrNU1rMURORE5NYWtGM1QwRlpTMHQzV1VKQ1FVZEVkbnBCUWtWM1VYRkVRMmhzQ2sxRWF6Vk9SMVpwV1ZkT2JVOUhSWGhOYW1jeVdXcE5NbGw2VVhkTlJGVXdUVWRGZDAxNlVtcFphbEp0V1ZSV2EwNUhWVFZOUWxGSFEybHpSMEZSVVVJS1p6YzRkMEZTVVVWQ1ozZEZZMGhXZW1GRVFsaENaMjl5UW1kRlJVRlpUeTlOUVVWV1FrVnJUVkl5YURCa1NFSjZUMms0ZGxveWJEQmhTRlpwVEcxT2RncGlVemxFWWpJMWEyUlhUakJpTTBwUVltMVZkbGw2Um5CTU1rWnFaRWRzZG1KdVRYWmpibFoxWTNrNGVrMTZaelZPZWxsNVRtcGpORTVET1doa1NGSnNDbUpZUWpCamVUaDRUVUpaUjBOcGMwZEJVVkZDWnpjNGQwRlNXVVZEUVhkSFkwaFdhV0pIYkdwTlJHOUhRMmx6UjBGUlVVSm5OemgzUVZKblJVeEJkM0VLWTIxV2QySjZjRVJpTWpWclpGZE9NR0l6U2xCaWJWVjJXWHBHY0U5dVNteGFhbkI1V2xkYWVrd3pVbWhhTTAxMlpHcEJkVTU1TkhkTlNVZEtRbWR2Y2dwQ1owVkZRV1JhTlVGblVVTkNTSE5GWlZGQ00wRklWVUV6VkRCM1lYTmlTRVZVU21wSFVqUmpiVmRqTTBGeFNrdFljbXBsVUVzekwyZzBjSGxuUXpod0NqZHZORUZCUVVkbllsWjRWM1JSUVVGQ1FVMUJVbXBDUlVGcFFXRm5UR1ZMYmpkRWVVSTRaekp3ZDBwSU5HNHZkakUwYTJkTVNEWkNPVlUyUVdkWlYxY0tNV2t4VFdGM1NXZGtkMDFrVmxjeFVEZExTR29yU0RKM1NVaElLekk1TVZGR09HaFpNbEJzZWxKYWMyOHJWalp3TkVkemQwTm5XVWxMYjFwSmVtb3dSUXBCZDAxRVlWRkJkMXBuU1hoQlRYSnNiRVJKZG1oV1N6RTNVV2RqYTNSaFEzZzJlRVZuVFcxd1owUjRhMUZhVjNwTFIycGxWVGQ2WW1WWWFFTlVORTAyQ21SQmFHRXZTRE55UkZaWmVubFJTWGhCVUVWbGExWnFWM1ZOWkc1WUwxbHJjak4wWlRGNGVFVkNVMGsxWVhoSWFHdFhRM2hCYlUxMWNFcDFaRnBZZUZNS2JYcHdSM0pYTDBoUFYxVkJiVFpaZUdkblBUMEtMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifX19fQ==","integratedTime":1788541167,"logIndex":2713875827,"logID":"c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"}}} \ No newline at end of file diff --git a/internal/selfupdate/verify.go b/internal/selfupdate/verify.go new file mode 100644 index 0000000..291a2f7 --- /dev/null +++ b/internal/selfupdate/verify.go @@ -0,0 +1,176 @@ +package selfupdate + +import ( + "bytes" + "context" + "crypto" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io" + "net/http" + "time" + + "github.com/ConductorOne/c1i/internal/transport" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/sigstore/sigstore-go/pkg/verify" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/theupdateframework/go-tuf/v2/metadata" + tuffetcher "github.com/theupdateframework/go-tuf/v2/metadata/fetcher" +) + +// The release manifest is signed keylessly by ConductorOne's reusable release +// workflow via Fulcio. These pin the exact identity that signature must carry: +// a wrong or absent pin makes the whole check worthless. +const ( + // releaseSANURI is the Subject Alternative Name (a URI) Fulcio stamps with + // the signing workflow's identity: the reusable workflow at the v4 tag. + releaseSANURI = "https://github.com/ConductorOne/github-workflows/.github/workflows/release.yaml@refs/tags/v4" + // releaseOIDCIssuer is the OIDC issuer that minted the workflow's identity + // token (GitHub Actions). + releaseOIDCIssuer = "https://token.actions.githubusercontent.com" + // releaseSourceRepositoryURI binds the shared release workflow to c1i. + releaseSourceRepositoryURI = "https://github.com/ConductorOne/c1i" + + trustRootTimeout = 30 * time.Second +) + +// Indirected so tests can pin different identities / a stub trust root without +// the network. Nothing in production writes to them. +var ( + pinnedSANURI = releaseSANURI + pinnedOIDCIssuer = releaseOIDCIssuer + pinnedSourceRepositoryURI = releaseSourceRepositoryURI + fetchTrustedRoot = fetchTrustedRootWithContext +) + +// VerifyManifest verifies that manifestBytes carries a valid Sigstore signature +// from ConductorOne's pinned release-workflow identity (keyless / Fulcio). dist +// serves the signature and certificate base64-encoded: sigBase64 decodes to the +// raw signature bytes, certBase64 decodes to the signing certificate in PEM. +func VerifyManifest(ctx context.Context, manifestBytes, sigBase64, certBase64, rekorBundle []byte) error { + return verifyManifest(ctx, manifestBytes, sigBase64, certBase64, rekorBundle, pinnedSANURI, pinnedOIDCIssuer, pinnedSourceRepositoryURI) +} + +// verifyManifest is the identity-parameterized core so a test can drive a +// deliberately wrong pin. Every check below is mandatory: the function returns +// nil only if the certificate identity matches the pin, the signature is valid +// over exactly manifestBytes, the certificate chains to a Fulcio root, and the +// signed Rekor entry proves the signature existed while the certificate was valid. +func verifyManifest(ctx context.Context, manifestBytes, sigBase64, certBase64, rekorBundle []byte, sanURI, issuer, sourceRepositoryURI string) error { + if err := ctx.Err(); err != nil { + return err + } + + sig, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(sigBase64))) + if err != nil { + return fmt.Errorf("manifest signature verification failed: decoding signature: %w", err) + } + certPEM, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(certBase64))) + if err != nil { + return fmt.Errorf("manifest signature verification failed: decoding certificate: %w", err) + } + block, _ := pem.Decode(certPEM) + if block == nil || block.Type != "CERTIFICATE" { + return fmt.Errorf("manifest signature verification failed: certificate is not PEM-encoded") + } + leaf, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("manifest signature verification failed: parsing certificate: %w", err) + } + + // Identity pin (local): the certificate must name the exact release + // workflow and OIDC issuer. + sanMatcher, err := verify.NewSANMatcher(sanURI, "") + if err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + issuerMatcher, err := verify.NewIssuerMatcher(issuer, "") + if err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + certID, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, certificate.Extensions{SourceRepositoryURI: sourceRepositoryURI}) + if err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + summary, err := certificate.SummarizeCertificate(leaf) + if err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + if err := certID.Verify(summary); err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + + // Signature (local): the certificate's key must sign exactly these bytes. + sv, err := signature.LoadVerifier(leaf.PublicKey, crypto.SHA256) + if err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + if err := sv.VerifySignature(bytes.NewReader(sig), bytes.NewReader(manifestBytes)); err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + + // Trust root + signed Rekor entry: the entry binds this manifest signature + // and certificate to a trusted transparency-log timestamp. + trustedRoot, err := fetchTrustedRoot(ctx) + if err != nil { + return fmt.Errorf("could not load Sigstore trust root: %w", err) + } + integratedTime, err := verifyRekorBundle(manifestBytes, sig, leaf, rekorBundle, trustedRoot) + if err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + chains, err := verify.VerifyLeafCertificate(integratedTime, leaf, trustedRoot) + if err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + if err := verify.VerifySignedCertificateTimestamp(chains, 1, trustedRoot); err != nil { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + return nil +} + +func fetchTrustedRootWithContext(ctx context.Context) (root.TrustedMaterial, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + opts := tuf.DefaultOptions(). + WithContext(ctx). + WithFetcher(trustRootFetcher{ctx: ctx}) + return root.FetchTrustedRootWithOptions(opts) +} + +type trustRootFetcher struct { + ctx context.Context +} + +var _ tuffetcher.Fetcher = trustRootFetcher{} + +func (f trustRootFetcher) DownloadFile(url string, maxLength int64, _ time.Duration) ([]byte, error) { + req, err := http.NewRequestWithContext(f.ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := transport.NewSingleAttemptHTTPClient(trustRootTimeout).Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, &metadata.ErrDownloadHTTP{StatusCode: resp.StatusCode, URL: url} + } + if length := resp.ContentLength; length > maxLength { + return nil, &metadata.ErrDownloadLengthMismatch{Msg: fmt.Sprintf("download failed for %s, length %d is larger than expected %d", url, length, maxLength)} + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxLength+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > maxLength { + return nil, &metadata.ErrDownloadLengthMismatch{Msg: fmt.Sprintf("download failed for %s: response exceeds %d bytes", url, maxLength)} + } + return body, nil +} diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go new file mode 100644 index 0000000..ab45e0b --- /dev/null +++ b/internal/selfupdate/verify_test.go @@ -0,0 +1,141 @@ +package selfupdate + +import ( + "context" + _ "embed" + "encoding/base64" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +// The real v0.7.0 release manifest and its detached Sigstore signature + +// certificate. These are public release artifacts (no secrets), used to drive +// the offline failure paths: identity and signature are checked locally before +// any trust-root fetch, so a wrong pin or tampered bytes fail without network. +var ( + //go:embed testdata/manifest.json + realManifest []byte + //go:embed testdata/manifest.json.sig + realSigB64 []byte + //go:embed testdata/manifest.json.cert + realCertB64 []byte + //go:embed testdata/manifest.json.sigstore.json + realRekorBundle []byte +) + +func TestVerifyManifestBadBase64(t *testing.T) { + err := VerifyManifest(context.Background(), realManifest, []byte("!!!not base64!!!"), realCertB64, realRekorBundle) + if err == nil { + t.Fatal("expected an error for a non-base64 signature") + } + if !strings.Contains(err.Error(), "signature verification failed") { + t.Errorf("error = %v", err) + } +} + +func TestVerifyManifestNonPEMCert(t *testing.T) { + notPEM := base64.StdEncoding.EncodeToString([]byte("this is not a PEM certificate")) + err := VerifyManifest(context.Background(), realManifest, realSigB64, []byte(notPEM), realRekorBundle) + if err == nil { + t.Fatal("expected an error for a non-PEM certificate") + } + if !strings.Contains(err.Error(), "not PEM-encoded") { + t.Errorf("error = %v", err) + } +} + +func TestVerifyManifestTamperedBytes(t *testing.T) { + // Real cert + real signature, but altered manifest bytes: the signature no + // longer covers these bytes, so verification must fail. This runs offline — + // the signature check precedes the trust-root fetch. + tampered := append([]byte(nil), realManifest...) + tampered[0] ^= 0xff + err := VerifyManifest(context.Background(), tampered, realSigB64, realCertB64, realRekorBundle) + if err == nil { + t.Fatal("expected an error for tampered manifest bytes") + } + if !strings.Contains(err.Error(), "signature verification failed") { + t.Errorf("error = %v", err) + } +} + +func TestVerifyManifestIdentityMismatch(t *testing.T) { + // Verify the real signature against a deliberately wrong pinned SAN. The + // identity check runs before the trust-root fetch, so this is offline. + err := verifyManifest(context.Background(), realManifest, realSigB64, realCertB64, realRekorBundle, + "https://github.com/evilcorp/evil/.github/workflows/release.yaml@refs/tags/v4", + pinnedOIDCIssuer, pinnedSourceRepositoryURI) + if err == nil { + t.Fatal("expected an error when the pinned SAN does not match the certificate") + } + if !strings.Contains(err.Error(), "signature verification failed") { + t.Errorf("error = %v", err) + } + + // A wrong issuer must fail too. + err = verifyManifest(context.Background(), realManifest, realSigB64, realCertB64, realRekorBundle, + pinnedSANURI, "https://accounts.google.com", pinnedSourceRepositoryURI) + if err == nil { + t.Fatal("expected an error when the pinned issuer does not match the certificate") + } + + err = verifyManifest(context.Background(), realManifest, realSigB64, realCertB64, realRekorBundle, + pinnedSANURI, pinnedOIDCIssuer, "https://github.com/ConductorOne/other") + if err == nil { + t.Fatal("expected an error when the source repository does not match the certificate") + } +} + +func TestTrustRootFetcherHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := (trustRootFetcher{ctx: ctx}).DownloadFile("https://tuf-repo-cdn.sigstore.dev/root.json", 1024, 0) + if !errors.Is(err, context.Canceled) { + t.Fatalf("DownloadFile error = %v, want context.Canceled", err) + } +} + +// TestVerifyManifestReal is the live integration test: it fetches the REAL +// v0.7.0 manifest, signature and certificate from dist and verifies them +// against the production pinned identity. It must PASS when online; it skips +// cleanly under -short or when the network is unavailable. +func TestVerifyManifestReal(t *testing.T) { + if testing.Short() { + t.Skip("skipping network integration test in -short mode") + } + const base = "https://dist.conductorone.com/releases/ConductorOne/c1i/v0.7.0" + man := fetchOrSkip(t, base+"/manifest.json") + sig := fetchOrSkip(t, base+"/manifest.json.sig") + cert := fetchOrSkip(t, base+"/manifest.json.cert") + rekorBundle := fetchOrSkip(t, base+"/manifest.json.sigstore.json") + + if err := VerifyManifest(context.Background(), man, sig, cert, rekorBundle); err != nil { + t.Fatalf("VerifyManifest on the real v0.7.0 release failed: %v", err) + } +} + +func fetchOrSkip(t *testing.T, url string) []byte { + t.Helper() + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + t.Fatalf("building request: %v", err) + } + resp, err := client.Do(req) + if err != nil { + t.Skipf("skipping: cannot reach %s: %v", url, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Skipf("skipping: %s returned HTTP %d", url, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, MaxMetadataBytes)) + if err != nil { + t.Skipf("skipping: reading %s: %v", url, err) + } + return body +} diff --git a/internal/transport/hardening_test.go b/internal/transport/hardening_test.go new file mode 100644 index 0000000..4ed707a --- /dev/null +++ b/internal/transport/hardening_test.go @@ -0,0 +1,127 @@ +package transport + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// TestResolveAllowedRedirect_Scheme covers the scheme rules for a same-path, +// same-host redirect: an https->http downgrade is refused, while an +// http->https upgrade and a same-scheme hop are allowed. +func TestResolveAllowedRedirect_Scheme(t *testing.T) { + cases := []struct { + name string + base string + loc string + wantOK bool + }{ + {"https->http downgrade refused", "https://dist.example/x", "http://dist.example/x", false}, + {"http->https upgrade allowed", "http://dist.example/x", "https://dist.example/x", true}, + {"https->https same-path allowed", "https://dist.example/x", "https://dist.example/x?q=1", true}, + {"scheme-relative keeps https allowed", "https://dist.example/x", "//dist.example/x", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + base, err := url.Parse(tc.base) + if err != nil { + t.Fatal(err) + } + _, ok := resolveAllowedRedirect(base, tc.loc) + if ok != tc.wantOK { + t.Errorf("resolveAllowedRedirect(%q, %q) ok = %v, want %v", tc.base, tc.loc, ok, tc.wantOK) + } + }) + } +} + +// TestClient_RefusesSchemeDowngradeRedirect drives a real server that (over +// http, standing in for the request scheme) issues an absolute https->http +// same-path redirect; the client must refuse it as *RedirectError rather than +// follow a downgrade. The request scheme is rewritten to https by the same +// scheme-swap trick used elsewhere so the guard sees an https base. +func TestClient_RefusesSchemeDowngradeRedirect(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/x/abc" { + t.Fatalf("unexpected request to %s", r.URL.EscapedPath()) + } + // Point Location at an explicit http URL at the same host:path. From an + // https base that's a downgrade the guard must refuse. + u := *r.URL + u.Scheme = "http" + u.Host = r.Host + w.Header().Set("Location", u.String()) + w.WriteHeader(http.StatusMovedPermanently) + })) + defer srv.Close() + + // httpsForcingTransport makes the redirectTripper's base request look like + // https so an http Location is a downgrade; it dials the real (http) server. + hc := &http.Client{Transport: &redirectTripper{next: &httpsForcingTransport{next: http.DefaultTransport}}} + c := &Client{httpClient: hc, maxRetries: 0} + + req, err := http.NewRequest(http.MethodGet, strings.Replace(srv.URL, "http://", "https://", 1)+"/x/abc", nil) + if err != nil { + t.Fatal(err) + } + _, err = c.Do(req) + var redirErr *RedirectError + if !errors.As(err, &redirErr) { + t.Fatalf("error = %T (%v), want *RedirectError for an https->http downgrade", err, err) + } +} + +// httpsForcingTransport dials over plain http (the httptest server) while +// leaving the request URL's https scheme intact for the guard to inspect. +type httpsForcingTransport struct{ next http.RoundTripper } + +func (h *httpsForcingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + dialURL := *req.URL + dialURL.Scheme = "http" + clone := req.Clone(req.Context()) + clone.URL = &dialURL + return h.next.RoundTrip(clone) +} + +// TestWithMaxResponseBytes bounds the body read: a response larger than the cap +// fails, one at or under it succeeds. Default (unset) stays unbounded. +func TestWithMaxResponseBytes(t *testing.T) { + const limit = 16 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := 8 + if r.URL.Query().Get("big") != "" { + n = 1024 + } + _, _ = w.Write([]byte(strings.Repeat("a", n))) + })) + defer srv.Close() + + bounded := New(srv.Client().Transport, WithMaxRetries(0), WithMaxResponseBytes(limit)) + // Under the cap: OK. + resp, err := get(t, bounded, srv.URL+"/small") + if err != nil { + t.Fatalf("under-cap read errored: %v", err) + } + if len(resp.Body) != 8 { + t.Errorf("body len = %d, want 8", len(resp.Body)) + } + // Over the limit: error, no body. + if _, err := get(t, bounded, srv.URL+"/big?big=1"); err == nil { + t.Fatal("over-cap read did not error") + } + + // Exactly at the cap: OK. + atCap := New(srv.Client().Transport, WithMaxRetries(0), WithMaxResponseBytes(8)) + if _, err := get(t, atCap, srv.URL+"/small"); err != nil { + t.Fatalf("at-cap read errored: %v", err) + } + + // Unbounded default: a large body is fine. + unbounded := New(srv.Client().Transport, WithMaxRetries(0)) + if r, err := get(t, unbounded, srv.URL+"/big?big=1"); err != nil || len(r.Body) != 1024 { + t.Fatalf("unbounded read = (%d bytes, %v), want (1024, nil)", len(r.Body), err) + } +} diff --git a/internal/transport/redirect.go b/internal/transport/redirect.go index b0d399a..7480488 100644 --- a/internal/transport/redirect.go +++ b/internal/transport/redirect.go @@ -98,6 +98,12 @@ func resolveAllowedRedirect(base *url.URL, location string) (*url.URL, bool) { if target.EscapedPath() != base.EscapedPath() { return nil, false } + // Refuse a scheme downgrade: an https request must not be redirected to a + // plaintext http target, which would carry the caller's credentials over + // cleartext. An http->https upgrade (or a same-scheme hop) stays allowed. + if strings.EqualFold(base.Scheme, "https") && !strings.EqualFold(target.Scheme, "https") { + return nil, false + } if !hostInScope(base, target) { return nil, false } diff --git a/internal/transport/transport.go b/internal/transport/transport.go index a9907ba..c238abf 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -140,6 +140,7 @@ type Client struct { httpClient *http.Client maxRetries int nonRetryable func(error) bool + maxRespBytes int64 } type buildConfig struct { @@ -147,6 +148,7 @@ type buildConfig struct { debug bool timeout time.Duration nonRetryable func(error) bool + maxRespBytes int64 } // Option configures a Client at construction time. @@ -175,6 +177,18 @@ func WithTimeout(d time.Duration) Option { return func(c *buildConfig) { c.timeout = d } } +// WithMaxResponseBytes caps how many bytes Do reads from a response body. A +// body larger than n makes Do fail rather than buffer it, so a hostile or +// misconfigured endpoint can't OOM the process before the caller inspects the +// content. n <= 0 (the default when unset) leaves the read unbounded, so +// existing callers are unchanged. +func WithMaxResponseBytes(n int64) Option { + if n < 0 { + n = 0 + } + return func(c *buildConfig) { c.maxRespBytes = n } +} + // WithNonRetryable marks a transport-level error (one Do would otherwise // retry for an idempotent method) as fatal instead, regardless of method or // remaining budget. It exists for a caller layered on top of Client that @@ -215,6 +229,7 @@ func New(base http.RoundTripper, opts ...Option) *Client { httpClient: &http.Client{Transport: tripper, Timeout: cfg.timeout}, maxRetries: cfg.maxRetries, nonRetryable: cfg.nonRetryable, + maxRespBytes: cfg.maxRespBytes, } } @@ -279,7 +294,7 @@ func (c *Client) sendWithRetry(req *http.Request) (*Response, error) { return nil, err } - body, readErr := io.ReadAll(resp.Body) + body, readErr := c.readBody(resp.Body) _ = resp.Body.Close() if isRetryableStatus(req.Method, resp.StatusCode) && attempt < c.maxRetries { @@ -302,6 +317,23 @@ func (c *Client) sendWithRetry(req *http.Request) (*Response, error) { } } +// readBody reads a response body, enforcing maxRespBytes when it is set. It +// reads one byte past the cap via io.LimitReader so an exactly-at-cap body is +// accepted while a larger one is refused before the whole thing is buffered. +func (c *Client) readBody(r io.Reader) ([]byte, error) { + if c.maxRespBytes <= 0 { + return io.ReadAll(r) + } + body, err := io.ReadAll(io.LimitReader(r, c.maxRespBytes+1)) + if err != nil { + return body, err + } + if int64(len(body)) > c.maxRespBytes { + return nil, fmt.Errorf("response body exceeds %d bytes", c.maxRespBytes) + } + return body, nil +} + // isIdempotent reports whether re-sending method after an ambiguous failure is // safe. Per RFC 9110 these methods are idempotent; POST and PATCH are not and // so are never retried on 5xx / transport errors. @@ -395,3 +427,9 @@ func parseRetryAfter(v string) (time.Duration, bool) { } return 0, false } + +// NewSingleAttemptHTTPClient adapts callers that require net/http directly. +// Callers retain request contexts and own retries. +func NewSingleAttemptHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout} +}