├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── check.yml ├── testdata ├── bad-go.txt ├── real.txt ├── gopath.txt ├── help.txt ├── unsupported.txt └── basic.txt ├── go.mod ├── .gitignore ├── LICENSE ├── go.sum ├── shell.nix ├── README.md ├── .secrets.baseline ├── license_test.go ├── internal └── installer │ ├── version.go │ ├── install.go │ ├── version_test.go │ └── install_test.go ├── main.go └── main_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Contrast-Security-OSS/go-team 2 | -------------------------------------------------------------------------------- /testdata/bad-go.txt: -------------------------------------------------------------------------------- 1 | # we want LookPath to find the dummy file below when looking for go 2 | chmod 777 $WORK/go 3 | env PATH=$WORK:$PATH 4 | 5 | ! contrast-go-installer -u :8080 latest 6 | stderr 'unable to run ''go env''' 7 | 8 | -- go -- 9 | bad file 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/contrast-security-oss/contrast-go-installer 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/rogpeppe/go-internal v1.12.0 7 | golang.org/x/net v0.40.0 8 | ) 9 | 10 | require ( 11 | golang.org/x/sys v0.33.0 // indirect 12 | golang.org/x/tools v0.1.12 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # output of `go build` 2 | contrast-go-installer 3 | 4 | # Binaries for programs and plugins 5 | *~ 6 | *.tmp 7 | *.exe 8 | *.dll 9 | *.so 10 | *.dylib 11 | *.spk 12 | 13 | # editor settings 14 | .vscode 15 | .idea 16 | 17 | # Test binary, built with `go test -c` 18 | *.test 19 | 20 | # Output of the go coverage tool 21 | *.out 22 | 23 | # direnv config 24 | .envrc 25 | -------------------------------------------------------------------------------- /testdata/real.txt: -------------------------------------------------------------------------------- 1 | [!real] skip 'tests with real hostname are disabled' 2 | [!net] skip 3 | [short] skip 4 | 5 | contrast-go-installer latest 6 | ! stdout . 7 | 8 | exec $GOBIN/contrast-go -h 9 | stderr 'Usage: contrast-go' 10 | 11 | contrast-go-installer 2.8.0 12 | exec $GOBIN/contrast-go -version 13 | stdout '2.8.0' 14 | 15 | env GOOS=windows 16 | ! contrast-go-installer latest 17 | stderr ^'no ''latest'' release found for windows/'$GOARCH 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Contrast Security, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 2 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 3 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 4 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 5 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 6 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 7 | golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= 8 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 9 | -------------------------------------------------------------------------------- /testdata/gopath.txt: -------------------------------------------------------------------------------- 1 | run-test-server 2 | 3 | env GOBIN= 4 | env GOPATH=$WORK/gopath 5 | env PATH=$GOPATH/bin:$PATH 6 | mkdir $GOPATH/bin 7 | 8 | contrast-go-installer -u $baseURL latest 9 | stderr ^'Downloaded ''latest''' 10 | stderr 'to '$WORK/gopath/bin/contrast-go 11 | exists $WORK/gopath/bin/contrast-go 12 | 13 | env GOPATH=$WORK/gopath2 14 | env PATH=$GOPATH/bin:$PATH 15 | # don't initialize $GOPATH/bin. The installer should create it. 16 | contrast-go-installer -u $baseURL latest 17 | stderr ^'Downloaded ''latest''' 18 | stderr 'to '$GOPATH/bin/contrast-go 19 | exists $GOPATH/bin/contrast-go 20 | 21 | env GOBIN= 22 | env GOPATH= 23 | ! contrast-go-installer -u $baseURL latest 24 | stderr 'installation directory issue' 25 | 26 | -- go.mod -- 27 | module test.com/test 28 | -------------------------------------------------------------------------------- /testdata/help.txt: -------------------------------------------------------------------------------- 1 | contrast-go-installer -h 2 | ! stdout . 3 | stderr ^'usage:' 4 | stderr 'contrast-go-installer is a utility for downloading and installing contrast-go' 5 | stderr 'Examples:' 6 | 7 | contrast-go-installer --help 8 | ! stdout . 9 | stderr ^'usage:' 10 | stderr 'contrast-go-installer is a utility for downloading and installing contrast-go' 11 | stderr 'Examples:' 12 | 13 | ! contrast-go-installer 14 | ! stdout . 15 | stderr ^'usage:' 16 | stderr 'contrast-go-installer is a utility for downloading and installing contrast-go' 17 | stderr 'Examples:' 18 | 19 | ! contrast-go-installer -badflag 20 | ! stdout . 21 | stderr 'flag provided but not defined: -badflag' 22 | stderr ^'usage:' 23 | stderr 'contrast-go-installer is a utility for downloading and installing contrast-go' 24 | -------------------------------------------------------------------------------- /testdata/unsupported.txt: -------------------------------------------------------------------------------- 1 | # negating run-test-server tells it to return 404s 2 | ! run-test-server 3 | 4 | ! contrast-go-installer -u $baseURL latest 5 | stderr 'Version "latest" does not exist. For a full list of versions, see' 6 | ! exists $GOBIN/contrast-go 7 | 8 | 9 | run-test-server 10 | 11 | 12 | env GOOS=linux 13 | env GOARCH=and64 14 | ! contrast-go-installer -u $baseURL latest 15 | stderr 'contrast-go is not available for platform "linux-and64".' 16 | stderr 'darwin-amd64, darwin-arm64, linux-amd64' 17 | ! exists $GOBIN/contrast-go 18 | 19 | env GOOS=darfin 20 | env GOARCH=amd64 21 | ! contrast-go-installer -u $baseURL latest 22 | stderr 'contrast-go is not available for platform "darfin-amd64".' 23 | stderr 'darwin-amd64, darwin-arm64, linux-amd64' 24 | ! exists $GOBIN/contrast-go -------------------------------------------------------------------------------- /testdata/basic.txt: -------------------------------------------------------------------------------- 1 | run-test-server 2 | 3 | env GOOS=linux 4 | env GOARCH=amd64 5 | contrast-go-installer -u $baseURL latest 6 | grep '/latest/linux-amd64/contrast-go' $GOBIN/contrast-go 7 | 8 | env GOOS=linux 9 | env GOARCH=amd64 10 | contrast-go-installer -u $baseURL 1.2.3 11 | grep '/1.2.3/linux-amd64/contrast-go' $GOBIN/contrast-go 12 | 13 | # expect that darwin/arm64 is not changed to darwin/amd64 when arm64 release exists 14 | env GOOS=darwin 15 | env GOARCH=arm64 16 | contrast-go-installer -u $baseURL latest 17 | grep '/latest/darwin-arm64/contrast-go' $GOBIN/contrast-go 18 | 19 | # expect that darwin/arm64 is changed to darwin/amd64 when arm64 release does not exist 20 | env GOOS=darwin 21 | env GOARCH=arm64 22 | contrast-go-installer -u $baseURL 1.2.3 23 | stderr ^'darwin/arm64 is not a release target for this' 24 | grep '/1.2.3/darwin-amd64/contrast-go' $GOBIN/contrast-go 25 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: 4 | schedule: 5 | # 7AM EST (11AM UTC) Mon-Fri 6 | - cron: "0 11 * * 1-5" 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | timeout-minutes: 15 16 | strategy: 17 | matrix: 18 | platform: [ubuntu-latest, macos-latest] 19 | version: ["1.23", "1.24"] 20 | runs-on: ${{ matrix.platform }} 21 | steps: 22 | - name: check out repository code 23 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 24 | 25 | - name: set up Go 26 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 27 | with: 28 | go-version: ${{ matrix.version }} 29 | cache: true 30 | 31 | - name: build 32 | run: go build ./... 33 | 34 | - name: run tests and benchmarks 35 | run: go test -race -bench=. -benchtime=1x ./... 36 | 37 | test-success: 38 | if: ${{ always() }} 39 | runs-on: ubuntu-latest 40 | needs: test 41 | timeout-minutes: 1 42 | steps: 43 | - name: check test matrix status 44 | if: ${{ needs.test.result != 'success' }} 45 | run: exit 1 46 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | # This file is for Nix users: https://nixos.org/ 2 | # 3 | # shell.nix is a configuration for nix-shell, which allows users to quickly 4 | # drop in to an environment which includes the basic tools needed for building 5 | # and testing. 6 | # 7 | # For more information about this or why it's useful, see: 8 | # https://nixos.org/guides/nix-pills/developing-with-nix-shell.html 9 | { pkgs ? import { } }: 10 | let 11 | detect-secrets = with pkgs.python3Packages; buildPythonPackage 12 | rec { 13 | pname = "detect-secrets"; 14 | version = "1.1.2"; 15 | disabled = isPy27; 16 | 17 | src = pkgs.fetchFromGitHub { 18 | owner = "Contrast-Labs"; 19 | repo = pname; 20 | rev = "7ed02347560610f3a2c963c0782a1aa853c6cde8"; 21 | sha256 = "1l35wf4xchmg7qnm4i93kpqxjk50k04v3kff03dqvga34wqac4jx"; 22 | }; 23 | 24 | propagatedBuildInputs = [ 25 | pyyaml 26 | requests 27 | ]; 28 | 29 | meta = with pkgs.lib; { 30 | description = "An enterprise friendly way of detecting and preventing secrets in code"; 31 | homepage = "https://github.com/Contrast-Labs/detect-secrets"; 32 | license = licenses.asl20; 33 | }; 34 | }; 35 | in 36 | pkgs.mkShell { 37 | buildInputs = with pkgs; [ 38 | git 39 | go 40 | gnumake 41 | 42 | # This is used for our pre-commit/pre-push hooks, which is meant to run on 43 | # each commit to prevent accidental addition of sensitive information. 44 | detect-secrets 45 | ]; 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # contrast-go-installer 2 | 3 | `contrast-go-installer` downloads and installs Contrast Security's 4 | [contrast-go](https://docs.contrastsecurity.com/en/go.html), which is used to 5 | instrument web applications to detect vulnerabilities at runtime. It chooses the 6 | correct contrast-go release for your OS and architecture, and downloads the 7 | requested version. 8 | 9 | Click for a [demo of contrast-go](http://www.youtube.com/watch?v=ffBWozHhASw). 10 | 11 | A full list of contrast-go releases can be found at 12 | https://pkg.contrastsecurity.com/go-agent-release/. 13 | 14 | ## System Requirements 15 | 16 | * go1.17 or later, which can be downloaded from https://go.dev/dl/. 17 | 18 | > **Note** 19 | > While `contrast-go-installer` works with version 1.17 and on, `contrast-go` 20 | requires one of the two latest Go major versions. For a full list of 21 | contrast-go's system requirements, see [OS and architecture 22 | requirements](https://docs.contrastsecurity.com/en/go-system-requirements.html). 23 | 24 | ## Usage 25 | 26 | To install the latest `contrast-go` version: 27 | ```sh 28 | go run github.com/contrast-security-oss/contrast-go-installer@latest latest 29 | ``` 30 | 31 | To install a specific `contrast-go` version: 32 | ```sh 33 | go run github.com/contrast-security-oss/contrast-go-installer@latest 3.1.0 34 | ``` 35 | 36 | The install location will be `$GOBIN` if set, otherwise `$GOPATH/bin`. To change 37 | the install location, override `$GOBIN` when running the command: 38 | 39 | ```sh 40 | GOBIN=/path/to/dir go run github.com/contrast-security-oss/contrast-go-installer@latest 3.1.0 41 | ``` 42 | 43 | 44 | ## Additional Help 45 | 46 | If you experience any issues with installation, or have any questions for the 47 | team, please contact us via our [support 48 | portal](https://support.contrastsecurity.com/hc/en-us) 49 | -------------------------------------------------------------------------------- /.secrets.baseline: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.1.2", 3 | "plugins_used": [ 4 | { 5 | "name": "ArtifactoryDetector" 6 | }, 7 | { 8 | "name": "AWSKeyDetector" 9 | }, 10 | { 11 | "name": "AzureStorageKeyDetector" 12 | }, 13 | { 14 | "name": "Base64HighEntropyString", 15 | "limit": 4.5 16 | }, 17 | { 18 | "name": "BasicAuthDetector" 19 | }, 20 | { 21 | "name": "CloudantDetector" 22 | }, 23 | { 24 | "name": "GitHubTokenDetector" 25 | }, 26 | { 27 | "name": "HexHighEntropyString", 28 | "limit": 3.0 29 | }, 30 | { 31 | "name": "IbmCloudIamDetector" 32 | }, 33 | { 34 | "name": "IbmCosHmacDetector" 35 | }, 36 | { 37 | "name": "JwtTokenDetector" 38 | }, 39 | { 40 | "name": "KeywordDetector", 41 | "keyword_exclude": "" 42 | }, 43 | { 44 | "name": "MailchimpDetector" 45 | }, 46 | { 47 | "name": "NpmDetector" 48 | }, 49 | { 50 | "name": "PrivateKeyDetector" 51 | }, 52 | { 53 | "name": "SendGridDetector" 54 | }, 55 | { 56 | "name": "SlackDetector" 57 | }, 58 | { 59 | "name": "SoftlayerDetector" 60 | }, 61 | { 62 | "name": "SquareOAuthDetector" 63 | }, 64 | { 65 | "name": "StripeDetector" 66 | }, 67 | { 68 | "name": "TwilioKeyDetector" 69 | } 70 | ], 71 | "filters_used": [ 72 | { 73 | "path": "detect_secrets.filters.allowlist.is_line_allowlisted" 74 | }, 75 | { 76 | "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", 77 | "min_level": 2 78 | }, 79 | { 80 | "path": "detect_secrets.filters.heuristic.is_indirect_reference" 81 | }, 82 | { 83 | "path": "detect_secrets.filters.heuristic.is_lock_file" 84 | }, 85 | { 86 | "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" 87 | }, 88 | { 89 | "path": "detect_secrets.filters.heuristic.is_potential_uuid" 90 | }, 91 | { 92 | "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" 93 | }, 94 | { 95 | "path": "detect_secrets.filters.heuristic.is_sequential_string" 96 | }, 97 | { 98 | "path": "detect_secrets.filters.heuristic.is_swagger_file" 99 | }, 100 | { 101 | "path": "detect_secrets.filters.heuristic.is_templated_secret" 102 | } 103 | ], 104 | "results": {}, 105 | "generated_at": "2022-06-28T18:33:44Z" 106 | } 107 | -------------------------------------------------------------------------------- /license_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Contrast Security, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "io/fs" 20 | "os" 21 | "path/filepath" 22 | "strings" 23 | "testing" 24 | "time" 25 | ) 26 | 27 | func TestLicense(t *testing.T) { 28 | licenseRaw, err := os.ReadFile("LICENSE") 29 | if err != nil { 30 | t.Fatalf("failed to read LICENSE: %v", err) 31 | } 32 | 33 | licenseLines := strings.Split(string(licenseRaw), "\n") 34 | 35 | expectedFirstLine := fmt.Sprintf("Copyright %d Contrast Security, Inc.", time.Now().Year()) 36 | if licenseLines[0] != expectedFirstLine { 37 | t.Fatalf("incorrect first line of LICENSE (got %q, want %q)", licenseLines[0], expectedFirstLine) 38 | } 39 | 40 | var sb strings.Builder 41 | 42 | // license is 13 lines long and this won't change so hardcode it 43 | for i := 0; i < 13; i++ { 44 | ll := licenseLines[i] 45 | sb.WriteString("//") 46 | if ll != "" { 47 | sb.WriteByte(' ') 48 | } 49 | sb.WriteString(ll) 50 | sb.WriteByte('\n') 51 | } 52 | expectedLicenseHeader := sb.String() 53 | t.Logf("expected license header:\n%s\n raw: %q", expectedLicenseHeader, expectedLicenseHeader) 54 | 55 | dir, err := os.Getwd() 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { 60 | if !strings.HasSuffix(path, ".go") { 61 | // skip non-Go files 62 | return nil 63 | } 64 | 65 | relPath, err := filepath.Rel(dir, path) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | t.Run("Headers/"+relPath, func(t *testing.T) { 70 | f, err := os.Open(path) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | defer f.Close() 75 | 76 | licenseSize := len(expectedLicenseHeader) 77 | buf := make([]byte, licenseSize) 78 | n, err := f.Read(buf) 79 | if err != nil { 80 | t.Fatalf("failed to read file: %v", err) 81 | } 82 | head := string(buf[:n]) 83 | t.Logf("head:\n%s\n\n raw: %q", head, head) 84 | if n != licenseSize || !strings.HasPrefix(head, "// Copyright") { 85 | t.Fatalf("missing license header") 86 | } 87 | if head != expectedLicenseHeader { 88 | t.Fatal("invalid license header") 89 | } 90 | }) 91 | 92 | return nil 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /internal/installer/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Contrast Security, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package installer 16 | 17 | import ( 18 | "fmt" 19 | "io" 20 | "sort" 21 | "strconv" 22 | "strings" 23 | 24 | "golang.org/x/net/html" 25 | "golang.org/x/net/html/atom" 26 | ) 27 | 28 | // version describes an available version; handles strings like 'latest' or '1.2.3'. 29 | type version struct { 30 | str string 31 | maj, min, patch int 32 | } 33 | 34 | // Greater returns true if v is greater than w 35 | func (v *version) Greater(w *version) bool { 36 | vslen, wslen := len(v.str) > 0, len(w.str) > 0 37 | if vslen && wslen { 38 | return v.str < w.str 39 | } 40 | if vslen { 41 | return true 42 | } 43 | if wslen { 44 | return false 45 | } 46 | //converts major/minor/patch to an int for comparison. assumes none of them will exceed 255. 47 | toint := func(x *version) int { return x.maj<<16 + x.min<<8 + x.patch } 48 | return toint(v) > toint(w) 49 | } 50 | 51 | func (v *version) String() string { 52 | if len(v.str) > 0 { 53 | return v.str 54 | } 55 | return fmt.Sprintf("%d.%d.%d", v.maj, v.min, v.patch) 56 | } 57 | 58 | func (v *version) Equal(w *version) bool { 59 | return v.str == w.str && 60 | v.maj == w.maj && 61 | v.min == w.min && 62 | v.patch == w.patch 63 | } 64 | 65 | // convert a string to type version, which allows comparison, sorting, etc 66 | func toVersion(s string) (v version) { 67 | var maj, min, patch uint64 68 | var err error 69 | if elems := strings.Split(s, "."); len(elems) == 3 { 70 | maj, err = strconv.ParseUint(elems[0], 10, 8) 71 | if err == nil { 72 | min, err = strconv.ParseUint(elems[1], 10, 8) 73 | } 74 | if err == nil { 75 | patch, err = strconv.ParseUint(elems[2], 10, 8) 76 | } 77 | if err == nil { 78 | v.maj, v.min, v.patch = int(maj), int(min), int(patch) 79 | return 80 | } 81 | } 82 | v.str = s 83 | return 84 | } 85 | 86 | type versions []version 87 | 88 | func (a versions) Len() int { return len(a) } 89 | func (a versions) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 90 | func (a versions) Less(i, j int) bool { return a[i].Greater(&a[j]) } // reversed - sorts descending, alpha then numeric. 91 | 92 | func (a versions) String() string { 93 | s := []string{} 94 | for _, v := range a { 95 | s = append(s, v.String()) 96 | } 97 | return strings.Join(s, ", ") 98 | } 99 | 100 | type ErrBadVersion struct { 101 | AvailableVersions versions 102 | BadVersion string 103 | } 104 | 105 | const maxVersionsListed = 5 106 | 107 | func (err *ErrBadVersion) Error() string { 108 | sort.Sort(err.AvailableVersions) 109 | if len(err.AvailableVersions) > maxVersionsListed { 110 | err.AvailableVersions = err.AvailableVersions[:maxVersionsListed] 111 | } 112 | return fmt.Sprintf("Version %q does not exist. Available versions include\n\t%s\n%s", 113 | err.BadVersion, err.AvailableVersions.String(), agentArchivePg) 114 | } 115 | 116 | // reads html from body, returning extracted versions 117 | func listVersions(body io.Reader) (versions, error) { 118 | var vers versions 119 | subdirs, err := htmlDir(body) 120 | if err != nil { 121 | return nil, err 122 | } 123 | for _, sub := range subdirs { 124 | vers = append(vers, toVersion(sub)) 125 | } 126 | return vers, nil 127 | } 128 | 129 | // reads html from body, returning extracted dir links 130 | func htmlDir(body io.Reader) ([]string, error) { 131 | var subdirs []string 132 | z := html.NewTokenizer(body) 133 | for { 134 | switch z.Next() { 135 | case html.ErrorToken: 136 | err := z.Err() 137 | if err == io.EOF { 138 | //reached end of input 139 | return subdirs, nil 140 | } 141 | return nil, fmt.Errorf("Dir specified does not exist. %s", agentArchivePg) 142 | case html.StartTagToken: 143 | if tok := z.Token(); tok.DataAtom == atom.A { 144 | for _, a := range tok.Attr { 145 | if a.Key == "href" { 146 | v := strings.TrimSuffix(a.Val, "/") 147 | // a link to a version will have one slash, which we already removed - skip anything else 148 | if strings.Count(v, "/") == 0 { 149 | subdirs = append(subdirs, v) 150 | } 151 | } 152 | } 153 | } 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Contrast Security, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "encoding/json" 20 | "errors" 21 | "flag" 22 | "fmt" 23 | "log" 24 | "os" 25 | "os/exec" 26 | "path/filepath" 27 | 28 | "github.com/contrast-security-oss/contrast-go-installer/internal/installer" 29 | ) 30 | 31 | const usageString = `usage: %[1]s 32 | 33 | contrast-go-installer is a utility for downloading and installing contrast-go. 34 | Installation is based on the values of GOOS, GOARCH, and GOBIN, as seen by 'go env'. 35 | 36 | Examples: 37 | %[1]s latest 38 | %[1]s 2.8.0 39 | 40 | For a full list of available versions, please visit: 41 | https://docs.contrastsecurity.com/en/go-agent-release-notes-and-archive.html 42 | ` 43 | 44 | var flags = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 45 | 46 | func usage() { 47 | cmd := filepath.Base(os.Args[0]) 48 | 49 | log.Printf(usageString, cmd) 50 | } 51 | 52 | type goenv struct { 53 | GOOS string 54 | GOARCH string 55 | GOBIN string 56 | GOPATH string 57 | } 58 | 59 | // `go env -json GOOS GOARCH GOBIN GOPATH` to collect settings 60 | func getEnv() (*goenv, error) { 61 | installedGo, err := exec.LookPath("go") 62 | if err != nil { 63 | return nil, fmt.Errorf("unable to locate go installation: %w", err) 64 | } 65 | 66 | cmd := exec.Command(installedGo, "env", "-json", "GOOS", "GOARCH", "GOBIN", "GOPATH") 67 | var stdout, stderr bytes.Buffer 68 | 69 | cmd.Stderr = &stderr 70 | cmd.Stdout = &stdout 71 | 72 | if err := cmd.Run(); err != nil { 73 | if stderr.Len() != 0 { 74 | return nil, fmt.Errorf("unable to run 'go env': %w\n\t%s", err, stderr.String()) 75 | } 76 | return nil, fmt.Errorf("unable to run 'go env': %w", err) 77 | } 78 | 79 | env := new(goenv) 80 | if err := json.Unmarshal(stdout.Bytes(), env); err != nil { 81 | return env, fmt.Errorf("unexpected 'go env' output: %w", err) 82 | } 83 | 84 | return env, nil 85 | } 86 | 87 | func main() { 88 | os.Exit(main1()) 89 | } 90 | 91 | func main1() int { 92 | log.SetFlags(0) 93 | flags.Usage = usage 94 | // this is used in testing to avoid having to talk to a real server 95 | source := flags.String("u", "https://pkg.contrastsecurity.com/go-agent-release", "") 96 | 97 | flags.Parse(os.Args[1:]) 98 | if len(flags.Args()) != 1 { 99 | flags.Usage() 100 | return 2 101 | } 102 | 103 | version := flags.Args()[0] 104 | 105 | env, err := getEnv() 106 | if err != nil { 107 | log.Printf("There was a problem reading the Go environment: %s", err) 108 | return 2 109 | } 110 | 111 | path, err := targetDir(env.GOBIN, env.GOPATH) 112 | if err != nil { 113 | log.Printf("Unable to find install path: %s", err) 114 | return 2 115 | } 116 | path = filepath.Join(path, "contrast-go") 117 | 118 | err = installer.Install(*source, version, env.GOOS, env.GOARCH, path) 119 | if err != nil && env.GOOS == "darwin" && env.GOARCH == "arm64" { 120 | // No darwin/arm64 binary? Try darwin/amd64. We don't do the same for 121 | // linux/arm64 since linux doesn't automagically translate binaries. 122 | bp := &installer.ErrBadPlatform{} 123 | if errors.As(err, &bp) { 124 | log.Println( 125 | "darwin/arm64 is not a release target for this contrast-go version.", 126 | "Setting release to darwin/amd64 to run in compatibility mode.", 127 | ) 128 | env.GOARCH = "amd64" 129 | err = installer.Install(*source, version, env.GOOS, env.GOARCH, path) 130 | } 131 | } 132 | 133 | if err != nil { 134 | log.Println(err) 135 | return 2 136 | } 137 | 138 | log.Printf( 139 | "Downloaded '%s' release for %s/%s to %s.\n", 140 | version, env.GOOS, env.GOARCH, path, 141 | ) 142 | 143 | return 0 144 | } 145 | 146 | // targetDir copies some of the logic in cmd/go/internal/modload/init.go to 147 | // figure out where 'go install' would put things. 148 | func targetDir(gobin, path string) (string, error) { 149 | if gobin != "" { 150 | return gobin, nil 151 | } 152 | 153 | list := filepath.SplitList(path) 154 | // This means that there is no $GOPATH env var and that the default wasn't 155 | // useable for some reason. From 'go help gopath': 156 | // 157 | // If the environment variable is unset, GOPATH defaults 158 | // to a subdirectory named "go" in the user's home directory 159 | // ($HOME/go on Unix, %USERPROFILE%\go on Windows), 160 | // unless that directory holds a Go distribution. 161 | // Run "go env GOPATH" to see the current GOPATH. 162 | // 163 | // We might be in the twilight zone if this happens because 'go env' likely 164 | // won't succeed if it can't locate a GOPATH; we won't get this far. 165 | if len(list) == 0 { 166 | return "", errors.New("'go env GOBIN' and 'go env GOPATH' were empty") 167 | } 168 | 169 | return filepath.Join(list[0], "bin"), nil 170 | } 171 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Contrast Security, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "crypto/sha256" 20 | "encoding/hex" 21 | "errors" 22 | "io" 23 | "net/http" 24 | "net/http/httptest" 25 | "os" 26 | "os/exec" 27 | "path/filepath" 28 | "strings" 29 | "testing" 30 | 31 | "github.com/rogpeppe/go-internal/testscript" 32 | ) 33 | 34 | func TestMain(m *testing.M) { 35 | os.Exit(testscript.RunMain(m, map[string]func() int{ 36 | "contrast-go-installer": main1, 37 | })) 38 | } 39 | 40 | // replacePath replaces the old location with the new location in $PATH 41 | func replacePath(path, old, new string) string { 42 | newpath := []string{} 43 | for _, filepath := range strings.Split(path, ":") { 44 | if filepath == old { 45 | newpath = append(newpath, new) 46 | } else { 47 | newpath = append(newpath, filepath) 48 | } 49 | } 50 | return strings.Join(newpath, ":") 51 | } 52 | 53 | func TestScripts(t *testing.T) { 54 | testscript.Run(t, testscript.Params{ 55 | Dir: "testdata", 56 | Setup: func(env *testscript.Env) error { 57 | bin := filepath.Join(env.WorkDir, "bin") 58 | if err := os.Mkdir(filepath.Join(env.WorkDir, "bin"), 0700); err != nil { 59 | env.T().Fatal(err) 60 | } 61 | 62 | // go env GOBIN 63 | installedGo, err := exec.LookPath("go") 64 | if err != nil { 65 | env.T().Fatal(err) 66 | } 67 | cmd := exec.Command(installedGo, "env", "GOBIN") 68 | var stdout bytes.Buffer 69 | cmd.Stdout = &stdout 70 | if err := cmd.Run(); err != nil { 71 | env.T().Fatal(err) 72 | } 73 | out := strings.Fields(stdout.String()) 74 | if len(out) > 0 { 75 | // Remove previous GOBIN from $PATH, and add the new GOBIN 76 | // to avoid shadowing contrast-go if it is already installed 77 | // on your machine 78 | env.Setenv("PATH", replacePath(env.Getenv("PATH"), out[0], bin)) 79 | } else { 80 | // If GOBIN unset, no need to replace it in PATH 81 | env.Setenv("PATH", env.Getenv("PATH")+":"+bin) 82 | } 83 | 84 | env.Setenv("GOBIN", bin) 85 | 86 | return nil 87 | }, 88 | Cmds: map[string]func(*testscript.TestScript, bool, []string){ 89 | "run-test-server": startServer, 90 | }, 91 | Condition: func(cond string) (bool, error) { 92 | switch cond { 93 | case "real": 94 | return false, nil 95 | } 96 | 97 | return false, errors.New("unrecognized condition") 98 | }, 99 | }) 100 | } 101 | 102 | const ( 103 | versdir = ` 104 |

