diff --git a/internal/commands/agenthooks/cx/hooks.go b/internal/commands/agenthooks/cx/hooks.go index 12b043c2e..5d437a8a0 100644 --- a/internal/commands/agenthooks/cx/hooks.go +++ b/internal/commands/agenthooks/cx/hooks.go @@ -14,6 +14,7 @@ import ( "github.com/checkmarx/ast-cli/internal/commands/agenthooks/sca" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/sessiontally" "github.com/checkmarx/ast-cli/internal/wrappers" + "github.com/checkmarx/ast-cli/internal/wrappers/utils" ) // canonical per-engine labels for the session summary (normalizes the legacy SCA/Oss split used by @@ -68,7 +69,7 @@ func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { sid := sessionIDFromToolCall(&ev) if finding, remediation, severity := scaScanner.CheckBashInstall(ev.Command, ev.WorkDir, agent, sid); finding != "" { sessiontally.Add(sid, engineSca, 1, 1) - logRemediationTelemetry(agent, "SCA", severity, sid) + logRemediationTelemetry(agent, "SCA", severity, sid, ev.WorkDir) return agenthooks.DenyWithContext(finding, remediation) } } @@ -113,7 +114,7 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict { agent := agentToString(ev.Agent) if blocked, reason, context, severity := asca.ScanFileEdit(&ev, telemetryWrapper, agent); blocked { sessiontally.Add(ev.SessionID, engineAsca, 1, 1) - logRemediationTelemetry(agent, "Asca", severity, ev.SessionID) + logRemediationTelemetry(agent, "Asca", severity, ev.SessionID, ev.WorkDir) return agenthooks.RejectEditWithContext(reason, context) } var kicsNote string @@ -121,7 +122,7 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict { blocked, reason, context, note, severity := kics.ScanFileEdit(&ev, kicsScanner, telemetryWrapper, agent) if blocked { sessiontally.Add(ev.SessionID, engineKics, 1, 1) - logRemediationTelemetry(agent, "IaC", severity, ev.SessionID) + logRemediationTelemetry(agent, "IaC", severity, ev.SessionID, ev.WorkDir) return agenthooks.RejectEditWithContext(reason, context) } kicsNote = note @@ -130,7 +131,7 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict { for _, diff := range ev.Changes { if finding, remediation, severity := scaScanner.CheckManifestEdit(ev.FilePath, fullAfterContent(ev.FilePath, diff), ev.WorkDir, agent, ev.SessionID); finding != "" { sessiontally.Add(ev.SessionID, engineSca, 1, 1) - logRemediationTelemetry(agent, "Oss", severity, ev.SessionID) + logRemediationTelemetry(agent, "Oss", severity, ev.SessionID, ev.WorkDir) return agenthooks.RejectEditWithContext(finding, remediation) } } @@ -246,7 +247,7 @@ func RegisterPassThrough() { } // logRemediationTelemetry sends telemetry when remediation context is delivered to the agent. -func logRemediationTelemetry(agent, engine, severity, sessionID string) { +func logRemediationTelemetry(agent, engine, severity, sessionID, workDir string) { if telemetryWrapper == nil { return } @@ -261,6 +262,7 @@ func logRemediationTelemetry(agent, engine, severity, sessionID string) { SubType: "fixWithAIAssist", ProblemSeverity: severity, AiAgentSessionId: sessionID, + GithubURL: utils.GitHubRemoteURL(workDir), } if err := telemetryWrapper.SendAIDataToLog(telemetryData); err != nil { diff --git a/internal/commands/agenthooks/cx/hooks_test.go b/internal/commands/agenthooks/cx/hooks_test.go index 36763db70..d89229920 100644 --- a/internal/commands/agenthooks/cx/hooks_test.go +++ b/internal/commands/agenthooks/cx/hooks_test.go @@ -5,6 +5,7 @@ package cx import ( "encoding/json" "os" + "os/exec" "path/filepath" "runtime" @@ -573,13 +574,13 @@ func TestLogRemediationTelemetry(t *testing.T) { t.Run("nil_wrapper_noop", func(t *testing.T) { telemetryWrapper = nil - logRemediationTelemetry("Claude", "SCA", "High", "s1") // must not panic + logRemediationTelemetry("Claude", "SCA", "High", "s1", "") // must not panic }) t.Run("sends_payload", func(t *testing.T) { tel := &recordingTelemetry{} telemetryWrapper = tel - logRemediationTelemetry("Cursor", "Asca", "Critical", "sess-9") + logRemediationTelemetry("Cursor", "Asca", "Critical", "sess-9", "") if len(tel.calls) != 1 { t.Fatalf("got %d calls, want 1", len(tel.calls)) } @@ -601,11 +602,39 @@ func TestLogRemediationTelemetry(t *testing.T) { t.Run("send_error_fail_open", func(t *testing.T) { tel := &recordingTelemetry{err: os.ErrPermission} telemetryWrapper = tel - logRemediationTelemetry("Claude", "SCA", "High", "s2") // must not panic + logRemediationTelemetry("Claude", "SCA", "High", "s2", "") // must not panic if len(tel.calls) != 1 { t.Fatalf("got %d calls, want 1", len(tel.calls)) } }) + + t.Run("includes_github_url", func(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + gitIn := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%v: %s", err, out) + } + } + gitIn("init") + gitIn("remote", "add", "origin", "git@github.com:acme/widgets.git") + + tel := &recordingTelemetry{} + telemetryWrapper = tel + logRemediationTelemetry("Cursor", "Asca", "High", "sess", dir) + if len(tel.calls) != 1 { + t.Fatalf("got %d calls, want 1", len(tel.calls)) + } + if tel.calls[0].GithubURL != "https://github.com/acme/widgets" { + t.Errorf("GithubURL = %q", tel.calls[0].GithubURL) + } + }) } // setEmptyHomeDir redirects the OS-specific home-dir env var to a fresh empty @@ -798,7 +827,7 @@ func TestLogRemediationTelemetry_NilWrapper_NoOp(t *testing.T) { telemetryWrapper = nil assert.NotPanics(t, func() { - logRemediationTelemetry("Claude", "SCA", "finding", "remediation") + logRemediationTelemetry("Claude", "SCA", "finding", "remediation", "") }) } @@ -808,7 +837,7 @@ func TestLogRemediationTelemetry_WithWrapper_Sends(t *testing.T) { telemetryWrapper = mock.TelemetryMockWrapper{} assert.NotPanics(t, func() { - logRemediationTelemetry("Claude", "SCA", "finding", "remediation") + logRemediationTelemetry("Claude", "SCA", "finding", "remediation", "") }) } diff --git a/internal/commands/agenthooks/guardrails/asca/asca.go b/internal/commands/agenthooks/guardrails/asca/asca.go index eed2bc7eb..786155fb2 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca.go +++ b/internal/commands/agenthooks/guardrails/asca/asca.go @@ -11,6 +11,7 @@ import ( "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" "github.com/checkmarx/ast-cli/internal/wrappers" "github.com/checkmarx/ast-cli/internal/wrappers/grpcs" + "github.com/checkmarx/ast-cli/internal/wrappers/utils" "github.com/spf13/viper" ) @@ -43,7 +44,7 @@ func ScanFileEdit(ev *agenthooks.FileEditEvent, telemetryWrapper wrappers.Teleme reason = "" context = "" } - logASCATelemetry(telemetryWrapper, agent, ev.SessionID, findingCount) + logASCATelemetry(telemetryWrapper, agent, ev.SessionID, findingCount, ev.WorkDir) }() if !isSupportedByASCA(ev.FilePath) { @@ -165,7 +166,7 @@ func shouldUpdateVersion() bool { // logASCATelemetry sends a telemetry event for ASCA scan results. // Called once after ASCA scan is performed with the actual finding count. -func logASCATelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, sessionID string, totalCount int) { +func logASCATelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, sessionID string, totalCount int, workDir string) { if telemetryWrapper == nil || totalCount == 0 { return } @@ -180,6 +181,7 @@ func logASCATelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, session SubType: "scan", ScanType: "asca", AiAgentSessionId: sessionID, + GithubURL: utils.GitHubRemoteURL(workDir), } if err := telemetryWrapper.SendAIDataToLog(telemetryData); err != nil { diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index f71a76c85..034d12dac 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca_test.go +++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go @@ -4,6 +4,7 @@ package asca import ( "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -568,7 +569,7 @@ func TestShouldUpdateVersion_DisabledReturnsFalse(t *testing.T) { func TestLogASCATelemetry_NilWrapper_NoOp(t *testing.T) { assert.NotPanics(t, func() { - logASCATelemetry(nil, "Claude", "", 3) + logASCATelemetry(nil, "Claude", "", 3, "") }) } @@ -580,7 +581,7 @@ func TestLogASCATelemetry_ZeroCount_DoesNotSend(t *testing.T) { return nil }, } - logASCATelemetry(telemetry, "Claude", "", 0) + logASCATelemetry(telemetry, "Claude", "", 0, "") assert.False(t, sent) } @@ -592,13 +593,40 @@ func TestLogASCATelemetry_WithFindings_Sends(t *testing.T) { return nil }, } - logASCATelemetry(telemetry, "Claude", "", 2) + logASCATelemetry(telemetry, "Claude", "", 2, "") assert.NotNil(t, captured) assert.Equal(t, "Asca", captured.Engine) assert.Equal(t, 2, captured.TotalCount) assert.Equal(t, "Claude", captured.AIProvider) } +func TestLogASCATelemetry_IncludesGitHubURL(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + gitIn := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + assert.NoError(t, err, string(out)) + } + gitIn("init") + gitIn("remote", "add", "origin", "ssh://git@github.com/acme/widgets.git") + + var captured *wrappers.DataForAITelemetry + telemetry := mock.TelemetryMockWrapper{ + CustomSendAIDataToLog: func(data *wrappers.DataForAITelemetry) error { + captured = data + return nil + }, + } + logASCATelemetry(telemetry, "Claude", "sess", 1, dir) + assert.NotNil(t, captured) + assert.Equal(t, "https://github.com/acme/widgets", captured.GithubURL) +} + // ── findingsSummary / formatFindings ───────────────────────────────────────── func TestFindingsSummary_IncludesRemediation(t *testing.T) { diff --git a/internal/commands/agenthooks/guardrails/kics/kics.go b/internal/commands/agenthooks/guardrails/kics/kics.go index 0b98adbc6..91d516964 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics.go +++ b/internal/commands/agenthooks/guardrails/kics/kics.go @@ -12,6 +12,7 @@ import ( "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" "github.com/checkmarx/ast-cli/internal/wrappers" + "github.com/checkmarx/ast-cli/internal/wrappers/utils" ) // isSupportedByKICS returns true when the file matches a KICS-supported extension or basename. @@ -59,7 +60,7 @@ func ScanFileEdit(ev *agenthooks.FileEditEvent, svc *Scanner, telemetryWrapper w note = skippedNote(ev.FilePath, fmt.Errorf("internal error: %v", r)) severity = "" } - logKicsTelemetry(telemetryWrapper, agent, ev.SessionID, findingCount) + logKicsTelemetry(telemetryWrapper, agent, ev.SessionID, findingCount, ev.WorkDir) }() if !isSupportedByKICS(ev.FilePath) { @@ -141,7 +142,7 @@ func highestSeverity(findings []iacrealtime.IacRealtimeResult) string { // logKicsTelemetry sends a telemetry event for KICS scan results. // Called once after KICS scan is performed with the actual finding count. -func logKicsTelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, sessionID string, totalCount int) { +func logKicsTelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, sessionID string, totalCount int, workDir string) { if telemetryWrapper == nil || totalCount == 0 { return } @@ -156,6 +157,7 @@ func logKicsTelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, session SubType: "scan", ScanType: "iac", AiAgentSessionId: sessionID, + GithubURL: utils.GitHubRemoteURL(workDir), } if err := telemetryWrapper.SendAIDataToLog(telemetryData); err != nil { diff --git a/internal/commands/agenthooks/guardrails/kics/kics_test.go b/internal/commands/agenthooks/guardrails/kics/kics_test.go index 058f6f1b1..30f78970c 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics_test.go +++ b/internal/commands/agenthooks/guardrails/kics/kics_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -239,7 +240,7 @@ func TestScanFileEdit_EngineDownProducesNote(t *testing.T) { func TestLogKicsTelemetry_NilWrapper_NoOp(t *testing.T) { assert.NotPanics(t, func() { - logKicsTelemetry(nil, "Claude", "", 3) + logKicsTelemetry(nil, "Claude", "", 3, "") }) } @@ -251,7 +252,7 @@ func TestLogKicsTelemetry_ZeroCount_DoesNotSend(t *testing.T) { return nil }, } - logKicsTelemetry(telemetry, "Claude", "", 0) + logKicsTelemetry(telemetry, "Claude", "", 0, "") assert.False(t, sent) } @@ -263,7 +264,7 @@ func TestLogKicsTelemetry_WithFindings_Sends(t *testing.T) { return nil }, } - logKicsTelemetry(telemetry, "Claude", "sess-1", 2) + logKicsTelemetry(telemetry, "Claude", "sess-1", 2, "") assert.NotNil(t, captured) assert.Equal(t, "IaC", captured.Engine) assert.Equal(t, 2, captured.TotalCount) @@ -273,3 +274,30 @@ func TestLogKicsTelemetry_WithFindings_Sends(t *testing.T) { assert.Equal(t, "iac", captured.ScanType) assert.Equal(t, "sess-1", captured.AiAgentSessionId) } + +func TestLogKicsTelemetry_IncludesGitHubURL(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + gitIn := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + assert.NoError(t, err, string(out)) + } + gitIn("init") + gitIn("remote", "add", "origin", "https://github.com/acme/widgets.git") + + var captured *wrappers.DataForAITelemetry + telemetry := mock.TelemetryMockWrapper{ + CustomSendAIDataToLog: func(data *wrappers.DataForAITelemetry) error { + captured = data + return nil + }, + } + logKicsTelemetry(telemetry, "Claude", "sess-1", 2, dir) + assert.NotNil(t, captured) + assert.Equal(t, "https://github.com/acme/widgets", captured.GithubURL) +} diff --git a/internal/commands/asca/asca-engine.go b/internal/commands/asca/asca-engine.go index cad19fd74..8164a3b89 100644 --- a/internal/commands/asca/asca-engine.go +++ b/internal/commands/asca/asca-engine.go @@ -3,6 +3,7 @@ package asca import ( "strings" + "github.com/checkmarx/ast-cli/internal/commands/util" "github.com/checkmarx/ast-cli/internal/commands/util/printer" commonParams "github.com/checkmarx/ast-cli/internal/params" "github.com/checkmarx/ast-cli/internal/services" @@ -18,7 +19,7 @@ const ( ascaLocationParam = "asca-location" ) -func RunScanASCACommand(jwtWrapper wrappers.JWTWrapper) func(cmd *cobra.Command, args []string) error { +func RunScanASCACommand(jwtWrapper wrappers.JWTWrapper, telemetryWrapper wrappers.TelemetryWrapper) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { var vorpalLocation string ASCALatestVersion, _ := cmd.Flags().GetBool(commonParams.ASCALatestVersion) @@ -55,6 +56,11 @@ func RunScanASCACommand(jwtWrapper wrappers.JWTWrapper) func(cmd *cobra.Command, if err != nil { return err } + totalCount := 0 + if scanResult != nil { + totalCount = len(scanResult.ScanDetails) + } + util.SendRealtimeScanTelemetry(telemetryWrapper, "Asca", "asca", fileSourceFlag, totalCount) err = printer.Print(cmd.OutOrStdout(), scanResult, printer.FormatJSON) if err != nil { return err diff --git a/internal/commands/asca/asca-engine_test.go b/internal/commands/asca/asca-engine_test.go index 0dcf8efff..3d1145eeb 100644 --- a/internal/commands/asca/asca-engine_test.go +++ b/internal/commands/asca/asca-engine_test.go @@ -129,7 +129,7 @@ func Test_runScanASCACommand(t *testing.T) { cmd.Flags().String(commonParams.SourcesFlag, ttt.sourceFlag, "") cmd.Flags().Bool(commonParams.ASCALatestVersion, ttt.engineFlag, "") cmd.Flags().String(commonParams.FormatFlag, printer.FormatJSON, "") - runFunc := RunScanASCACommand(&mock.JWTMockWrapper{}) + runFunc := RunScanASCACommand(&mock.JWTMockWrapper{}, nil) err := runFunc(cmd, []string{}) if (err != nil) != ttt.wantErr { t.Errorf("RunScanASCACommand() error = %v, wantErr %v", err, ttt.wantErr) @@ -185,7 +185,7 @@ func Test_runScanASCAWithAscaLocationFlagCommand(t *testing.T) { cmd.Flags().Bool(commonParams.ASCALatestVersion, ttt.engineFlag, "") cmd.Flags().String(commonParams.FormatFlag, printer.FormatJSON, "") cmd.Flags().String(commonParams.ASCALocationFlag, ttt.ascaLocation, "") - runFunc := RunScanASCACommand(&mock.JWTMockWrapper{}) + runFunc := RunScanASCACommand(&mock.JWTMockWrapper{}, nil) err := runFunc(cmd, []string{}) if (err != nil) != ttt.wantErr { t.Errorf("RunScanASCACommand() error = %v, wantErr %v", err, ttt.wantErr) diff --git a/internal/commands/iac-realtime-engine.go b/internal/commands/iac-realtime-engine.go index cf253471e..025bf0496 100644 --- a/internal/commands/iac-realtime-engine.go +++ b/internal/commands/iac-realtime-engine.go @@ -1,6 +1,7 @@ package commands import ( + "github.com/checkmarx/ast-cli/internal/commands/util" "github.com/checkmarx/ast-cli/internal/commands/util/printer" errorconstants "github.com/checkmarx/ast-cli/internal/constants/errors" commonParams "github.com/checkmarx/ast-cli/internal/params" @@ -12,6 +13,14 @@ import ( func RunScanIacRealtimeCommand( jwtWrapper wrappers.JWTWrapper, featureFlagWrapper wrappers.FeatureFlagsWrapper, +) func(cmd *cobra.Command, args []string) error { + return runScanIacRealtime(jwtWrapper, featureFlagWrapper, nil) +} + +func runScanIacRealtime( + jwtWrapper wrappers.JWTWrapper, + featureFlagWrapper wrappers.FeatureFlagsWrapper, + telemetryWrapper wrappers.TelemetryWrapper, ) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, _ []string) error { fileSourceFlag, _ := cmd.Flags().GetString(commonParams.SourcesFlag) @@ -29,6 +38,8 @@ func RunScanIacRealtimeCommand( return err } + util.SendRealtimeScanTelemetry(telemetryWrapper, "IaC", "iac", fileSourceFlag, len(results)) + err = printer.Print(cmd.OutOrStdout(), results, printer.FormatJSON) if err != nil { return errorconstants.NewRealtimeEngineError("failed to return IaC Realtime vulnerabilities").Error() diff --git a/internal/commands/ignore_vulnerability.go b/internal/commands/ignore_vulnerability.go index 1f3f22d1c..f7e47a8fd 100644 --- a/internal/commands/ignore_vulnerability.go +++ b/internal/commands/ignore_vulnerability.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/MakeNowJust/heredoc" @@ -101,7 +102,7 @@ func runIgnoreVulnerability(telemetryWrapper wrappers.TelemetryWrapper) func(cmd action = "revived" } if !remove && changed > 0 { - logIgnoreTelemetry(telemetryWrapper, scanType) + logIgnoreTelemetry(telemetryWrapper, scanType, filepath.Dir(ignoredFilePath)) } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %d vulnerability(ies); %d entr%s now in %s\n", @@ -141,7 +142,7 @@ func plural(n int) string { } // logIgnoreTelemetry sends telemetry when an agent ignores a finding via the realtime ignore file. -func logIgnoreTelemetry(telemetryWrapper wrappers.TelemetryWrapper, scanType string) { +func logIgnoreTelemetry(telemetryWrapper wrappers.TelemetryWrapper, scanType, workDir string) { if telemetryWrapper == nil { return } @@ -159,6 +160,7 @@ func logIgnoreTelemetry(telemetryWrapper wrappers.TelemetryWrapper, scanType str Type: "hooks-ignore", SubType: "ignorePackage", AiAgentSessionId: sessionID, + GithubURL: utils.GitHubRemoteURL(workDir), } if err := telemetryWrapper.SendAIDataToLog(telemetryData); err != nil { diff --git a/internal/commands/oss-realtime-engine.go b/internal/commands/oss-realtime-engine.go index 31d49d9e7..a550bc60f 100644 --- a/internal/commands/oss-realtime-engine.go +++ b/internal/commands/oss-realtime-engine.go @@ -1,6 +1,7 @@ package commands import ( + "github.com/checkmarx/ast-cli/internal/commands/util" "github.com/checkmarx/ast-cli/internal/commands/util/printer" errorconstants "github.com/checkmarx/ast-cli/internal/constants/errors" commonParams "github.com/checkmarx/ast-cli/internal/params" @@ -13,6 +14,7 @@ func RunScanOssRealtimeCommand( realtimeScannerWrapper wrappers.RealtimeScannerWrapper, jwtWrapper wrappers.JWTWrapper, featureFlagWrapper wrappers.FeatureFlagsWrapper, + telemetryWrapper wrappers.TelemetryWrapper, ) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, _ []string) error { fileSourceFlag, _ := cmd.Flags().GetString(commonParams.SourcesFlag) @@ -29,6 +31,12 @@ func RunScanOssRealtimeCommand( return err } + totalCount := 0 + if packages != nil { + totalCount = len(packages.Packages) + } + util.SendRealtimeScanTelemetry(telemetryWrapper, "Oss", "oss", fileSourceFlag, totalCount) + err = printer.Print(cmd.OutOrStdout(), packages, printer.FormatJSON) if err != nil { return errorconstants.NewRealtimeEngineError("failed to return packages").Error() diff --git a/internal/commands/root.go b/internal/commands/root.go index 3b968210e..2e42db8ae 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -204,6 +204,7 @@ func NewAstCLI( containerResolverWrapper, realTimeWrapper, tenantWrapper, + telemetryWrapper, ) projectCmd := NewProjectCommand(applicationsWrapper, projectsWrapper, groupsWrapper, accessManagementWrapper, featureFlagsWrapper) dastEnvironmentsCmd := dast.NewDastEnvironmentsCommand(dastEnvironmentsWrapper) diff --git a/internal/commands/scan.go b/internal/commands/scan.go index 1a7969013..51b58f2d8 100644 --- a/internal/commands/scan.go +++ b/internal/commands/scan.go @@ -204,6 +204,7 @@ func NewScanCommand( containerResolverWrapper wrappers.ContainerResolverWrapper, realtimeScannerWrapper wrappers.RealtimeScannerWrapper, tenantWrapper wrappers.TenantConfigurationWrapper, + telemetryWrapper wrappers.TelemetryWrapper, ) *cobra.Command { scanCmd := &cobra.Command{ Use: "scan", @@ -243,7 +244,7 @@ func NewScanCommand( showScanCmd := scanShowSubCommand(scansWrapper, sastMetadataWrapper) - scanASCACmd := scanASCASubCommand(jwtWrapper, featureFlagsWrapper) + scanASCACmd := scanASCASubCommand(jwtWrapper, telemetryWrapper) workflowScanCmd := scanWorkflowSubCommand(scansWrapper) @@ -255,17 +256,17 @@ func NewScanCommand( logsCmd := scanLogsSubCommand(logsWrapper) - kicsRealtimeCmd := scanRealtimeSubCommand() + kicsRealtimeCmd := scanRealtimeSubCommand(telemetryWrapper) scaRealtimeCmd := scarealtime.NewScaRealtimeCommand(scaRealTimeWrapper) - ossRealtimeCmd := scanOssRealtimeSubCommand(realtimeScannerWrapper, jwtWrapper, featureFlagsWrapper) + ossRealtimeCmd := scanOssRealtimeSubCommand(realtimeScannerWrapper, jwtWrapper, featureFlagsWrapper, telemetryWrapper) containersRealtimeCmd := scanContainersRealtimeSubCommand(realtimeScannerWrapper, jwtWrapper, featureFlagsWrapper) secretsRealtimeCmd := scanSecretsRealtimeSubCommand(jwtWrapper, featureFlagsWrapper) - iacRealtimeCmd := scanIacRealtimeSubCommand(jwtWrapper, featureFlagsWrapper) + iacRealtimeCmd := scanIacRealtimeSubCommand(jwtWrapper, featureFlagsWrapper, telemetryWrapper) addFormatFlagToMultipleCommands( []*cobra.Command{listScansCmd, showScanCmd, workflowScanCmd}, @@ -294,7 +295,7 @@ func NewScanCommand( return scanCmd } -func scanRealtimeSubCommand() *cobra.Command { +func scanRealtimeSubCommand(telemetryWrapper wrappers.TelemetryWrapper) *cobra.Command { kicsContainerID := uuid.New() kicsName := kicsContainerPrefixName + kicsContainerID.String() viper.Set(commonParams.KicsContainerNameKey, kicsName) @@ -315,7 +316,7 @@ func scanRealtimeSubCommand() *cobra.Command { `, ), }, - RunE: runKicksRealtime(), + RunE: runKicksRealtime(telemetryWrapper), } realtimeScanCmd.PersistentFlags(). StringSliceVar( @@ -470,7 +471,7 @@ func scanShowSubCommand(scansWrapper wrappers.ScansWrapper, sastMetadataWrapper return showScanCmd } -func scanASCASubCommand(jwtWrapper wrappers.JWTWrapper, featureFlagsWrapper wrappers.FeatureFlagsWrapper) *cobra.Command { +func scanASCASubCommand(jwtWrapper wrappers.JWTWrapper, telemetryWrapper wrappers.TelemetryWrapper) *cobra.Command { scanASCACmd := &cobra.Command{ Hidden: true, Use: "asca", @@ -489,7 +490,7 @@ func scanASCASubCommand(jwtWrapper wrappers.JWTWrapper, featureFlagsWrapper wrap `, ), }, - RunE: asca.RunScanASCACommand(jwtWrapper), + RunE: asca.RunScanASCACommand(jwtWrapper, telemetryWrapper), } scanASCACmd.PersistentFlags().Bool(commonParams.ASCALatestVersion, false, @@ -513,6 +514,7 @@ func scanOssRealtimeSubCommand( realtimeScannerWrapper wrappers.RealtimeScannerWrapper, jwtWrapper wrappers.JWTWrapper, featureFlagsWrapper wrappers.FeatureFlagsWrapper, + telemetryWrapper wrappers.TelemetryWrapper, ) *cobra.Command { scanOssRealtimeCmd := &cobra.Command{ Hidden: true, @@ -531,7 +533,7 @@ func scanOssRealtimeSubCommand( `, ), }, - RunE: RunScanOssRealtimeCommand(realtimeScannerWrapper, jwtWrapper, featureFlagsWrapper), + RunE: RunScanOssRealtimeCommand(realtimeScannerWrapper, jwtWrapper, featureFlagsWrapper, telemetryWrapper), } scanOssRealtimeCmd.PersistentFlags().StringP( @@ -553,6 +555,7 @@ func scanOssRealtimeSubCommand( func scanIacRealtimeSubCommand( jwtWrapper wrappers.JWTWrapper, featureFlagsWrapper wrappers.FeatureFlagsWrapper, + telemetryWrapper wrappers.TelemetryWrapper, ) *cobra.Command { scanIacRealtimeCmd := &cobra.Command{ Hidden: true, @@ -571,7 +574,7 @@ func scanIacRealtimeSubCommand( `, ), }, - RunE: RunScanIacRealtimeCommand(jwtWrapper, featureFlagsWrapper), + RunE: runScanIacRealtime(jwtWrapper, featureFlagsWrapper, telemetryWrapper), } scanIacRealtimeCmd.PersistentFlags().StringP( @@ -3675,7 +3678,7 @@ func runDownloadLogs(logsWrapper wrappers.LogsWrapper) func(*cobra.Command, []st } } -func runKicksRealtime() func(*cobra.Command, []string) error { +func runKicksRealtime(telemetryWrapper wrappers.TelemetryWrapper) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, _ []string) error { // Create temp location and add it to container volumes volumeMap, tempDir, err := createKicsScanEnv(cmd) @@ -3685,6 +3688,10 @@ func runKicksRealtime() func(*cobra.Command, []string) error { // Run kics container err = runKicsScan(cmd, volumeMap, tempDir, aditionalParameters) + if err == nil { + fileSource, _ := cmd.Flags().GetString(commonParams.KicsRealtimeFile) + util.SendRealtimeScanTelemetry(telemetryWrapper, "IaC", "iac", fileSource, 0) + } // Removing temporary dir logger.PrintIfVerbose(containerFolderRemoving) removeErr := os.RemoveAll(tempDir) diff --git a/internal/commands/telemetry.go b/internal/commands/telemetry.go index b858454ac..c06602986 100644 --- a/internal/commands/telemetry.go +++ b/internal/commands/telemetry.go @@ -1,9 +1,12 @@ package commands import ( + "os" + "github.com/MakeNowJust/heredoc" "github.com/checkmarx/ast-cli/internal/params" "github.com/checkmarx/ast-cli/internal/wrappers" + "github.com/checkmarx/ast-cli/internal/wrappers/utils" "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -27,7 +30,7 @@ func telemetryAISubCommand(telemetryAIWrapper wrappers.TelemetryWrapper) *cobra. Long: "Collects telemetry data for user interactions related to AI features.", Example: heredoc.Doc( ` - $ cx telemetry ai --ai-provider --problem-severity --type --sub-type --agent --engine + $ cx telemetry ai --ai-provider --problem-severity --type --sub-type --agent --engine --github-url `, ), @@ -43,6 +46,7 @@ func telemetryAISubCommand(telemetryAIWrapper wrappers.TelemetryWrapper) *cobra. telemetryAICmd.PersistentFlags().String(params.ScanTypeFlag, "", "Scan Type") telemetryAICmd.PersistentFlags().String(params.StatusFlag, "", "Status") telemetryAICmd.PersistentFlags().Int(params.TotalCountFlag, 0, "Total Count") + telemetryAICmd.PersistentFlags().String(params.GithubURLFlag, "", "GitHub repository URL. When omitted, the working directory's GitHub origin is used") return telemetryAICmd } @@ -58,6 +62,8 @@ func runTelemetryAI(telemetryWrapper wrappers.TelemetryWrapper) func(*cobra.Comm scanType, _ := cmd.Flags().GetString("scan-type") status, _ := cmd.Flags().GetString("status") totalCount, _ := cmd.Flags().GetInt("total-count") + githubURL, _ := cmd.Flags().GetString(params.GithubURLFlag) + workDir, _ := os.Getwd() uniqueID := wrappers.GetUniqueID() err := telemetryWrapper.SendAIDataToLog(&wrappers.DataForAITelemetry{ AIProvider: aiProvider, @@ -70,6 +76,7 @@ func runTelemetryAI(telemetryWrapper wrappers.TelemetryWrapper) func(*cobra.Comm Status: status, TotalCount: totalCount, UniqueID: uniqueID, + GithubURL: utils.ResolveGitHubURL(githubURL, workDir), }) if err != nil { diff --git a/internal/commands/telemetry_test.go b/internal/commands/telemetry_test.go index 539929235..4da6cd825 100644 --- a/internal/commands/telemetry_test.go +++ b/internal/commands/telemetry_test.go @@ -1,6 +1,7 @@ package commands import ( + "os/exec" "testing" "github.com/checkmarx/ast-cli/internal/wrappers" @@ -17,3 +18,54 @@ func TestRunTelemetryAI_SendToLogSuccess(t *testing.T) { func TestTelemetryHelp(t *testing.T) { execCmdNilAssertion(t, "help", "telemetry", "ai") } + +func TestRunTelemetryAI_PassesGitHubURL(t *testing.T) { + var got *wrappers.DataForAITelemetry + cmd := telemetryAISubCommand(mock.TelemetryMockWrapper{ + CustomSendAIDataToLog: func(data *wrappers.DataForAITelemetry) error { + got = data + return nil + }, + }) + cmd.SetArgs([]string{"--type", "click", "--github-url", "git@github.com:acme/widgets.git"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if got == nil || got.GithubURL != "https://github.com/acme/widgets" || got.Type != "click" { + t.Fatalf("payload = %+v", got) + } +} + +func TestRunTelemetryAI_DetectsGitHubURLFromCwd(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + gitIn := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%v: %s", err, out) + } + } + gitIn("init") + gitIn("remote", "add", "origin", "https://github.com/acme/from-cwd.git") + t.Chdir(dir) + + var got *wrappers.DataForAITelemetry + cmd := telemetryAISubCommand(mock.TelemetryMockWrapper{ + CustomSendAIDataToLog: func(data *wrappers.DataForAITelemetry) error { + got = data + return nil + }, + }) + cmd.SetArgs([]string{"--type", "click"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if got == nil || got.GithubURL != "https://github.com/acme/from-cwd" { + t.Fatalf("GithubURL = %+v", got) + } +} diff --git a/internal/commands/util/telemetry.go b/internal/commands/util/telemetry.go new file mode 100644 index 000000000..538b4fdb5 --- /dev/null +++ b/internal/commands/util/telemetry.go @@ -0,0 +1,45 @@ +package util + +import ( + "path/filepath" + "strings" + + "github.com/checkmarx/ast-cli/internal/logger" + "github.com/checkmarx/ast-cli/internal/wrappers" + "github.com/checkmarx/ast-cli/internal/wrappers/utils" +) + +// SendRealtimeScanTelemetry posts one telemetry event for a completed realtime scan. +// githubUrl is the GitHub origin of the scanned file's directory. A nil wrapper is a no-op. +func SendRealtimeScanTelemetry(telemetry wrappers.TelemetryWrapper, engine, scanType, fileSource string, totalCount int) { + if telemetry == nil { + return + } + err := telemetry.SendAIDataToLog(&wrappers.DataForAITelemetry{ + AIProvider: utils.GetOptionalParam("aiProvider"), + Agent: utils.GetOptionalParam("agent"), + Engine: engine, + ScanType: scanType, + Type: "scan", + SubType: "scan", + TotalCount: totalCount, + UniqueID: wrappers.GetUniqueID(), + AiAgentSessionId: utils.GetOptionalParam("aiAgentSessionId"), + GithubURL: utils.GitHubRemoteURL(scanWorkDir(fileSource)), + }) + if err != nil { + logger.PrintfIfVerbose("Failed to send telemetry: %v", err) + } +} + +func scanWorkDir(fileSource string) string { + fileSource = strings.TrimSpace(fileSource) + if fileSource == "" { + return "" + } + first := strings.TrimSpace(strings.Split(fileSource, ",")[0]) + if first == "" { + return "" + } + return filepath.Dir(first) +} diff --git a/internal/commands/util/telemetry_test.go b/internal/commands/util/telemetry_test.go new file mode 100644 index 000000000..1b2ff2686 --- /dev/null +++ b/internal/commands/util/telemetry_test.go @@ -0,0 +1,51 @@ +package util + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/checkmarx/ast-cli/internal/wrappers" + "github.com/checkmarx/ast-cli/internal/wrappers/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSendRealtimeScanTelemetry_NilWrapper(t *testing.T) { + assert.NotPanics(t, func() { + SendRealtimeScanTelemetry(nil, "Oss", "oss", "package.json", 1) + }) +} + +func TestSendRealtimeScanTelemetry_IncludesGitHubURL(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + gitIn := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + } + gitIn("init") + gitIn("remote", "add", "origin", "git@github.com:acme/widgets.git") + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o600)) + + var got *wrappers.DataForAITelemetry + tel := mock.TelemetryMockWrapper{ + CustomSendAIDataToLog: func(data *wrappers.DataForAITelemetry) error { + got = data + return nil + }, + } + SendRealtimeScanTelemetry(tel, "Oss", "oss", filepath.Join(dir, "package.json"), 4) + require.NotNil(t, got) + assert.Equal(t, "https://github.com/acme/widgets", got.GithubURL) + assert.Equal(t, "Oss", got.Engine) + assert.Equal(t, "oss", got.ScanType) + assert.Equal(t, "scan", got.Type) + assert.Equal(t, 4, got.TotalCount) +} diff --git a/internal/params/flags.go b/internal/params/flags.go index 5b322ff39..e4242776a 100644 --- a/internal/params/flags.go +++ b/internal/params/flags.go @@ -18,6 +18,7 @@ const ( StatusFlag = "status" scanTypeFlag = "scan-type" TotalCountFlag = "total-count" + GithubURLFlag = "github-url" OriginFlag = "origin" AgentFlagUsage = "Scan origin name" ApplicationName = "application-name" diff --git a/internal/wrappers/telemetry-http.go b/internal/wrappers/telemetry-http.go index f1d6afcf7..ab881e171 100644 --- a/internal/wrappers/telemetry-http.go +++ b/internal/wrappers/telemetry-http.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" + "github.com/checkmarx/ast-cli/internal/logger" commonParams "github.com/checkmarx/ast-cli/internal/params" "github.com/pkg/errors" "github.com/spf13/viper" @@ -22,6 +23,11 @@ func NewHTTPTelemetryAIWrapper(path string) *TelemetryHTTPWrapper { } func (r *TelemetryHTTPWrapper) SendAIDataToLog(data *DataForAITelemetry) error { + if data == nil { + return errors.New("telemetry data is nil") + } + logger.PrintfIfVerbose("Telemetry githubUrl=%q engine=%s scanType=%s type=%s subType=%s totalCount=%d", + data.GithubURL, data.Engine, data.ScanType, data.Type, data.SubType, data.TotalCount) clientTimeout := viper.GetUint(commonParams.ClientTimeoutKey) jsonBytes, err := json.Marshal(data) if err != nil { diff --git a/internal/wrappers/telemetry.go b/internal/wrappers/telemetry.go index 39ec639b1..37329814a 100644 --- a/internal/wrappers/telemetry.go +++ b/internal/wrappers/telemetry.go @@ -12,6 +12,7 @@ type DataForAITelemetry struct { TotalCount int `json:"totalCount"` UniqueID string `json:"uniqueId"` AiAgentSessionId string `json:"aiAgentSessionId,omitempty"` + GithubURL string `json:"githubUrl,omitempty"` } type TelemetryWrapper interface { diff --git a/internal/wrappers/utils/github.go b/internal/wrappers/utils/github.go new file mode 100644 index 000000000..1d5f505eb --- /dev/null +++ b/internal/wrappers/utils/github.go @@ -0,0 +1,109 @@ +package utils + +import ( + "context" + "net/url" + "os" + "os/exec" + "strings" + "time" +) + +const ( + gitConfigTimeout = 2 * time.Second + urlSplitParts = 2 + gitHubPathParts = 2 + gitHubHost = "github.com" +) + +// NormalizeGitHubURL converts a GitHub remote (HTTPS or SSH) into +// https://github.com/owner/repo. Credentials are dropped. A non-GitHub URL returns "". +func NormalizeGitHubURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(toHTTPS(raw)) + if err != nil || parsed.Hostname() == "" { + return "" + } + host := strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.") + if host != gitHubHost { + return "" + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < gitHubPathParts || parts[0] == "" || parts[1] == "" { + return "" + } + repo := strings.TrimSuffix(parts[1], ".git") + if repo == "" { + return "" + } + return "https://" + gitHubHost + "/" + parts[0] + "/" + repo +} + +// GitHubRemoteURL returns the canonical GitHub origin URL for the repository +// that contains dir. An empty dir uses the current working directory. +// A directory that is not a GitHub checkout returns "". +func GitHubRemoteURL(dir string) string { + dir = strings.TrimSpace(dir) + if dir == "" { + wd, err := os.Getwd() + if err != nil { + return "" + } + dir = wd + } + return gitHubRemoteAt(dir) +} + +// ResolveGitHubURL prefers an explicit URL from the caller. Recognized GitHub +// remotes are canonicalized; any other explicit value is kept as given. +// When explicit is empty, the origin remote of dir is used. +func ResolveGitHubURL(explicit, dir string) string { + explicit = strings.TrimSpace(explicit) + if explicit == "" { + return GitHubRemoteURL(dir) + } + if canonical := NormalizeGitHubURL(explicit); canonical != "" { + return canonical + } + return explicit +} + +func gitHubRemoteAt(dir string) string { + ctx, cancel := context.WithTimeout(context.Background(), gitConfigTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "-C", dir, "config", "--get", "remote.origin.url") + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=never") + out, err := cmd.Output() + if err != nil { + return "" + } + return NormalizeGitHubURL(string(out)) +} + +// toHTTPS rewrites common git remote spellings into an https URL string +// that net/url can parse. Already-https values are returned unchanged. +func toHTTPS(raw string) string { + if !strings.Contains(raw, "://") && strings.Contains(raw, "@") && strings.Contains(raw, ":") { + parts := strings.SplitN(raw, "@", urlSplitParts) + if len(parts) == urlSplitParts { + hostAndPath := strings.SplitN(parts[1], ":", urlSplitParts) + if len(hostAndPath) == urlSplitParts { + return "https://" + hostAndPath[0] + "/" + hostAndPath[1] + } + } + } + if strings.HasPrefix(raw, "ssh://") { + rest := strings.TrimPrefix(raw, "ssh://") + if i := strings.Index(rest, "@"); i >= 0 { + rest = rest[i+1:] + } + return "https://" + rest + } + if strings.HasPrefix(raw, "git://") { + return "https://" + strings.TrimPrefix(raw, "git://") + } + return raw +} diff --git a/internal/wrappers/utils/github_test.go b/internal/wrappers/utils/github_test.go new file mode 100644 index 000000000..3fb191406 --- /dev/null +++ b/internal/wrappers/utils/github_test.go @@ -0,0 +1,83 @@ +package utils + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeGitHubURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "https", raw: "https://github.com/acme/widgets.git", want: "https://github.com/acme/widgets"}, + {name: "https no suffix", raw: "https://github.com/acme/widgets", want: "https://github.com/acme/widgets"}, + {name: "www", raw: "https://www.github.com/acme/widgets/", want: "https://github.com/acme/widgets"}, + {name: "credentials stripped", raw: "https://user:token@github.com/acme/widgets.git", want: "https://github.com/acme/widgets"}, + {name: "ssh scp", raw: "git@github.com:acme/widgets.git", want: "https://github.com/acme/widgets"}, + {name: "ssh url", raw: "ssh://git@github.com/acme/widgets.git", want: "https://github.com/acme/widgets"}, + {name: "git protocol", raw: "git://github.com/acme/widgets.git", want: "https://github.com/acme/widgets"}, + {name: "tree path ignored", raw: "https://github.com/acme/widgets/tree/main", want: "https://github.com/acme/widgets"}, + {name: "gitlab", raw: "https://gitlab.com/acme/widgets.git", want: ""}, + {name: "owner only", raw: "https://github.com/acme", want: ""}, + {name: "empty", raw: " ", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, NormalizeGitHubURL(tt.raw)) + }) + } +} + +func TestResolveGitHubURL_ExplicitWins(t *testing.T) { + assert.Equal(t, "https://github.com/acme/widgets", ResolveGitHubURL("git@github.com:acme/widgets.git", "")) + assert.Equal(t, "https://github.example.com/acme/widgets", ResolveGitHubURL(" https://github.example.com/acme/widgets ", t.TempDir())) +} + +func TestGitHubRemoteURL(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + } + git("init") + git("remote", "add", "origin", "git@github.com:acme/widgets.git") + + assert.Equal(t, "https://github.com/acme/widgets", GitHubRemoteURL(dir)) + + sub := filepath.Join(dir, "nested") + require.NoError(t, os.Mkdir(sub, 0o750)) + assert.Equal(t, "https://github.com/acme/widgets", GitHubRemoteURL(sub)) + + assert.Equal(t, "", GitHubRemoteURL(t.TempDir())) +} + +func TestGitHubRemoteURL_EmptyDirUsesCwd(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + cmd := exec.Command("git", "init") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + cmd = exec.Command("git", "remote", "add", "origin", "https://github.com/acme/from-cwd.git") + cmd.Dir = dir + out, err = cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + t.Chdir(dir) + assert.Equal(t, "https://github.com/acme/from-cwd", GitHubRemoteURL("")) +}