diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 0000000..85a4605 --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,52 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "kfutil/pkg/upgrade" + "kfutil/pkg/version" + + "github.com/spf13/cobra" +) + +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade kfutil to the latest release", + Long: `Fetches a kfutil release from GitHub, verifies its SHA-256 checksum, +and atomically replaces the running binary. + +By default the latest published release is installed. Pass --version with any +valid GitHub tag (e.g. v1.9.0, v1.10.0-beta.1) to install a specific release, +including pre-releases and older versions. + +Examples: + kfutil upgrade # install latest + kfutil upgrade --version v1.8.0 # install a specific tag + kfutil upgrade --dry-run # preview without changing anything`, + RunE: func(cmd *cobra.Command, args []string) error { + targetVersion, _ := cmd.Flags().GetString("version") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + informDebug(debugFlag) + return upgrade.Run(version.VERSION, targetVersion, dryRun, force) + }, +} + +func init() { + upgradeCmd.Flags().String("version", "", "GitHub tag to install (default: latest release)") + upgradeCmd.Flags().Bool("dry-run", false, "Show what would be downloaded without replacing the binary") + upgradeCmd.Flags().Bool("force", false, "Upgrade even if already at the target version") + RootCmd.AddCommand(upgradeCmd) +} diff --git a/docs/kfutil.md b/docs/kfutil.md index a775de3..28a6fce 100644 --- a/docs/kfutil.md +++ b/docs/kfutil.md @@ -46,4 +46,5 @@ A CLI wrapper around the Keyfactor Platform API. * [kfutil status](kfutil_status.md) - List the status of Keyfactor services. * [kfutil store-types](kfutil_store-types.md) - Keyfactor certificate store types APIs and utilities. * [kfutil stores](kfutil_stores.md) - Keyfactor certificate stores APIs and utilities. +* [kfutil upgrade](kfutil_upgrade.md) - Upgrade kfutil to the latest release * [kfutil version](kfutil_version.md) - Shows version of kfutil diff --git a/docs/kfutil_upgrade.md b/docs/kfutil_upgrade.md new file mode 100644 index 0000000..e962275 --- /dev/null +++ b/docs/kfutil_upgrade.md @@ -0,0 +1,57 @@ +## kfutil upgrade + +Upgrade kfutil to the latest release + +### Synopsis + +Fetches a kfutil release from GitHub, verifies its SHA-256 checksum, +and atomically replaces the running binary. + +By default the latest published release is installed. Pass --version with any +valid GitHub tag (e.g. v1.9.0, v1.10.0-beta.1) to install a specific release, +including pre-releases and older versions. + +Examples: + kfutil upgrade # install latest + kfutil upgrade --version v1.8.0 # install a specific tag + kfutil upgrade --dry-run # preview without changing anything + +``` +kfutil upgrade [flags] +``` + +### Options + +``` + --dry-run Show what would be downloaded without replacing the binary + --force Upgrade even if already at the target version + -h, --help help for upgrade + --version string GitHub tag to install (default: latest release) +``` + +### Options inherited from parent commands + +``` + --api-path string API Path to use for authenticating to Keyfactor Command. (default is KeyfactorAPI) (default "KeyfactorAPI") + --auth-provider-profile string The profile to use defined in the securely stored config. If not specified the config named 'default' will be used if it exists. (default "default") + --auth-provider-type string Provider type choices: (azid) + --client-id string OAuth2 client-id to use for authenticating to Keyfactor Command. + --client-secret string OAuth2 client-secret to use for authenticating to Keyfactor Command. + --config string Full path to config file in JSON format. (default is $HOME/.keyfactor/command_config.json) + --debug Enable debugFlag logging. + --domain string Domain to use for authenticating to Keyfactor Command. + --exp Enable expEnabled features. (USE AT YOUR OWN RISK, these features are not supported and may change or be removed at any time.) + --format text How to format the CLI output. Currently only text is supported. (default "text") + --hostname string Hostname to use for authenticating to Keyfactor Command. + --no-prompt Do not prompt for any user input and assume defaults or environmental variables are set. + --offline Will not attempt to connect to GitHub for latest release information and resources. + --password string Password to use for authenticating to Keyfactor Command. WARNING: Remember to delete your console history if providing kfcPassword here in plain text. + --profile string Use a specific profile from your config file. If not specified the config named 'default' will be used if it exists. + --skip-tls-verify Disable TLS verification for API requests to Keyfactor Command. + --token-url string OAuth2 token endpoint full URL to use for authenticating to Keyfactor Command. + --username string Username to use for authenticating to Keyfactor Command. +``` + +### SEE ALSO + +* [kfutil](kfutil.md) - Keyfactor CLI utilities diff --git a/go.mod b/go.mod index 51a3e5f..6c75862 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 github.com/joho/godotenv v1.5.1 + github.com/minio/selfupdate v0.6.0 github.com/rs/zerolog v1.34.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -27,6 +28,7 @@ require ( ) require ( + aead.dev/minisign v0.2.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect diff --git a/go.sum b/go.sum index b7adc5d..5cbac31 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= @@ -83,6 +85,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -115,12 +119,16 @@ go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNH 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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= @@ -130,9 +138,12 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -145,12 +156,14 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= diff --git a/pkg/upgrade/upgrade.go b/pkg/upgrade/upgrade.go new file mode 100644 index 0000000..f211357 --- /dev/null +++ b/pkg/upgrade/upgrade.go @@ -0,0 +1,631 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package upgrade + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/user" + "runtime" + "strings" + "time" + + "github.com/minio/selfupdate" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +const ( + releasesURL = "https://api.github.com/repos/Keyfactor/kfutil/releases" + binaryName = "kfutil" + + // maxBinaryBytes caps the extracted binary size to prevent OOM on malformed archives. + maxBinaryBytes = 100 * 1024 * 1024 // 100 MiB +) + +// apiClient is used for GitHub API metadata calls (short, bounded latency). +var apiClient = &http.Client{Timeout: 30 * time.Second} + +// downloadClient is used for binary asset downloads (larger payloads). +var downloadClient = &http.Client{Timeout: 5 * time.Minute} + +// allowedTokenHosts are the only hosts to which GITHUB_TOKEN may be forwarded. +var allowedTokenHosts = map[string]bool{ + "api.github.com": true, + "github.com": true, + "objects.githubusercontent.com": true, +} + +// GitHubRelease is the minimal shape we need from the GitHub releases API. +type GitHubRelease struct { + TagName string `json:"tag_name"` + Assets []GitHubAsset `json:"assets"` +} + +type GitHubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// resolveOperator returns the current OS user name for audit log fields. +func resolveOperator() string { + u, err := user.Current() + if err != nil { + log.Warn().Err(err). + Str("event", "upgrade.operator_resolution_failed"). + Msg("could not resolve OS user identity — audit logs will use 'unknown'") + return "unknown" + } + return u.Username +} + +// normalizeTag returns the tag as-is, or "latest" when the tag is empty, +// so log fields are unambiguous when no --version flag was passed. +func normalizeTag(tag string) string { + if tag == "" { + return "latest" + } + return tag +} + +// sanitizeURL strips query-string parameters before a URL is written to a log +// field or console output. This prevents presigned CDN URLs (which carry +// time-limited credentials in their query strings) from appearing in logs. +func sanitizeURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() +} + +// currentExecutable resolves the path to the running binary for audit log fields. +func currentExecutable(operator string) string { + exe, err := os.Executable() + if err != nil { + log.Warn().Err(err). + Str("event", "upgrade.executable_resolution_failed"). + Str("operator", operator). + Msg("could not resolve executable path — audit logs will record 'kfutil' as executable") + return "kfutil" + } + return exe +} + +// Run fetches the target release, verifies the checksum, and replaces the +// running binary. targetVersion may be any valid GitHub tag (e.g. "v1.9.0") +// or empty to use the latest release. +func Run(currentVersion, targetVersion string, dryRun, force bool) error { + operator := resolveOperator() + + log.Info(). + Str("event", "upgrade.run_started"). + Str("operator", operator). + Str("current_version", currentVersion). + Str("target_version", normalizeTag(targetVersion)). + Bool("force", force). + Bool("dry_run", dryRun). + Msg("upgrade run initiated") + + release, err := fetchRelease(targetVersion, operator) + if err != nil { + log.Error().Err(err). + Str("event", "upgrade.fetch_release_failed"). + Str("operator", operator). + Str("tag", normalizeTag(targetVersion)). + Msg("failed to fetch release metadata") + return err + } + + // Strip leading "v" for comparison with version.VERSION which has no prefix. + releaseVer := strings.TrimPrefix(release.TagName, "v") + currentVer := strings.TrimPrefix(currentVersion, "v") + + fmt.Printf("Current version : %s\n", currentVer) + fmt.Printf("Target version : %s\n", releaseVer) + + if !force && targetVersion == "" && releaseVer == currentVer { + log.Info(). + Str("event", "upgrade.already_current"). + Str("operator", operator). + Str("version", currentVer). + Msg("binary is already at the latest version — no action taken") + fmt.Println("Already at the latest version.") + return nil + } + + // Log whenever --force is set so the flag is always traceable in the audit record. + if force { + log.Warn(). + Str("event", "upgrade.force_override"). + Str("operator", operator). + Str("current_version", currentVer). + Str("target_version", releaseVer). + Msg("--force flag set: safety checks may be bypassed") + } + + assetName := archiveAssetName(release.TagName) + sumsName := fmt.Sprintf("kfutil_%s_SHA256SUMS", strings.TrimPrefix(release.TagName, "v")) + + archiveURL, sumsURL := "", "" + for _, a := range release.Assets { + switch a.Name { + case assetName: + archiveURL = a.BrowserDownloadURL + case sumsName: + sumsURL = a.BrowserDownloadURL + } + } + + if archiveURL == "" { + log.Error(). + Str("event", "upgrade.asset_not_found"). + Str("operator", operator). + Str("tag", release.TagName). + Str("asset_name", assetName). + Str("os", runtime.GOOS). + Str("arch", runtime.GOARCH). + Msg("no matching release asset found for current platform") + return fmt.Errorf("no release asset found for %s/%s (looked for %q)\navailable assets:\n%s", + runtime.GOOS, runtime.GOARCH, assetName, listAssets(release.Assets)) + } + + if sumsURL == "" { + log.Error(). + Str("event", "upgrade.sums_missing"). + Str("operator", operator). + Str("tag", release.TagName). + Msg("release has no SHA256SUMS asset — upgrade aborted") + return fmt.Errorf("release %s has no SHA256SUMS asset — upgrade aborted", release.TagName) + } + + exe := currentExecutable(operator) + + if dryRun { + log.Info(). + Str("event", "upgrade.dry_run"). + Str("operator", operator). + Str("from_version", currentVer). + Str("to_version", releaseVer). + Str("executable", exe). + Str("source_url", sanitizeURL(archiveURL)). + Msg("dry-run: no changes applied") + fmt.Printf("\n[dry-run] Would download : %s\n", sanitizeURL(archiveURL)) + fmt.Printf("[dry-run] Would verify : %s\n", sanitizeURL(sumsURL)) + fmt.Printf("[dry-run] Would replace : %s\n", exe) + return nil + } + + fmt.Printf("Downloading %s ...\n", assetName) + archiveData, err := download(archiveURL, operator) + if err != nil { + log.Error().Err(err). + Str("event", "upgrade.download_failed"). + Str("operator", operator). + Str("source_url", sanitizeURL(archiveURL)). + Msg("archive download failed") + return fmt.Errorf("download failed: %w", err) + } + + binary, err := extractBinary(archiveData, binaryName) + if err != nil { + log.Error().Err(err). + Str("event", "upgrade.extract_failed"). + Str("operator", operator). + Str("source_url", sanitizeURL(archiveURL)). + Msg("binary extraction from archive failed") + return fmt.Errorf("extract failed: %w", err) + } + + fmt.Println("Verifying checksum ...") + sumsData, err := download(sumsURL, operator) + if err != nil { + log.Error().Err(err). + Str("event", "upgrade.checksum_download_failed"). + Str("operator", operator). + Str("sums_url", sanitizeURL(sumsURL)). + Msg("SHA256SUMS download failed") + return fmt.Errorf("checksum download failed: %w", err) + } + // Verify the hash of the zip archive, not the extracted binary — + // goreleaser's SHA256SUMS records hashes of the zip archives. + if err := verifyChecksum(archiveData, assetName, sumsData); err != nil { + log.Error().Err(err). + Str("event", "upgrade.checksum_mismatch"). + Str("asset", assetName). + Str("operator", operator). + Str("source_url", sanitizeURL(archiveURL)). + Msg("checksum verification failed") + return err + } + log.Info(). + Str("event", "upgrade.checksum_verified"). + Str("operator", operator). + Str("asset", assetName). + Str("source_url", sanitizeURL(archiveURL)). + Msg("SHA-256 checksum verified successfully") + fmt.Println("Checksum OK.") + + log.Info(). + Str("event", "upgrade.applying"). + Str("from_version", currentVer). + Str("to_version", releaseVer). + Str("executable", exe). + Str("operator", operator). + Str("source_url", sanitizeURL(archiveURL)). + Bool("force", force). + Msg("applying binary replacement") + + fmt.Println("Applying update ...") + if err := apply(bytes.NewReader(binary), operator, currentVer, releaseVer, exe, sanitizeURL(archiveURL)); err != nil { + failureReason := "apply_error" + if os.IsPermission(err) { + failureReason = "permission_denied" + } + log.Error().Err(err). + Str("event", "upgrade.apply_failed"). + Str("from_version", currentVer). + Str("to_version", releaseVer). + Str("executable", exe). + Str("operator", operator). + Str("source_url", sanitizeURL(archiveURL)). + Str("failure_reason", failureReason). + Msg("binary replacement failed") + return err + } + + log.Info(). + Str("event", "upgrade.applied"). + Str("from_version", currentVer). + Str("to_version", releaseVer). + Str("executable", exe). + Str("operator", operator). + Str("source_url", sanitizeURL(archiveURL)). + Msg("binary replacement complete") + + fmt.Printf("Upgraded to %s.\n", release.TagName) + return nil +} + +// fetchRelease returns the latest release or a specific tagged release. +func fetchRelease(tag, operator string) (*GitHubRelease, error) { + return fetchReleaseFrom(releasesURL, tag, operator) +} + +// fetchReleaseFrom is the testable core of fetchRelease; baseURL replaces releasesURL. +func fetchReleaseFrom(baseURL, tag, operator string) (*GitHubRelease, error) { + var reqURL string + if tag == "" { + reqURL = baseURL + "/latest" + } else { + // URL-encode the tag so path-separator or query characters in user + // input cannot alter the request URL structure. + reqURL = baseURL + "/tags/" + url.PathEscape(tag) + } + + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + log.Warn().Err(err). + Str("event", "upgrade.github_api_request_build_failed"). + Str("operator", operator). + Str("url", sanitizeURL(reqURL)). + Msg("failed to construct GitHub API request") + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + tokenPresent := os.Getenv("GITHUB_TOKEN") != "" + tokenForwarded := false + if tokenPresent { + // GitHub API is always a trusted host — token is always forwarded when present. + tokenForwarded = true + req.Header.Set("Authorization", "Bearer "+os.Getenv("GITHUB_TOKEN")) + } + + start := time.Now() + resp, err := apiClient.Do(req) + if err != nil { + log.Error().Err(err). + Str("event", "upgrade.github_api_network_error"). + Str("operator", operator). + Str("url", sanitizeURL(reqURL)). + Str("method", http.MethodGet). + Int64("latency_ms", time.Since(start).Milliseconds()). + Bool("github_token_present", tokenPresent). + Bool("github_token_forwarded", tokenForwarded). + Msg("GitHub API network request failed") + return nil, fmt.Errorf("GitHub API request failed: %w", err) + } + defer resp.Body.Close() + + // Two events fire for non-200 responses: github_api_response (Warn) captures + // transport telemetry (latency, headers); github_api_rejected (Error) captures + // the control decision. Both are intentional — removing the Warn event would + // drop latency_ms from the audit trail. + var ev *zerolog.Event + if resp.StatusCode >= 400 { + ev = log.Warn() + } else { + ev = log.Info() + } + ev.Str("event", "upgrade.github_api_response"). + Str("url", sanitizeURL(reqURL)). + Str("method", http.MethodGet). + Int("status_code", resp.StatusCode). + Int64("latency_ms", time.Since(start).Milliseconds()). + Bool("github_token_present", tokenPresent). + Bool("github_token_forwarded", tokenForwarded). + Str("operator", operator). + Msg("GitHub API response received") + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + var errMsg string + if tag != "" { + errMsg = fmt.Sprintf("release tag %q not found", tag) + } else { + errMsg = "no releases found for this repository" + } + log.Error(). + Str("event", "upgrade.github_api_rejected"). + Str("operator", operator). + Int("status_code", resp.StatusCode). + Str("url", sanitizeURL(reqURL)). + Msg("GitHub API rejected the upgrade request") + return nil, fmt.Errorf("%s", errMsg) + case http.StatusForbidden, http.StatusTooManyRequests: + log.Error(). + Str("event", "upgrade.github_api_rejected"). + Str("operator", operator). + Int("status_code", resp.StatusCode). + Str("url", sanitizeURL(reqURL)). + Msg("GitHub API rejected the upgrade request") + return nil, fmt.Errorf("GitHub API rate limited (HTTP %d) — set GITHUB_TOKEN to increase limits", resp.StatusCode) + default: + log.Error(). + Str("event", "upgrade.github_api_rejected"). + Str("operator", operator). + Int("status_code", resp.StatusCode). + Str("url", sanitizeURL(reqURL)). + Msg("GitHub API rejected the upgrade request") + return nil, fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode) + } + + var rel GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + log.Error().Err(err). + Str("event", "upgrade.release_parse_failed"). + Str("operator", operator). + Str("url", sanitizeURL(reqURL)). + Int("status_code", resp.StatusCode). + Msg("failed to parse GitHub release response body") + return nil, fmt.Errorf("failed to parse release response: %w", err) + } + return &rel, nil +} + +// archiveAssetName returns the goreleaser archive name for the current platform. +// Pattern: kfutil___.zip (version without leading "v") +func archiveAssetName(tag string) string { + ver := strings.TrimPrefix(tag, "v") + goos := runtime.GOOS + goarch := runtime.GOARCH + return fmt.Sprintf("kfutil_%s_%s_%s.zip", ver, goos, goarch) +} + +// download fetches a URL and returns the body bytes. GITHUB_TOKEN is only +// forwarded to hosts in allowedTokenHosts to prevent token exfiltration via +// a tampered BrowserDownloadURL. +func download(rawURL, operator string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + log.Warn().Err(err). + Str("event", "upgrade.http_request_build_failed"). + Str("operator", operator). + Str("url", sanitizeURL(rawURL)). + Msg("failed to construct HTTP request") + return nil, err + } + + tok := os.Getenv("GITHUB_TOKEN") + tokenPresent := tok != "" + tokenForwarded := false + if tokenPresent { + if parsed, err := url.Parse(rawURL); err == nil && allowedTokenHosts[parsed.Hostname()] { + tokenForwarded = true + req.Header.Set("Authorization", "Bearer "+tok) + } + } + + start := time.Now() + resp, err := downloadClient.Do(req) + if err != nil { + log.Error().Err(err). + Str("event", "upgrade.http_network_error"). + Str("operator", operator). + Str("url", sanitizeURL(rawURL)). + Str("method", http.MethodGet). + Int64("latency_ms", time.Since(start).Milliseconds()). + Bool("github_token_present", tokenPresent). + Bool("github_token_forwarded", tokenForwarded). + Msg("HTTP network request failed") + return nil, err + } + defer resp.Body.Close() + + // Two events fire for non-200 responses: http_response (Warn) captures + // transport telemetry (latency, headers); http_request_failed (Error) captures + // the control decision. Both are intentional — removing the Warn event would + // drop latency_ms from the audit trail. + var ev *zerolog.Event + if resp.StatusCode >= 400 { + ev = log.Warn() + } else { + ev = log.Info() + } + ev.Str("event", "upgrade.http_response"). + Str("url", sanitizeURL(rawURL)). + Str("method", http.MethodGet). + Int("status_code", resp.StatusCode). + Int64("latency_ms", time.Since(start).Milliseconds()). + Bool("github_token_present", tokenPresent). + Bool("github_token_forwarded", tokenForwarded). + Str("operator", operator). + Msg("HTTP response received") + + if resp.StatusCode != http.StatusOK { + log.Error(). + Str("event", "upgrade.http_request_failed"). + Str("operator", operator). + Int("status_code", resp.StatusCode). + Str("url", sanitizeURL(rawURL)). + Msg("HTTP download request returned non-success status") + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, sanitizeURL(rawURL)) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBinaryBytes)) + if err != nil { + log.Error().Err(err). + Str("event", "upgrade.http_body_read_error"). + Str("operator", operator). + Str("url", sanitizeURL(rawURL)). + Msg("HTTP response body read failed after successful connection") + return nil, err + } + // LimitReader silently caps at maxBinaryBytes; detect and reject truncated payloads. + if int64(len(data)) >= maxBinaryBytes { + log.Error(). + Str("event", "upgrade.download_size_limit_reached"). + Str("operator", operator). + Str("url", sanitizeURL(rawURL)). + Int64("limit_bytes", maxBinaryBytes). + Msg("HTTP response body reached size cap — download rejected") + return nil, fmt.Errorf("response body from %s exceeded maximum allowed size (%d bytes)", sanitizeURL(rawURL), maxBinaryBytes) + } + return data, nil +} + +// extractBinary unpacks the binary named or .exe from a zip archive. +func extractBinary(zipData []byte, name string) ([]byte, error) { + r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + return nil, fmt.Errorf("invalid zip archive: %w", err) + } + + targets := []string{name, name + ".exe"} + for _, f := range r.File { + for _, t := range targets { + if f.Name == t { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + // LimitReader prevents OOM on malformed or zip-bomb archives. + data, err := io.ReadAll(io.LimitReader(rc, maxBinaryBytes)) + if err != nil { + return nil, err + } + if int64(len(data)) >= maxBinaryBytes { + return nil, fmt.Errorf("binary %q exceeds maximum allowed size (%d bytes)", name, maxBinaryBytes) + } + return data, nil + } + } + } + return nil, fmt.Errorf("binary %q not found in archive", name) +} + +// verifyChecksum checks the SHA-256 of archiveData against the goreleaser SUMS file. +// The SUMS file has lines: " " +// A missing entry is an error — the SUMS file must cover the target asset. +func verifyChecksum(archiveData []byte, assetName string, sumsData []byte) error { + h := sha256.Sum256(archiveData) + got := hex.EncodeToString(h[:]) + + for _, line := range strings.Split(string(sumsData), "\n") { + parts := strings.Fields(line) + if len(parts) == 2 && parts[1] == assetName { + if parts[0] != got { + return fmt.Errorf("checksum mismatch for %s\n expected: %s\n got: %s", assetName, parts[0], got) + } + return nil + } + } + return fmt.Errorf("no checksum entry found for %s in SHA256SUMS", assetName) +} + +// apply replaces the running binary using minio/selfupdate. +func apply(binary io.Reader, operator, fromVersion, toVersion, exe, sourceURL string) error { + log.Info(). + Str("event", "upgrade.apply_started"). + Str("operator", operator). + Str("from_version", fromVersion). + Str("to_version", toVersion). + Str("executable", exe). + Str("source_url", sourceURL). + Msg("binary write commencing via selfupdate") + err := selfupdate.Apply(binary, selfupdate.Options{}) + if err != nil { + applyErr := err // preserve original cause before RollbackError unwraps it + if rbErr := selfupdate.RollbackError(err); rbErr != nil { + log.Error().Err(rbErr). + Str("event", "upgrade.rollback_failed"). + Str("apply_error", applyErr.Error()). + Str("operator", operator). + Str("from_version", fromVersion). + Str("to_version", toVersion). + Str("executable", exe). + Str("source_url", sourceURL). + Msg("binary replacement failed and rollback also failed — binary may be corrupted") + return fmt.Errorf("upgrade failed and rollback also failed: %w", rbErr) + } + // Rollback succeeded — log at Warn so auditors can distinguish a + // recovery action from routine Info events. Include the original error + // so the root cause is in the structured record (SOC1 completeness). + log.Warn().Err(err). + Str("event", "upgrade.rollback_succeeded"). + Str("operator", operator). + Str("from_version", fromVersion). + Str("to_version", toVersion). + Str("executable", exe). + Str("source_url", sourceURL). + Msg("apply failed but binary was successfully rolled back to prior version") + if os.IsPermission(err) { + return fmt.Errorf("permission denied writing to %s\nTry re-running with elevated privileges (sudo kfutil upgrade)", exe) + } + return fmt.Errorf("upgrade failed: %w", err) + } + return nil +} + +func listAssets(assets []GitHubAsset) string { + var sb strings.Builder + for _, a := range assets { + sb.WriteString(" ") + sb.WriteString(a.Name) + sb.WriteString("\n") + } + return sb.String() +} diff --git a/pkg/upgrade/upgrade_test.go b/pkg/upgrade/upgrade_test.go new file mode 100644 index 0000000..edec1c7 --- /dev/null +++ b/pkg/upgrade/upgrade_test.go @@ -0,0 +1,325 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package upgrade + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captureLog redirects the global zerolog logger to a buffer for the duration +// of the test, then restores it. Returns the buffer so callers can assert on +// logged event names. +func captureLog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + orig := log.Logger + log.Logger = zerolog.New(&buf) + t.Cleanup(func() { log.Logger = orig }) + return &buf +} + +// ── archiveAssetName ────────────────────────────────────────────────────────── + +func TestArchiveAssetName(t *testing.T) { + name := archiveAssetName("v1.9.0") + expected := fmt.Sprintf("kfutil_1.9.0_%s_%s.zip", runtime.GOOS, runtime.GOARCH) + assert.Equal(t, expected, name) +} + +func TestArchiveAssetName_NoLeadingV(t *testing.T) { + assert.Equal(t, archiveAssetName("v1.2.3"), archiveAssetName("v1.2.3")) + // Both should strip the "v" + withV := archiveAssetName("v1.2.3") + assert.NotContains(t, withV, "_v1.") +} + +// ── verifyChecksum ──────────────────────────────────────────────────────────── + +func TestVerifyChecksum_Match(t *testing.T) { + data := []byte("fake binary content") + h := sha256.Sum256(data) + hashHex := hex.EncodeToString(h[:]) + sums := fmt.Sprintf("%s kfutil_1.9.0_linux_amd64.zip\n", hashHex) + + err := verifyChecksum(data, "kfutil_1.9.0_linux_amd64.zip", []byte(sums)) + require.NoError(t, err) +} + +func TestVerifyChecksum_Mismatch(t *testing.T) { + data := []byte("fake binary content") + sums := "badhash kfutil_1.9.0_linux_amd64.zip\n" + err := verifyChecksum(data, "kfutil_1.9.0_linux_amd64.zip", []byte(sums)) + require.Error(t, err) + assert.Contains(t, err.Error(), "checksum mismatch") +} + +func TestVerifyChecksum_AssetNotInSums(t *testing.T) { + // Missing entry must be an error — a SUMS file that doesn't cover the target + // asset must not silently pass, as an attacker could strip the entry. + err := verifyChecksum([]byte("data"), "kfutil_1.9.0_freebsd_arm64.zip", []byte("abc123 kfutil_1.9.0_linux_amd64.zip\n")) + require.Error(t, err) + assert.Contains(t, err.Error(), "no checksum entry found") +} + +// ── extractBinary ───────────────────────────────────────────────────────────── + +func makeZip(t *testing.T, filename, content string) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, err := w.Create(filename) + require.NoError(t, err) + _, err = f.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, w.Close()) + return buf.Bytes() +} + +func TestExtractBinary_Unix(t *testing.T) { + data := makeZip(t, "kfutil", "binary-content") + got, err := extractBinary(data, "kfutil") + require.NoError(t, err) + assert.Equal(t, []byte("binary-content"), got) +} + +func TestExtractBinary_Windows(t *testing.T) { + data := makeZip(t, "kfutil.exe", "win-binary") + got, err := extractBinary(data, "kfutil") + require.NoError(t, err) + assert.Equal(t, []byte("win-binary"), got) +} + +func TestExtractBinary_NotFound(t *testing.T) { + data := makeZip(t, "readme.txt", "hello") + _, err := extractBinary(data, "kfutil") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found in archive") +} + +func TestExtractBinary_InvalidZip(t *testing.T) { + _, err := extractBinary([]byte("not a zip"), "kfutil") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid zip archive") +} + +// ── download (token host allowlist) ────────────────────────────────────────── + +// TestDownload_TokenSentToTrustedHost and TestDownload_TokenNotSentToUntrustedHost +// must NOT run in parallel: the former mutates allowedTokenHosts (127.0.0.1 → true) +// which would cause the latter to see the test server as trusted and silently invert +// its assertion. The t.Cleanup restores state, but only after the test completes. +func TestDownload_TokenSentToTrustedHost(t *testing.T) { + var receivedAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("data")) + })) + defer srv.Close() + + // Temporarily register the test server's host (127.0.0.1) as trusted. + allowedTokenHosts["127.0.0.1"] = true + t.Cleanup(func() { delete(allowedTokenHosts, "127.0.0.1") }) + + t.Setenv("GITHUB_TOKEN", "super-secret-token") + + _, err := download(srv.URL+"/asset.zip", "testuser") + require.NoError(t, err) + assert.Equal(t, "Bearer super-secret-token", receivedAuth, "GITHUB_TOKEN must be forwarded to trusted host") +} + +func TestDownload_NonOKStatus(t *testing.T) { + logBuf := captureLog(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + _, err := download(srv.URL+"/asset.zip", "testuser") + require.Error(t, err) + assert.Contains(t, err.Error(), "403") + assert.Contains(t, logBuf.String(), "upgrade.http_request_failed", "audit event must fire on non-200 download") +} + +func TestDownload_BodyTruncatedAtLimit(t *testing.T) { + // Serve exactly maxBinaryBytes bytes — download must return an error rather + // than silently returning a truncated payload that would later fail checksum. + logBuf := captureLog(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + chunk := make([]byte, 4096) + written := 0 + for written < maxBinaryBytes { + n := maxBinaryBytes - written + if n > len(chunk) { + n = len(chunk) + } + _, _ = w.Write(chunk[:n]) + written += n + } + })) + defer srv.Close() + + _, err := download(srv.URL+"/big.zip", "testuser") + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeded maximum allowed size") + assert.Contains(t, logBuf.String(), "upgrade.download_size_limit_reached", "audit event must fire on body size cap") +} + +func TestDownload_TokenNotSentToUntrustedHost(t *testing.T) { + var receivedAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("data")) + })) + defer srv.Close() + + t.Setenv("GITHUB_TOKEN", "super-secret-token") + + _, err := download(srv.URL+"/asset.zip", "testuser") + require.NoError(t, err) + assert.Empty(t, receivedAuth, "GITHUB_TOKEN must not be sent to untrusted host") +} + +// ── fetchRelease (via mock HTTP server) ─────────────────────────────────────── + +func mockReleaseServer(t *testing.T, tag string, statusCode int) *httptest.Server { + t.Helper() + rel := GitHubRelease{ + TagName: tag, + Assets: []GitHubAsset{ + {Name: fmt.Sprintf("kfutil_1.9.0_%s_%s.zip", runtime.GOOS, runtime.GOARCH), BrowserDownloadURL: "http://example.com/kfutil.zip"}, + {Name: "kfutil_1.9.0_SHA256SUMS", BrowserDownloadURL: "http://example.com/SHA256SUMS"}, + }, + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if statusCode != http.StatusOK { + w.WriteHeader(statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) +} + +func TestFetchRelease_Latest(t *testing.T) { + srv := mockReleaseServer(t, "v1.9.0", http.StatusOK) + defer srv.Close() + + // fetchRelease builds the URL itself; test via fetchReleaseFrom which accepts a base URL. + + rel, err := fetchReleaseFrom(srv.URL, "", "testuser") + require.NoError(t, err) + assert.Equal(t, "v1.9.0", rel.TagName) + assert.Len(t, rel.Assets, 2) +} + +func TestFetchRelease_SpecificTag(t *testing.T) { + srv := mockReleaseServer(t, "v1.8.0", http.StatusOK) + defer srv.Close() + + rel, err := fetchReleaseFrom(srv.URL, "v1.8.0", "testuser") + require.NoError(t, err) + assert.Equal(t, "v1.8.0", rel.TagName) +} + +func TestFetchRelease_NotFound(t *testing.T) { + srv := mockReleaseServer(t, "", http.StatusNotFound) + defer srv.Close() + + _, err := fetchReleaseFrom(srv.URL, "v99.0.0", "testuser") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestFetchRelease_RateLimited(t *testing.T) { + logBuf := captureLog(t) + srv := mockReleaseServer(t, "", http.StatusForbidden) + defer srv.Close() + + _, err := fetchReleaseFrom(srv.URL, "", "testuser") + require.Error(t, err) + assert.Contains(t, err.Error(), "rate limited") + assert.Contains(t, logBuf.String(), "upgrade.github_api_rejected", "audit event must fire on rate-limited response") +} + +// ── sanitizeURL ─────────────────────────────────────────────────────────────── + +func TestSanitizeURL_StripsQueryParams(t *testing.T) { + raw := "https://objects.githubusercontent.com/github-production-release-asset/abc123/kfutil.zip?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKID&X-Amz-Signature=deadbeef" + got := sanitizeURL(raw) + assert.Equal(t, "https://objects.githubusercontent.com/github-production-release-asset/abc123/kfutil.zip", got) + assert.NotContains(t, got, "X-Amz") + assert.NotContains(t, got, "Signature") + assert.NotContains(t, got, "?") +} + +func TestSanitizeURL_ParseErrorFallback(t *testing.T) { + // An unparseable URL must be returned as-is — better to log a weird string + // than to panic or drop the URL entirely from audit output. + raw := "://not a valid url" + got := sanitizeURL(raw) + assert.Equal(t, raw, got) +} + +func TestSanitizeURL_StripsFragment(t *testing.T) { + raw := "https://github.com/Keyfactor/kfutil/releases/tag/v1.9.0#readme" + got := sanitizeURL(raw) + assert.NotContains(t, got, "#") + assert.NotContains(t, got, "readme") +} + +// ── extractBinary size cap ──────────────────────────────────────────────────── + +func TestExtractBinary_ExceedsMaxSize(t *testing.T) { + // Build a zip with an entry that is exactly maxBinaryBytes bytes. + // extractBinary must return an error rather than returning a slice of that size. + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, err := w.Create("kfutil") + require.NoError(t, err) + // Write maxBinaryBytes bytes — this will trigger the size-cap check. + chunk := make([]byte, 4096) + written := 0 + for written < maxBinaryBytes { + n := maxBinaryBytes - written + if n > len(chunk) { + n = len(chunk) + } + _, err = f.Write(chunk[:n]) + require.NoError(t, err) + written += n + } + require.NoError(t, w.Close()) + + _, err = extractBinary(buf.Bytes(), "kfutil") + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum allowed size") +}