diff --git a/.github/workflows/codeChecks.yml b/.github/workflows/codeChecks.yml index 19b832c..c349b7b 100644 --- a/.github/workflows/codeChecks.yml +++ b/.github/workflows/codeChecks.yml @@ -5,6 +5,7 @@ on: paths: - ".github/workflows/codeChecks.yml" - ".goreleaser.yaml" + - ".golangci.yml" - ".testcoverage.yml" - "devenv.*" - "cmd/**" @@ -13,108 +14,121 @@ on: - "*.go" - "go.*" +permissions: + contents: read + jobs: - go_tests: + go_lint: + name: Go lint + runs-on: ubuntu-latest + strategy: + max-parallel: 2 + matrix: + go-version: ["1.25"] + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: stable + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: latest + + go_vulncheck: + name: Go vulnerabilities check runs-on: ubuntu-latest strategy: - max-parallel: 1 + max-parallel: 2 matrix: go-version: ["1.25"] steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ matrix.go-version }} - name: Install dependencies - run: go get . + run: go mod tidy - - name: Build - run: go build -v ./... - - - name: Test with the Go CLI - run: go test -v ./... - - - name: Check for vulnerabilities - uses: golang/govulncheck-action@v1 + - name: govulncheck test + uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 with: go-version-input: ${{ matrix.go-version }} go-package: ./... work-dir: . - # # WARN this action will install devenv 2.x.x while - # # the repo still uses 1.11.1. Disabling it until devenv is upgraded - # devenv_test: - # needs: go_tests - # runs-on: ubuntu-latest - # - # steps: - # - name: Checkout - # uses: actions/checkout@v5 - # - # - uses: cachix/install-nix-action@v31 - # with: - # github_access_token: ${{ secrets.GITHUB_TOKEN }} - # nix_path: nixpkgs=channel:nixos-25.11 - # - # - uses: cachix/cachix-action@v16 - # with: - # name: devenv - # - # - name: Install devenv.sh - # run: nix profile add nixpkgs#devenv - # - # - name: Build the devenv shell and run any pre-commit hooks - # env: - # JWTINFO_TEST_AUTH0: ${{ secrets.JWTINFO_TEST_AUTH0 }} - # run: devenv test - # timeout-minutes: 15 - # - go_test_coverage_check: - needs: go_tests + go_tests: + name: Go tests and build runs-on: ubuntu-latest + strategy: + max-parallel: 2 + matrix: + go-version: ["1.25"] + steps: - - uses: actions/checkout@v5 - - uses: actions/setup-go@v6 + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ matrix.go-version }} + + - name: Install dependencies + run: go mod tidy + + - name: Test with the Go CLI + run: go test -v ./... + + - name: Build + run: go build -v ./... + + goreleaser_test: + name: GoReleaser release test + runs-on: ubuntu-latest + + strategy: + max-parallel: 2 + matrix: + go-version: ["1.25"] + + needs: + - go_lint + - go_tests + - go_vulncheck + + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - go-version: "1.25" + fetch-depth: 0 + + - name: Install Nix + uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 + + - name: Install Syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - - name: generate test coverage - run: go test ./... -coverprofile=./cover.out -covermode=atomic -coverpkg=./... + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ matrix.go-version }} - - name: check test coverage - continue-on-error: ${{ github.ref_name != 'main' }} - uses: vladopajic/go-test-coverage@v2 + - name: Run GoReleaser test + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 with: - config: ./.testcoverage.yml - git-token: ${{ github.ref_name == 'main' && secrets.GITHUB_TOKEN || '' }} - git-branch: badges - - # goreleaser_test: - # needs: devenv_test - # runs-on: ubuntu-latest - # - # steps: - # - name: Checkout - # uses: actions/checkout@v5 - # with: - # fetch-depth: 0 - # - # - name: Set up QEMU - # uses: docker/setup-qemu-action@v3 - # - # - name: Set up Go - # uses: actions/setup-go@v6 - # with: - # go-version: "1.25" - # - # - name: Run GoReleaser test - # uses: goreleaser/goreleaser-action@v6 - # with: - # version: "~> 2" - # args: release --snapshot --clean - # workdir: . + version: "~> 2" + args: release --snapshot --clean + workdir: . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65ef021..a2a65f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,47 +4,55 @@ on: push: tags: - "*" + workflow_run: + workflows: ["CodeChecks"] + types: + - completed permissions: contents: write +env: + CGO_ENABLED: 0 + DOCKER_CLI_EXPERIMENTAL: "enabled" + GO_VERSION: "1.25" + jobs: goreleaser: runs-on: ubuntu-latest - strategy: - matrix: - go-version: ["1.25"] - - env: - DOCKER_CLI_EXPERIMENTAL: "enabled" + if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 - - uses: cachix/install-nix-action@v31 + - name: Install Nix + uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 with: github_access_token: ${{ secrets.GH_GORELEASER_TOKEN }} + - name: Install Syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GH_GORELEASER_TOKEN }} - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: ${{ matrix.go-version }} + go-version: ${{ env.GO_VERSION }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 with: version: "~> 2" args: release --clean @@ -53,4 +61,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GH_GORELEASER_TOKEN }} - name: Refresh Go Report Card - uses: creekorful/goreportcard-action@v1.0 + uses: creekorful/goreportcard-action@1f35ced8cdac2cba28c9a2f2288a16aacfd507f9 # v1.0 diff --git a/.golangci.yml b/.golangci.yml index 2899fcb..b3d6579 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,6 +6,7 @@ linters: - dupword - dupl - durationcheck + - errcheck - errorlint - errchkjson - misspell @@ -13,6 +14,9 @@ linters: - iface - unconvert - revive + - gocyclo + - cyclop + - gocognit - wsl_v5 - tagliatelle - testifylint @@ -27,6 +31,10 @@ linters: settings: gocyclo: min-complexity: 15 + cyclop: + max-complexity: 15 + gocognit: + min-complexity: 15 revive: enable-all-rules: true @@ -38,7 +46,7 @@ linters: disabled: true exclude: [""] - name: exported - disabled: true + disabled: false - name: line-length-limit severity: warning @@ -53,13 +61,17 @@ linters: exclude: [""] arguments: [15] - # https://github.com/mgechev/revive/blob/HEAD/RULES_DESCRIPTIONS.md#cyclomatic - name: cyclomatic severity: warning disabled: false exclude: [""] arguments: [15] + - name: unhandled-error + severity: warning + disabled: false + exclude: [""] + exclusions: generated: lax presets: @@ -75,3 +87,11 @@ linters: - third_party$ - builtin$ - examples$ + - "(^|.*/|\\\\)vendor/.*" + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5293e8a..2574fc6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,18 +2,24 @@ # vim: set ts=2 sw=2 tw=0 fo=cnqoj --- version: 2 + project_name: https-wrench + dist: ./dist/ + force_token: github + env_files: gitea_token: ~/nope github_token: ~/nope + release: disable: false skip_upload: false github: owner: xenos76 name: https-wrench + before: hooks: - go mod download @@ -51,6 +57,15 @@ archives: - completions/* - manpages/* +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" + algorithm: sha256 + +sboms: + - artifacts: archive + - id: source + artifacts: source + nfpms: - maintainer: Zeno Belli vendor: xenos76 on Github diff --git a/devenv.nix b/devenv.nix index 3114c12..ddd9481 100644 --- a/devenv.nix +++ b/devenv.nix @@ -189,7 +189,7 @@ in { test -d dist || mkdir dist APP_VERSION=$(git describe --tags || echo '0.0.0') && GO_MODULE_NAME=$(go list -m) && - CGO_ENABLED=0 go build -o ./dist/https-wrench -ldflags "-X $GO_MODULE_NAME/cmd.version=$APP_VERSION" main.go + CGO_ENABLED=0 go build -o ./dist/https-wrench -ldflags "-X $GO_MODULE_NAME/internal/cmd.version=$APP_VERSION" main.go ''; scripts.goreleaser-test-release.exec = '' @@ -284,7 +284,7 @@ in { scripts.test-requests-sample-config.exec = '' gum format "## test request with sample config" - ./dist/https-wrench requests --config ./cmd/embedded/config-example.yaml + ./dist/https-wrench requests --config ./internal/cmd/embedded/config-example.yaml ''; scripts.test-requests-k3s.exec = '' diff --git a/internal/certinfo/certinfo.go b/internal/certinfo/certinfo.go index 2ee6762..aaa28fd 100644 --- a/internal/certinfo/certinfo.go +++ b/internal/certinfo/certinfo.go @@ -20,8 +20,8 @@ const ( emptyString = "" ) -// CertinfoConfig holds the configuration and results for certificate and key information retrieval. -type CertinfoConfig struct { +// Config holds the configuration and results for certificate and key information retrieval. +type Config struct { // CACertsPool is the pool of root CA certificates used for verification. CACertsPool *x509.CertPool // CACertsFilePath is the path to the CA certificate bundle file. @@ -86,14 +86,14 @@ func (InputReader) ReadPassword(fd int) ([]byte, error) { return term.ReadPassword(fd) } -// NewCertinfoConfig creates a new CertinfoConfig with the system's default certificate pool. -func NewCertinfoConfig() (*CertinfoConfig, error) { +// New creates a new Config with the system's default certificate pool. +func New() (*Config, error) { defaultCertPool, err := x509.SystemCertPool() if err != nil { return nil, err } - c := CertinfoConfig{ + c := Config{ CACertsPool: defaultCertPool, } @@ -103,7 +103,7 @@ func NewCertinfoConfig() (*CertinfoConfig, error) { // SetCaPoolFromFile loads a CA certificate pool from the specified PEM bundle file. // Note that x509.SystemCertPool is not used in this case. All certificates // from the system certificate pool are excluded. -func (c *CertinfoConfig) SetCaPoolFromFile(filePath string, fileReader Reader) error { +func (c *Config) SetCaPoolFromFile(filePath string, fileReader Reader) error { if filePath != emptyString { caCertsPool, err := GetRootCertsFromFile( filePath, @@ -121,7 +121,7 @@ func (c *CertinfoConfig) SetCaPoolFromFile(filePath string, fileReader Reader) e } // SetCertsFromFile loads a certificate bundle from the specified PEM file. -func (c *CertinfoConfig) SetCertsFromFile(filePath string, fileReader Reader) error { +func (c *Config) SetCertsFromFile(filePath string, fileReader Reader) error { if filePath != emptyString { certs, err := GetCertsFromBundle( filePath, @@ -141,7 +141,7 @@ func (c *CertinfoConfig) SetCertsFromFile(filePath string, fileReader Reader) er // SetPrivateKeyFromFile loads a private key from the specified PEM file. // If the key is encrypted, it will attempt to retrieve the passphrase from the environment // variable passed as argument or from the interactive prompt. -func (c *CertinfoConfig) SetPrivateKeyFromFile( +func (c *Config) SetPrivateKeyFromFile( filePath string, keyPwEnvVar string, fileReader Reader, @@ -164,7 +164,7 @@ func (c *CertinfoConfig) SetPrivateKeyFromFile( } // SetTLSEndpoint parses a host:port string and fetches the remote certificates from that endpoint. -func (c *CertinfoConfig) SetTLSEndpoint(hostport string) error { +func (c *Config) SetTLSEndpoint(hostport string) error { if hostport != emptyString { eHost, ePort, err := net.SplitHostPort(hostport) if err != nil { @@ -185,13 +185,13 @@ func (c *CertinfoConfig) SetTLSEndpoint(hostport string) error { } // SetTLSInsecure sets whether TLS certificate verification should be skipped for the remote endpoint. -func (c *CertinfoConfig) SetTLSInsecure(skipVerify bool) *CertinfoConfig { +func (c *Config) SetTLSInsecure(skipVerify bool) *Config { c.TLSInsecure = skipVerify return c } // SetTLSServerName sets the ServerName to use for SNI when connecting to a remote TLS endpoint. -func (c *CertinfoConfig) SetTLSServerName(serverName string) *CertinfoConfig { +func (c *Config) SetTLSServerName(serverName string) *Config { if serverName != emptyString { c.TLSServerName = serverName } diff --git a/internal/certinfo/certinfo_handlers.go b/internal/certinfo/certinfo_handlers.go index 588dd80..cab2d8c 100644 --- a/internal/certinfo/certinfo_handlers.go +++ b/internal/certinfo/certinfo_handlers.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" "github.com/dustin/go-humanize" "github.com/xenos76/https-wrench/internal/style" @@ -25,7 +26,7 @@ import ( // to the provided writer in a human-readable format. // //nolint:revive -func (c *CertinfoConfig) PrintData(w io.Writer) error { +func (c *Config) PrintData(w io.Writer) error { ks := style.ItemKey.PaddingBottom(0).PaddingTop(1).PaddingLeft(1) sl := style.CertKeyP4.Bold(true) sv := style.CertValue.Bold(false) @@ -34,6 +35,21 @@ func (c *CertinfoConfig) PrintData(w io.Writer) error { fmt.Fprintln(w, style.LgSprintf(style.Cmd, "Certinfo")) fmt.Fprintln(w) + c.printPrivateKey(w, ks, sl, sv) + + if err := c.printLocalCerts(w, ks, sl, sv); err != nil { + return err + } + + if err := c.printRemoteCerts(w, ks, sl, sv); err != nil { + return err + } + + return c.printCACerts(w, ks, sl, sv) +} + +// printPrivateKey prints the loaded private key information if available. +func (c *Config) printPrivateKey(w io.Writer, ks, sl, sv lipgloss.Style) { if c.PrivKey != nil { fmt.Fprintln(w, style.LgSprintf(ks, "PrivateKey")) fmt.Fprintln(w, style.LgSprintf( @@ -43,7 +59,10 @@ func (c *CertinfoConfig) PrintData(w io.Writer) error { )) style.PrintKeyInfoStyle(w, c.PrivKey) } +} +// printLocalCerts prints the information for certificates loaded from a local bundle file. +func (c *Config) printLocalCerts(w io.Writer, ks, sl, sv lipgloss.Style) error { if len(c.CertsBundle) > 0 { fmt.Fprintln(w, style.LgSprintf(ks, "Certificates")) @@ -72,6 +91,11 @@ func (c *CertinfoConfig) PrintData(w io.Writer) error { CertsToTables(w, c.CertsBundle) } + return nil +} + +// printRemoteCerts prints the information for certificates retrieved from a remote TLS endpoint. +func (c *Config) printRemoteCerts(w io.Writer, ks, sl, sv lipgloss.Style) error { if len(c.TLSEndpointCerts) > 0 { endpoint := sv.Render(c.TLSEndpointHost + ":" + c.TLSEndpointPort) @@ -109,6 +133,11 @@ func (c *CertinfoConfig) PrintData(w io.Writer) error { CertsToTables(w, c.TLSEndpointCerts) } + return nil +} + +// printCACerts prints the information for CA certificates loaded from a file. +func (c *Config) printCACerts(w io.Writer, ks, sl, sv lipgloss.Style) error { if len(c.CACertsFilePath) > 0 { fmt.Fprintln(w, style.LgSprintf(ks, "CA Certificates")) fmt.Fprintln(w, @@ -139,7 +168,7 @@ func (c *CertinfoConfig) PrintData(w io.Writer) error { // GetRemoteCerts establishes a TLS connection to the configured endpoint and retrieves // the peer certificate chain. It also performs certificate verification unless TLSInsecure is true. -func (c *CertinfoConfig) GetRemoteCerts() error { +func (c *Config) GetRemoteCerts() error { tlsConfig := &tls.Config{ RootCAs: c.CACertsPool, InsecureSkipVerify: c.TLSInsecure, diff --git a/internal/certinfo/certinfo_handlers_test.go b/internal/certinfo/certinfo_handlers_test.go index 62d46a8..6365efe 100644 --- a/internal/certinfo/certinfo_handlers_test.go +++ b/internal/certinfo/certinfo_handlers_test.go @@ -139,7 +139,7 @@ func TestCertinfo_GetRemoteCerts(t *testing.T) { defer ts.Close() - cc, err := NewCertinfoConfig() + cc, err := New() require.NoError(t, err) cc.SetTLSServerName(tt.srvCfg.serverName) @@ -412,7 +412,7 @@ func TestCertinfo_PrintData(t *testing.T) { t.Run("PrintData local cert private key match error", func(t *testing.T) { buffer := bytes.Buffer{} - cc, err := NewCertinfoConfig() + cc, err := New() require.NoError(t, err) // Inject a bad public key to force certMatchPrivateKey to fail @@ -429,7 +429,7 @@ func TestCertinfo_PrintData(t *testing.T) { t.Run("PrintData remote cert private key match error", func(t *testing.T) { buffer := bytes.Buffer{} - cc, err := NewCertinfoConfig() + cc, err := New() require.NoError(t, err) cc.PrivKey = "dummy_key" @@ -446,7 +446,7 @@ func TestCertinfo_PrintData(t *testing.T) { t.Run("PrintData CA cert file read error", func(t *testing.T) { buffer := bytes.Buffer{} - cc, err := NewCertinfoConfig() + cc, err := New() require.NoError(t, err) cc.CACertsFilePath = "non_existent_file.pem" @@ -476,7 +476,7 @@ func runPrintDataSubtest(t *testing.T, tt printDataTestCase) { buffer := bytes.Buffer{} - cc, err := NewCertinfoConfig() + cc, err := New() require.NoError(t, err) require.NoError(t, cc.SetPrivateKeyFromFile(tt.keyFile, "notSet", inputReader)) diff --git a/internal/certinfo/certinfo_test.go b/internal/certinfo/certinfo_test.go index c74af9d..8ac7181 100644 --- a/internal/certinfo/certinfo_test.go +++ b/internal/certinfo/certinfo_test.go @@ -8,11 +8,11 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewCertinfoConfig(t *testing.T) { - t.Run("NewCertinfoConfig", func(t *testing.T) { +func TestNew(t *testing.T) { + t.Run("New", func(t *testing.T) { t.Parallel() - cc, err := NewCertinfoConfig() + cc, err := New() require.NoError(t, err) require.NotNil(t, cc.CACertsPool) @@ -99,12 +99,12 @@ func TestCertinfo_SetCaPoolFromFile(t *testing.T) { t.Run("File Read Error Test "+tt.desc, func(t *testing.T) { t.Parallel() - cc, errNew := NewCertinfoConfig() + cc, errNew := New() require.NoError(t, errNew) err := cc.SetCaPoolFromFile(tt.caCertFile, tt.reader) - // CertinfoConfig methods do nothing if an empty string is passed + // Config methods do nothing if an empty string is passed // as filePath if tt.caCertFile == emptyString { require.NoError(t, err) @@ -123,7 +123,7 @@ func TestCertinfo_SetCaPoolFromFile(t *testing.T) { t.Run("File Read Success Test", func(t *testing.T) { t.Parallel() - cc, errNew := NewCertinfoConfig() + cc, errNew := New() require.NoError(t, errNew) err := cc.SetCaPoolFromFile( @@ -158,12 +158,12 @@ func TestCertinfo_SetCertsFromFile(t *testing.T) { t.Run("File Read Error Test "+tt.desc, func(t *testing.T) { t.Parallel() - cc, errNew := NewCertinfoConfig() + cc, errNew := New() require.NoError(t, errNew) err := cc.SetCertsFromFile(tt.certFile, tt.reader) - // CertinfoConfig methods do nothing if an empty string is passed + // Config methods do nothing if an empty string is passed // as filePath if tt.certFile == emptyString { require.NoError(t, err) @@ -182,7 +182,7 @@ func TestCertinfo_SetCertsFromFile(t *testing.T) { t.Run("File Read Success Test", func(t *testing.T) { t.Parallel() - cc, errNew := NewCertinfoConfig() + cc, errNew := New() require.NoError(t, errNew) err := cc.SetCertsFromFile( @@ -215,7 +215,7 @@ func TestCertinfo_SetPrivateKeyFromFile(t *testing.T) { t.Run("File Read Error Test "+tt.desc, func(t *testing.T) { t.Parallel() - cc, errNew := NewCertinfoConfig() + cc, errNew := New() require.NoError(t, errNew) err := cc.SetPrivateKeyFromFile( @@ -224,7 +224,7 @@ func TestCertinfo_SetPrivateKeyFromFile(t *testing.T) { tt.reader, ) - // CertinfoConfig methods do nothing if an empty string is passed + // Config methods do nothing if an empty string is passed // as filePath if tt.keyFile == emptyString { require.NoError(t, err) @@ -243,7 +243,7 @@ func TestCertinfo_SetPrivateKeyFromFile(t *testing.T) { t.Run("File Read Success Test", func(t *testing.T) { t.Parallel() - cc, errNew := NewCertinfoConfig() + cc, errNew := New() require.NoError(t, errNew) err := cc.SetPrivateKeyFromFile( @@ -284,7 +284,7 @@ func TestCertinfo_SetTLSInsecure(t *testing.T) { t.Run(testname, func(t *testing.T) { t.Parallel() - cc, errNew := NewCertinfoConfig() + cc, errNew := New() require.NoError(t, errNew) cc.SetTLSInsecure(tt) @@ -311,7 +311,7 @@ func TestCertinfo_SetTLSServerName(t *testing.T) { t.Run(testname, func(t *testing.T) { t.Parallel() - cc, errNew := NewCertinfoConfig() + cc, errNew := New() require.NoError(t, errNew) cc.SetTLSServerName(tt) @@ -391,7 +391,7 @@ func TestCertinfo_SetTLSEndpoint(t *testing.T) { t.Run(tt.desc, func(t *testing.T) { t.Parallel() - cc, errNew := NewCertinfoConfig() + cc, errNew := New() require.NoError(t, errNew) err := cc.SetTLSEndpoint(tt.endpoint) diff --git a/cmd/certinfo.go b/internal/cmd/certinfo.go similarity index 98% rename from cmd/certinfo.go rename to internal/cmd/certinfo.go index 03cac8c..6e370c5 100644 --- a/cmd/certinfo.go +++ b/internal/cmd/certinfo.go @@ -61,7 +61,7 @@ Examples: return } - certinfoCfg, err := certinfo.NewCertinfoConfig() + certinfoCfg, err := certinfo.New() if err != nil { cmd.Printf("Error creating new Certinfo config: %s", err) return diff --git a/cmd/certinfo_test.go b/internal/cmd/certinfo_test.go similarity index 100% rename from cmd/certinfo_test.go rename to internal/cmd/certinfo_test.go diff --git a/cmd/config.go b/internal/cmd/config.go similarity index 100% rename from cmd/config.go rename to internal/cmd/config.go diff --git a/cmd/config_test.go b/internal/cmd/config_test.go similarity index 100% rename from cmd/config_test.go rename to internal/cmd/config_test.go diff --git a/cmd/embedded/config-example.yaml b/internal/cmd/embedded/config-example.yaml similarity index 100% rename from cmd/embedded/config-example.yaml rename to internal/cmd/embedded/config-example.yaml diff --git a/cmd/jwks.go b/internal/cmd/jwks.go similarity index 100% rename from cmd/jwks.go rename to internal/cmd/jwks.go diff --git a/cmd/jwtinfo.go b/internal/cmd/jwtinfo.go similarity index 100% rename from cmd/jwtinfo.go rename to internal/cmd/jwtinfo.go diff --git a/cmd/jwtinfo_test.go b/internal/cmd/jwtinfo_test.go similarity index 100% rename from cmd/jwtinfo_test.go rename to internal/cmd/jwtinfo_test.go diff --git a/cmd/man.go b/internal/cmd/man.go similarity index 100% rename from cmd/man.go rename to internal/cmd/man.go diff --git a/cmd/man_test.go b/internal/cmd/man_test.go similarity index 100% rename from cmd/man_test.go rename to internal/cmd/man_test.go diff --git a/cmd/requests.go b/internal/cmd/requests.go similarity index 100% rename from cmd/requests.go rename to internal/cmd/requests.go diff --git a/cmd/requests_test.go b/internal/cmd/requests_test.go similarity index 100% rename from cmd/requests_test.go rename to internal/cmd/requests_test.go diff --git a/cmd/root.go b/internal/cmd/root.go similarity index 100% rename from cmd/root.go rename to internal/cmd/root.go diff --git a/cmd/root_test.go b/internal/cmd/root_test.go similarity index 100% rename from cmd/root_test.go rename to internal/cmd/root_test.go diff --git a/internal/jwks/jwks_test.go b/internal/jwks/jwks_test.go index 0226e4d..2ce5034 100644 --- a/internal/jwks/jwks_test.go +++ b/internal/jwks/jwks_test.go @@ -100,7 +100,7 @@ func TestGenerateJWKS_Errors(t *testing.T) { t.Run("Invalid PEM", func(t *testing.T) { invalidFile := filepath.Join(tmpDir, "invalid.pem") - err := os.WriteFile(invalidFile, []byte("not a pem"), 0644) + err := os.WriteFile(invalidFile, []byte("not a pem"), 0o644) require.NoError(t, err) _, err = GenerateJWKS(context.Background(), invalidFile, "") diff --git a/internal/jwtinfo/jwtinfo.go b/internal/jwtinfo/jwtinfo.go index 88c4e97..18b338d 100644 --- a/internal/jwtinfo/jwtinfo.go +++ b/internal/jwtinfo/jwtinfo.go @@ -241,82 +241,55 @@ func isValidJSON(data []byte) bool { // //nolint:revive func (jtd *JwtTokenData) DecodeBase64() error { - tokens := []struct { - name string - raw string - }{ - { - name: "AccessToken", - raw: jtd.AccessTokenRaw, - }, - { - name: "RefreshToken", - raw: jtd.RefreshTokenRaw, - }, - } - - for _, token := range tokens { - if token.raw == emptyString { - continue + if jtd.AccessTokenRaw != emptyString { + header, claims, err := decodeToken("AccessToken", jtd.AccessTokenRaw) + if err != nil { + return err } - var tokenHeader []byte - - var tokenClaims []byte - - var err error - - tokenB64Elements := strings.Split(token.raw, ".") - if len(tokenB64Elements) != 3 { - return fmt.Errorf("invalid three dotted JWT format in %s", token.name) - } + jtd.AccessTokenHeader = header + jtd.AccessTokenClaims = claims + } - tokenHeader, err = base64.RawURLEncoding.DecodeString(tokenB64Elements[0]) + if jtd.RefreshTokenRaw != emptyString { + header, claims, err := decodeToken("RefreshToken", jtd.RefreshTokenRaw) if err != nil { - return fmt.Errorf( - "unable to decode base64 header from %s: %w", - token.name, - err, - ) + return err } - if !isValidJSON(tokenHeader) { - return fmt.Errorf( - "invalid JSON found in header from %s: %w", - token.name, - err, - ) - } + jtd.RefreshTokenHeader = header + jtd.RefreshTokenClaims = claims + } - tokenClaims, err = base64.RawURLEncoding.DecodeString(tokenB64Elements[1]) - if err != nil { - return fmt.Errorf( - "unable to decode base64 claims from %s: %w", - token.name, - err, - ) - } + return nil +} - if !isValidJSON(tokenClaims) { - return fmt.Errorf( - "invalid JSON found in claims from %s: %w", - token.name, - err, - ) - } +// decodeToken decodes and validates a single JWT token string (header and claims). +func decodeToken(name, raw string) (header []byte, claims []byte, err error) { + tokenB64Elements := strings.Split(raw, ".") + if len(tokenB64Elements) != 3 { + return nil, nil, fmt.Errorf("invalid three dotted JWT format in %s", name) + } - if token.name == "AccessToken" { - jtd.AccessTokenHeader = tokenHeader - jtd.AccessTokenClaims = tokenClaims - } + header, err = base64.RawURLEncoding.DecodeString(tokenB64Elements[0]) + if err != nil { + return nil, nil, fmt.Errorf("unable to decode base64 header from %s: %w", name, err) + } - if token.name == "RefreshToken" { - jtd.RefreshTokenHeader = tokenHeader - jtd.RefreshTokenClaims = tokenClaims - } + if !isValidJSON(header) { + return nil, nil, fmt.Errorf("invalid JSON found in header from %s", name) } - return nil + claims, err = base64.RawURLEncoding.DecodeString(tokenB64Elements[1]) + if err != nil { + return nil, nil, fmt.Errorf("unable to decode base64 claims from %s: %w", name, err) + } + + if !isValidJSON(claims) { + return nil, nil, fmt.Errorf("invalid JSON found in claims from %s", name) + } + + return header, claims, nil } // ParseUnverified parses the access token without verifying its signature. diff --git a/internal/requests/main_test.go b/internal/requests/main_test.go index b190a20..d73f56d 100644 --- a/internal/requests/main_test.go +++ b/internal/requests/main_test.go @@ -150,7 +150,7 @@ func createTmpFileWithContent(tempDir string, filePattern string, fileContent [] err = errors.Join(err, f.Close()) }() - err = os.WriteFile(f.Name(), fileContent, 0644) + err = os.WriteFile(f.Name(), fileContent, 0o644) if err != nil { return emptyString, err } diff --git a/internal/requests/requests.go b/internal/requests/requests.go index c9c41dd..8a89862 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -278,7 +278,6 @@ func (r *RequestConfig) PrintRequestDebug(w io.Writer, req *http.Request) error // //nolint:revive func (r *RequestConfig) PrintResponseDebug(w io.Writer, resp *http.Response) { - // TODO: return an error if resp == nil { return } @@ -293,26 +292,32 @@ func (r *RequestConfig) PrintResponseDebug(w io.Writer, resp *http.Response) { fmt.Fprintf(w, "Requested url: %s\n", resp.Request.URL) fmt.Fprintf(w, "Response dump:\n%s\n", string(respDump)) - if resp.TLS != nil { - fmt.Fprintln(w, "TLS:") - fmt.Fprintf(w, "Version: %v\n", TLSVersionName(resp.TLS.Version)) - fmt.Fprintf(w, "CipherSuite: %v\n", cipherSuiteName(resp.TLS.CipherSuite)) - - for i, cert := range resp.TLS.PeerCertificates { - fmt.Fprintf(w, "Certificate %d:\n", i) - certinfo.PrintCertInfo(cert, 1, w) - } - - for i, chain := range resp.TLS.VerifiedChains { - fmt.Fprintf(w, "Verified Chain %d:\n", i) - - for j, cert := range chain { - fmt.Fprintf(w, " Cert %d:\n", j) - certinfo.PrintCertInfo(cert, 2, w) - } - } - } else { - fmt.Fprintln(w, "TLS: Not available (non-TLS connection)") + r.printTLSInfo(w, resp.TLS) + } +} + +// printTLSInfo formats and prints the TLS connection state information to the provided writer. +func (r *RequestConfig) printTLSInfo(w io.Writer, tlsState *tls.ConnectionState) { + if tlsState == nil { + fmt.Fprintln(w, "TLS: Not available (non-TLS connection)") + return + } + + fmt.Fprintln(w, "TLS:") + fmt.Fprintf(w, "Version: %v\n", TLSVersionName(tlsState.Version)) + fmt.Fprintf(w, "CipherSuite: %v\n", cipherSuiteName(tlsState.CipherSuite)) + + for i, cert := range tlsState.PeerCertificates { + fmt.Fprintf(w, "Certificate %d:\n", i) + certinfo.PrintCertInfo(cert, 1, w) + } + + for i, chain := range tlsState.VerifiedChains { + fmt.Fprintf(w, "Verified Chain %d:\n", i) + + for j, cert := range chain { + fmt.Fprintf(w, " Cert %d:\n", j) + certinfo.PrintCertInfo(cert, 2, w) } } } @@ -628,89 +633,104 @@ func processHTTPRequestsByHost( ) ([]ResponseData, error) { var responseDataList []ResponseData - requestBodyBytes := []byte(r.RequestBody) - r.PrintTitle(isVerbose) for _, host := range r.Hosts { - reqClient, err := NewHTTPClientFromRequestConfig( - r, - host.Name, - caPool) + hostResults, err := processRequestsForHost(r, host, caPool, isVerbose) if err != nil { return nil, err } - urlList, err := getUrlsFromHost(host) - if err != nil { - return nil, err - } + responseDataList = append(responseDataList, hostResults...) + } - for _, reqURL := range urlList { - responseData := ResponseData{ - Request: r, - TransportAddress: reqClient.transportAddress, - URL: reqURL, - } - - requestBodyReader := bytes.NewReader(requestBodyBytes) - - req, err := http.NewRequest( - reqClient.method, - reqURL, - requestBodyReader, - ) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - ua := httpUserAgent - if len(r.UserAgent) > 0 { - ua = r.UserAgent - } - - req.Header.Add("User-Agent", ua) - - for _, header := range r.RequestHeaders { - req.Header.Add(header.Key, header.Value) - } - - if err := r.PrintRequestDebug(os.Stdout, req); err != nil { - fmt.Fprintf(os.Stderr, "Warning: PrintRequestDebug failed: %v\n", err) - } - - resp, err := reqClient.client.Do(req) - if err != nil { - // if the request returns and error, we track it in - // the responseData and stop processing. - // Going further and importing the *http.Response into - // ResponseData would result in a nil pointer error. - // Avoiding that error will cost some duplicated code - // in this branch mirroring the end of the outer one. - responseData.Error = err - responseDataList = append(responseDataList, responseData) - responseData.PrintResponseData(isVerbose) - - continue - } - - r.PrintResponseDebug(os.Stdout, resp) - - responseData.Response = resp - - if r.ResponseBodyMatchRegexp != emptyString || responseData.Request.PrintResponseBody { - responseData.ImportResponseBody() - } - - err = resp.Body.Close() - if err != nil { - fmt.Printf("unable to close response Body: %v\n", err) - } - - responseDataList = append(responseDataList, responseData) - responseData.PrintResponseData(isVerbose) - } + return responseDataList, nil +} + +// processRequestsForHost initializes the HTTP client and executes all configured URIs for a single host. +func processRequestsForHost( + r RequestConfig, + host Host, + caPool *x509.CertPool, + isVerbose bool, +) ([]ResponseData, error) { + var responseDataList []ResponseData + + reqClient, err := NewHTTPClientFromRequestConfig(r, host.Name, caPool) + if err != nil { + return nil, err + } + + urlList, err := getUrlsFromHost(host) + if err != nil { + return nil, err + } + + requestBodyBytes := []byte(r.RequestBody) + + for _, reqURL := range urlList { + responseData := executeSingleRequest(r, reqClient, reqURL, requestBodyBytes, isVerbose) + responseDataList = append(responseDataList, responseData) + responseData.PrintResponseData(isVerbose) } return responseDataList, nil } + +// executeSingleRequest performs a single HTTP request and returns the collected response data. +func executeSingleRequest( + r RequestConfig, + reqClient *RequestHTTPClient, + reqURL string, + requestBodyBytes []byte, + isVerbose bool, +) ResponseData { + responseData := ResponseData{ + Request: r, + TransportAddress: reqClient.transportAddress, + URL: reqURL, + } + + requestBodyReader := bytes.NewReader(requestBodyBytes) + + req, err := http.NewRequest(reqClient.method, reqURL, requestBodyReader) + if err != nil { + responseData.Error = fmt.Errorf("failed to create request: %w", err) + return responseData + } + + ua := httpUserAgent + if len(r.UserAgent) > 0 { + ua = r.UserAgent + } + + for _, header := range r.RequestHeaders { + req.Header.Add(header.Key, header.Value) + } + + req.Header.Set("User-Agent", ua) + + if err := r.PrintRequestDebug(os.Stdout, req); err != nil { + fmt.Fprintf(os.Stderr, "Warning: PrintRequestDebug failed: %v\n", err) + } + + resp, err := reqClient.client.Do(req) + if err != nil { + responseData.Error = err + return responseData + } + + r.PrintResponseDebug(os.Stdout, resp) + + responseData.Response = resp + + if r.ResponseBodyMatchRegexp != emptyString || responseData.Request.PrintResponseBody { + responseData.ImportResponseBody() + } + + if err := resp.Body.Close(); err != nil { + fmt.Printf("unable to close response Body: %v\n", err) + } + + return responseData +} diff --git a/internal/requests/requests_handlers.go b/internal/requests/requests_handlers.go index 9a0dd9c..a89c401 100644 --- a/internal/requests/requests_handlers.go +++ b/internal/requests/requests_handlers.go @@ -285,6 +285,7 @@ func (rd *ResponseData) ImportResponseBody() { } // PrintResponseData prints the collected response data (status, headers, body) if verbose mode is enabled. +// //nolint:revive func (rd ResponseData) PrintResponseData(isVerbose bool) { if !isVerbose { diff --git a/main.go b/main.go index eddd0ad..5a5e98e 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ import ( "fmt" "os" - "github.com/xenos76/https-wrench/cmd" + "github.com/xenos76/https-wrench/internal/cmd" ) func main() { diff --git a/sonar-scanner.sh b/sonar-scanner.sh index 8797cee..041bea4 100755 --- a/sonar-scanner.sh +++ b/sonar-scanner.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +export SONAR_TOKEN=$(cat /home/xeno/.config/https-wrench/sonar_token_https-wrench) + sonar-scanner -Dsonar.organization=xenos76 \ -Dsonar.projectKey=xenOs76_https-wrench \ -Dsonar.go.coverage.reportPaths=cover.out \