Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ codacy-cli

#Ignore vscode AI rules
.github/instructions/codacy.instructions.md

#Ignore superpowers docs
docs/superpowers/
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---
27 changes: 23 additions & 4 deletions cmd/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 18 additions & 2 deletions cmd/upload_sbom.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"time"

"codacy/cli-v2/utils/httpclient"
"codacy/cli-v2/utils/logger"

"github.com/fatih/color"
Expand All @@ -29,14 +30,25 @@ 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.
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)")
Expand Down Expand Up @@ -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)
}
Expand Down
15 changes: 15 additions & 0 deletions cmd/upload_sbom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions codacy-client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package codacyclient

import (
"codacy/cli-v2/domain"
"codacy/cli-v2/utils/httpclient"
"encoding/json"
"fmt"
"io"
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions integration-tests/proxy-tls/connect_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""mitmproxy addon: log every host the CLI routes through the proxy.

Check notice on line 1 in integration-tests/proxy-tls/connect_logger.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

integration-tests/proxy-tls/connect_logger.py#L1

Multi-line docstring summary should start at the second line (D213)

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")

Check warning on line 9 in integration-tests/proxy-tls/connect_logger.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

integration-tests/proxy-tls/connect_logger.py#L9

Probable insecure usage of temp file/directory.


class ConnectLogger:
def _write(self, host):
with open(LOG, "a") as f:

Check warning on line 14 in integration-tests/proxy-tls/connect_logger.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

integration-tests/proxy-tls/connect_logger.py#L14

Missing 'encoding' parameter. 'open()' uses device locale encodings by default, corrupting files with special characters.
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()]
106 changes: 106 additions & 0 deletions integration-tests/proxy-tls/run.sh
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 4 additions & 2 deletions tools/patterns.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tools

import (
"codacy/cli-v2/domain"
"codacy/cli-v2/utils/httpclient"
"encoding/json"
"fmt"
"io"
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion utils/download.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package utils

import (
"codacy/cli-v2/utils/httpclient"
"codacy/cli-v2/utils/logger"
"fmt"
"io"
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading