Skip to content

Commit 27e119a

Browse files
feat(httpclient): add proxy and TLS support (OD-30) (#205)
Add utils/httpclient factory: honors HTTP_PROXY/HTTPS_PROXY/NO_PROXY, custom CA via SSL_CERT_FILE, and CODACY_CLI_INSECURE toggle (no insecure default). Migrate all HTTP callsites. Mirror proxy/TLS warnings into codacy-cli.log. Document in README.
1 parent ebec24b commit 27e119a

15 files changed

Lines changed: 494 additions & 11 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,6 @@ codacy-cli
4545

4646
#Ignore vscode AI rules
4747
.github/instructions/codacy.instructions.md
48+
49+
#Ignore superpowers docs
50+
docs/superpowers/

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,34 @@ export CODACY_CLI_V2_VERSION="1.0.0-main.133.3607792"
262262
Check the [releases](https://github.com/codacy/codacy-cli-v2/releases) page for all available versions.
263263

264264
---
265+
266+
## Proxy & TLS
267+
268+
The CLI honors standard proxy environment variables for all outbound HTTP(S):
269+
270+
- `HTTP_PROXY` / `HTTPS_PROXY` — proxy URL for plain/HTTPS requests
271+
- `NO_PROXY` — comma-separated hosts that bypass the proxy
272+
273+
### Corporate proxies with TLS interception
274+
275+
If your proxy presents its own (MITM) certificate, point the CLI at the proxy's CA bundle so TLS verification still passes:
276+
277+
```sh
278+
export SSL_CERT_FILE=/path/to/corporate-ca.pem
279+
```
280+
281+
`SSL_CERT_FILE` certificates are appended to the system trust store.
282+
283+
### Disabling TLS verification (last resort)
284+
285+
```sh
286+
export CODACY_CLI_INSECURE=1
287+
```
288+
289+
This disables certificate verification entirely and prints a warning. Prefer `SSL_CERT_FILE`. Insecure mode is never enabled by default.
290+
291+
### Testing proxy/TLS behavior
292+
293+
`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`.
294+
295+
---

cmd/upload.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"codacy/cli-v2/config"
66
"codacy/cli-v2/domain"
77
"codacy/cli-v2/plugins"
8+
"codacy/cli-v2/utils/httpclient"
89
"encoding/json"
910
"fmt"
1011
"io"
@@ -251,7 +252,11 @@ func resultsFinalWithProjectToken(commitUUID string, projectToken string) {
251252
req.Header.Set("Content-Type", "application/json")
252253
req.Header.Set("project-token", projectToken)
253254

254-
client := &http.Client{}
255+
client, err := httpclient.New()
256+
if err != nil {
257+
fmt.Println("Error:", err)
258+
return
259+
}
255260
resp, err := client.Do(req)
256261
if err != nil {
257262
fmt.Println("Error:", err)
@@ -269,7 +274,11 @@ func resultsFinalWithAPIToken(commitUUID string, apiToken string, provider strin
269274
req.Header.Set("Content-Type", "application/json")
270275
req.Header.Set("api-token", apiToken)
271276

272-
client := &http.Client{}
277+
client, err := httpclient.New()
278+
if err != nil {
279+
fmt.Println("Error:", err)
280+
return
281+
}
273282
resp, err := client.Do(req)
274283
if err != nil {
275284
fmt.Println("Error:", err)
@@ -341,7 +350,12 @@ func sendResultsWithProjectToken(payload []map[string]interface{}, commitUUID st
341350
req.Header.Set("content-type", "application/json")
342351
req.Header.Set("project-token", projectToken)
343352

344-
resp, err := http.DefaultClient.Do(req)
353+
client, err := httpclient.New()
354+
if err != nil {
355+
fmt.Printf("Error creating http client: %v\n", err)
356+
os.Exit(1)
357+
}
358+
resp, err := client.Do(req)
345359
if err != nil {
346360
fmt.Printf("Error sending results: %v\n", err)
347361
os.Exit(1)
@@ -372,7 +386,12 @@ func sendResultsWithAPIToken(payload []map[string]interface{}, commitUUID string
372386
req.Header.Set("content-type", "application/json")
373387
req.Header.Set("api-token", apiToken)
374388

375-
resp, err := http.DefaultClient.Do(req)
389+
client, err := httpclient.New()
390+
if err != nil {
391+
fmt.Printf("Error creating http client: %v\n", err)
392+
os.Exit(1)
393+
}
394+
resp, err := client.Do(req)
376395
if err != nil {
377396
fmt.Printf("Error sending results: %v\n", err)
378397
os.Exit(1)

cmd/upload_sbom.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212
"time"
1313

14+
"codacy/cli-v2/utils/httpclient"
1415
"codacy/cli-v2/utils/logger"
1516

1617
"github.com/fatih/color"
@@ -29,14 +30,25 @@ var (
2930
sbomFormat string
3031
sbomBaseURL string
3132

32-
sbomHTTPClient httpDoer = &http.Client{Timeout: 5 * time.Minute}
33+
// sbomHTTPClient is nil by default and resolved lazily via defaultSBOMClient.
34+
// Tests may set it to a stub implementing httpDoer.
35+
sbomHTTPClient httpDoer
3336
)
3437

3538
// httpDoer abstracts the Do method of http.Client for testing.
3639
type httpDoer interface {
3740
Do(req *http.Request) (*http.Response, error)
3841
}
3942

43+
// defaultSBOMClient returns the injected client if set, else a factory client
44+
// honoring proxy/TLS configuration.
45+
func defaultSBOMClient() (httpDoer, error) {
46+
if sbomHTTPClient != nil {
47+
return sbomHTTPClient, nil
48+
}
49+
return httpclient.New(httpclient.WithTimeout(5 * time.Minute))
50+
}
51+
4052
func init() {
4153
uploadSBOMCmd.Flags().StringVarP(&sbomAPIToken, "api-token", "a", "", "API token for Codacy API (required)")
4254
uploadSBOMCmd.Flags().StringVarP(&sbomProvider, "provider", "p", "", "Git provider (gh, gl, bb) (required)")
@@ -239,7 +251,11 @@ func uploadSBOMToCodacy(sbomPath, imageName, tag string, params sbomUploadParams
239251
req.Header.Set("Accept", "application/json")
240252
req.Header.Set("api-token", params.apiToken)
241253

242-
resp, err := sbomHTTPClient.Do(req)
254+
client, err := defaultSBOMClient()
255+
if err != nil {
256+
return fmt.Errorf("failed to create http client: %w", err)
257+
}
258+
resp, err := client.Do(req)
243259
if err != nil {
244260
return fmt.Errorf("request failed: %w", err)
245261
}

cmd/upload_sbom_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,25 @@ import (
77
"net/http/httptest"
88
"os"
99
"testing"
10+
"time"
1011

1112
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
1214
)
1315

16+
func TestDefaultSBOMClient_UsesHTTPClientFactory(t *testing.T) {
17+
saved := sbomHTTPClient
18+
defer func() { sbomHTTPClient = saved }()
19+
20+
sbomHTTPClient = nil // force default path
21+
c, err := defaultSBOMClient()
22+
require.NoError(t, err)
23+
require.NotNil(t, c)
24+
hc, ok := c.(*http.Client)
25+
require.True(t, ok)
26+
assert.Equal(t, 5*time.Minute, hc.Timeout)
27+
}
28+
1429
type sbomTestState struct {
1530
apiToken string
1631
provider string

codacy-client/client.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package codacyclient
22

33
import (
44
"codacy/cli-v2/domain"
5+
"codacy/cli-v2/utils/httpclient"
56
"encoding/json"
67
"fmt"
78
"io"
@@ -16,8 +17,9 @@ const timeout = 10 * time.Second
1617
var CodacyApiBase = "https://app.codacy.com"
1718

1819
func getRequest(url string, apiToken string) ([]byte, error) {
19-
client := &http.Client{
20-
Timeout: timeout,
20+
client, err := httpclient.New(httpclient.WithTimeout(timeout))
21+
if err != nil {
22+
return nil, fmt.Errorf("failed to create http client: %w", err)
2123
}
2224

2325
req, err := http.NewRequest("GET", url, nil)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""mitmproxy addon: log every host the CLI routes through the proxy.
2+
3+
Logs at CONNECT time (http_connect) so HTTPS flows are recorded even when the
4+
client later rejects the server certificate — which is exactly the case we test.
5+
Also logs plain-HTTP requests. Host list is written to $PROXY_CONNECT_LOG.
6+
"""
7+
import os
8+
9+
LOG = os.environ.get("PROXY_CONNECT_LOG", "/tmp/proxy-connects.txt")
10+
11+
12+
class ConnectLogger:
13+
def _write(self, host):
14+
with open(LOG, "a") as f:
15+
f.write(host + "\n")
16+
17+
def http_connect(self, flow):
18+
self._write(flow.request.host)
19+
20+
def request(self, flow):
21+
self._write(flow.request.pretty_host)
22+
23+
24+
addons = [ConnectLogger()]

integration-tests/proxy-tls/run.sh

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/bin/bash
2+
# Real-life proxy/TLS test for codacy-cli-v2 (OD-30).
3+
#
4+
# Runs the ACTUAL cli-v2 binary through a REAL mitmproxy MITM proxy against the
5+
# real app.codacy.com, simulating a corporate TLS-intercepting proxy. Asserts:
6+
#
7+
# A. proxy + custom CA (SSL_CERT_FILE) -> success, traffic seen by proxy
8+
# B. proxy, no CA -> TLS verification failure, traffic seen
9+
# C. proxy + CODACY_CLI_INSECURE -> success, traffic seen
10+
# D. NO_PROXY for app.codacy.com -> success, proxy NOT traversed
11+
#
12+
# Cases A and C require the OD-30 feature (custom CA + insecure toggle). Before
13+
# that is implemented they FAIL with "certificate is not trusted" — that failure
14+
# is the baseline that proves the feature is needed. After implementation, green.
15+
#
16+
# Loopable: PROXY_TLS_LOOP=5 ./run.sh
17+
# Requires: mitmproxy (mitmdump). brew install mitmproxy
18+
set -uo pipefail
19+
20+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
21+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22+
CLI="$REPO_ROOT/cli-v2"
23+
PROXY_PORT="${PROXY_PORT:-8899}"
24+
CA="$HOME/.mitmproxy/mitmproxy-ca-cert.pem"
25+
WORK="$(mktemp -d)"
26+
export PROXY_CONNECT_LOG="$WORK/connects.txt"
27+
MITM_PID=""
28+
29+
red() { printf '\033[31m%s\033[0m\n' "$*"; }
30+
green() { printf '\033[32m%s\033[0m\n' "$*"; }
31+
32+
cleanup() {
33+
[ -n "$MITM_PID" ] && kill "$MITM_PID" 2>/dev/null
34+
rm -rf "$WORK"
35+
}
36+
trap cleanup EXIT
37+
38+
command -v mitmdump >/dev/null 2>&1 || { red "mitmdump not found. Install: brew install mitmproxy"; exit 2; }
39+
[ -x "$CLI" ] || { echo "Building cli-v2..."; (cd "$REPO_ROOT" && make build) || exit 2; }
40+
41+
# Start proxy with the connect-logging addon.
42+
mitmdump -p "$PROXY_PORT" -q -s "$HERE/connect_logger.py" >"$WORK/mitm.log" 2>&1 &
43+
MITM_PID=$!
44+
45+
# Wait for proxy to bind and generate its CA.
46+
for _ in $(seq 1 40); do
47+
[ -f "$CA" ] && nc -z localhost "$PROXY_PORT" 2>/dev/null && break
48+
sleep 0.3
49+
done
50+
[ -f "$CA" ] || { red "mitmproxy CA not generated at $CA"; cat "$WORK/mitm.log"; exit 2; }
51+
52+
# Fresh, network-touching, tokenless CLI command. init hits app.codacy.com/api/v3.
53+
# Args are VAR=val pairs prepended to the cli invocation via env.
54+
run_init() {
55+
local dir="$WORK/proj.$RANDOM"
56+
mkdir -p "$dir"
57+
( cd "$dir" && env "$@" "$CLI" init >"$WORK/last.log" 2>&1 )
58+
local rc=$?
59+
rm -rf "$dir"
60+
return $rc
61+
}
62+
63+
proxy_saw_codacy() { grep -q "codacy.com" "$PROXY_CONNECT_LOG" 2>/dev/null; }
64+
65+
FAILURES=0
66+
# check NAME EXPECT_RC(0|fail) EXPECT_PROXY(yes|no) -- VAR=val ...
67+
check() {
68+
local name="$1" want_rc="$2" want_proxy="$3"; shift 3; [ "$1" = "--" ] && shift
69+
: >"$PROXY_CONNECT_LOG"
70+
run_init "$@"; local rc=$?
71+
sleep 0.3 # let addon flush
72+
local saw="no"; proxy_saw_codacy && saw="yes"
73+
local ok=1
74+
[ "$want_rc" = "0" ] && [ "$rc" -ne 0 ] && ok=0
75+
[ "$want_rc" = "fail" ] && [ "$rc" -eq 0 ] && ok=0
76+
[ "$want_proxy" != "$saw" ] && ok=0
77+
if [ "$ok" -eq 1 ]; then
78+
green "PASS $name (rc=$rc, proxy_saw=$saw)"
79+
else
80+
red "FAIL $name (rc=$rc want=$want_rc, proxy_saw=$saw want=$want_proxy)"
81+
echo "----- cli output (tail) -----"; tail -3 "$WORK/last.log" 2>/dev/null; echo "-----------------------------"
82+
FAILURES=$((FAILURES+1))
83+
fi
84+
}
85+
86+
run_suite() {
87+
local P="http://localhost:$PROXY_PORT"
88+
echo "== A: proxy + custom CA (needs OD-30) =="
89+
check "A custom-CA" 0 yes -- HTTPS_PROXY="$P" HTTP_PROXY="$P" SSL_CERT_FILE="$CA"
90+
echo "== B: proxy, no CA (expect TLS failure) =="
91+
check "B no-CA-fails" fail yes -- HTTPS_PROXY="$P" HTTP_PROXY="$P"
92+
echo "== C: proxy + insecure (needs OD-30) =="
93+
check "C insecure" 0 yes -- HTTPS_PROXY="$P" HTTP_PROXY="$P" CODACY_CLI_INSECURE=1
94+
echo "== D: NO_PROXY bypass =="
95+
check "D no_proxy-bypass" 0 no -- HTTPS_PROXY="$P" NO_PROXY="app.codacy.com,api.codacy.com" SSL_CERT_FILE="$CA"
96+
}
97+
98+
LOOP="${PROXY_TLS_LOOP:-1}"
99+
for i in $(seq 1 "$LOOP"); do
100+
[ "$LOOP" -gt 1 ] && echo "### iteration $i/$LOOP ###"
101+
run_suite
102+
done
103+
104+
echo
105+
if [ "$FAILURES" -eq 0 ]; then green "ALL PROXY/TLS CHECKS PASSED"; else red "$FAILURES check(s) FAILED"; fi
106+
exit "$FAILURES"

tools/patterns.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tools
22

33
import (
44
"codacy/cli-v2/domain"
5+
"codacy/cli-v2/utils/httpclient"
56
"encoding/json"
67
"fmt"
78
"io"
@@ -11,8 +12,9 @@ import (
1112

1213
// FetchDefaultEnabledPatterns fetches default patterns from Codacy API for a given tool UUID
1314
func FetchDefaultEnabledPatterns(toolUUID string) ([]domain.PatternDefinition, error) {
14-
client := &http.Client{
15-
Timeout: 10 * time.Second,
15+
client, err := httpclient.New(httpclient.WithTimeout(10 * time.Second))
16+
if err != nil {
17+
return nil, fmt.Errorf("failed to create http client: %w", err)
1618
}
1719

1820
// Fetch default patterns from Codacy API

utils/download.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package utils
22

33
import (
4+
"codacy/cli-v2/utils/httpclient"
45
"codacy/cli-v2/utils/logger"
56
"fmt"
67
"io"
@@ -47,7 +48,10 @@ func DownloadFile(url string, destDir string) (string, error) {
4748
logger.Debug("Making HTTP GET request", logrus.Fields{
4849
"url": url,
4950
})
50-
client := &http.Client{}
51+
client, err := httpclient.New(httpclient.WithTimeout(0)) // no timeout: large binaries
52+
if err != nil {
53+
return "", fmt.Errorf("failed to create http client: %w", err)
54+
}
5155
req, err := http.NewRequest("GET", url, nil)
5256
if err != nil {
5357
return "", fmt.Errorf("failed to create request: %w", err)

0 commit comments

Comments
 (0)