Skip to content
Closed
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
51 changes: 0 additions & 51 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package root

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -117,56 +116,6 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command {
return cmd
}

// runSummary is the JSON object written to stderr when observability env vars are set.
type runSummary struct {
Event string `json:"event"`
InvocationID string `json:"invocation_id"`
Command string `json:"command"`
Status string `json:"status"`
DurationMs int64 `json:"duration_ms"`
Error string `json:"error,omitempty"`
}

func shouldEmitRunSummary() bool {
return os.Getenv("ALGOLIA_CLI_NON_INTERACTIVE") == "1" ||
os.Getenv("ALGOLIA_CLI_OBSERVABILITY") == "1"
}

func emitRunSummary(stderr io.Writer, ctx context.Context, cmd *cobra.Command, runErr error, duration time.Duration) {
meta := telemetry.GetEventMetadata(ctx)
invocationID := ""
if meta != nil {
invocationID = meta.InvocationID
}
commandPath := ""
if meta != nil && meta.CommandPath != "" {
commandPath = meta.CommandPath
}
if commandPath == "" && cmd != nil {
commandPath = cmd.CommandPath()
}
status := "ok"
errMsg := ""
if runErr != nil {
status = "error"
errMsg = runErr.Error()
if len(errMsg) > 500 {
errMsg = errMsg[:497] + "..."
}
}
s := runSummary{
Event: "cli_run",
InvocationID: invocationID,
Command: commandPath,
Status: status,
DurationMs: duration.Milliseconds(),
Error: errMsg,
}
enc := json.NewEncoder(stderr)
enc.SetEscapeHTML(false)
_ = enc.Encode(s)
}

func Execute() exitCode {
hasDebug := os.Getenv("DEBUG") != ""
hasTelemetry := os.Getenv("ALGOLIA_CLI_TELEMETRY") != "0"
Expand Down
108 changes: 108 additions & 0 deletions pkg/cmd/root/run_summary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package root

import (
"context"
"encoding/json"
"io"
"os"
"regexp"
"time"

"github.com/spf13/cobra"

"github.com/algolia/cli/pkg/telemetry"
)

// runSummary is the JSON object written to stderr when DEBUG is set.
type runSummary struct {
Event string `json:"event"`
InvocationID string `json:"invocation_id"`
Command string `json:"command"`
Status string `json:"status"`
DurationMs int64 `json:"duration_ms"`
Error string `json:"error,omitempty"`
}

func shouldEmitRunSummary() bool {
return os.Getenv("DEBUG") != ""
}

// sensitiveFlagPattern matches flags that take a secret value; submatch 1 = flag name (e.g. --api-key= or -p ), submatch 2 = value.
var sensitiveFlagPattern = regexp.MustCompile(
`(--(?:api-key|application-id|admin-api-key)=)(\S+)|(--(?:api-key|application-id|admin-api-key)\s+)(\S+)|(-p\s+)(\S+)`)
Comment on lines +30 to +32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to hide -p ? It's profile for most/all commands, might be useful to keep visible

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I couldn't find a command to see this specific behavior


// sensitiveValuePattern matches key/value pairs in error messages; submatch 1 = label, submatch 2 = value.
var sensitiveValuePattern = regexp.MustCompile(
`(?i)(api[_\s]?key|application[_\s]?id)\s*[:=]\s*([^\s]+)`)

// maskWithLast4 returns a masked string showing only the last 4 chars (e.g. ***c123). If len <= 4, returns ****.
func maskWithLast4(s string) string {
if len(s) <= 4 {
return "****"
}
return "***" + s[len(s)-4:]
}

// sanitizeRunSummaryCommand redacts sensitive flag values, keeping last 4 chars (e.g. --api-key=***c123).
func sanitizeRunSummaryCommand(cmd string) string {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this is getting much too much just to get a summary of what was ran.
Plus when we are using DEBUG, we already enable verbose mode.

return sensitiveFlagPattern.ReplaceAllStringFunc(cmd, func(match string) string {
subs := sensitiveFlagPattern.FindStringSubmatch(match)
if len(subs) < 3 {
return match
}
for i := 1; i < len(subs); i += 2 {
if subs[i] != "" && i+1 < len(subs) && subs[i+1] != "" {
return subs[i] + maskWithLast4(subs[i+1])
}
}
return match
})
}

// sanitizeRunSummaryError redacts sensitive values in error messages, keeping last 4 chars (e.g. api_key: ***c123).
func sanitizeRunSummaryError(errMsg string) string {
s := sensitiveValuePattern.ReplaceAllStringFunc(errMsg, func(match string) string {
subs := sensitiveValuePattern.FindStringSubmatch(match)
if len(subs) >= 3 && subs[2] != "" {
return subs[1] + ": " + maskWithLast4(subs[2])
}
return match
})
if len(s) > 500 {
s = s[:497] + "..."
}
return s
}

func emitRunSummary(stderr io.Writer, ctx context.Context, cmd *cobra.Command, runErr error, duration time.Duration) {
meta := telemetry.GetEventMetadata(ctx)
invocationID := ""
if meta != nil {
invocationID = meta.InvocationID
}
commandPath := ""
if meta != nil && meta.CommandPath != "" {
commandPath = meta.CommandPath
}
if commandPath == "" && cmd != nil {
commandPath = cmd.CommandPath()
}
commandPath = sanitizeRunSummaryCommand(commandPath)
status := "ok"
errMsg := ""
if runErr != nil {
status = "error"
errMsg = sanitizeRunSummaryError(runErr.Error())
}
s := runSummary{
Event: "cli_run",
InvocationID: invocationID,
Command: commandPath,
Status: status,
DurationMs: duration.Milliseconds(),
Error: errMsg,
}
enc := json.NewEncoder(stderr)
enc.SetEscapeHTML(false)
_ = enc.Encode(s)
}
50 changes: 50 additions & 0 deletions pkg/cmd/root/run_summary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package root

import (
"strings"
"testing"
)

func TestSanitizeRunSummaryCommand(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"no secrets", "algolia indices list", "algolia indices list"},
{"api-key equals", "algolia --api-key=abc123 indices list", "algolia --api-key=***c123 indices list"},
{"application-id equals", "algolia --application-id=MYAPP indices list", "algolia --application-id=***YAPP indices list"},
{"api-key space", "algolia --api-key abc123 indices list", "algolia --api-key ***c123 indices list"},
{"profile short", "algolia -p myprofile indices list", "algolia -p ***file indices list"},
{"short value", "algolia --api-key=ab indices list", "algolia --api-key=**** indices list"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeRunSummaryCommand(tt.in)
if got != tt.want {
t.Errorf("sanitizeRunSummaryCommand(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

func TestSanitizeRunSummaryError(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"no secrets", "connection refused", "connection refused"},
{"api_key colon", "error: api_key: abc123def456", "error: api_key: ***f456"},
{"application_id equals", "invalid application_id=MYAPP", "invalid application_id: ***YAPP"},
{"truncate long", strings.Repeat("x", 600), strings.Repeat("x", 497) + "..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeRunSummaryError(tt.in)
if got != tt.want {
t.Errorf("sanitizeRunSummaryError() = %q, want %q", got, tt.want)
}
})
}
}
Loading