diff --git a/.gitignore b/.gitignore index 3eee14ee..ec05207c 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ codacy-cli #Ignore vscode AI rules .github/instructions/codacy.instructions.md + +#Ignore superpowers docs +docs/superpowers/ diff --git a/README.md b/README.md index 5dbe4754..e8fa2a91 100644 --- a/README.md +++ b/README.md @@ -262,3 +262,34 @@ export CODACY_CLI_V2_VERSION="1.0.0-main.133.3607792" Check the [releases](https://github.com/codacy/codacy-cli-v2/releases) page for all available versions. --- + +## Proxy & TLS + +The CLI honors standard proxy environment variables for all outbound HTTP(S): + +- `HTTP_PROXY` / `HTTPS_PROXY` — proxy URL for plain/HTTPS requests +- `NO_PROXY` — comma-separated hosts that bypass the proxy + +### Corporate proxies with TLS interception + +If your proxy presents its own (MITM) certificate, point the CLI at the proxy's CA bundle so TLS verification still passes: + +```sh +export SSL_CERT_FILE=/path/to/corporate-ca.pem +``` + +`SSL_CERT_FILE` certificates are appended to the system trust store. + +### Disabling TLS verification (last resort) + +```sh +export CODACY_CLI_INSECURE=1 +``` + +This disables certificate verification entirely and prints a warning. Prefer `SSL_CERT_FILE`. Insecure mode is never enabled by default. + +### Testing proxy/TLS behavior + +`integration-tests/proxy-tls/run.sh` runs the CLI through a real `mitmproxy` (`brew install mitmproxy`) against `app.codacy.com` and asserts the matrix above. Loop with `PROXY_TLS_LOOP=5 integration-tests/proxy-tls/run.sh`. + +--- diff --git a/cmd/upload.go b/cmd/upload.go index 0b265c86..4c6d4b3c 100644 --- a/cmd/upload.go +++ b/cmd/upload.go @@ -5,6 +5,7 @@ import ( "codacy/cli-v2/config" "codacy/cli-v2/domain" "codacy/cli-v2/plugins" + "codacy/cli-v2/utils/httpclient" "encoding/json" "fmt" "io" @@ -251,7 +252,11 @@ func resultsFinalWithProjectToken(commitUUID string, projectToken string) { req.Header.Set("Content-Type", "application/json") req.Header.Set("project-token", projectToken) - client := &http.Client{} + client, err := httpclient.New() + if err != nil { + fmt.Println("Error:", err) + return + } resp, err := client.Do(req) if err != nil { fmt.Println("Error:", err) @@ -269,7 +274,11 @@ func resultsFinalWithAPIToken(commitUUID string, apiToken string, provider strin req.Header.Set("Content-Type", "application/json") req.Header.Set("api-token", apiToken) - client := &http.Client{} + client, err := httpclient.New() + if err != nil { + fmt.Println("Error:", err) + return + } resp, err := client.Do(req) if err != nil { fmt.Println("Error:", err) @@ -341,7 +350,12 @@ func sendResultsWithProjectToken(payload []map[string]interface{}, commitUUID st req.Header.Set("content-type", "application/json") req.Header.Set("project-token", projectToken) - resp, err := http.DefaultClient.Do(req) + client, err := httpclient.New() + if err != nil { + fmt.Printf("Error creating http client: %v\n", err) + os.Exit(1) + } + resp, err := client.Do(req) if err != nil { fmt.Printf("Error sending results: %v\n", err) os.Exit(1) @@ -372,7 +386,12 @@ func sendResultsWithAPIToken(payload []map[string]interface{}, commitUUID string req.Header.Set("content-type", "application/json") req.Header.Set("api-token", apiToken) - resp, err := http.DefaultClient.Do(req) + client, err := httpclient.New() + if err != nil { + fmt.Printf("Error creating http client: %v\n", err) + os.Exit(1) + } + resp, err := client.Do(req) if err != nil { fmt.Printf("Error sending results: %v\n", err) os.Exit(1) diff --git a/cmd/upload_sbom.go b/cmd/upload_sbom.go index 6fd2181a..22407d43 100644 --- a/cmd/upload_sbom.go +++ b/cmd/upload_sbom.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "codacy/cli-v2/utils/httpclient" "codacy/cli-v2/utils/logger" "github.com/fatih/color" @@ -29,7 +30,9 @@ var ( sbomFormat string sbomBaseURL string - sbomHTTPClient httpDoer = &http.Client{Timeout: 5 * time.Minute} + // sbomHTTPClient is nil by default and resolved lazily via defaultSBOMClient. + // Tests may set it to a stub implementing httpDoer. + sbomHTTPClient httpDoer ) // httpDoer abstracts the Do method of http.Client for testing. @@ -37,6 +40,15 @@ type httpDoer interface { Do(req *http.Request) (*http.Response, error) } +// defaultSBOMClient returns the injected client if set, else a factory client +// honoring proxy/TLS configuration. +func defaultSBOMClient() (httpDoer, error) { + if sbomHTTPClient != nil { + return sbomHTTPClient, nil + } + return httpclient.New(httpclient.WithTimeout(5 * time.Minute)) +} + func init() { uploadSBOMCmd.Flags().StringVarP(&sbomAPIToken, "api-token", "a", "", "API token for Codacy API (required)") uploadSBOMCmd.Flags().StringVarP(&sbomProvider, "provider", "p", "", "Git provider (gh, gl, bb) (required)") @@ -239,7 +251,11 @@ func uploadSBOMToCodacy(sbomPath, imageName, tag string, params sbomUploadParams req.Header.Set("Accept", "application/json") req.Header.Set("api-token", params.apiToken) - resp, err := sbomHTTPClient.Do(req) + client, err := defaultSBOMClient() + if err != nil { + return fmt.Errorf("failed to create http client: %w", err) + } + resp, err := client.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } diff --git a/cmd/upload_sbom_test.go b/cmd/upload_sbom_test.go index 4a4d98df..b741151d 100644 --- a/cmd/upload_sbom_test.go +++ b/cmd/upload_sbom_test.go @@ -7,10 +7,25 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestDefaultSBOMClient_UsesHTTPClientFactory(t *testing.T) { + saved := sbomHTTPClient + defer func() { sbomHTTPClient = saved }() + + sbomHTTPClient = nil // force default path + c, err := defaultSBOMClient() + require.NoError(t, err) + require.NotNil(t, c) + hc, ok := c.(*http.Client) + require.True(t, ok) + assert.Equal(t, 5*time.Minute, hc.Timeout) +} + type sbomTestState struct { apiToken string provider string diff --git a/codacy-client/client.go b/codacy-client/client.go index def5edfd..d46776d9 100644 --- a/codacy-client/client.go +++ b/codacy-client/client.go @@ -2,6 +2,7 @@ package codacyclient import ( "codacy/cli-v2/domain" + "codacy/cli-v2/utils/httpclient" "encoding/json" "fmt" "io" @@ -16,8 +17,9 @@ const timeout = 10 * time.Second var CodacyApiBase = "https://app.codacy.com" func getRequest(url string, apiToken string) ([]byte, error) { - client := &http.Client{ - Timeout: timeout, + client, err := httpclient.New(httpclient.WithTimeout(timeout)) + if err != nil { + return nil, fmt.Errorf("failed to create http client: %w", err) } req, err := http.NewRequest("GET", url, nil) diff --git a/integration-tests/proxy-tls/connect_logger.py b/integration-tests/proxy-tls/connect_logger.py new file mode 100644 index 00000000..6db6418b --- /dev/null +++ b/integration-tests/proxy-tls/connect_logger.py @@ -0,0 +1,24 @@ +"""mitmproxy addon: log every host the CLI routes through the proxy. + +Logs at CONNECT time (http_connect) so HTTPS flows are recorded even when the +client later rejects the server certificate — which is exactly the case we test. +Also logs plain-HTTP requests. Host list is written to $PROXY_CONNECT_LOG. +""" +import os + +LOG = os.environ.get("PROXY_CONNECT_LOG", "/tmp/proxy-connects.txt") + + +class ConnectLogger: + def _write(self, host): + with open(LOG, "a") as f: + f.write(host + "\n") + + def http_connect(self, flow): + self._write(flow.request.host) + + def request(self, flow): + self._write(flow.request.pretty_host) + + +addons = [ConnectLogger()] diff --git a/integration-tests/proxy-tls/run.sh b/integration-tests/proxy-tls/run.sh new file mode 100755 index 00000000..787d5216 --- /dev/null +++ b/integration-tests/proxy-tls/run.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Real-life proxy/TLS test for codacy-cli-v2 (OD-30). +# +# Runs the ACTUAL cli-v2 binary through a REAL mitmproxy MITM proxy against the +# real app.codacy.com, simulating a corporate TLS-intercepting proxy. Asserts: +# +# A. proxy + custom CA (SSL_CERT_FILE) -> success, traffic seen by proxy +# B. proxy, no CA -> TLS verification failure, traffic seen +# C. proxy + CODACY_CLI_INSECURE -> success, traffic seen +# D. NO_PROXY for app.codacy.com -> success, proxy NOT traversed +# +# Cases A and C require the OD-30 feature (custom CA + insecure toggle). Before +# that is implemented they FAIL with "certificate is not trusted" — that failure +# is the baseline that proves the feature is needed. After implementation, green. +# +# Loopable: PROXY_TLS_LOOP=5 ./run.sh +# Requires: mitmproxy (mitmdump). brew install mitmproxy +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLI="$REPO_ROOT/cli-v2" +PROXY_PORT="${PROXY_PORT:-8899}" +CA="$HOME/.mitmproxy/mitmproxy-ca-cert.pem" +WORK="$(mktemp -d)" +export PROXY_CONNECT_LOG="$WORK/connects.txt" +MITM_PID="" + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } + +cleanup() { + [ -n "$MITM_PID" ] && kill "$MITM_PID" 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT + +command -v mitmdump >/dev/null 2>&1 || { red "mitmdump not found. Install: brew install mitmproxy"; exit 2; } +[ -x "$CLI" ] || { echo "Building cli-v2..."; (cd "$REPO_ROOT" && make build) || exit 2; } + +# Start proxy with the connect-logging addon. +mitmdump -p "$PROXY_PORT" -q -s "$HERE/connect_logger.py" >"$WORK/mitm.log" 2>&1 & +MITM_PID=$! + +# Wait for proxy to bind and generate its CA. +for _ in $(seq 1 40); do + [ -f "$CA" ] && nc -z localhost "$PROXY_PORT" 2>/dev/null && break + sleep 0.3 +done +[ -f "$CA" ] || { red "mitmproxy CA not generated at $CA"; cat "$WORK/mitm.log"; exit 2; } + +# Fresh, network-touching, tokenless CLI command. init hits app.codacy.com/api/v3. +# Args are VAR=val pairs prepended to the cli invocation via env. +run_init() { + local dir="$WORK/proj.$RANDOM" + mkdir -p "$dir" + ( cd "$dir" && env "$@" "$CLI" init >"$WORK/last.log" 2>&1 ) + local rc=$? + rm -rf "$dir" + return $rc +} + +proxy_saw_codacy() { grep -q "codacy.com" "$PROXY_CONNECT_LOG" 2>/dev/null; } + +FAILURES=0 +# check NAME EXPECT_RC(0|fail) EXPECT_PROXY(yes|no) -- VAR=val ... +check() { + local name="$1" want_rc="$2" want_proxy="$3"; shift 3; [ "$1" = "--" ] && shift + : >"$PROXY_CONNECT_LOG" + run_init "$@"; local rc=$? + sleep 0.3 # let addon flush + local saw="no"; proxy_saw_codacy && saw="yes" + local ok=1 + [ "$want_rc" = "0" ] && [ "$rc" -ne 0 ] && ok=0 + [ "$want_rc" = "fail" ] && [ "$rc" -eq 0 ] && ok=0 + [ "$want_proxy" != "$saw" ] && ok=0 + if [ "$ok" -eq 1 ]; then + green "PASS $name (rc=$rc, proxy_saw=$saw)" + else + red "FAIL $name (rc=$rc want=$want_rc, proxy_saw=$saw want=$want_proxy)" + echo "----- cli output (tail) -----"; tail -3 "$WORK/last.log" 2>/dev/null; echo "-----------------------------" + FAILURES=$((FAILURES+1)) + fi +} + +run_suite() { + local P="http://localhost:$PROXY_PORT" + echo "== A: proxy + custom CA (needs OD-30) ==" + check "A custom-CA" 0 yes -- HTTPS_PROXY="$P" HTTP_PROXY="$P" SSL_CERT_FILE="$CA" + echo "== B: proxy, no CA (expect TLS failure) ==" + check "B no-CA-fails" fail yes -- HTTPS_PROXY="$P" HTTP_PROXY="$P" + echo "== C: proxy + insecure (needs OD-30) ==" + check "C insecure" 0 yes -- HTTPS_PROXY="$P" HTTP_PROXY="$P" CODACY_CLI_INSECURE=1 + echo "== D: NO_PROXY bypass ==" + check "D no_proxy-bypass" 0 no -- HTTPS_PROXY="$P" NO_PROXY="app.codacy.com,api.codacy.com" SSL_CERT_FILE="$CA" +} + +LOOP="${PROXY_TLS_LOOP:-1}" +for i in $(seq 1 "$LOOP"); do + [ "$LOOP" -gt 1 ] && echo "### iteration $i/$LOOP ###" + run_suite +done + +echo +if [ "$FAILURES" -eq 0 ]; then green "ALL PROXY/TLS CHECKS PASSED"; else red "$FAILURES check(s) FAILED"; fi +exit "$FAILURES" diff --git a/tools/patterns.go b/tools/patterns.go index 5baa0f3d..9697c9bb 100644 --- a/tools/patterns.go +++ b/tools/patterns.go @@ -2,6 +2,7 @@ package tools import ( "codacy/cli-v2/domain" + "codacy/cli-v2/utils/httpclient" "encoding/json" "fmt" "io" @@ -11,8 +12,9 @@ import ( // FetchDefaultEnabledPatterns fetches default patterns from Codacy API for a given tool UUID func FetchDefaultEnabledPatterns(toolUUID string) ([]domain.PatternDefinition, error) { - client := &http.Client{ - Timeout: 10 * time.Second, + client, err := httpclient.New(httpclient.WithTimeout(10 * time.Second)) + if err != nil { + return nil, fmt.Errorf("failed to create http client: %w", err) } // Fetch default patterns from Codacy API diff --git a/utils/download.go b/utils/download.go index 47d349c4..7481abbf 100644 --- a/utils/download.go +++ b/utils/download.go @@ -1,6 +1,7 @@ package utils import ( + "codacy/cli-v2/utils/httpclient" "codacy/cli-v2/utils/logger" "fmt" "io" @@ -47,7 +48,10 @@ func DownloadFile(url string, destDir string) (string, error) { logger.Debug("Making HTTP GET request", logrus.Fields{ "url": url, }) - client := &http.Client{} + client, err := httpclient.New(httpclient.WithTimeout(0)) // no timeout: large binaries + if err != nil { + return "", fmt.Errorf("failed to create http client: %w", err) + } req, err := http.NewRequest("GET", url, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) diff --git a/utils/httpclient/client.go b/utils/httpclient/client.go new file mode 100644 index 00000000..8061200b --- /dev/null +++ b/utils/httpclient/client.go @@ -0,0 +1,33 @@ +package httpclient + +import "net/http" + +// New returns an *http.Client whose transport honors proxy environment +// variables (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) and applies CA/TLS configuration +// from SSL_CERT_FILE and CODACY_CLI_INSECURE. +// +// It returns an error if a configured CA bundle cannot be read or parsed, so +// callers fail loudly on misconfiguration rather than silently falling back to +// the system trust store. +func New(opts ...Option) (*http.Client, error) { + o := &Options{} + for _, fn := range opts { + fn(o) + } + + tlsCfg, err := buildTLSConfig() + if err != nil { + return nil, err + } + + // Clone DefaultTransport to preserve Go's tuned defaults (connection pooling, + // idle/handshake timeouts, HTTP/2) and only override proxy + TLS. + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = http.ProxyFromEnvironment + transport.TLSClientConfig = tlsCfg + + return &http.Client{ + Timeout: o.Timeout, + Transport: transport, + }, nil +} diff --git a/utils/httpclient/httpclient_test.go b/utils/httpclient/httpclient_test.go new file mode 100644 index 00000000..51bb90c9 --- /dev/null +++ b/utils/httpclient/httpclient_test.go @@ -0,0 +1,124 @@ +package httpclient + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// certToPEM encodes the test server's leaf certificate as PEM. +func certToPEM(t *testing.T, srv *httptest.Server) []byte { + t.Helper() + return encodeCertPEM(srv.Certificate().Raw) +} + +func TestBuildTLSConfig_DefaultRejectsSelfSigned(t *testing.T) { + os.Unsetenv(EnvInsecure) + os.Unsetenv(EnvCABundle) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer srv.Close() + + cfg, err := buildTLSConfig() + require.NoError(t, err) + c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}} + _, err = c.Get(srv.URL) + assert.Error(t, err, "self-signed server must be rejected without a custom CA") +} + +func TestBuildTLSConfig_CustomCASucceeds(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + caPath := filepath.Join(t.TempDir(), "ca.pem") + require.NoError(t, os.WriteFile(caPath, certToPEM(t, srv), 0o600)) + t.Setenv(EnvCABundle, caPath) + os.Unsetenv(EnvInsecure) + + cfg, err := buildTLSConfig() + require.NoError(t, err) + require.NotNil(t, cfg.RootCAs) + c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}} + resp, err := c.Get(srv.URL) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestBuildTLSConfig_InsecureSkipsVerify(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer srv.Close() + t.Setenv(EnvInsecure, "1") + os.Unsetenv(EnvCABundle) + + cfg, err := buildTLSConfig() + require.NoError(t, err) + assert.True(t, cfg.InsecureSkipVerify) + c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}} + resp, err := c.Get(srv.URL) + require.NoError(t, err) + resp.Body.Close() +} + +func TestBuildTLSConfig_MissingBundleErrors(t *testing.T) { + t.Setenv(EnvCABundle, filepath.Join(t.TempDir(), "does-not-exist.pem")) + os.Unsetenv(EnvInsecure) + _, err := buildTLSConfig() + assert.Error(t, err) +} + +func TestBuildTLSConfig_BadBundleErrors(t *testing.T) { + bad := filepath.Join(t.TempDir(), "bad.pem") + require.NoError(t, os.WriteFile(bad, []byte("not a certificate"), 0o600)) + t.Setenv(EnvCABundle, bad) + os.Unsetenv(EnvInsecure) + _, err := buildTLSConfig() + assert.Error(t, err) +} + +func TestNew_SetsProxyAndTimeout(t *testing.T) { + os.Unsetenv(EnvInsecure) + os.Unsetenv(EnvCABundle) + c, err := New(WithTimeout(7 * time.Second)) + require.NoError(t, err) + assert.Equal(t, 7*time.Second, c.Timeout) + + tr, ok := c.Transport.(*http.Transport) + require.True(t, ok) + // Proxy resolver must be wired. Env resolution itself is covered by the + // real-life harness; ProxyFromEnvironment caches and is unsafe to unit-test. + assert.NotNil(t, tr.Proxy) + assert.NotNil(t, tr.TLSClientConfig) +} + +func TestNew_PropagatesCABundleError(t *testing.T) { + t.Setenv(EnvCABundle, filepath.Join(t.TempDir(), "missing.pem")) + os.Unsetenv(EnvInsecure) + _, err := New() + assert.Error(t, err) +} + +func TestNew_CustomCAEndToEnd(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + caPath := filepath.Join(t.TempDir(), "ca.pem") + require.NoError(t, os.WriteFile(caPath, certToPEM(t, srv), 0o600)) + t.Setenv(EnvCABundle, caPath) + os.Unsetenv(EnvInsecure) + + c, err := New() + require.NoError(t, err) + resp, err := c.Get(srv.URL) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/utils/httpclient/options.go b/utils/httpclient/options.go new file mode 100644 index 00000000..614ab033 --- /dev/null +++ b/utils/httpclient/options.go @@ -0,0 +1,45 @@ +package httpclient + +import ( + "os" + "strings" + "time" +) + +// Env vars controlling TLS/CA behavior. +const ( + // EnvInsecure, when truthy, disables TLS certificate verification. + EnvInsecure = "CODACY_CLI_INSECURE" + // EnvCABundle points to a PEM bundle appended to the system trust pool. + // SSL_CERT_FILE is the OpenSSL-standard name corporate tooling already sets. + EnvCABundle = "SSL_CERT_FILE" +) + +// Options configure a client built by New. +type Options struct { + // Timeout is the http.Client timeout. Zero means no timeout. + Timeout time.Duration +} + +// Option mutates Options. +type Option func(*Options) + +// WithTimeout sets the client timeout. Pass 0 for no timeout (large downloads). +func WithTimeout(d time.Duration) Option { + return func(o *Options) { o.Timeout = d } +} + +// insecureEnv reports whether TLS verification is disabled via EnvInsecure. +func insecureEnv() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvInsecure))) { + case "1", "true", "yes": + return true + default: + return false + } +} + +// caBundlePath returns the configured CA bundle path, or "" if unset. +func caBundlePath() string { + return strings.TrimSpace(os.Getenv(EnvCABundle)) +} diff --git a/utils/httpclient/tls.go b/utils/httpclient/tls.go new file mode 100644 index 00000000..f4443368 --- /dev/null +++ b/utils/httpclient/tls.go @@ -0,0 +1,52 @@ +package httpclient + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "os" +) + +// buildTLSConfig assembles the TLS config from env: +// - CODACY_CLI_INSECURE truthy -> InsecureSkipVerify (with a stderr warning) +// - SSL_CERT_FILE set -> its PEM certs appended to the system pool +func buildTLSConfig() (*tls.Config, error) { + cfg := &tls.Config{MinVersion: tls.VersionTLS12} + + if insecureEnv() { + cfg.InsecureSkipVerify = true + // Routed through the standard logger so it lands in codacy-cli.log too + // (logger.Initialize mirrors stdlib log output into the file). + log.Println("WARNING: TLS certificate verification is DISABLED (CODACY_CLI_INSECURE set). " + + "Traffic can be intercepted. Prefer setting SSL_CERT_FILE to your proxy's CA instead.") + return cfg, nil + } + + // Only override RootCAs when a custom CA bundle is configured. Otherwise leave + // it nil so Go falls back to default system verification — building an explicit + // pool unconditionally is wasteful and, if SystemCertPool fails, would leave an + // empty pool that rejects every TLS handshake. + if path := caBundlePath(); path != "" { + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + pemBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read CA bundle from %s (%s): %w", EnvCABundle, path, err) + } + if !pool.AppendCertsFromPEM(pemBytes) { + return nil, fmt.Errorf("no valid certificates found in CA bundle %s (%s)", EnvCABundle, path) + } + cfg.RootCAs = pool + } + + return cfg, nil +} + +// encodeCertPEM encodes DER certificate bytes as PEM. Used by tests. +func encodeCertPEM(der []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} diff --git a/utils/logger/logger.go b/utils/logger/logger.go index d8982145..09a1f621 100644 --- a/utils/logger/logger.go +++ b/utils/logger/logger.go @@ -3,6 +3,8 @@ package logger import ( "codacy/cli-v2/constants" "fmt" + "io" + stdlog "log" "os" "path/filepath" "runtime" @@ -85,6 +87,11 @@ func Initialize(logsDir string) error { // We'll handle caller information ourselves fileLogger.SetReportCaller(false) + // Mirror Go's standard logger into the same log file while preserving terminal + // output. User-facing warnings emitted via log.Printf/log.Fatalf — including + // proxy/TLS failures — otherwise reach only the terminal, never codacy-cli.log. + stdlog.SetOutput(io.MultiWriter(os.Stderr, lumberjackLogger)) + return nil }