From 852170d6cf940306aa98ef5ff62f26448df183d0 Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Tue, 9 Jun 2026 16:04:54 +0200 Subject: [PATCH 01/11] docs: add CLIv2 proxy & TLS support design (OD-30) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-09-cliv2-proxy-tls-support-design.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md diff --git a/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md b/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md new file mode 100644 index 0000000..3961e7d --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md @@ -0,0 +1,155 @@ +# CLIv2 Proxy & TLS Support — Design + +- **Ticket:** [OD-30](https://linear.app/codacy/issue/OD-30/cliv2-verify-and-add-proxy-support) ([CF-2439](https://codacy.atlassian.net/browse/CF-2439)) +- **Date:** 2026-06-09 +- **Repo:** `codacy-cli-v2` +- **Status:** Approved design, pending implementation plan + +## Goal + +Ensure the Go analysis CLIv2 respects standard proxy and SSL configuration when run +directly, launched by the MCP server, or launched by the VS Code extension. Add custom +CA bundle support and an explicit insecure-TLS toggle that maps the VS Code +`http.proxyStrictSSL` setting. + +## Current State (verified) + +Roughly 7 HTTP callsites, each building its own client; none set a custom `Transport`: + +| File | Callsite | Notes | +|------|----------|-------| +| `codacy-client/client.go` | `getRequest` | `&http.Client{Timeout: 10s}` (API v3 GETs) | +| `tools/patterns.go` | `FetchDefaultEnabledPatterns` | `&http.Client{Timeout: 10s}` | +| `utils/download.go` | `DownloadFile` | `&http.Client{}` (tool/runtime binary downloads) | +| `cmd/upload.go` | `resultsFinalWithProjectToken`, `resultsFinalWithAPIToken` | `&http.Client{}` (legacy api.codacy.com) | +| `cmd/upload.go` | `sendResultsWithProjectToken`, `sendResultsWithAPIToken` | `http.DefaultClient` | +| `cmd/upload_sbom.go` | `sbomHTTPClient` | `&http.Client{Timeout: 5m}` behind `httpDoer` interface | + +**Key facts:** + +1. Go's `http.DefaultTransport` already uses `http.ProxyFromEnvironment`. Omitting + `Transport` (as all callsites do) means `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` + are **already honored today** everywhere. Proxy support is mostly *verify*, not *add*. +2. Subprocess tools/runtimes spawned by the CLI inherit the environment, so proxy env + vars flow to them automatically. +3. No `InsecureSkipVerify` anywhere → no insecure default to remove. Good. + +**Gaps:** + +- No custom CA bundle support → corporate MITM proxies fail TLS. +- No shared client factory → cannot inject CA/TLS config without touching every callsite. +- No way to honor `http.proxyStrictSSL=false` from the extension. + +## Approach + +Central client factory (`utils/httpclient`) with an explicitly-built TLS root pool. All +callsites switch to it. Chosen over a shared-transport-only patch or a minimal +per-callsite patch because the ticket wants a single verified proxy/CA path, and the +existing `httpDoer` interface already signals a preference for injectable clients. + +## Components + +### `utils/httpclient` package + +``` +utils/httpclient/ + client.go // New(opts...) *http.Client + tls.go // buildTLSConfig() (*tls.Config, error) + options.go // Option funcs (WithTimeout, ...) + env resolution +``` + +`New` always sets `Transport.Proxy = http.ProxyFromEnvironment` (locks proxy support) +and `Transport.TLSClientConfig` from `buildTLSConfig`. Returns a plain `*http.Client`, +so no callsite signatures change. + +### TLS / CA construction (`tls.go`) + +```go +func buildTLSConfig() (*tls.Config, error) { + cfg := &tls.Config{MinVersion: tls.VersionTLS12} + if insecureEnv() { + cfg.InsecureSkipVerify = true + logger.Warn("TLS verification disabled (CODACY_CLI_INSECURE). " + + "Insecure — proxy MITM not validated.") + return cfg, nil + } + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + if f := os.Getenv("SSL_CERT_FILE"); f != "" { + pem, err := os.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("read SSL_CERT_FILE %s: %w", f, err) + } + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("no valid certs in %s", f) + } + } + cfg.RootCAs = pool + return cfg, nil +} +``` + +The pool is built explicitly (system pool + appended `SSL_CERT_FILE`) rather than +relying on Go's implicit `SSL_CERT_FILE` handling, which varies by platform (notably +macOS). Corporate CA then works without disabling verification. Insecure mode +short-circuits and warns once. + +### Configuration surface + +| Env var | Effect | Default | +|---------|--------|---------| +| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | Proxy routing (Go default) | unset | +| `SSL_CERT_FILE` | Extra CA bundle appended to system pool | unset | +| `CODACY_CLI_INSECURE` (`1`/`true`) | Disable TLS verification; maps `http.proxyStrictSSL=false` | off | + +Env chosen as the signal channel because the extension and MCP server both control the +child process environment uniformly, with no CLI-arg plumbing. + +## Callsite Migration + +Replace every `&http.Client{...}` / `http.DefaultClient` with `httpclient.New(...)`: + +- `codacy-client/client.go` → `New(WithTimeout(10 * time.Second))` +- `tools/patterns.go` → `New(WithTimeout(10 * time.Second))` +- `utils/download.go` → `New(WithTimeout(0))` (large binaries, no timeout) +- `cmd/upload.go` ×4 → `New()` (default) +- `cmd/upload_sbom.go` → `sbomHTTPClient = httpclient.New(WithTimeout(5 * time.Minute))`, + preserving the `httpDoer` interface for test injection + +## Error Handling + +- CA read/parse failure returns an error and aborts the request — do not silently fall + back to the system pool (surfaces misconfiguration). +- TLS handshake errors propagate as today; `codacy-client` error messages gain a hint: + "if behind a corporate proxy, set SSL_CERT_FILE or CODACY_CLI_INSECURE". +- Insecure mode emits a single stderr warning per process. + +## Testing + +**Unit (`utils/httpclient`):** +- Proxy: `Transport.Proxy` non-nil after `New`. +- CA: load a test PEM via `SSL_CERT_FILE`, assert pool grows; bad PEM returns error. +- Insecure: `CODACY_CLI_INSECURE` sets `InsecureSkipVerify`. + +**Integration:** +- `httptest.NewTLSServer` with self-signed cert: request fails by default, succeeds when + `SSL_CERT_FILE` points at that cert, succeeds with `CODACY_CLI_INSECURE`. + +**Manual matrix (documented in README):** +- Direct run + mitmproxy. +- `NO_PROXY` excluded host. +- Launched via MCP server. +- Launched via VS Code extension. + +## Documentation + +- README: new "Proxy & TLS" section covering the three env-var groups and the manual + test matrix. + +## Out of Scope (follow-up) + +- `codacy-vscode-extension`: map `http.proxy` → `HTTP_PROXY`, `http.proxyStrictSSL=false` + → `CODACY_CLI_INSECURE` when spawning the CLI. Same mapping for the MCP server. + Tracked separately from OD-30 (this ticket is `[CLIv2]`). From 38d7a04ace2288d4b147ceda0cf04641d587b9b8 Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Tue, 9 Jun 2026 16:10:16 +0200 Subject: [PATCH 02/11] docs: add proven hermetic proxy/TLS test harness to OD-30 design Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-09-cliv2-proxy-tls-support-design.md | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md b/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md index 3961e7d..a02318c 100644 --- a/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md +++ b/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md @@ -128,19 +128,43 @@ Replace every `&http.Client{...}` / `http.DefaultClient` with `httpclient.New(.. ## Testing -**Unit (`utils/httpclient`):** -- Proxy: `Transport.Proxy` non-nil after `New`. -- CA: load a test PEM via `SSL_CERT_FILE`, assert pool grows; bad PEM returns error. -- Insecure: `CODACY_CLI_INSECURE` sets `InsecureSkipVerify`. - -**Integration:** -- `httptest.NewTLSServer` with self-signed cert: request fails by default, succeeds when - `SSL_CERT_FILE` points at that cert, succeeds with `CODACY_CLI_INSECURE`. - -**Manual matrix (documented in README):** -- Direct run + mitmproxy. -- `NO_PROXY` excluded host. -- Launched via MCP server. +The primary test loop is a **hermetic, in-process Go harness** in +`utils/httpclient`. It uses `httptest.NewTLSServer` (which mints a self-signed cert +exposed via `server.Certificate()`) plus a local `httptest` forward proxy. No real +network, no `app.codacy.com`, no external `mitmproxy` process — so it runs fast and +loops cleanly under `go test -count=N ./utils/httpclient`. It exercises the single +factory that every callsite uses, so passing the harness means every callsite is +covered. + +This approach is validated by a proof-of-concept (`.workdir/proxytls-poc`, throwaway) +with all four cases green and stable across `-count=5`: + +| Test | Asserts | +|------|---------| +| `TestTLSDefaultFailsWithoutCA` | self-signed server → request fails with `x509: certificate signed by unknown authority` (no insecure default) | +| `TestTLSWithCustomCASucceeds` | server cert appended to pool via `SSL_CERT_FILE` → request succeeds | +| `TestTLSInsecureSucceeds` | `CODACY_CLI_INSECURE` set → `InsecureSkipVerify`, request succeeds | +| `TestProxyTraversed` | client routes through local forward proxy; proxy hit-count == 1, body from origin | + +**Additional unit assertions (`utils/httpclient`):** +- `Transport.Proxy` is non-nil after `New` (proxy support locked in). +- Bad/empty PEM in `SSL_CERT_FILE` → `New`/`buildTLSConfig` returns an error. +- `NO_PROXY` honored: with proxy env set, a host matching `NO_PROXY` resolves + `Transport.Proxy(req) == nil` (assert on the resolver, not a live dial). + +**Why not black-box the `cli-v2` binary in the loop:** the API base URL is a hardcoded +package var (`codacyclient.CodacyApiBase`) plus string literals in `tools/patterns.go`, +`cmd/upload.go`, and `cmd/upload_sbom.go`. Pointing the built binary at a local TLS +server would require threading a base-URL override through all of them — out of scope +here. The Go harness gives equivalent coverage of the proxy/TLS logic without it. +(A `CODACY_API_BASE_URL` override to enable end-to-end black-box runs is a reasonable +follow-up; see below.) + +**Manual matrix (documented in README, run before release):** +- Direct run + `mitmproxy` (real corporate-MITM simulation): fails, then passes with its + CA via `SSL_CERT_FILE`. +- `NO_PROXY` excluded host bypasses the proxy. +- Launched via MCP server with proxy env set. - Launched via VS Code extension. ## Documentation @@ -153,3 +177,5 @@ Replace every `&http.Client{...}` / `http.DefaultClient` with `httpclient.New(.. - `codacy-vscode-extension`: map `http.proxy` → `HTTP_PROXY`, `http.proxyStrictSSL=false` → `CODACY_CLI_INSECURE` when spawning the CLI. Same mapping for the MCP server. Tracked separately from OD-30 (this ticket is `[CLIv2]`). +- `CODACY_API_BASE_URL` env override consolidating `CodacyApiBase` and the hardcoded + literals, to enable end-to-end black-box CLI runs against a local test server. From 0ad1e180208f590dae4a5ad2613d27c349b1d45b Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Tue, 9 Jun 2026 16:18:38 +0200 Subject: [PATCH 03/11] test: add real-life mitmproxy proxy/TLS harness (OD-30) Runs the actual cli-v2 binary through a real mitmproxy against app.codacy.com. Baseline confirms SSL_CERT_FILE is ignored on macOS and CODACY_CLI_INSECURE is unimplemented; both cases flip green once OD-30 lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-09-cliv2-proxy-tls-support-design.md | 31 ++++- integration-tests/proxy-tls/connect_logger.py | 24 ++++ integration-tests/proxy-tls/run.sh | 106 ++++++++++++++++++ 3 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 integration-tests/proxy-tls/connect_logger.py create mode 100755 integration-tests/proxy-tls/run.sh diff --git a/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md b/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md index a02318c..69265d9 100644 --- a/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md +++ b/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md @@ -160,12 +160,31 @@ here. The Go harness gives equivalent coverage of the proxy/TLS logic without it (A `CODACY_API_BASE_URL` override to enable end-to-end black-box runs is a reasonable follow-up; see below.) -**Manual matrix (documented in README, run before release):** -- Direct run + `mitmproxy` (real corporate-MITM simulation): fails, then passes with its - CA via `SSL_CERT_FILE`. -- `NO_PROXY` excluded host bypasses the proxy. -- Launched via MCP server with proxy env set. -- Launched via VS Code extension. +**Real-life harness (`integration-tests/proxy-tls/run.sh`) — already written:** +Runs the actual `cli-v2` binary through a real `mitmproxy` (real CA, real TLS +interception) against the real `app.codacy.com`, using tokenless `cli-v2 init` as the +network-touching command. A mitmproxy addon (`connect_logger.py`) records proxy +traversal at CONNECT time, so traversal is detected even when the client rejects the +cert. Four cases: A custom-CA, B no-CA-fails, C insecure, D NO_PROXY-bypass. Loopable +via `PROXY_TLS_LOOP=N`. + +Baseline run (pre-implementation) confirms the gap and validates the design: + +| Case | Baseline result | Note | +|------|-----------------|------| +| A custom-CA | FAIL (`certificate is not trusted`), proxy traversed | `SSL_CERT_FILE` ignored today | +| B no-CA | PASS (TLS correctly fails), proxy traversed | no insecure default | +| C insecure | FAIL, proxy traversed | `CODACY_CLI_INSECURE` not implemented | +| D NO_PROXY | PASS, proxy bypassed | env-bypass works | + +A and C must flip to green after implementation; that is the acceptance gate. + +**Confirmed on macOS:** Go does **not** auto-read `SSL_CERT_FILE` (case A fails with the +system trust path). This validates building the root pool explicitly +(`SystemCertPool()` + `AppendCertsFromPEM`) rather than relying on Go's implicit env +handling. + +- Also run via MCP server and VS Code extension with proxy env set (manual). ## Documentation diff --git a/integration-tests/proxy-tls/connect_logger.py b/integration-tests/proxy-tls/connect_logger.py new file mode 100644 index 0000000..6db6418 --- /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 0000000..787d521 --- /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" From ee9e6afa9faedfa4d11c0d9fa2a65228e5173440 Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Tue, 9 Jun 2026 16:24:53 +0200 Subject: [PATCH 04/11] docs: add OD-30 proxy/TLS implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-09-cliv2-proxy-tls-support.md | 853 ++++++++++++++++++ 1 file changed, 853 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-cliv2-proxy-tls-support.md diff --git a/docs/superpowers/plans/2026-06-09-cliv2-proxy-tls-support.md b/docs/superpowers/plans/2026-06-09-cliv2-proxy-tls-support.md new file mode 100644 index 0000000..efcd247 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-cliv2-proxy-tls-support.md @@ -0,0 +1,853 @@ +# CLIv2 Proxy & TLS Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the Go CLIv2 a single HTTP client factory that honors proxy env vars, supports a custom CA bundle (`SSL_CERT_FILE`), and offers an explicit insecure-TLS toggle (`CODACY_CLI_INSECURE`), then route every HTTP callsite through it. + +**Architecture:** A new `utils/httpclient` package builds `*http.Client` instances whose `http.Transport` always sets `Proxy: http.ProxyFromEnvironment` and a `TLSClientConfig` assembled from the system cert pool plus an optional `SSL_CERT_FILE` bundle (or `InsecureSkipVerify` when the insecure toggle is set). All ~7 existing callsites switch to `httpclient.New(...)`. + +**Tech Stack:** Go 1.24, stdlib `net/http` + `crypto/tls` + `crypto/x509`, `testify/assert`, `net/http/httptest`. Module path: `codacy/cli-v2`. + +**Spec:** `docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md` + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `utils/httpclient/options.go` (create) | `Options`, `WithTimeout` functional option, env-var constants + readers | +| `utils/httpclient/tls.go` (create) | `buildTLSConfig()` — system pool + `SSL_CERT_FILE` append + insecure toggle + stderr warning | +| `utils/httpclient/client.go` (create) | `New(opts ...Option) (*http.Client, error)` — wires proxy + TLS into a transport | +| `utils/httpclient/httpclient_test.go` (create) | Hermetic tests: TLS default-fail, custom CA, insecure, bad bundle, proxy set | +| `codacy-client/client.go` (modify) | `getRequest` uses `httpclient.New` | +| `tools/patterns.go` (modify) | `FetchDefaultEnabledPatterns` uses `httpclient.New` | +| `utils/download.go` (modify) | `DownloadFile` uses `httpclient.New(WithTimeout(0))` | +| `cmd/upload.go` (modify) | 4 callsites use `httpclient.New` | +| `cmd/upload_sbom.go` (modify) | package-var client built lazily via `httpclient.New`, keep `httpDoer` test injection | +| `README.md` (modify) | "Proxy & TLS" docs section | + +**Important environment notes for the implementer:** +- `logger.Warn` writes to a rotating **log file** and only after `logger.Initialize` ran (`fileLogger != nil`). It will NOT surface the insecure warning to the user. The insecure warning MUST go to **stderr** via `fmt.Fprintln(os.Stderr, ...)`. +- `http.ProxyFromEnvironment` caches its resolved proxy config with a `sync.Once` for the life of the process. Do NOT write a unit test that sets `HTTPS_PROXY`/`NO_PROXY` with `t.Setenv` and asserts on `tr.Proxy(req)` — it will be flaky/cached. Unit tests assert only that `tr.Proxy != nil`. Real proxy traversal + `NO_PROXY` bypass are covered by the existing real-life harness `integration-tests/proxy-tls/run.sh`. +- Confirmed on macOS: Go does NOT auto-read `SSL_CERT_FILE` into the system pool. The pool must be built explicitly (`x509.SystemCertPool()` + `AppendCertsFromPEM`). + +--- + +## Task 1: Create `utils/httpclient` package — options + +**Files:** +- Create: `utils/httpclient/options.go` + +- [ ] **Step 1: Write options.go** + +```go +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)) +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `go build ./utils/httpclient/` +Expected: PASS (no output). The package has no other files yet, so this only checks syntax. + +- [ ] **Step 3: Commit** + +```bash +git add utils/httpclient/options.go +git commit -m "feat(httpclient): add options and env-var readers" +``` + +--- + +## Task 2: TLS config builder (TDD) + +**Files:** +- Create: `utils/httpclient/tls.go` +- Test: `utils/httpclient/httpclient_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `utils/httpclient/httpclient_test.go`: + +```go +package httpclient + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeServerCAFile writes the test server's cert to a PEM file and returns its path. +func writeServerCAFile(t *testing.T, srv *httptest.Server) string { + t.Helper() + certPEM := "-----BEGIN CERTIFICATE-----\n" + // httptest sets srv.Certificate(); encode it to PEM. + der := srv.Certificate().Raw + _ = der + // Use the helper from crypto/x509 via pem in the real impl test below. + return certPEM +} + +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) +} + +// 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) +} + +var _ = time.Second // keep time import if unused elsewhere +var _ = tls.VersionTLS12 +var _ = writeServerCAFile +``` + +Note: the helper `writeServerCAFile` above is unused scaffolding; delete it once `certToPEM`/`encodeCertPEM` are in place. `encodeCertPEM` is defined in tls.go (Step 3). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./utils/httpclient/ -run TestBuildTLSConfig -v` +Expected: FAIL — `undefined: buildTLSConfig` and `undefined: encodeCertPEM`. + +- [ ] **Step 3: Write tls.go** + +```go +package httpclient + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "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 + fmt.Fprintln(os.Stderr, + "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 + } + + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + + if path := caBundlePath(); path != "" { + 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 is a test/helper utility: encode DER cert bytes as PEM. +func encodeCertPEM(der []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./utils/httpclient/ -run TestBuildTLSConfig -v` +Expected: PASS — all 5 `TestBuildTLSConfig_*` tests green. + +- [ ] **Step 5: Commit** + +```bash +git add utils/httpclient/tls.go utils/httpclient/httpclient_test.go +git commit -m "feat(httpclient): build TLS config from SSL_CERT_FILE + insecure toggle" +``` + +--- + +## Task 3: Client factory (TDD) + +**Files:** +- Create: `utils/httpclient/client.go` +- Test: `utils/httpclient/httpclient_test.go` (append) + +- [ ] **Step 1: Append failing tests** + +Add to `utils/httpclient/httpclient_test.go`: + +```go +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) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./utils/httpclient/ -run TestNew -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Write client.go** + +```go +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 + } + + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: tlsCfg, + } + + return &http.Client{ + Timeout: o.Timeout, + Transport: transport, + }, nil +} +``` + +- [ ] **Step 4: Run the full package test suite** + +Run: `go test ./utils/httpclient/ -v` +Expected: PASS — all tests green. + +- [ ] **Step 5: Confirm hermetic loop is stable** + +Run: `go test ./utils/httpclient/ -count=5` +Expected: `ok codacy/cli-v2/utils/httpclient` (no FAIL across 5 iterations). + +- [ ] **Step 6: Commit** + +```bash +git add utils/httpclient/client.go utils/httpclient/httpclient_test.go +git commit -m "feat(httpclient): add New client factory wiring proxy + TLS" +``` + +--- + +## Task 4: Migrate `codacy-client/client.go` + +**Files:** +- Modify: `codacy-client/client.go:13-32` (the `getRequest` function and `timeout` const) + +- [ ] **Step 1: Replace the inline client** + +In `codacy-client/client.go`, add the import `"codacy/cli-v2/utils/httpclient"` (internal-imports group). Replace the start of `getRequest`: + +Current: +```go +func getRequest(url string, apiToken string) ([]byte, error) { + client := &http.Client{ + Timeout: timeout, + } + + req, err := http.NewRequest("GET", url, nil) +``` + +New: +```go +func getRequest(url string, apiToken string) ([]byte, error) { + 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) +``` + +If `net/http` becomes otherwise unused in the file, keep it — `http.NewRequest` still uses it. (`fmt` is already imported.) + +- [ ] **Step 2: Compile** + +Run: `go build ./codacy-client/` +Expected: PASS. + +- [ ] **Step 3: Run existing package tests** + +Run: `go test ./codacy-client/` +Expected: PASS (or "no test files" — either is acceptable; no behavior change for the happy path). + +- [ ] **Step 4: Commit** + +```bash +git add codacy-client/client.go +git commit -m "refactor(codacy-client): route getRequest through httpclient factory" +``` + +--- + +## Task 5: Migrate `tools/patterns.go` + +**Files:** +- Modify: `tools/patterns.go:12-25` + +- [ ] **Step 1: Replace the inline client** + +Add import `"codacy/cli-v2/utils/httpclient"`. Replace: + +Current: +```go + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Fetch default patterns from Codacy API + url := fmt.Sprintf("https://app.codacy.com/api/v3/tools/%s/patterns", toolUUID) + req, err := http.NewRequest("GET", url, nil) +``` + +New: +```go + 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 + url := fmt.Sprintf("https://app.codacy.com/api/v3/tools/%s/patterns", toolUUID) + req, err := http.NewRequest("GET", url, nil) +``` + +If the `time` import becomes unused after this change, remove it. (It is still used here via `10 * time.Second`, so keep it.) + +- [ ] **Step 2: Compile** + +Run: `go build ./tools/` +Expected: PASS. + +- [ ] **Step 3: Run tests** + +Run: `go test ./tools/ -run TestFetchDefaultEnabledPatterns` +Expected: PASS or "no tests to run" (no such test exists today — acceptable). + +- [ ] **Step 4: Commit** + +```bash +git add tools/patterns.go +git commit -m "refactor(tools): route FetchDefaultEnabledPatterns through httpclient" +``` + +--- + +## Task 6: Migrate `utils/download.go` + +**Files:** +- Modify: `utils/download.go:50` (and imports) + +- [ ] **Step 1: Replace the inline client** + +Add import `"codacy/cli-v2/utils/httpclient"`. Replace: + +Current: +```go + client := &http.Client{} + req, err := http.NewRequest("GET", url, nil) +``` + +New: +```go + 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) +``` + +- [ ] **Step 2: Compile** + +Run: `go build ./utils/...` +Expected: PASS. + +- [ ] **Step 3: Run tests** + +Run: `go test ./utils/...` +Expected: PASS (or "no test files"). + +- [ ] **Step 4: Commit** + +```bash +git add utils/download.go +git commit -m "refactor(utils): route DownloadFile through httpclient factory" +``` + +--- + +## Task 7: Migrate `cmd/upload.go` (4 callsites) + +**Files:** +- Modify: `cmd/upload.go` at lines ~254, ~272 (`&http.Client{}`) and ~344, ~375 (`http.DefaultClient.Do`) + +- [ ] **Step 1: Replace `resultsFinalWithProjectToken` client (~line 254)** + +Add import `"codacy/cli-v2/utils/httpclient"`. Replace: + +```go + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Println("Error:", err) + return + } +``` + +with: + +```go + client, err := httpclient.New() + if err != nil { + fmt.Println("Error:", err) + return + } + resp, err := client.Do(req) + if err != nil { + fmt.Println("Error:", err) + return + } +``` + +- [ ] **Step 2: Replace `resultsFinalWithAPIToken` client (~line 272)** + +Apply the identical change as Step 1 to the second `client := &http.Client{}` in `resultsFinalWithAPIToken`. + +- [ ] **Step 3: Replace `sendResultsWithProjectToken` DefaultClient (~line 344)** + +Replace: + +```go + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("Error sending results: %v\n", err) + os.Exit(1) + } +``` + +with: + +```go + 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) + } +``` + +- [ ] **Step 4: Replace `sendResultsWithAPIToken` DefaultClient (~line 375)** + +Apply the identical change as Step 3 to the second `http.DefaultClient.Do(req)` in `sendResultsWithAPIToken`. + +- [ ] **Step 5: Compile (catches unused `net/http` import)** + +Run: `go build ./cmd/` +Expected: PASS. If the compiler reports `"net/http" imported and not used`, remove the `net/http` import; if it reports it IS used (e.g. `http.NewRequest` remains), keep it. `cmd/upload.go` still calls `http.NewRequest`, so the import stays. + +- [ ] **Step 6: Run upload tests** + +Run: `go test ./cmd/ -run TestUpload -v` +Expected: PASS (existing `cmd/upload_test.go` must stay green). + +- [ ] **Step 7: Commit** + +```bash +git add cmd/upload.go +git commit -m "refactor(upload): route legacy upload calls through httpclient" +``` + +--- + +## Task 8: Migrate `cmd/upload_sbom.go` (preserve test injection) + +**Files:** +- Modify: `cmd/upload_sbom.go:32` (package var) and the upload function around line 242 + +**Context:** `sbomHTTPClient httpDoer = &http.Client{Timeout: 5 * time.Minute}` is a package-level var that `upload_sbom_test.go` overrides for injection. A package-var initializer cannot return an error, so we make the default lazy. + +- [ ] **Step 1: Write/extend the failing test** + +In `cmd/upload_sbom_test.go`, add a test asserting the default client is created via the factory when none is injected: + +```go +func TestDefaultSBOMClient_UsesHTTPClientFactory(t *testing.T) { + 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) +} +``` + +Add imports `"github.com/stretchr/testify/require"` and `"time"` to the test file if not already present. + +- [ ] **Step 2: Run to verify it fails** + +Run: `go test ./cmd/ -run TestDefaultSBOMClient -v` +Expected: FAIL — `undefined: defaultSBOMClient`. + +- [ ] **Step 3: Change the package var to lazy default** + +Add import `"codacy/cli-v2/utils/httpclient"`. Replace: + +```go + sbomHTTPClient httpDoer = &http.Client{Timeout: 5 * time.Minute} +``` + +with: + +```go + // sbomHTTPClient is nil by default and resolved lazily via defaultSBOMClient. + // Tests may set it to a stub implementing httpDoer. + sbomHTTPClient httpDoer +``` + +Add this helper near the `httpDoer` interface definition: + +```go +// defaultSBOMClient returns the injected client if set, else a factory client. +func defaultSBOMClient() (httpDoer, error) { + if sbomHTTPClient != nil { + return sbomHTTPClient, nil + } + return httpclient.New(httpclient.WithTimeout(5 * time.Minute)) +} +``` + +- [ ] **Step 4: Use the helper at the request callsite (~line 242)** + +Replace: + +```go + resp, err := sbomHTTPClient.Do(req) +``` + +with: + +```go + client, err := defaultSBOMClient() + if err != nil { + return fmt.Errorf("failed to create http client: %w", err) + } + resp, err := client.Do(req) +``` + +(If the enclosing function does not return `error`, adapt to its existing error style — check the signature of the function containing line 242 and match how it reports errors. The function is the SBOM upload handler; it returns an error.) + +- [ ] **Step 5: Compile** + +Run: `go build ./cmd/` +Expected: PASS. Remove the now-unused `time` import only if the compiler flags it (it remains used by `defaultSBOMClient`). + +- [ ] **Step 6: Run SBOM tests** + +Run: `go test ./cmd/ -run "TestUploadSBOM|TestDefaultSBOMClient" -v` +Expected: PASS — existing SBOM tests plus the new one are green. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/upload_sbom.go cmd/upload_sbom_test.go +git commit -m "refactor(sbom): lazy default client via httpclient, keep test injection" +``` + +--- + +## Task 9: Full build + test sweep + +**Files:** none (verification task) + +- [ ] **Step 1: Build everything** + +Run: `go build ./...` +Expected: PASS (no output). + +- [ ] **Step 2: Run the whole unit suite** + +Run: `go test ./...` +Expected: PASS for all packages (pre-existing network-dependent tests behave as before). + +- [ ] **Step 3: Vet** + +Run: `go vet ./...` +Expected: no findings in touched packages. + +- [ ] **Step 4: Commit (if vet/build produced fixups)** + +```bash +git add -A +git commit -m "chore: build/test/vet sweep for proxy-tls support" || echo "nothing to commit" +``` + +--- + +## Task 10: Documentation + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add a "Proxy & TLS" section** + +Append to `README.md` (place after the configuration/usage section; match existing heading depth): + +```markdown +## 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`. +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: document proxy and TLS configuration" +``` + +--- + +## Task 11: Real-life acceptance gate + +**Files:** none (uses existing `integration-tests/proxy-tls/run.sh`) + +- [ ] **Step 1: Build the binary** + +Run: `make build` +Expected: produces `./cli-v2`. + +- [ ] **Step 2: Run the real-life harness** + +Run: `PROXY_PORT=8899 integration-tests/proxy-tls/run.sh` +Expected: ALL FOUR cases PASS now — +- A custom-CA: PASS (rc=0, proxy_saw=yes) ← was FAIL pre-implementation +- B no-CA-fails: PASS +- C insecure: PASS (rc=0, proxy_saw=yes) ← was FAIL pre-implementation +- D no_proxy-bypass: PASS +Final line: `ALL PROXY/TLS CHECKS PASSED`, exit 0. + +- [ ] **Step 3: Confirm stability under loop** + +Run: `PROXY_TLS_LOOP=3 PROXY_PORT=8899 integration-tests/proxy-tls/run.sh` +Expected: all cases PASS in every iteration. + +> If A or C still fail with "certificate is not trusted", the TLS config is not +> being applied at that callsite — re-check that the failing path goes through +> `httpclient.New` (Tasks 4–8) and not a stray `http.Client`/`http.DefaultClient`. + +--- + +## Self-Review Notes + +- **Spec coverage:** proxy via `ProxyFromEnvironment` (Task 3 + all migrations); `NO_PROXY` (inherited from Go default, verified Task 11 case D); custom CA via `SSL_CERT_FILE` (Task 2, verified Tasks 3 & 11); insecure toggle (Task 2, verified Tasks 3 & 11); no insecure default (Task 2 default path, verified Task 11 case B); central factory + all 7 callsites (Tasks 4–8); error-on-bad-CA (Task 3, `TestNew_PropagatesCABundleError`); docs (Task 10); test loops — hermetic (Task 3 Step 5) and real-life (Task 11). +- **Insecure warning to stderr** (not `logger.Warn`) because the logger writes to a file and only post-Initialize — see env notes. +- **Proxy env unit-test caching** avoided per the `ProxyFromEnvironment` sync.Once note; real proxy/NO_PROXY behavior covered by the harness. +- **Out of scope (follow-up tickets, per spec):** extension/MCP mapping of `http.proxyStrictSSL` → `CODACY_CLI_INSECURE`; `CODACY_API_BASE_URL` override for black-box CLI runs. +``` From 0526cf8e5ed59e76195433ed59f49c50174e206a Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Tue, 9 Jun 2026 16:25:44 +0200 Subject: [PATCH 05/11] feat(httpclient): add proxy/TLS-aware http client factory (OD-30) Co-Authored-By: Claude Opus 4.8 (1M context) --- utils/httpclient/client.go | 32 +++++++ utils/httpclient/httpclient_test.go | 124 ++++++++++++++++++++++++++++ utils/httpclient/options.go | 45 ++++++++++ utils/httpclient/tls.go | 47 +++++++++++ 4 files changed, 248 insertions(+) create mode 100644 utils/httpclient/client.go create mode 100644 utils/httpclient/httpclient_test.go create mode 100644 utils/httpclient/options.go create mode 100644 utils/httpclient/tls.go diff --git a/utils/httpclient/client.go b/utils/httpclient/client.go new file mode 100644 index 0000000..259d8a8 --- /dev/null +++ b/utils/httpclient/client.go @@ -0,0 +1,32 @@ +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 + } + + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + 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 0000000..51bb90c --- /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 0000000..614ab03 --- /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 0000000..930fc38 --- /dev/null +++ b/utils/httpclient/tls.go @@ -0,0 +1,47 @@ +package httpclient + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "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 + fmt.Fprintln(os.Stderr, + "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 + } + + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + + if path := caBundlePath(); path != "" { + 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}) +} From 71521bae27cc7b0613547b1f770b691529b2900a Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Tue, 9 Jun 2026 16:26:22 +0200 Subject: [PATCH 06/11] refactor: route codacy-client, tools, download through httpclient (OD-30) Co-Authored-By: Claude Opus 4.8 (1M context) --- codacy-client/client.go | 6 ++++-- tools/patterns.go | 6 ++++-- utils/download.go | 6 +++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/codacy-client/client.go b/codacy-client/client.go index def5edf..d46776d 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/tools/patterns.go b/tools/patterns.go index 5baa0f3..9697c9b 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 47d349c..7481abb 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) From 2d9d7ea3eceb01171a33cc55d24b4adb572b5424 Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Tue, 9 Jun 2026 16:27:35 +0200 Subject: [PATCH 07/11] refactor(cmd): route upload + sbom through httpclient, keep test injection (OD-30) Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/upload.go | 27 +++++++++++++++++++++++---- cmd/upload_sbom.go | 20 ++++++++++++++++++-- cmd/upload_sbom_test.go | 15 +++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/cmd/upload.go b/cmd/upload.go index 0b265c8..4c6d4b3 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 6fd2181..22407d4 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 4a4d98d..b741151 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 From e946c72268388970607197b5ddaa0b6b97e22195 Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Tue, 9 Jun 2026 16:29:20 +0200 Subject: [PATCH 08/11] docs: document proxy and TLS configuration (OD-30) Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 5dbe475..e8fa2a9 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`. + +--- From a66ee27dc49856daf060b6b60b4e34cd7ac0a764 Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Wed, 10 Jun 2026 09:33:05 +0200 Subject: [PATCH 09/11] chore: gitignore docs/superpowers/ and untrack committed specs --- .gitignore | 3 + .../2026-06-09-cliv2-proxy-tls-support.md | 853 ------------------ ...26-06-09-cliv2-proxy-tls-support-design.md | 200 ---- 3 files changed, 3 insertions(+), 1053 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-09-cliv2-proxy-tls-support.md delete mode 100644 docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md diff --git a/.gitignore b/.gitignore index 3eee14e..ec05207 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/docs/superpowers/plans/2026-06-09-cliv2-proxy-tls-support.md b/docs/superpowers/plans/2026-06-09-cliv2-proxy-tls-support.md deleted file mode 100644 index efcd247..0000000 --- a/docs/superpowers/plans/2026-06-09-cliv2-proxy-tls-support.md +++ /dev/null @@ -1,853 +0,0 @@ -# CLIv2 Proxy & TLS Support Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Give the Go CLIv2 a single HTTP client factory that honors proxy env vars, supports a custom CA bundle (`SSL_CERT_FILE`), and offers an explicit insecure-TLS toggle (`CODACY_CLI_INSECURE`), then route every HTTP callsite through it. - -**Architecture:** A new `utils/httpclient` package builds `*http.Client` instances whose `http.Transport` always sets `Proxy: http.ProxyFromEnvironment` and a `TLSClientConfig` assembled from the system cert pool plus an optional `SSL_CERT_FILE` bundle (or `InsecureSkipVerify` when the insecure toggle is set). All ~7 existing callsites switch to `httpclient.New(...)`. - -**Tech Stack:** Go 1.24, stdlib `net/http` + `crypto/tls` + `crypto/x509`, `testify/assert`, `net/http/httptest`. Module path: `codacy/cli-v2`. - -**Spec:** `docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md` - ---- - -## File Structure - -| File | Responsibility | -|------|----------------| -| `utils/httpclient/options.go` (create) | `Options`, `WithTimeout` functional option, env-var constants + readers | -| `utils/httpclient/tls.go` (create) | `buildTLSConfig()` — system pool + `SSL_CERT_FILE` append + insecure toggle + stderr warning | -| `utils/httpclient/client.go` (create) | `New(opts ...Option) (*http.Client, error)` — wires proxy + TLS into a transport | -| `utils/httpclient/httpclient_test.go` (create) | Hermetic tests: TLS default-fail, custom CA, insecure, bad bundle, proxy set | -| `codacy-client/client.go` (modify) | `getRequest` uses `httpclient.New` | -| `tools/patterns.go` (modify) | `FetchDefaultEnabledPatterns` uses `httpclient.New` | -| `utils/download.go` (modify) | `DownloadFile` uses `httpclient.New(WithTimeout(0))` | -| `cmd/upload.go` (modify) | 4 callsites use `httpclient.New` | -| `cmd/upload_sbom.go` (modify) | package-var client built lazily via `httpclient.New`, keep `httpDoer` test injection | -| `README.md` (modify) | "Proxy & TLS" docs section | - -**Important environment notes for the implementer:** -- `logger.Warn` writes to a rotating **log file** and only after `logger.Initialize` ran (`fileLogger != nil`). It will NOT surface the insecure warning to the user. The insecure warning MUST go to **stderr** via `fmt.Fprintln(os.Stderr, ...)`. -- `http.ProxyFromEnvironment` caches its resolved proxy config with a `sync.Once` for the life of the process. Do NOT write a unit test that sets `HTTPS_PROXY`/`NO_PROXY` with `t.Setenv` and asserts on `tr.Proxy(req)` — it will be flaky/cached. Unit tests assert only that `tr.Proxy != nil`. Real proxy traversal + `NO_PROXY` bypass are covered by the existing real-life harness `integration-tests/proxy-tls/run.sh`. -- Confirmed on macOS: Go does NOT auto-read `SSL_CERT_FILE` into the system pool. The pool must be built explicitly (`x509.SystemCertPool()` + `AppendCertsFromPEM`). - ---- - -## Task 1: Create `utils/httpclient` package — options - -**Files:** -- Create: `utils/httpclient/options.go` - -- [ ] **Step 1: Write options.go** - -```go -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)) -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `go build ./utils/httpclient/` -Expected: PASS (no output). The package has no other files yet, so this only checks syntax. - -- [ ] **Step 3: Commit** - -```bash -git add utils/httpclient/options.go -git commit -m "feat(httpclient): add options and env-var readers" -``` - ---- - -## Task 2: TLS config builder (TDD) - -**Files:** -- Create: `utils/httpclient/tls.go` -- Test: `utils/httpclient/httpclient_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `utils/httpclient/httpclient_test.go`: - -```go -package httpclient - -import ( - "crypto/tls" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// writeServerCAFile writes the test server's cert to a PEM file and returns its path. -func writeServerCAFile(t *testing.T, srv *httptest.Server) string { - t.Helper() - certPEM := "-----BEGIN CERTIFICATE-----\n" - // httptest sets srv.Certificate(); encode it to PEM. - der := srv.Certificate().Raw - _ = der - // Use the helper from crypto/x509 via pem in the real impl test below. - return certPEM -} - -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) -} - -// 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) -} - -var _ = time.Second // keep time import if unused elsewhere -var _ = tls.VersionTLS12 -var _ = writeServerCAFile -``` - -Note: the helper `writeServerCAFile` above is unused scaffolding; delete it once `certToPEM`/`encodeCertPEM` are in place. `encodeCertPEM` is defined in tls.go (Step 3). - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./utils/httpclient/ -run TestBuildTLSConfig -v` -Expected: FAIL — `undefined: buildTLSConfig` and `undefined: encodeCertPEM`. - -- [ ] **Step 3: Write tls.go** - -```go -package httpclient - -import ( - "crypto/tls" - "crypto/x509" - "encoding/pem" - "fmt" - "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 - fmt.Fprintln(os.Stderr, - "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 - } - - pool, err := x509.SystemCertPool() - if err != nil || pool == nil { - pool = x509.NewCertPool() - } - - if path := caBundlePath(); path != "" { - 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 is a test/helper utility: encode DER cert bytes as PEM. -func encodeCertPEM(der []byte) []byte { - return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./utils/httpclient/ -run TestBuildTLSConfig -v` -Expected: PASS — all 5 `TestBuildTLSConfig_*` tests green. - -- [ ] **Step 5: Commit** - -```bash -git add utils/httpclient/tls.go utils/httpclient/httpclient_test.go -git commit -m "feat(httpclient): build TLS config from SSL_CERT_FILE + insecure toggle" -``` - ---- - -## Task 3: Client factory (TDD) - -**Files:** -- Create: `utils/httpclient/client.go` -- Test: `utils/httpclient/httpclient_test.go` (append) - -- [ ] **Step 1: Append failing tests** - -Add to `utils/httpclient/httpclient_test.go`: - -```go -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) -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./utils/httpclient/ -run TestNew -v` -Expected: FAIL — `undefined: New`. - -- [ ] **Step 3: Write client.go** - -```go -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 - } - - transport := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - TLSClientConfig: tlsCfg, - } - - return &http.Client{ - Timeout: o.Timeout, - Transport: transport, - }, nil -} -``` - -- [ ] **Step 4: Run the full package test suite** - -Run: `go test ./utils/httpclient/ -v` -Expected: PASS — all tests green. - -- [ ] **Step 5: Confirm hermetic loop is stable** - -Run: `go test ./utils/httpclient/ -count=5` -Expected: `ok codacy/cli-v2/utils/httpclient` (no FAIL across 5 iterations). - -- [ ] **Step 6: Commit** - -```bash -git add utils/httpclient/client.go utils/httpclient/httpclient_test.go -git commit -m "feat(httpclient): add New client factory wiring proxy + TLS" -``` - ---- - -## Task 4: Migrate `codacy-client/client.go` - -**Files:** -- Modify: `codacy-client/client.go:13-32` (the `getRequest` function and `timeout` const) - -- [ ] **Step 1: Replace the inline client** - -In `codacy-client/client.go`, add the import `"codacy/cli-v2/utils/httpclient"` (internal-imports group). Replace the start of `getRequest`: - -Current: -```go -func getRequest(url string, apiToken string) ([]byte, error) { - client := &http.Client{ - Timeout: timeout, - } - - req, err := http.NewRequest("GET", url, nil) -``` - -New: -```go -func getRequest(url string, apiToken string) ([]byte, error) { - 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) -``` - -If `net/http` becomes otherwise unused in the file, keep it — `http.NewRequest` still uses it. (`fmt` is already imported.) - -- [ ] **Step 2: Compile** - -Run: `go build ./codacy-client/` -Expected: PASS. - -- [ ] **Step 3: Run existing package tests** - -Run: `go test ./codacy-client/` -Expected: PASS (or "no test files" — either is acceptable; no behavior change for the happy path). - -- [ ] **Step 4: Commit** - -```bash -git add codacy-client/client.go -git commit -m "refactor(codacy-client): route getRequest through httpclient factory" -``` - ---- - -## Task 5: Migrate `tools/patterns.go` - -**Files:** -- Modify: `tools/patterns.go:12-25` - -- [ ] **Step 1: Replace the inline client** - -Add import `"codacy/cli-v2/utils/httpclient"`. Replace: - -Current: -```go - client := &http.Client{ - Timeout: 10 * time.Second, - } - - // Fetch default patterns from Codacy API - url := fmt.Sprintf("https://app.codacy.com/api/v3/tools/%s/patterns", toolUUID) - req, err := http.NewRequest("GET", url, nil) -``` - -New: -```go - 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 - url := fmt.Sprintf("https://app.codacy.com/api/v3/tools/%s/patterns", toolUUID) - req, err := http.NewRequest("GET", url, nil) -``` - -If the `time` import becomes unused after this change, remove it. (It is still used here via `10 * time.Second`, so keep it.) - -- [ ] **Step 2: Compile** - -Run: `go build ./tools/` -Expected: PASS. - -- [ ] **Step 3: Run tests** - -Run: `go test ./tools/ -run TestFetchDefaultEnabledPatterns` -Expected: PASS or "no tests to run" (no such test exists today — acceptable). - -- [ ] **Step 4: Commit** - -```bash -git add tools/patterns.go -git commit -m "refactor(tools): route FetchDefaultEnabledPatterns through httpclient" -``` - ---- - -## Task 6: Migrate `utils/download.go` - -**Files:** -- Modify: `utils/download.go:50` (and imports) - -- [ ] **Step 1: Replace the inline client** - -Add import `"codacy/cli-v2/utils/httpclient"`. Replace: - -Current: -```go - client := &http.Client{} - req, err := http.NewRequest("GET", url, nil) -``` - -New: -```go - 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) -``` - -- [ ] **Step 2: Compile** - -Run: `go build ./utils/...` -Expected: PASS. - -- [ ] **Step 3: Run tests** - -Run: `go test ./utils/...` -Expected: PASS (or "no test files"). - -- [ ] **Step 4: Commit** - -```bash -git add utils/download.go -git commit -m "refactor(utils): route DownloadFile through httpclient factory" -``` - ---- - -## Task 7: Migrate `cmd/upload.go` (4 callsites) - -**Files:** -- Modify: `cmd/upload.go` at lines ~254, ~272 (`&http.Client{}`) and ~344, ~375 (`http.DefaultClient.Do`) - -- [ ] **Step 1: Replace `resultsFinalWithProjectToken` client (~line 254)** - -Add import `"codacy/cli-v2/utils/httpclient"`. Replace: - -```go - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - fmt.Println("Error:", err) - return - } -``` - -with: - -```go - client, err := httpclient.New() - if err != nil { - fmt.Println("Error:", err) - return - } - resp, err := client.Do(req) - if err != nil { - fmt.Println("Error:", err) - return - } -``` - -- [ ] **Step 2: Replace `resultsFinalWithAPIToken` client (~line 272)** - -Apply the identical change as Step 1 to the second `client := &http.Client{}` in `resultsFinalWithAPIToken`. - -- [ ] **Step 3: Replace `sendResultsWithProjectToken` DefaultClient (~line 344)** - -Replace: - -```go - resp, err := http.DefaultClient.Do(req) - if err != nil { - fmt.Printf("Error sending results: %v\n", err) - os.Exit(1) - } -``` - -with: - -```go - 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) - } -``` - -- [ ] **Step 4: Replace `sendResultsWithAPIToken` DefaultClient (~line 375)** - -Apply the identical change as Step 3 to the second `http.DefaultClient.Do(req)` in `sendResultsWithAPIToken`. - -- [ ] **Step 5: Compile (catches unused `net/http` import)** - -Run: `go build ./cmd/` -Expected: PASS. If the compiler reports `"net/http" imported and not used`, remove the `net/http` import; if it reports it IS used (e.g. `http.NewRequest` remains), keep it. `cmd/upload.go` still calls `http.NewRequest`, so the import stays. - -- [ ] **Step 6: Run upload tests** - -Run: `go test ./cmd/ -run TestUpload -v` -Expected: PASS (existing `cmd/upload_test.go` must stay green). - -- [ ] **Step 7: Commit** - -```bash -git add cmd/upload.go -git commit -m "refactor(upload): route legacy upload calls through httpclient" -``` - ---- - -## Task 8: Migrate `cmd/upload_sbom.go` (preserve test injection) - -**Files:** -- Modify: `cmd/upload_sbom.go:32` (package var) and the upload function around line 242 - -**Context:** `sbomHTTPClient httpDoer = &http.Client{Timeout: 5 * time.Minute}` is a package-level var that `upload_sbom_test.go` overrides for injection. A package-var initializer cannot return an error, so we make the default lazy. - -- [ ] **Step 1: Write/extend the failing test** - -In `cmd/upload_sbom_test.go`, add a test asserting the default client is created via the factory when none is injected: - -```go -func TestDefaultSBOMClient_UsesHTTPClientFactory(t *testing.T) { - 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) -} -``` - -Add imports `"github.com/stretchr/testify/require"` and `"time"` to the test file if not already present. - -- [ ] **Step 2: Run to verify it fails** - -Run: `go test ./cmd/ -run TestDefaultSBOMClient -v` -Expected: FAIL — `undefined: defaultSBOMClient`. - -- [ ] **Step 3: Change the package var to lazy default** - -Add import `"codacy/cli-v2/utils/httpclient"`. Replace: - -```go - sbomHTTPClient httpDoer = &http.Client{Timeout: 5 * time.Minute} -``` - -with: - -```go - // sbomHTTPClient is nil by default and resolved lazily via defaultSBOMClient. - // Tests may set it to a stub implementing httpDoer. - sbomHTTPClient httpDoer -``` - -Add this helper near the `httpDoer` interface definition: - -```go -// defaultSBOMClient returns the injected client if set, else a factory client. -func defaultSBOMClient() (httpDoer, error) { - if sbomHTTPClient != nil { - return sbomHTTPClient, nil - } - return httpclient.New(httpclient.WithTimeout(5 * time.Minute)) -} -``` - -- [ ] **Step 4: Use the helper at the request callsite (~line 242)** - -Replace: - -```go - resp, err := sbomHTTPClient.Do(req) -``` - -with: - -```go - client, err := defaultSBOMClient() - if err != nil { - return fmt.Errorf("failed to create http client: %w", err) - } - resp, err := client.Do(req) -``` - -(If the enclosing function does not return `error`, adapt to its existing error style — check the signature of the function containing line 242 and match how it reports errors. The function is the SBOM upload handler; it returns an error.) - -- [ ] **Step 5: Compile** - -Run: `go build ./cmd/` -Expected: PASS. Remove the now-unused `time` import only if the compiler flags it (it remains used by `defaultSBOMClient`). - -- [ ] **Step 6: Run SBOM tests** - -Run: `go test ./cmd/ -run "TestUploadSBOM|TestDefaultSBOMClient" -v` -Expected: PASS — existing SBOM tests plus the new one are green. - -- [ ] **Step 7: Commit** - -```bash -git add cmd/upload_sbom.go cmd/upload_sbom_test.go -git commit -m "refactor(sbom): lazy default client via httpclient, keep test injection" -``` - ---- - -## Task 9: Full build + test sweep - -**Files:** none (verification task) - -- [ ] **Step 1: Build everything** - -Run: `go build ./...` -Expected: PASS (no output). - -- [ ] **Step 2: Run the whole unit suite** - -Run: `go test ./...` -Expected: PASS for all packages (pre-existing network-dependent tests behave as before). - -- [ ] **Step 3: Vet** - -Run: `go vet ./...` -Expected: no findings in touched packages. - -- [ ] **Step 4: Commit (if vet/build produced fixups)** - -```bash -git add -A -git commit -m "chore: build/test/vet sweep for proxy-tls support" || echo "nothing to commit" -``` - ---- - -## Task 10: Documentation - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Add a "Proxy & TLS" section** - -Append to `README.md` (place after the configuration/usage section; match existing heading depth): - -```markdown -## 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`. -``` - -- [ ] **Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: document proxy and TLS configuration" -``` - ---- - -## Task 11: Real-life acceptance gate - -**Files:** none (uses existing `integration-tests/proxy-tls/run.sh`) - -- [ ] **Step 1: Build the binary** - -Run: `make build` -Expected: produces `./cli-v2`. - -- [ ] **Step 2: Run the real-life harness** - -Run: `PROXY_PORT=8899 integration-tests/proxy-tls/run.sh` -Expected: ALL FOUR cases PASS now — -- A custom-CA: PASS (rc=0, proxy_saw=yes) ← was FAIL pre-implementation -- B no-CA-fails: PASS -- C insecure: PASS (rc=0, proxy_saw=yes) ← was FAIL pre-implementation -- D no_proxy-bypass: PASS -Final line: `ALL PROXY/TLS CHECKS PASSED`, exit 0. - -- [ ] **Step 3: Confirm stability under loop** - -Run: `PROXY_TLS_LOOP=3 PROXY_PORT=8899 integration-tests/proxy-tls/run.sh` -Expected: all cases PASS in every iteration. - -> If A or C still fail with "certificate is not trusted", the TLS config is not -> being applied at that callsite — re-check that the failing path goes through -> `httpclient.New` (Tasks 4–8) and not a stray `http.Client`/`http.DefaultClient`. - ---- - -## Self-Review Notes - -- **Spec coverage:** proxy via `ProxyFromEnvironment` (Task 3 + all migrations); `NO_PROXY` (inherited from Go default, verified Task 11 case D); custom CA via `SSL_CERT_FILE` (Task 2, verified Tasks 3 & 11); insecure toggle (Task 2, verified Tasks 3 & 11); no insecure default (Task 2 default path, verified Task 11 case B); central factory + all 7 callsites (Tasks 4–8); error-on-bad-CA (Task 3, `TestNew_PropagatesCABundleError`); docs (Task 10); test loops — hermetic (Task 3 Step 5) and real-life (Task 11). -- **Insecure warning to stderr** (not `logger.Warn`) because the logger writes to a file and only post-Initialize — see env notes. -- **Proxy env unit-test caching** avoided per the `ProxyFromEnvironment` sync.Once note; real proxy/NO_PROXY behavior covered by the harness. -- **Out of scope (follow-up tickets, per spec):** extension/MCP mapping of `http.proxyStrictSSL` → `CODACY_CLI_INSECURE`; `CODACY_API_BASE_URL` override for black-box CLI runs. -``` diff --git a/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md b/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md deleted file mode 100644 index 69265d9..0000000 --- a/docs/superpowers/specs/2026-06-09-cliv2-proxy-tls-support-design.md +++ /dev/null @@ -1,200 +0,0 @@ -# CLIv2 Proxy & TLS Support — Design - -- **Ticket:** [OD-30](https://linear.app/codacy/issue/OD-30/cliv2-verify-and-add-proxy-support) ([CF-2439](https://codacy.atlassian.net/browse/CF-2439)) -- **Date:** 2026-06-09 -- **Repo:** `codacy-cli-v2` -- **Status:** Approved design, pending implementation plan - -## Goal - -Ensure the Go analysis CLIv2 respects standard proxy and SSL configuration when run -directly, launched by the MCP server, or launched by the VS Code extension. Add custom -CA bundle support and an explicit insecure-TLS toggle that maps the VS Code -`http.proxyStrictSSL` setting. - -## Current State (verified) - -Roughly 7 HTTP callsites, each building its own client; none set a custom `Transport`: - -| File | Callsite | Notes | -|------|----------|-------| -| `codacy-client/client.go` | `getRequest` | `&http.Client{Timeout: 10s}` (API v3 GETs) | -| `tools/patterns.go` | `FetchDefaultEnabledPatterns` | `&http.Client{Timeout: 10s}` | -| `utils/download.go` | `DownloadFile` | `&http.Client{}` (tool/runtime binary downloads) | -| `cmd/upload.go` | `resultsFinalWithProjectToken`, `resultsFinalWithAPIToken` | `&http.Client{}` (legacy api.codacy.com) | -| `cmd/upload.go` | `sendResultsWithProjectToken`, `sendResultsWithAPIToken` | `http.DefaultClient` | -| `cmd/upload_sbom.go` | `sbomHTTPClient` | `&http.Client{Timeout: 5m}` behind `httpDoer` interface | - -**Key facts:** - -1. Go's `http.DefaultTransport` already uses `http.ProxyFromEnvironment`. Omitting - `Transport` (as all callsites do) means `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` - are **already honored today** everywhere. Proxy support is mostly *verify*, not *add*. -2. Subprocess tools/runtimes spawned by the CLI inherit the environment, so proxy env - vars flow to them automatically. -3. No `InsecureSkipVerify` anywhere → no insecure default to remove. Good. - -**Gaps:** - -- No custom CA bundle support → corporate MITM proxies fail TLS. -- No shared client factory → cannot inject CA/TLS config without touching every callsite. -- No way to honor `http.proxyStrictSSL=false` from the extension. - -## Approach - -Central client factory (`utils/httpclient`) with an explicitly-built TLS root pool. All -callsites switch to it. Chosen over a shared-transport-only patch or a minimal -per-callsite patch because the ticket wants a single verified proxy/CA path, and the -existing `httpDoer` interface already signals a preference for injectable clients. - -## Components - -### `utils/httpclient` package - -``` -utils/httpclient/ - client.go // New(opts...) *http.Client - tls.go // buildTLSConfig() (*tls.Config, error) - options.go // Option funcs (WithTimeout, ...) + env resolution -``` - -`New` always sets `Transport.Proxy = http.ProxyFromEnvironment` (locks proxy support) -and `Transport.TLSClientConfig` from `buildTLSConfig`. Returns a plain `*http.Client`, -so no callsite signatures change. - -### TLS / CA construction (`tls.go`) - -```go -func buildTLSConfig() (*tls.Config, error) { - cfg := &tls.Config{MinVersion: tls.VersionTLS12} - if insecureEnv() { - cfg.InsecureSkipVerify = true - logger.Warn("TLS verification disabled (CODACY_CLI_INSECURE). " + - "Insecure — proxy MITM not validated.") - return cfg, nil - } - pool, err := x509.SystemCertPool() - if err != nil || pool == nil { - pool = x509.NewCertPool() - } - if f := os.Getenv("SSL_CERT_FILE"); f != "" { - pem, err := os.ReadFile(f) - if err != nil { - return nil, fmt.Errorf("read SSL_CERT_FILE %s: %w", f, err) - } - if !pool.AppendCertsFromPEM(pem) { - return nil, fmt.Errorf("no valid certs in %s", f) - } - } - cfg.RootCAs = pool - return cfg, nil -} -``` - -The pool is built explicitly (system pool + appended `SSL_CERT_FILE`) rather than -relying on Go's implicit `SSL_CERT_FILE` handling, which varies by platform (notably -macOS). Corporate CA then works without disabling verification. Insecure mode -short-circuits and warns once. - -### Configuration surface - -| Env var | Effect | Default | -|---------|--------|---------| -| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | Proxy routing (Go default) | unset | -| `SSL_CERT_FILE` | Extra CA bundle appended to system pool | unset | -| `CODACY_CLI_INSECURE` (`1`/`true`) | Disable TLS verification; maps `http.proxyStrictSSL=false` | off | - -Env chosen as the signal channel because the extension and MCP server both control the -child process environment uniformly, with no CLI-arg plumbing. - -## Callsite Migration - -Replace every `&http.Client{...}` / `http.DefaultClient` with `httpclient.New(...)`: - -- `codacy-client/client.go` → `New(WithTimeout(10 * time.Second))` -- `tools/patterns.go` → `New(WithTimeout(10 * time.Second))` -- `utils/download.go` → `New(WithTimeout(0))` (large binaries, no timeout) -- `cmd/upload.go` ×4 → `New()` (default) -- `cmd/upload_sbom.go` → `sbomHTTPClient = httpclient.New(WithTimeout(5 * time.Minute))`, - preserving the `httpDoer` interface for test injection - -## Error Handling - -- CA read/parse failure returns an error and aborts the request — do not silently fall - back to the system pool (surfaces misconfiguration). -- TLS handshake errors propagate as today; `codacy-client` error messages gain a hint: - "if behind a corporate proxy, set SSL_CERT_FILE or CODACY_CLI_INSECURE". -- Insecure mode emits a single stderr warning per process. - -## Testing - -The primary test loop is a **hermetic, in-process Go harness** in -`utils/httpclient`. It uses `httptest.NewTLSServer` (which mints a self-signed cert -exposed via `server.Certificate()`) plus a local `httptest` forward proxy. No real -network, no `app.codacy.com`, no external `mitmproxy` process — so it runs fast and -loops cleanly under `go test -count=N ./utils/httpclient`. It exercises the single -factory that every callsite uses, so passing the harness means every callsite is -covered. - -This approach is validated by a proof-of-concept (`.workdir/proxytls-poc`, throwaway) -with all four cases green and stable across `-count=5`: - -| Test | Asserts | -|------|---------| -| `TestTLSDefaultFailsWithoutCA` | self-signed server → request fails with `x509: certificate signed by unknown authority` (no insecure default) | -| `TestTLSWithCustomCASucceeds` | server cert appended to pool via `SSL_CERT_FILE` → request succeeds | -| `TestTLSInsecureSucceeds` | `CODACY_CLI_INSECURE` set → `InsecureSkipVerify`, request succeeds | -| `TestProxyTraversed` | client routes through local forward proxy; proxy hit-count == 1, body from origin | - -**Additional unit assertions (`utils/httpclient`):** -- `Transport.Proxy` is non-nil after `New` (proxy support locked in). -- Bad/empty PEM in `SSL_CERT_FILE` → `New`/`buildTLSConfig` returns an error. -- `NO_PROXY` honored: with proxy env set, a host matching `NO_PROXY` resolves - `Transport.Proxy(req) == nil` (assert on the resolver, not a live dial). - -**Why not black-box the `cli-v2` binary in the loop:** the API base URL is a hardcoded -package var (`codacyclient.CodacyApiBase`) plus string literals in `tools/patterns.go`, -`cmd/upload.go`, and `cmd/upload_sbom.go`. Pointing the built binary at a local TLS -server would require threading a base-URL override through all of them — out of scope -here. The Go harness gives equivalent coverage of the proxy/TLS logic without it. -(A `CODACY_API_BASE_URL` override to enable end-to-end black-box runs is a reasonable -follow-up; see below.) - -**Real-life harness (`integration-tests/proxy-tls/run.sh`) — already written:** -Runs the actual `cli-v2` binary through a real `mitmproxy` (real CA, real TLS -interception) against the real `app.codacy.com`, using tokenless `cli-v2 init` as the -network-touching command. A mitmproxy addon (`connect_logger.py`) records proxy -traversal at CONNECT time, so traversal is detected even when the client rejects the -cert. Four cases: A custom-CA, B no-CA-fails, C insecure, D NO_PROXY-bypass. Loopable -via `PROXY_TLS_LOOP=N`. - -Baseline run (pre-implementation) confirms the gap and validates the design: - -| Case | Baseline result | Note | -|------|-----------------|------| -| A custom-CA | FAIL (`certificate is not trusted`), proxy traversed | `SSL_CERT_FILE` ignored today | -| B no-CA | PASS (TLS correctly fails), proxy traversed | no insecure default | -| C insecure | FAIL, proxy traversed | `CODACY_CLI_INSECURE` not implemented | -| D NO_PROXY | PASS, proxy bypassed | env-bypass works | - -A and C must flip to green after implementation; that is the acceptance gate. - -**Confirmed on macOS:** Go does **not** auto-read `SSL_CERT_FILE` (case A fails with the -system trust path). This validates building the root pool explicitly -(`SystemCertPool()` + `AppendCertsFromPEM`) rather than relying on Go's implicit env -handling. - -- Also run via MCP server and VS Code extension with proxy env set (manual). - -## Documentation - -- README: new "Proxy & TLS" section covering the three env-var groups and the manual - test matrix. - -## Out of Scope (follow-up) - -- `codacy-vscode-extension`: map `http.proxy` → `HTTP_PROXY`, `http.proxyStrictSSL=false` - → `CODACY_CLI_INSECURE` when spawning the CLI. Same mapping for the MCP server. - Tracked separately from OD-30 (this ticket is `[CLIv2]`). -- `CODACY_API_BASE_URL` env override consolidating `CodacyApiBase` and the hardcoded - literals, to enable end-to-end black-box CLI runs against a local test server. From e6b5a4f5e1913da458fa092eb85bf11443947004 Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Wed, 10 Jun 2026 09:36:36 +0200 Subject: [PATCH 10/11] fix(httpclient): clone DefaultTransport + only set RootCAs with custom CA (OD-30) Address PR #205 review: - Clone http.DefaultTransport to preserve connection pooling, idle/handshake timeouts, and HTTP/2 instead of building a bare Transport. - Only set tls.Config.RootCAs when SSL_CERT_FILE is configured; leave nil otherwise so Go uses default system verification. Prevents an empty pool (when SystemCertPool fails) from rejecting all TLS handshakes. Co-Authored-By: Claude Fable 5 --- utils/httpclient/client.go | 9 +++++---- utils/httpclient/tls.go | 15 +++++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/utils/httpclient/client.go b/utils/httpclient/client.go index 259d8a8..8061200 100644 --- a/utils/httpclient/client.go +++ b/utils/httpclient/client.go @@ -20,10 +20,11 @@ func New(opts ...Option) (*http.Client, error) { return nil, err } - transport := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - TLSClientConfig: tlsCfg, - } + // 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, diff --git a/utils/httpclient/tls.go b/utils/httpclient/tls.go index 930fc38..df963d5 100644 --- a/utils/httpclient/tls.go +++ b/utils/httpclient/tls.go @@ -22,12 +22,15 @@ func buildTLSConfig() (*tls.Config, error) { return cfg, nil } - pool, err := x509.SystemCertPool() - if err != nil || pool == nil { - pool = x509.NewCertPool() - } - + // 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) @@ -35,9 +38,9 @@ func buildTLSConfig() (*tls.Config, error) { if !pool.AppendCertsFromPEM(pemBytes) { return nil, fmt.Errorf("no valid certificates found in CA bundle %s (%s)", EnvCABundle, path) } + cfg.RootCAs = pool } - cfg.RootCAs = pool return cfg, nil } From 1c306058c34d544d704fa8dff92c8f4a9a368a25 Mon Sep 17 00:00:00 2001 From: "andrzej.janczak" Date: Thu, 11 Jun 2026 09:03:22 +0200 Subject: [PATCH 11/11] fix(logger): mirror stdlib log into codacy-cli.log; log insecure warning to file (OD-30) Proxy/TLS warnings (and all log.Printf/log.Fatalf output) reached only the terminal, never codacy-cli.log. Initialize now redirects Go's standard logger to io.MultiWriter(stderr, logfile), and the CODACY_CLI_INSECURE warning is emitted via log.Println so it lands in the file too. Co-Authored-By: Claude Fable 5 --- utils/httpclient/tls.go | 8 +++++--- utils/logger/logger.go | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/utils/httpclient/tls.go b/utils/httpclient/tls.go index df963d5..f444336 100644 --- a/utils/httpclient/tls.go +++ b/utils/httpclient/tls.go @@ -5,6 +5,7 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "log" "os" ) @@ -16,9 +17,10 @@ func buildTLSConfig() (*tls.Config, error) { if insecureEnv() { cfg.InsecureSkipVerify = true - fmt.Fprintln(os.Stderr, - "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.") + // 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 } diff --git a/utils/logger/logger.go b/utils/logger/logger.go index d898214..09a1f62 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 }