Index of go-agent-release/

105 |
Name    Last modified      Size

106 |
1.2.3/  26-Feb-2021 22:24    -
107 | 3.0.0/   07-Jul-2022 15:47    -
108 | latest/  22-Feb-2021 15:28    -
109 | 

Online Server
` 110 | 111 | archdir = ` 112 |

Index of go-agent-release/3.0.0

113 |
Name              Last modified      Size

114 |
../
115 | darwin-amd64/      07-Jul-2022 15:47    -
116 | darwin-arm64/      07-Jul-2022 15:47    -
117 | linux-amd64/       07-Jul-2022 15:47    -
118 | dependencies.csv   07-Jul-2022 15:47  1.25 KB
119 | 

Online Server
` 120 | 121 | archdirNoArm = ` 122 |

Index of go-agent-release/1.2.3

123 |
Name              Last modified      Size

124 |
../
125 | darwin-amd64/      07-Jul-2022 15:47    -
126 | linux-amd64/       07-Jul-2022 15:47    -
127 | dependencies.csv   07-Jul-2022 15:47  1.25 KB
128 | 

Online Server
` 129 | ) 130 | 131 | var ( 132 | allowedOses = []string{"linux", "darwin"} 133 | allowedArches = []string{"amd64", "arm64"} 134 | ) 135 | 136 | func allowed(list []string, val string) bool { 137 | for _, elem := range list { 138 | if val == elem { 139 | return true 140 | } 141 | } 142 | return false 143 | } 144 | 145 | // startServer starts a test server to handle downloads and puts the server's 146 | // address in the $baseURL environment variable. 147 | func startServer(ts *testscript.TestScript, neg bool, args []string) { 148 | srvHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 149 | if neg { 150 | w.WriteHeader(404) 151 | return 152 | } 153 | paths := strings.Split(r.RequestURI[1:], "/") 154 | switch len(paths) { 155 | case 0: 156 | _, _ = w.Write([]byte(versdir)) 157 | case 1: 158 | switch paths[0] { 159 | case "latest": 160 | http.Redirect(w, r, "../3.0.0", http.StatusSeeOther) 161 | return 162 | case "3.0.0": 163 | _, _ = w.Write([]byte(archdir)) 164 | case "1.2.3": 165 | _, _ = w.Write([]byte(archdirNoArm)) 166 | default: 167 | w.WriteHeader(404) 168 | } 169 | //case 2: 170 | // this would be the dir containing contrast-go, but we don't read it. handled by default case. 171 | case 3: 172 | osArch := strings.Split(paths[1], "-") 173 | if len(osArch) != 2 { 174 | w.WriteHeader(404) 175 | return 176 | } 177 | arches := allowedArches 178 | if paths[0] != "latest" && paths[0] != "3.0.0" { 179 | // only later revisions have native arm64 binaries 180 | arches = []string{"amd64"} 181 | } 182 | 183 | if !allowed(arches, osArch[1]) { 184 | w.WriteHeader(404) 185 | return 186 | } 187 | if !allowed(allowedOses, osArch[0]) { 188 | w.WriteHeader(404) 189 | return 190 | } 191 | if paths[2] == "contrast-go" { 192 | _, _ = w.Write([]byte(r.RequestURI)) 193 | } else { 194 | w.WriteHeader(404) 195 | } 196 | default: 197 | ts.Fatalf("unexpected request for %s\n", r.RequestURI) 198 | } 199 | }) 200 | headHandler := func(h http.Handler) http.Handler { 201 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 202 | if r.Method == http.MethodHead { 203 | h := sha256.New() 204 | _, err := io.Copy(h, bytes.NewBufferString(r.RequestURI)) 205 | if err != nil { 206 | ts.Fatalf("unable to calculate checksum: %s", err) 207 | } 208 | hash := hex.EncodeToString(h.Sum(nil)) 209 | w.Header().Set("X-Checksum-sha256", hash) 210 | } 211 | h.ServeHTTP(w, r) 212 | }) 213 | } 214 | s := httptest.NewServer(headHandler(srvHandler)) 215 | ts.Defer(s.Close) 216 | ts.Logf("test server listening at: %s", s.URL) 217 | 218 | ts.Setenv("baseURL", s.URL) 219 | } 220 | -------------------------------------------------------------------------------- /internal/installer/install.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Contrast Security, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package installer 16 | 17 | import ( 18 | "crypto/sha256" 19 | "encoding/hex" 20 | "errors" 21 | "fmt" 22 | "io" 23 | "net" 24 | "net/http" 25 | "os" 26 | "os/exec" 27 | "path/filepath" 28 | "strings" 29 | ) 30 | 31 | const ( 32 | artifactPath = "/%s/%s-%s/contrast-go" 33 | 34 | agentArchivePg = `For a full list of versions, see 35 | https://docs.contrastsecurity.com/en/go-agent-release-notes-and-archive.html` 36 | 37 | badver = `Version %q does not exist. ` + agentArchivePg 38 | sysRequirementsPg = `For system requirements, see 39 | https://docs.contrastsecurity.com/en/go-system-requirements.html` 40 | 41 | agentInstallPg = "https://docs.contrastsecurity.com/en/install-go.html" 42 | unknownError = `Sorry, something strange happened. Please try again later or 43 | install manually. For the latter, see the instructions at 44 | ` + agentInstallPg 45 | ) 46 | 47 | // Install attempts to download a release of contrast-go matching version, os, 48 | // and arch into path and chmod it to an executable. 49 | func Install(baseURL, version, os, arch, path string) error { 50 | id := installData{ 51 | baseURL: baseURL, 52 | version: version, 53 | os: os, 54 | arch: arch, 55 | dst: path, 56 | } 57 | tmp, err := id.download() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | return id.install(tmp, nil) 63 | } 64 | 65 | type installData struct { 66 | baseURL string 67 | version string // version to download 68 | os string // target os 69 | arch string // target arch 70 | dst string // final destination 71 | 72 | tmpdir string // set during testing to facilitate cleanup, otherwise empty 73 | } 74 | 75 | // download to a temp location, returning the temp file's location 76 | func (id *installData) download() (string, error) { 77 | url := fmt.Sprintf(id.baseURL+artifactPath, id.version, id.os, id.arch) 78 | tmp, err := os.CreateTemp(id.tmpdir, "contrast-go*") 79 | if err != nil { 80 | return "", fmt.Errorf("unable to create tmp for download: %w", err) 81 | } 82 | defer tmp.Close() 83 | res, err := makeRequest(http.MethodHead, url) 84 | if err != nil { 85 | return "", err 86 | } 87 | if res.StatusCode == http.StatusNotFound { 88 | return "", id.dlNotFoundError(res) 89 | } 90 | if res.StatusCode != http.StatusOK { 91 | return "", fmt.Errorf( 92 | "server did not return 200 for %v: %v", 93 | url, res.Status, 94 | ) 95 | } 96 | wantHash := res.Header.Get("X-Checksum-Sha256") 97 | 98 | res, err = makeRequest(http.MethodGet, url) 99 | if err != nil { 100 | return "", err 101 | } 102 | defer res.Body.Close() 103 | 104 | hash := sha256.New() 105 | if n, err := io.Copy(io.MultiWriter(tmp, hash), res.Body); err != nil { 106 | return "", fmt.Errorf( 107 | "couldn't download file (%d bytes read of %d expected): %w", 108 | n, res.ContentLength, err, 109 | ) 110 | } 111 | 112 | gotHash := hex.EncodeToString(hash.Sum(nil)) 113 | if wantHash != gotHash { 114 | return "", fmt.Errorf("checksum mismatch, expected %q instead of %q", wantHash, gotHash) 115 | } 116 | 117 | fi, err := os.Stat(tmp.Name()) 118 | if err != nil { 119 | return "", fmt.Errorf("cannot verify download: %w", err) 120 | } 121 | if res.ContentLength > -1 && fi.Size() != res.ContentLength { 122 | return "", fmt.Errorf( 123 | "downloaded file size %v does not match expected value %d", 124 | fi.Size(), 125 | res.ContentLength, 126 | ) 127 | } 128 | return tmp.Name(), nil 129 | } 130 | 131 | // move from temp location to final 132 | func (id installData) install(tmpFile string, lookupFunc func() (string, error)) error { 133 | if lookupFunc == nil { 134 | // pass in custom lookup function for testing 135 | lookupFunc = func() (string, error) { 136 | return exec.LookPath("contrast-go") 137 | } 138 | } 139 | if err := os.MkdirAll(filepath.Dir(id.dst), 0755); err != nil { 140 | return fmt.Errorf("installation directory issue: %w", err) 141 | } 142 | if err := os.Rename(tmpFile, id.dst); err != nil { 143 | return err 144 | } 145 | 146 | if err := os.Chmod(id.dst, 0755); err != nil { 147 | return fmt.Errorf("permission issue: %w", err) 148 | } 149 | 150 | path, err := lookupFunc() 151 | if err != nil { 152 | return fmt.Errorf( 153 | `contrast-go was installed at %s, but this location was not found in $PATH. 154 | Make sure that the $PATH environment variable includes %s`, 155 | id.dst, 156 | id.dst, 157 | ) 158 | } 159 | if path != id.dst { 160 | return fmt.Errorf( 161 | "contrast-go installed at %s, but shadowed in path by %s", 162 | id.dst, 163 | path, 164 | ) 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func makeRequest(method, url string) (*http.Response, error) { 171 | client := http.DefaultClient 172 | req, err := http.NewRequest(method, url, nil) 173 | if err != nil { 174 | return nil, err 175 | } 176 | req.Header.Set("User-Agent", "contrast-go-installer/0") 177 | response, responseErr := client.Do(req) 178 | 179 | if netErr := new(net.OpError); errors.As(responseErr, &netErr) { 180 | return response, fmt.Errorf("there is a network communication issue: %s", responseErr) 181 | } 182 | 183 | return response, responseErr 184 | } 185 | 186 | // determine what went wrong and return a nice error for the user 187 | func (id installData) dlNotFoundError(res *http.Response) error { 188 | // first, check the version 189 | url := fmt.Sprintf("%s/%s", id.baseURL, id.version) 190 | res2, err := makeRequest(http.MethodGet, url) 191 | if err != nil { 192 | return fmt.Errorf(badver, id.version) 193 | } 194 | defer res2.Body.Close() 195 | if res2.StatusCode != http.StatusOK { 196 | // invalid version; tell user what versions are valid 197 | res2, err = makeRequest(http.MethodGet, id.baseURL) 198 | if err != nil { 199 | return fmt.Errorf(badver, id.version) 200 | } 201 | defer res2.Body.Close() 202 | if res2.StatusCode != http.StatusOK { 203 | return fmt.Errorf(badver, id.version) 204 | } 205 | avail, err := listVersions(res2.Body) 206 | if err != nil { 207 | return fmt.Errorf(badver, id.version) 208 | } 209 | 210 | return &ErrBadVersion{ 211 | AvailableVersions: avail, 212 | BadVersion: id.version, 213 | } 214 | } 215 | 216 | avail, err := listPlatforms(res2.Body) 217 | if err != nil || len(avail) < 2 { 218 | return fmt.Errorf(unknownError) 219 | } 220 | // os and/or arch is invalid 221 | return &ErrBadPlatform{ 222 | Available: avail, 223 | Arch: id.arch, 224 | OS: id.os, 225 | } 226 | } 227 | 228 | // reads html from body, returning extracted platforms 229 | func listPlatforms(body io.Reader) ([]string, error) { 230 | var plats []string 231 | subdirs, err := htmlDir(body) 232 | if err != nil { 233 | return nil, err 234 | } 235 | for _, sub := range subdirs { 236 | if !strings.Contains(sub, "-") { 237 | // all platforms contain a dash: os-arch. throw away anything else. 238 | continue 239 | } 240 | plats = append(plats, sub) 241 | } 242 | return plats, nil 243 | } 244 | 245 | type ErrBadPlatform struct { 246 | Available []string 247 | Arch, OS string 248 | } 249 | 250 | func (err *ErrBadPlatform) Error() string { 251 | return fmt.Sprintf("contrast-go is not available for platform \"%s-%s\". Available platforms:\n\t%s\n%s", 252 | err.OS, err.Arch, strings.Join(err.Available, ", "), sysRequirementsPg) 253 | } 254 | -------------------------------------------------------------------------------- /internal/installer/version_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Contrast Security, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package installer 16 | 17 | import ( 18 | "bytes" 19 | "net/http" 20 | "strings" 21 | "testing" 22 | ) 23 | 24 | func Test_listVersions(t *testing.T) { 25 | t.Run("correctly parses versions", func(t *testing.T) { 26 | htm := []byte(` 27 | Index of go-agent-release/ 28 |

