diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..4b0a717 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,53 @@ +name-template: "v$RESOLVED_VERSION" +tag-template: "v$RESOLVED_VERSION" + +categories: + - title: "💥 Breaking Changes" + semver-increment: "major" + when: + conventional: + breaking: true + - title: "🚀 Features" + semver-increment: "minor" + when: + conventional: + type: "feat" + - title: "🔒 Security" + semver-increment: "patch" + when: + labels: + - "security" + - title: "📦 Dependencies" + semver-increment: "patch" + when: + labels: + - "renovate" + - title: "🐛 Fixes" + semver-increment: "patch" + when: + labels: + - "fix" + +autolabeler: + - label: "breaking" + title: + - "/^(feat|fix)!:/" + - label: "feature" + title: + - '/^feat(\(.+\))?:/' + - label: "fix" + title: + - '/^fix(?!\(deps\))(\(.+\))?:/' + - label: "renovate" + title: + - '/^(fix|chore)\(deps\):/' + - label: "security" + title: + - '/^fix\(security\):/' + +change-template: "- $TITLE (#$NUMBER) @$AUTHOR" + +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml new file mode 100644 index 0000000..4824fa9 --- /dev/null +++ b/.github/workflows/build_test.yml @@ -0,0 +1,31 @@ +name: "Test the build" + +on: + pull_request: + +jobs: + test: + name: test if aws-ssh can be built + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v7 + - name: set up Go + uses: actions/setup-go@v7 + with: + go-version: 1.27.x + - name: cache modules + uses: actions/cache@v6 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: download dependencies + run: go mod download + - name: golangci-lint + uses: golangci/golangci-lint-action@v9.3.0 + - name: build the app + run: go build + - name: test the app + run: go test -v -race ./... diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 0000000..ca78829 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,51 @@ +name: Vulnerability Scan + +on: + push: + branches: + - master + schedule: + - cron: "0 21 * * *" + workflow_dispatch: {} + +jobs: + govulncheck: + name: Run govulncheck + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v7 + + - name: set up Go + uses: actions/setup-go@v7 + with: + go-version: 1.27.x + + - name: install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: run govulncheck + id: scan + run: | + if govulncheck ./...; then + echo "found=false" >> "$GITHUB_OUTPUT" + else + echo "found=true" >> "$GITHUB_OUTPUT" + fi + + - name: notify slack + if: steps.scan.outputs.found == 'true' + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ vars.SLACK_CHANNEL_ID }} + SLACK_USERGROUP_ID: ${{ vars.SLACK_USERGROUP_ID }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + + TEXT=" govulncheck found a reachable vulnerability in \`ssm-parent\`. <${RUN_URL}|View the run> for details." + + curl -sf -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ + -H "Content-Type: application/json; charset=utf-8" \ + --data "$(jq -n --arg channel "$SLACK_CHANNEL_ID" --arg text "$TEXT" '{channel: $channel, text: $text}')" diff --git a/.github/workflows/pr-autolabeler.yml b/.github/workflows/pr-autolabeler.yml new file mode 100644 index 0000000..0a15a39 --- /dev/null +++ b/.github/workflows/pr-autolabeler.yml @@ -0,0 +1,23 @@ +name: PR Autolabeler + +on: + pull_request: + types: + - opened + - reopened + - synchronize + branches: + - master + +jobs: + auto-label: + name: Auto Label + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Autolabel PR + uses: release-drafter/release-drafter/autolabeler@v7 + env: + GITHUB_TOKEN: ${{ secrets.CICD_TOKEN }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..5f72565 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,56 @@ +name: Release Drafter + +on: + push: + branches: + - master + +jobs: + update-release-draft: + name: Update Release Draft + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + steps: + - name: Update release draft + uses: release-drafter/release-drafter@v7 + env: + GITHUB_TOKEN: ${{ secrets.CICD_TOKEN }} + + publish-if-security-fix: + needs: update-release-draft + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check if pushed commit's PR was a security fix + id: check + env: + GH_TOKEN: ${{ secrets.CICD_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + + IS_SECURITY=$(gh api "repos/${GH_REPO}/commits/${{ github.sha }}/pulls" \ + --jq 'any(.[].labels[].name; . == "security")') + + echo "is_security=$IS_SECURITY" >> "$GITHUB_OUTPUT" + + - name: Publish current draft release + if: steps.check.outputs.is_security == 'true' + env: + GH_TOKEN: ${{ secrets.CICD_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + + DRAFT_TAG=$(gh release list --limit 10 --json tagName,isDraft --jq '[.[] | select(.isDraft == true)] | .[0].tagName // empty') + + if [ -z "$DRAFT_TAG" ]; then + echo "No draft release found — nothing to publish." + exit 0 + fi + + echo "Publishing draft release: $DRAFT_TAG" + gh release edit "$DRAFT_TAG" --draft=false diff --git a/.github/workflows/release-publish-scheduled.yml b/.github/workflows/release-publish-scheduled.yml new file mode 100644 index 0000000..6867211 --- /dev/null +++ b/.github/workflows/release-publish-scheduled.yml @@ -0,0 +1,31 @@ +name: Publish Scheduled Release + +on: + schedule: + - cron: "0 17 1,15 * *" + workflow_dispatch: {} + +permissions: + contents: write + +jobs: + publish_draft_release: + name: Publish Draft Release + runs-on: ubuntu-latest + steps: + - name: Publish current draft release, if any + env: + GH_TOKEN: ${{ secrets.CICD_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + + DRAFT_TAG=$(gh release list --limit 10 --json tagName,isDraft --jq '[.[] | select(.isDraft == true)] | .[0].tagName // empty') + + if [ -z "$DRAFT_TAG" ]; then + echo "No draft release found — nothing to publish." + exit 0 + fi + + echo "Publishing draft release: $DRAFT_TAG" + gh release edit "$DRAFT_TAG" --draft=false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 000595d..4596a0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,29 +1,25 @@ name: Build and publish aws-ssh on: - push: - tags: - - '*' + release: + types: [published] + jobs: - build_and_deploy: - name: build and deploy aws-ssh + build_and_publish: + name: build and publish aws-ssh runs-on: ubuntu-latest steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Unshallow to restore tags - run: git fetch --prune --unshallow - - - name: Set up Go - uses: actions/setup-go@v1 + - name: checkout + uses: actions/checkout@v7 with: - go-version: 1.17.x - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v1 + fetch-depth: 0 # needed for tags + - name: set up Go + uses: actions/setup-go@v7 + with: + go-version: 1.27.x + - name: run GoReleaser + uses: goreleaser/goreleaser-action@v7 with: version: latest - args: release --rm-dist + args: release env: GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} diff --git a/cmd/completion.go b/cmd/completion.go index c5bde04..8b11000 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -54,13 +54,13 @@ PowerShell: Run: func(cmd *cobra.Command, args []string) { switch args[0] { case "bash": - cmd.Root().GenBashCompletion(os.Stdout) + cobra.CheckErr(cmd.Root().GenBashCompletion(os.Stdout)) case "zsh": - cmd.Root().GenZshCompletion(os.Stdout) + cobra.CheckErr(cmd.Root().GenZshCompletion(os.Stdout)) case "fish": - cmd.Root().GenFishCompletion(os.Stdout, true) + cobra.CheckErr(cmd.Root().GenFishCompletion(os.Stdout, true)) case "powershell": - cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + cobra.CheckErr(cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)) } }, } diff --git a/cmd/connect.go b/cmd/connect.go index bb915ba..5383452 100644 --- a/cmd/connect.go +++ b/cmd/connect.go @@ -103,21 +103,18 @@ func init() { connectCmd.Flags().StringP("ssh-config-path", "c", defaultSSHConfigFile, "Path to the ssh config to generate") connectCmd.Flags().StringP("user", "u", "", "Existing user on the instance") - viper.BindPFlag("instanceid", connectCmd.Flags().Lookup("instanceid")) - viper.BindPFlag("proxyjump", connectCmd.Flags().Lookup("proxyjump")) - viper.BindPFlag("ssh-config-path", connectCmd.Flags().Lookup("ssh-config-path")) - viper.BindPFlag("user", connectCmd.Flags().Lookup("user")) + cobra.CheckErr(viper.BindPFlag("instanceid", connectCmd.Flags().Lookup("instanceid"))) + cobra.CheckErr(viper.BindPFlag("proxyjump", connectCmd.Flags().Lookup("proxyjump"))) + cobra.CheckErr(viper.BindPFlag("ssh-config-path", connectCmd.Flags().Lookup("ssh-config-path"))) + cobra.CheckErr(viper.BindPFlag("user", connectCmd.Flags().Lookup("user"))) // custom completion for instances - connectCmd.RegisterFlagCompletionFunc("instanceid", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + cobra.CheckErr(connectCmd.RegisterFlagCompletionFunc("instanceid", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { cache := cache.NewYAMLCache(viper.GetString("cache-dir")) - names, err := cache.ListCanonicalNames() - if err != nil { - // can happen if there's no cache yet - // safe to just ignore it here - } + // the error is expected if there's no cache yet, safe to ignore + names, _ := cache.ListCanonicalNames() return names, cobra.ShellCompDirectiveNoFileComp - }) + })) rootCmd.AddCommand(connectCmd) } diff --git a/cmd/root.go b/cmd/root.go index ca43129..1d931ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,8 +14,6 @@ import ( "github.com/spf13/viper" ) -var cfgFile string - // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "aws-ssh", @@ -58,10 +56,10 @@ func init() { rootCmd.PersistentFlags().StringSliceP("profile", "p", []string{}, "Profiles to query. Can be specified multiple times. If not specified, goes through all profiles in ~/.aws/config and ~/.aws/credentials") rootCmd.PersistentFlags().StringP("cache-dir", "", defaultCacheDir, "Cache dir, which is used by \"update\" and \"connect\" commands") - viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) - viper.BindPFlag("no-profile-prefix", rootCmd.PersistentFlags().Lookup("no-profile-prefix")) - viper.BindPFlag("profiles", rootCmd.PersistentFlags().Lookup("profile")) - viper.BindPFlag("cache-dir", rootCmd.PersistentFlags().Lookup("cache-dir")) + cobra.CheckErr(viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))) + cobra.CheckErr(viper.BindPFlag("no-profile-prefix", rootCmd.PersistentFlags().Lookup("no-profile-prefix"))) + cobra.CheckErr(viper.BindPFlag("profiles", rootCmd.PersistentFlags().Lookup("profile"))) + cobra.CheckErr(viper.BindPFlag("cache-dir", rootCmd.PersistentFlags().Lookup("cache-dir"))) viper.SetEnvPrefix("aws_ssh") // will be uppercased diff --git a/cmd/update.go b/cmd/update.go index 8ba0c70..0ced17a 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -28,13 +28,6 @@ allowing to automatically fill those values in. log.WithError(err).Warn("got some errors") } - var sshEntries []lib.SSHEntry - - // go through all profileSummaries and - // create sshEntries out of it - for _, summary := range profileSummaries { - sshEntries = append(sshEntries, summary.SSHEntries...) - } if err := cache.Save(profileSummaries); err != nil { log.WithError(err).Fatal("couldn't save cache") diff --git a/lib/aws.go b/lib/aws.go index 5a772b3..c12c976 100644 --- a/lib/aws.go +++ b/lib/aws.go @@ -178,7 +178,7 @@ func DescribeProfile(profile ProfileConfig, sum chan profileSummary, errChan cha config.WithSharedConfigProfile(profile.Name)) if err != nil { - errChan <- fmt.Errorf("Couldn't create session for '%s': %s", profile.Name, err) + errChan <- fmt.Errorf("couldn't create session for '%s': %s", profile.Name, err) return } @@ -208,15 +208,13 @@ func DescribeProfile(profile ProfileConfig, sum chan profileSummary, errChan cha break } else { for _, reservation := range result.Reservations { - for _, instance := range reservation.Instances { - profileSummary.Instances = append(profileSummary.Instances, instance) - } + profileSummary.Instances = append(profileSummary.Instances, reservation.Instances...) } } } if err != nil { - errChan <- fmt.Errorf("Can't get full information for '%s': %s", profile, err) + errChan <- fmt.Errorf("can't get full information for '%s': %s", profile, err) } else { sum <- profileSummary } diff --git a/lib/cache/yaml.go b/lib/cache/yaml.go index 3c7dcc4..a59bf5b 100644 --- a/lib/cache/yaml.go +++ b/lib/cache/yaml.go @@ -29,14 +29,14 @@ type YAMLCacheIndex struct { func (y *YAMLCache) saveIndex() error { // save index - var indexFileName = path.Join(y.basedir, fmt.Sprintf("index.yaml")) + var indexFileName = path.Join(y.basedir, "index.yaml") indexFile, err := os.OpenFile(indexFileName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return fmt.Errorf("can't open %s: %s", indexFileName, err) } - defer indexFile.Close() + defer func() { _ = indexFile.Close() }() encoder := yaml.NewEncoder(indexFile) err = encoder.Encode(&y.index) if err != nil { @@ -54,7 +54,7 @@ func (y *YAMLCache) loadIndex() error { return nil } // load index - var indexFileName = path.Join(y.basedir, fmt.Sprintf("index.yaml")) + var indexFileName = path.Join(y.basedir, "index.yaml") indexFile, err := os.OpenFile(indexFileName, os.O_RDONLY, 0644) if err != nil { if os.IsNotExist(err) { @@ -63,7 +63,7 @@ func (y *YAMLCache) loadIndex() error { return fmt.Errorf("can't open %s: %s", indexFileName, err) } - defer indexFile.Close() + defer func() { _ = indexFile.Close() }() decoder := yaml.NewDecoder(indexFile) err = decoder.Decode(&y.index) if err != nil { @@ -93,7 +93,7 @@ func (y *YAMLCache) Save(profileSummaries []lib.ProcessedProfileSummary) error { return fmt.Errorf("can't open %s: %s", fileName, err) } - defer file.Close() + defer func() { _ = file.Close() }() encoder := yaml.NewEncoder(file) err = encoder.Encode(&sshEntry) if err != nil { @@ -143,7 +143,7 @@ func (y *YAMLCache) Lookup(name string) (lib.SSHEntry, error) { return entry, fmt.Errorf("no names in index, try \"aws-ssh update\"") } idx, err := fuzzyfinder.Find(y.index.CanonicalNames, func(i int) string { - return fmt.Sprintf("%s", y.index.CanonicalNames[i]) + return y.index.CanonicalNames[i] }) if err == fuzzyfinder.ErrAbort { return entry, fmt.Errorf("nothing was selected in fuzzy match") @@ -168,7 +168,7 @@ func (y *YAMLCache) Lookup(name string) (lib.SSHEntry, error) { return entry, fmt.Errorf("can't open %s: %s", fileName, err) } - defer file.Close() + defer func() { _ = file.Close() }() decoder := yaml.NewDecoder(file) err = decoder.Decode(&entry) if err != nil { diff --git a/lib/ec2connect/connect.go b/lib/ec2connect/connect.go index 52170f4..0fcb85a 100644 --- a/lib/ec2connect/connect.go +++ b/lib/ec2connect/connect.go @@ -116,7 +116,7 @@ func pushEC2Connect(profile, instanceID, instanceUser, pubKey string) (string, s } if len(ec2Result.Reservations) == 0 || len(ec2Result.Reservations[0].Instances) == 0 { - return "", "", fmt.Errorf("Couldn't find the instance %s", instanceID) + return "", "", fmt.Errorf("couldn't find the instance %s", instanceID) } ec2Instance := ec2Result.Reservations[0].Instances[0] diff --git a/lib/lcs.go b/lib/lcs.go index c61a1b5..1bb3eb4 100644 --- a/lib/lcs.go +++ b/lib/lcs.go @@ -27,11 +27,12 @@ func lcs(a, b string) string { // read the substring out from the matrix s := make([]rune, 0, lengths[aLen][bLen]) for x, y := aLen, bLen; x != 0 && y != 0; { - if lengths[x][y] == lengths[x-1][y] { + switch lengths[x][y] { + case lengths[x-1][y]: x-- - } else if lengths[x][y] == lengths[x][y-1] { + case lengths[x][y-1]: y-- - } else { + default: s = append(s, arunes[x-1]) x-- y-- diff --git a/lib/reconf.go b/lib/reconf.go index 94df3f0..b50ccf9 100644 --- a/lib/reconf.go +++ b/lib/reconf.go @@ -2,7 +2,6 @@ package lib import ( "io" - "io/ioutil" "os" "path" @@ -24,7 +23,7 @@ func Reconf(profiles []ProfileConfig, filename string, noProfilePrefix bool) { sshEntries = append(sshEntries, summary.SSHEntries...) } - tmpfile, err := ioutil.TempFile(path.Dir(filename), "aws-ssh") + tmpfile, err := os.CreateTemp(path.Dir(filename), "aws-ssh") ctx := log.WithField("tmpfile", tmpfile.Name()) if err != nil { ctx.WithError(err).Fatal("Couldn't create a temporary file") diff --git a/lib/types.go b/lib/types.go index a08d548..4f2320d 100644 --- a/lib/types.go +++ b/lib/types.go @@ -24,7 +24,7 @@ func (e SSHEntries) SaveConfig(path string) error { if err != nil { return fmt.Errorf("can't open file for writing: %s", err) } - defer file.Close() + defer func() { _ = file.Close() }() for _, entry := range e { if _, err := io.WriteString(file, entry.ConfigFormat()); err != nil { return fmt.Errorf("can't write to file: %s", err) diff --git a/lib/util.go b/lib/util.go index 7d4c11c..91314e6 100644 --- a/lib/util.go +++ b/lib/util.go @@ -11,7 +11,7 @@ import ( const bastionCanonicalName = "bastion" -var sanitiser = regexp.MustCompile("[\\s-]+") +var sanitiser = regexp.MustCompile(`[\s-]+`) func getTagValue(tag string, tags []types.Tag, caseInsensitive ...bool) string { if len(caseInsensitive) > 0 { diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..af9a9a4 --- /dev/null +++ b/renovate.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "timezone": "Pacific/Auckland", + "semanticCommits": "enabled", + "labels": ["renovate"], + "packageRules": [ + { + "groupName": "all non-major dependencies", + "matchPackageNames": ["*"], + "matchUpdateTypes": ["patch", "minor"], + "automerge": true, + "automergeType": "pr", + "platformAutomerge": true + }, + { + "groupName": "all major dependencies", + "matchPackageNames": ["*"], + "matchUpdateTypes": ["major"], + "automerge": false + }, + { + "groupName": "all major github actions", + "matchManagers": ["github-actions"], + "matchUpdateTypes": ["major"], + "automerge": true, + "automergeType": "pr", + "platformAutomerge": true + } + ], + "vulnerabilityAlerts": { + "labels": ["security"] + } +}