From 146a92702906d6976a8ea8e334b9e291cbce058e Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 22 Jun 2026 09:11:48 +0200 Subject: [PATCH] fix(cli): avoid leaking raw ANSI color codes in non-rendering consoles The CLI logs through zerolog's ConsoleWriter, which colorized output unconditionally. Consumers that do not interpret ANSI escape codes (a Jenkins console without the AnsiColor plugin, piped output, log files) showed them as raw control sequences. Color detection now follows the same model as the widely used supports-color/chalk family: honor the NO_COLOR and CLICOLOR_FORCE / FORCE_COLOR conventions, enable color on an interactive terminal, and for non-terminal output enable it only for CI systems known to render ANSI (GitHub Actions, GitLab CI, CircleCI, Travis, Buildkite, Drone, AppVeyor). Jenkins is intentionally excluded, so it falls back to the no-color default and keeps its logs clean. Closes #3228 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 5dbf672f-418b-459c-b51f-485c213d3c40 --- app/cli/cmd/color.go | 91 +++++++++++++++++++++++++++ app/cli/cmd/color_test.go | 127 ++++++++++++++++++++++++++++++++++++++ app/cli/main.go | 12 +++- 3 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 app/cli/cmd/color.go create mode 100644 app/cli/cmd/color_test.go diff --git a/app/cli/cmd/color.go b/app/cli/cmd/color.go new file mode 100644 index 000000000..94db3dc31 --- /dev/null +++ b/app/cli/cmd/color.go @@ -0,0 +1,91 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "os" + + "golang.org/x/term" +) + +// colorCapableCISignals are environment variables set by CI systems whose logs +// render ANSI escape codes, so colored output is desirable there even though +// the process is not attached to an interactive terminal. The list mirrors the +// one used by the widely adopted supports-color/chalk detection. +// +// Jenkins is intentionally absent: its console does not interpret ANSI escape +// codes unless the AnsiColor plugin is installed, so leaving it out makes the +// CLI fall back to the non-terminal default (no color) and avoids leaking raw +// escape sequences into the build log. +var colorCapableCISignals = []string{ + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "TRAVIS", + "BUILDKITE", + "DRONE", + "APPVEYOR", +} + +// LogColorDisabled reports whether colorized log output should be disabled. It +// follows the same approach as the supports-color/chalk family: honor the +// NO_COLOR / FORCE_COLOR conventions, then enable color on an interactive +// terminal or in a CI system known to render ANSI escape codes. +func LogColorDisabled() bool { + return !colorSupported(os.LookupEnv, term.IsTerminal(int(os.Stderr.Fd()))) +} + +// colorSupported decides whether ANSI color should be emitted, given a way to +// look up environment variables and whether stderr is an interactive terminal. +// It is kept pure so the decision logic can be unit tested. +// +// Precedence: +// 1. NO_COLOR set, any value -> no color (https://no-color.org/). Universal +// opt-out, wins over everything. +// 2. CLICOLOR_FORCE / FORCE_COLOR set to a non-empty, non-"0" value -> color. +// Universal opt-in (https://bixense.com/clicolors/ and FORCE_COLOR). +// 3. TERM=dumb -> no color. +// 4. Interactive terminal -> color. +// 5. Non-terminal (CI, pipe, file) -> color only for CI systems known to +// render ANSI (see colorCapableCISignals); otherwise no color. This keeps +// piped output and ANSI-incapable consoles (e.g. Jenkins) clean. +func colorSupported(lookupEnv func(string) (string, bool), isTerminal bool) bool { + if _, ok := lookupEnv("NO_COLOR"); ok { + return false + } + + for _, key := range []string{"CLICOLOR_FORCE", "FORCE_COLOR"} { + if v, ok := lookupEnv(key); ok && v != "" && v != "0" { + return true + } + } + + if v, ok := lookupEnv("TERM"); ok && v == "dumb" { + return false + } + + if isTerminal { + return true + } + + for _, key := range colorCapableCISignals { + if _, ok := lookupEnv(key); ok { + return true + } + } + + return false +} diff --git a/app/cli/cmd/color_test.go b/app/cli/cmd/color_test.go new file mode 100644 index 000000000..52e01b7a6 --- /dev/null +++ b/app/cli/cmd/color_test.go @@ -0,0 +1,127 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestColorSupported(t *testing.T) { + testCases := []struct { + name string + env map[string]string + isTerminal bool + want bool + }{ + { + name: "interactive terminal gets color", + isTerminal: true, + want: true, + }, + { + name: "plain non-terminal (pipe/file) gets no color", + isTerminal: false, + want: false, + }, + { + name: "NO_COLOR disables color even on a terminal", + env: map[string]string{"NO_COLOR": "1"}, + isTerminal: true, + want: false, + }, + { + name: "NO_COLOR with empty value still disables (presence wins)", + env: map[string]string{"NO_COLOR": ""}, + isTerminal: true, + want: false, + }, + { + name: "CLICOLOR_FORCE forces color on a non-terminal", + env: map[string]string{"CLICOLOR_FORCE": "1"}, + isTerminal: false, + want: true, + }, + { + name: "FORCE_COLOR forces color on a non-terminal", + env: map[string]string{"FORCE_COLOR": "1"}, + isTerminal: false, + want: true, + }, + { + name: "CLICOLOR_FORCE=0 does not force color", + env: map[string]string{"CLICOLOR_FORCE": "0"}, + isTerminal: false, + want: false, + }, + { + name: "NO_COLOR takes precedence over FORCE_COLOR", + env: map[string]string{"NO_COLOR": "1", "FORCE_COLOR": "1"}, + isTerminal: true, + want: false, + }, + { + name: "TERM=dumb disables color on a terminal", + env: map[string]string{"TERM": "dumb"}, + isTerminal: true, + want: false, + }, + // CI systems that render ANSI keep color despite no terminal. + { + name: "GitHub Actions gets color", + env: map[string]string{"GITHUB_ACTIONS": "true", "CI": "true"}, + isTerminal: false, + want: true, + }, + { + name: "GitLab CI gets color", + env: map[string]string{"GITLAB_CI": "true", "CI": "true"}, + isTerminal: false, + want: true, + }, + // Jenkins is not in the color-capable list, so a non-terminal Jenkins + // console gets no color and avoids leaking raw escape codes. + { + name: "Jenkins gets no color", + env: map[string]string{"JENKINS_URL": "http://jenkins.local/", "BUILD_NUMBER": "10"}, + isTerminal: false, + want: false, + }, + { + name: "Jenkins with FORCE_COLOR opts back in", + env: map[string]string{"JENKINS_URL": "http://jenkins.local/", "FORCE_COLOR": "1"}, + isTerminal: false, + want: true, + }, + { + name: "generic CI not known to render ANSI gets no color", + env: map[string]string{"CI": "true"}, + isTerminal: false, + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lookup := func(key string) (string, bool) { + v, ok := tc.env[key] + return v, ok + } + assert.Equal(t, tc.want, colorSupported(lookup, tc.isTerminal)) + }) + } +} diff --git a/app/cli/main.go b/app/cli/main.go index 42d56ec01..ba566a15f 100644 --- a/app/cli/main.go +++ b/app/cli/main.go @@ -33,8 +33,16 @@ import ( ) func main() { - // Couldn't find an easier way to disable the timestamp - logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, FormatTimestamp: func(interface{}) string { return "" }}) + // Couldn't find an easier way to disable the timestamp. + // Enable color on interactive terminals and CI systems that render ANSI, + // and disable it for ANSI-incapable consumers (e.g. a Jenkins console + // without the AnsiColor plugin, piped output) to avoid leaking raw escape + // codes. See cmd.LogColorDisabled. + logger := zerolog.New(zerolog.ConsoleWriter{ + Out: os.Stderr, + NoColor: cmd.LogColorDisabled(), + FormatTimestamp: func(interface{}) string { return "" }, + }) rootCmd := cmd.NewRootCmd(logger) if err := cmd.Execute(rootCmd); err != nil { msg, exitCode := errorInfo(err, logger)