Index of go-agent-release/

29 |
Name    Last modified      Size

30 |
0.10.0/  26-Feb-2021 22:24    -
 31 | 0.11.0/  09-Mar-2021 17:35    -
 32 | 0.12.0/  16-Mar-2021 16:22    -
 33 | 3.0.0/   07-Jul-2022 15:47    -
 34 | latest/  22-Feb-2021 15:28    -
 35 | something else
 36 | 
37 |
Artifactory Online Server
`) 38 | 39 | var want versions 40 | for _, v := range []string{"0.10.0", "0.11.0", "0.12.0", "3.0.0", "latest"} { 41 | want = append(want, toVersion(v)) 42 | } 43 | 44 | buf := bytes.NewReader(htm) 45 | got, err := listVersions(buf) 46 | if err != nil { 47 | t.Errorf("listVersions() error = %v", err) 48 | } 49 | if len(want) != len(got) { 50 | t.Errorf("want %d versions, got %d", len(want), len(got)) 51 | } 52 | for i := range want { 53 | if len(got) < i+1 { 54 | break 55 | } 56 | if !want[i].Equal(&got[i]) { 57 | t.Errorf("mismatch: want[%d]==%q, got[%d]==%q", i, want[i], i, got[i]) 58 | } 59 | } 60 | 61 | if t.Failed() { 62 | t.Logf("\nwant: %#v\n got: %#v", want, got) 63 | } 64 | }) 65 | t.Run("versions pseudo-dir is parseable", func(t *testing.T) { 66 | resp, err := http.Get(dlsite) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | if resp.StatusCode != http.StatusOK { 71 | t.Fatalf("unexpected http response %s from %s", resp.Status, resp.Request.URL) 72 | } 73 | got, err := listVersions(resp.Body) 74 | if err != nil { 75 | t.Error(err) 76 | } 77 | var want versions 78 | for _, v := range []string{"3.0.0", "latest"} { // will any versions ever be removed? 79 | want = append(want, toVersion(v)) 80 | } 81 | for _, g := range got { 82 | for i := 0; i < len(want); i++ { 83 | if g == want[i] { 84 | want = append(want[:i], want[i+1:]...) 85 | } 86 | } 87 | } 88 | if len(want) != 0 { 89 | t.Errorf("got %#v\nwhich is missing %#v", got, want) 90 | } 91 | }) 92 | } 93 | 94 | func TestErrBadVersion_Error(t *testing.T) { 95 | // ensures returned versions are sorted with 'latest' first, then numeric versions descending 96 | want := "latest, 3.0.0, 1.12.3, 1.2.3, 0.12.0" 97 | var avail versions 98 | for _, v := range []string{"0.10.0", "1.12.3", "0.11.0", "0.12.0", "3.0.0", "latest", "1.2.3"} { 99 | avail = append(avail, toVersion(v)) 100 | } 101 | 102 | err := &ErrBadVersion{ 103 | AvailableVersions: avail, 104 | BadVersion: "badVer", 105 | } 106 | got := err.Error() 107 | if !strings.Contains(got, want) { 108 | t.Errorf("\nwant %s\n got %s", want, got) 109 | } 110 | } 111 | 112 | func TestVersion_Equal(t *testing.T) { 113 | numeric := version{maj: 1, min: 2, patch: 3} 114 | str := version{str: "vers"} 115 | 116 | tests := []struct { 117 | name string 118 | lhs, rhs version 119 | want bool 120 | }{ 121 | { 122 | name: "string equal", 123 | lhs: str, 124 | rhs: str, 125 | want: true, 126 | }, 127 | { 128 | name: "string inequal", 129 | lhs: version{}, 130 | rhs: str, 131 | want: false, 132 | }, 133 | { 134 | name: "numeric equal", 135 | lhs: numeric, 136 | rhs: numeric, 137 | want: true, 138 | }, 139 | { 140 | name: "numeric inequal maj", 141 | lhs: numeric, 142 | rhs: version{maj: numeric.maj + 1, min: numeric.min, patch: numeric.patch}, 143 | want: false, 144 | }, 145 | { 146 | name: "numeric inequal min", 147 | lhs: numeric, 148 | rhs: version{maj: numeric.maj, min: numeric.min + 1, patch: numeric.patch}, 149 | want: false, 150 | }, 151 | { 152 | name: "numeric inequal patch", 153 | lhs: numeric, 154 | rhs: version{maj: numeric.maj, min: numeric.min, patch: numeric.patch + 1}, 155 | want: false, 156 | }, 157 | } 158 | for _, td := range tests { 159 | t.Run(td.name, func(t *testing.T) { 160 | got := td.lhs.Equal(&td.rhs) 161 | if got != td.want { 162 | t.Errorf("want %t got %t for lhs=%#v\nrhs=%#v", td.want, got, td.lhs, td.rhs) 163 | } 164 | }) 165 | } 166 | } 167 | 168 | func TestVersion_Greater(t *testing.T) { 169 | numeric := version{maj: 1, min: 2, patch: 3} 170 | str := version{str: "vers"} 171 | 172 | tests := []struct { 173 | name string 174 | lhs, rhs version 175 | want bool 176 | }{ 177 | { 178 | name: "string equal", 179 | lhs: str, 180 | rhs: str, 181 | want: false, 182 | }, 183 | { 184 | name: "string empty", 185 | lhs: version{}, 186 | rhs: str, 187 | want: false, 188 | }, 189 | { 190 | name: "string greater", 191 | lhs: version{str: "aaa"}, 192 | rhs: str, 193 | want: true, 194 | }, 195 | { 196 | name: "string less", 197 | lhs: version{str: "xxx"}, 198 | rhs: str, 199 | want: false, 200 | }, 201 | { 202 | name: "numeric equal", 203 | lhs: numeric, 204 | rhs: numeric, 205 | want: false, 206 | }, 207 | { 208 | name: "numeric lt maj", 209 | lhs: numeric, 210 | rhs: version{maj: numeric.maj + 1, min: numeric.min, patch: numeric.patch}, 211 | want: false, 212 | }, 213 | { 214 | name: "numeric lt min", 215 | lhs: numeric, 216 | rhs: version{maj: numeric.maj, min: numeric.min + 1, patch: numeric.patch}, 217 | want: false, 218 | }, 219 | { 220 | name: "numeric lt patch", 221 | lhs: numeric, 222 | rhs: version{maj: numeric.maj, min: numeric.min, patch: numeric.patch + 1}, 223 | want: false, 224 | }, 225 | { 226 | name: "numeric gt maj", 227 | lhs: numeric, 228 | rhs: version{maj: numeric.maj - 1, min: numeric.min, patch: numeric.patch}, 229 | want: true, 230 | }, 231 | { 232 | name: "numeric gt min", 233 | lhs: numeric, 234 | rhs: version{maj: numeric.maj, min: numeric.min - 1, patch: numeric.patch}, 235 | want: true, 236 | }, 237 | { 238 | name: "numeric gt patch", 239 | lhs: numeric, 240 | rhs: version{maj: numeric.maj, min: numeric.min, patch: numeric.patch - 1}, 241 | want: true, 242 | }, 243 | } 244 | for _, td := range tests { 245 | t.Run(td.name, func(t *testing.T) { 246 | got := td.lhs.Greater(&td.rhs) 247 | if got != td.want { 248 | t.Errorf("want %t got %t for\nlhs=%#v\nrhs=%#v", td.want, got, td.lhs, td.rhs) 249 | } 250 | }) 251 | } 252 | } 253 | 254 | func Test_toVersion(t *testing.T) { 255 | tests := []struct { 256 | name string 257 | in string 258 | wantV version 259 | }{ 260 | { 261 | name: "semver", 262 | in: "1.2.3", 263 | wantV: version{maj: 1, min: 2, patch: 3}, 264 | }, 265 | { 266 | name: "too many dots", 267 | in: "1.2.3.", 268 | wantV: version{str: "1.2.3."}, 269 | }, 270 | { 271 | name: "too few dots", 272 | in: "1.23", 273 | wantV: version{str: "1.23"}, 274 | }, 275 | { 276 | name: "empty", 277 | in: "", 278 | wantV: version{}, 279 | }, 280 | { 281 | name: "string", 282 | in: "latest", 283 | wantV: version{str: "latest"}, 284 | }, 285 | { 286 | name: "dots but not numeric", 287 | in: "1.a.5", 288 | wantV: version{str: "1.a.5"}, 289 | }, 290 | } 291 | for _, td := range tests { 292 | t.Run(td.name, func(t *testing.T) { 293 | gotV := toVersion(td.in) 294 | if !gotV.Equal(&td.wantV) { 295 | t.Errorf("\nwant %#v\n got %#v", td.wantV, gotV) 296 | } 297 | }) 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /internal/installer/install_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Contrast Security, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package installer 16 | 17 | import ( 18 | "bytes" 19 | "crypto/sha256" 20 | "encoding/hex" 21 | "errors" 22 | "fmt" 23 | "io" 24 | "net/http" 25 | "net/http/httptest" 26 | "os" 27 | "path/filepath" 28 | "strings" 29 | "testing" 30 | ) 31 | 32 | const dlsite = "https://pkg.contrastsecurity.com/go-agent-release" 33 | 34 | func Test_userAgent(t *testing.T) { 35 | t.Run("test user agent", func(t *testing.T) { 36 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 | _, _ = w.Write([]byte("some data")) 38 | })) 39 | t.Cleanup(s.Close) 40 | response, err := makeRequest(http.MethodGet, s.URL) 41 | 42 | if err != nil { 43 | t.Fatalf("unexpected err: %v", err) 44 | } 45 | 46 | if !strings.Contains(response.Request.Header.Get("User-agent"), "contrast-go-installer") { 47 | t.Fatalf("expected constrast-go-install in user-agent header, got: %v", response.Request.Header.Get("User-agent")) 48 | } 49 | }) 50 | } 51 | 52 | func Test_download(t *testing.T) { 53 | checksumHandler := func(b []byte) http.Handler { 54 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 55 | h := sha256.New() 56 | if _, err := io.Copy(h, bytes.NewBuffer(b)); err != nil { 57 | t.Fatalf("unable to calculate checksum: %s", err) 58 | } 59 | hash := hex.EncodeToString(h.Sum(nil)) 60 | w.Header().Set("X-Checksum-Sha256", hash) 61 | }) 62 | } 63 | var tests = map[string]struct { 64 | handler http.Handler 65 | headHandler http.Handler 66 | 67 | // if non-nil, is called to configure the server 68 | server func(*httptest.Server) *httptest.Server 69 | 70 | // if non-empty, expect an error containing the string 71 | expectErr string 72 | }{ 73 | "simple": { 74 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 75 | _, _ = w.Write([]byte("some data")) 76 | }), 77 | headHandler: checksumHandler([]byte("some data")), 78 | }, 79 | "404": { 80 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 81 | w.WriteHeader(404) 82 | }), 83 | headHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 84 | w.WriteHeader(404) 85 | }), 86 | expectErr: `Version "v" does not exist. For a full list of versions, see`, 87 | }, 88 | "500": { 89 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 | w.WriteHeader(500) 91 | }), 92 | headHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 93 | w.WriteHeader(500) 94 | }), 95 | expectErr: "server did not return 200", 96 | }, 97 | "EOF from content-length mismatch": { 98 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 99 | w.Header().Set("Content-Length", "1000") 100 | w.WriteHeader(200) 101 | _, _ = w.Write([]byte("not 1000 bytes")) 102 | }), 103 | headHandler: checksumHandler([]byte("not 1000 bytes")), 104 | expectErr: "couldn't download file", 105 | }, 106 | "bad connection": { 107 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 108 | _, _ = w.Write([]byte("some data")) 109 | }), 110 | server: func(s *httptest.Server) *httptest.Server { 111 | s.Close() 112 | return s 113 | }, 114 | headHandler: checksumHandler([]byte("some data")), 115 | expectErr: "there is a network communication issue", 116 | }, 117 | "untrusted cert": { 118 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 119 | _, _ = w.Write([]byte("some data")) 120 | }), 121 | server: func(s *httptest.Server) *httptest.Server { 122 | return httptest.NewTLSServer(s.Config.Handler) 123 | }, 124 | headHandler: checksumHandler([]byte("some data")), 125 | expectErr: "certificate", 126 | }, 127 | "follows redirect": { 128 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 129 | if !strings.Contains(r.RequestURI, "redirect") { 130 | http.Redirect(w, r, "/redirect", http.StatusMovedPermanently) 131 | return 132 | } 133 | _, _ = w.Write([]byte("some data")) 134 | }), 135 | headHandler: checksumHandler([]byte("some data")), 136 | }, 137 | "follows redirect to error": { 138 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 139 | if !strings.Contains(r.RequestURI, "redirect") { 140 | http.Redirect(w, r, "/redirect", http.StatusMovedPermanently) 141 | return 142 | } 143 | w.WriteHeader(404) 144 | }), 145 | headHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 146 | w.WriteHeader(404) 147 | }), 148 | expectErr: `Version "v" does not exist. For a full list of versions, see`, 149 | }, 150 | "url is correctly formatted": { 151 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 152 | if r.RequestURI == "/v/os-arch/contrast-go" { 153 | _, _ = w.Write([]byte("ok")) 154 | return 155 | } 156 | w.WriteHeader(404) 157 | }), 158 | headHandler: checksumHandler([]byte("ok")), 159 | }, 160 | "lists available versions when given version is not available": { 161 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 162 | if strings.Contains(r.RequestURI, "/v") { 163 | w.WriteHeader(404) 164 | return 165 | } 166 | _, _ = w.Write([]byte(`0.1.21.2.3latest`)) 167 | }), 168 | headHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 169 | if strings.Contains(r.RequestURI, "/v") { 170 | w.WriteHeader(404) 171 | return 172 | } 173 | checksumHandler([]byte(`0.1.21.2.3latest`)).ServeHTTP(w, r) 174 | }), 175 | expectErr: "\"v\" does not exist. Available versions include\n\tlatest, 1.2.3, 0.1.2", 176 | }, 177 | "invalid checksum": { 178 | handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 179 | _, _ = w.Write([]byte("some data")) 180 | }), 181 | headHandler: checksumHandler([]byte("different data")), 182 | expectErr: "checksum mismatch, expected", 183 | }, 184 | } 185 | 186 | handler := func(handler, headHandler http.Handler) http.Handler { 187 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 188 | if r.Method == http.MethodHead && headHandler != nil { 189 | headHandler.ServeHTTP(w, r) 190 | return 191 | } 192 | handler.ServeHTTP(w, r) 193 | }) 194 | } 195 | 196 | for name, test := range tests { 197 | t.Run(name, func(t *testing.T) { 198 | s := httptest.NewServer(handler(test.handler, test.headHandler)) 199 | if test.server != nil { 200 | s = test.server(s) 201 | } 202 | t.Cleanup(s.Close) 203 | id := installData{ 204 | baseURL: s.URL, 205 | version: "v", 206 | os: "os", 207 | arch: "arch", 208 | tmpdir: t.TempDir(), 209 | } 210 | _, err := id.download() 211 | switch { 212 | case (test.expectErr == "") != (err == nil): 213 | t.Fatalf("unexpected err: %v", err) 214 | 215 | case test.expectErr != "": 216 | if !strings.Contains(err.Error(), test.expectErr) { 217 | t.Fatalf( 218 | "error did not contain expected string %q:\n%v", 219 | test.expectErr, err, 220 | ) 221 | } 222 | } 223 | }) 224 | } 225 | } 226 | 227 | func Test_install(t *testing.T) { 228 | var tests = map[string]struct { 229 | tmpPresent bool 230 | 231 | // if the handler lets the file download, save it to this path in a tmp 232 | // dir; defaults to "contrast-go" 233 | dst string 234 | 235 | expectErr string 236 | 237 | expectNotExist bool 238 | 239 | lookupFunc func() (string, error) 240 | }{ 241 | "basic": { 242 | tmpPresent: true, 243 | }, 244 | "missing dir": { 245 | tmpPresent: true, 246 | dst: filepath.Join(t.TempDir(), "dir", "contrast-go"), 247 | }, 248 | "missing": { 249 | tmpPresent: false, 250 | expectErr: "no such file", 251 | expectNotExist: true, 252 | }, 253 | "unwriteable dir": { 254 | dst: filepath.Join("dir", "contrast-go"), 255 | expectErr: "rename", 256 | expectNotExist: true, 257 | }, 258 | "inaccessible": { 259 | tmpPresent: true, 260 | expectErr: "not found in $PATH", 261 | expectNotExist: false, 262 | lookupFunc: func() (string, error) { 263 | return "", fmt.Errorf("not found in path") 264 | }, 265 | }, 266 | "shadowed": { 267 | tmpPresent: true, 268 | expectErr: "shadowed in path", 269 | expectNotExist: false, 270 | lookupFunc: func() (string, error) { 271 | return "/made/up/directory", nil 272 | }, 273 | }, 274 | } 275 | for name, test := range tests { 276 | t.Run(name, func(t *testing.T) { 277 | tmp := t.TempDir() + "/tmpfile" 278 | id := installData{ 279 | dst: test.dst, 280 | } 281 | if len(id.dst) == 0 { 282 | id.dst = t.TempDir() + "/contrast-go" 283 | } 284 | if test.tmpPresent { 285 | if err := os.WriteFile(tmp, []byte(t.Name()), 0o644); err != nil { 286 | t.Fatal(err) 287 | } 288 | } 289 | if test.lookupFunc == nil { 290 | test.lookupFunc = func() (string, error) { 291 | return id.dst, nil 292 | } 293 | } 294 | err := id.install(tmp, test.lookupFunc) 295 | switch { 296 | case (test.expectErr == "") != (err == nil): 297 | t.Fatalf("unexpected err: %v", err) 298 | 299 | case test.expectErr != "": 300 | if !strings.Contains(err.Error(), test.expectErr) { 301 | t.Fatalf( 302 | "error did not contain expected string %q:\n%v", 303 | test.expectErr, err, 304 | ) 305 | } 306 | } 307 | fi, err := os.Stat(id.dst) 308 | if test.expectNotExist { 309 | if !errors.Is(err, os.ErrNotExist) { 310 | t.Fatalf("expected file to not exist") 311 | } 312 | return 313 | } 314 | if err != nil { 315 | t.Fatal(err) 316 | } 317 | 318 | if fi.Size() < 1 { 319 | t.Fatal("unexpected 0 length file") 320 | } 321 | 322 | if fi.Mode()&0100 == 0 { 323 | t.Fatalf("file with mode %v is not executable", fi.Mode()) 324 | } 325 | }) 326 | } 327 | } 328 | --------------------------------------------------------------------------------