diff --git a/cmd/cloud/cloud.go b/cmd/cloud/cloud.go new file mode 100644 index 0000000..b57b784 --- /dev/null +++ b/cmd/cloud/cloud.go @@ -0,0 +1,18 @@ +package cloud + +import ( + "github.com/spf13/cobra" +) + +func NewCloudCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "cloud", + Short: "SafeDep Cloud operations", + } + + cmd.AddCommand(newSyncCommand()) + cmd.AddCommand(newLoginCommand()) + cmd.AddCommand(newLogoutCommand()) + + return cmd +} diff --git a/cmd/cloud/login.go b/cmd/cloud/login.go new file mode 100644 index 0000000..48a857b --- /dev/null +++ b/cmd/cloud/login.go @@ -0,0 +1,72 @@ +package cloud + +import ( + "github.com/safedep/dry/cloud" + "github.com/safedep/dry/log" + "github.com/safedep/pmg/internal/ui" + "github.com/safedep/pmg/usefulerror" + "github.com/spf13/cobra" +) + +func newLoginCommand() *cobra.Command { + return &cobra.Command{ + Use: "login", + Short: "Store SafeDep Cloud credentials securely", + RunE: runLogin, + } +} + +func runLogin(cmd *cobra.Command, args []string) error { + tenantID, err := ui.PromptInput("Tenant ID: ") + if err != nil { + ui.ErrorExit(usefulerror.Useful(). + Wrap(err). + WithCode(usefulerror.ErrCodeLifecycle). + WithHumanError("Failed to read Tenant ID")) + } + + if tenantID == "" { + ui.ErrorExit(usefulerror.Useful(). + WithCode(usefulerror.ErrCodeInvalidArgument). + WithHumanError("Tenant ID cannot be empty")) + } + + apiKey, err := ui.PromptSecret("API Key: ") + if err != nil { + ui.ErrorExit(usefulerror.Useful(). + Wrap(err). + WithCode(usefulerror.ErrCodeLifecycle). + WithHumanError("Failed to read API Key")) + } + + if apiKey == "" { + ui.ErrorExit(usefulerror.Useful(). + WithCode(usefulerror.ErrCodeInvalidArgument). + WithHumanError("API Key cannot be empty")) + } + + store, err := cloud.NewKeychainCredentialStore() + if err != nil { + ui.ErrorExit(usefulerror.Useful(). + Wrap(err). + WithCode(usefulerror.ErrCodeLifecycle). + WithHumanError("Failed to initialize credential store"). + WithHelp("Your system may not support secure credential storage")) + } + defer func() { + if err := store.Close(); err != nil { + log.Warnf("failed to close credential store: %v", err) + } + }() + + if err := store.SaveAPIKeyCredential(apiKey, tenantID); err != nil { + ui.ErrorExit(usefulerror.Useful(). + Wrap(err). + WithCode(usefulerror.ErrCodeLifecycle). + WithHumanError("Failed to save credentials"). + WithHelp("Your system may not support secure credential storage")) + } + + ui.Successf("Credentials saved securely") + return nil +} diff --git a/cmd/cloud/logout.go b/cmd/cloud/logout.go new file mode 100644 index 0000000..3f45be9 --- /dev/null +++ b/cmd/cloud/logout.go @@ -0,0 +1,43 @@ +package cloud + +import ( + "github.com/safedep/dry/cloud" + "github.com/safedep/dry/log" + "github.com/safedep/pmg/internal/ui" + "github.com/safedep/pmg/usefulerror" + "github.com/spf13/cobra" +) + +func newLogoutCommand() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Clear stored SafeDep Cloud credentials", + RunE: runLogout, + } +} + +func runLogout(cmd *cobra.Command, args []string) error { + store, err := cloud.NewKeychainCredentialStore() + if err != nil { + ui.ErrorExit(usefulerror.Useful(). + Wrap(err). + WithCode(usefulerror.ErrCodeLifecycle). + WithHumanError("Failed to initialize credential store"). + WithHelp("Your system may not support secure credential storage")) + } + defer func() { + if err := store.Close(); err != nil { + log.Warnf("failed to close credential store: %v", err) + } + }() + + if err := store.Clear(); err != nil { + ui.ErrorExit(usefulerror.Useful(). + Wrap(err). + WithCode(usefulerror.ErrCodeLifecycle). + WithHumanError("Failed to clear credentials")) + } + + ui.Successf("Credentials cleared from keychain") + return nil +} diff --git a/cmd/cloud/sync.go b/cmd/cloud/sync.go new file mode 100644 index 0000000..8f87805 --- /dev/null +++ b/cmd/cloud/sync.go @@ -0,0 +1,74 @@ +package cloud + +import ( + "context" + "time" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/analytics" + "github.com/safedep/pmg/internal/audit" + "github.com/safedep/pmg/internal/ui" + "github.com/safedep/pmg/usefulerror" + "github.com/spf13/cobra" +) + +var syncTimeout time.Duration + +func newSyncCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "sync", + Short: "Sync pending audit events to SafeDep Cloud", + RunE: runSync, + } + + cmd.Flags().DurationVar(&syncTimeout, "timeout", 15*time.Minute, "Maximum time to spend syncing events") + + return cmd +} + +func runSync(cmd *cobra.Command, args []string) error { + cfg := config.Get() + + if analytics.IsDisabled() { + ui.Infof("Cloud sync is disabled because telemetry is disabled (PMG_DISABLE_TELEMETRY)") + return nil + } + + if !cfg.Config.Cloud.Enabled { + ui.ErrorExit(usefulerror.Useful(). + WithCode(usefulerror.ErrCodeLifecycle). + WithHumanError("Cloud sync is not enabled"). + WithHelp("Set 'cloud.enabled: true' in PMG config to enable cloud sync")) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), syncTimeout) + defer cancel() + + bundle, err := audit.NewSyncClientBundle(cfg) + if err != nil { + ui.ErrorExit(usefulerror.Useful(). + Wrap(err). + WithCode(usefulerror.ErrCodeLifecycle). + WithHumanError("Failed to initialize cloud sync client"). + WithHelp("Run 'pmg cloud login' to store credentials, or set SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables")) + } + defer func() { + if err := bundle.Close(); err != nil { + log.Warnf("failed to close sync client: %v", err) + } + }() + + synced, err := bundle.Sync(ctx) + if err != nil { + ui.ErrorExit(usefulerror.Useful(). + Wrap(err). + WithCode(usefulerror.ErrCodeNetwork). + WithHumanError("Failed to sync events to SafeDep Cloud"). + WithHelp("Check your network connectivity and ensure SafeDep Cloud is reachable"). + WithAdditionalHelp("Override the cloud endpoint with SAFEDEP_CLOUD_DATA_ADDR if needed")) + } + + ui.Successf("Synced %d events to SafeDep Cloud", synced) + return nil +} diff --git a/go.mod b/go.mod index 0c7b1b3..25fc0b1 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jedib0t/go-pretty/v6 v6.6.7 github.com/posthog/posthog-go v1.5.12 - github.com/safedep/dry v0.0.0-20260409102613-51a5bb88eb14 + github.com/safedep/dry v0.0.0-20260411074023-b589e91de472 github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12 github.com/sony/gobreaker/v2 v2.4.0 github.com/spf13/cobra v1.9.1 @@ -20,18 +20,20 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 golang.org/x/sync v0.20.0 - golang.org/x/term v0.40.0 + golang.org/x/term v0.42.0 google.golang.org/grpc v1.80.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/caarlos0/env/v11 v11.3.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denisbrodbeck/machineid v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -46,6 +48,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-github/v74 v74.0.0 // indirect @@ -70,13 +73,14 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/jsonc v0.3.2 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 3dc0443..49d4cab 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1 h1:NXwdBG3BiC6xWH4iG3csbT+JHF9u1jl5ThDhumCnKnk= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/gen/go/safedep/api/grpc/go v1.6.1-20260409081445-73994c4e35a3.1 h1:OKKSMXf1k7NZ1qcp5gfpTw0F3QGVM/X6ghkRs9GtrIA= @@ -29,6 +31,8 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/Buvy github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -83,6 +87,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= @@ -162,6 +168,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safedep/dry v0.0.0-20260409102613-51a5bb88eb14 h1:ajMAtCe1asSa4jz8Vl65CNtzTgWTAaHdeWRu0lDn8p4= github.com/safedep/dry v0.0.0-20260409102613-51a5bb88eb14/go.mod h1:JOiCF9w4plbcMS6XnLNpDaSoUEabdscP+eFk+kROHrs= +github.com/safedep/dry v0.0.0-20260411074023-b589e91de472 h1:8CztWcu+C7alCjAS5AVDRhxr/LL/9y6lHwpZKmxZjvE= +github.com/safedep/dry v0.0.0-20260411074023-b589e91de472/go.mod h1:JOiCF9w4plbcMS6XnLNpDaSoUEabdscP+eFk+kROHrs= github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12 h1:NzARvPtncPbVI8a8Z0JKpJ7XJCSPpsRstV7wAgEtmOU= github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12/go.mod h1:fyt+PACz6dtEoqsnE0BPPv/lHpuBG/8zkDqeIVcyRY4= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -196,6 +204,8 @@ github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -261,8 +271,12 @@ golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= diff --git a/internal/audit/audit.go b/internal/audit/audit.go index a6ff8e3..43b28d4 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -9,7 +9,6 @@ import ( "github.com/safedep/dry/log" "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" - "github.com/safedep/pmg/usefulerror" ) var global *auditor @@ -31,14 +30,10 @@ func Initialize(cfg *config.RuntimeConfig) error { if cfg.Config.Cloud.Enabled && !analytics.IsDisabled() { cs, err := newCloudSink(cfg) if err != nil { - return usefulerror.Useful(). - Wrap(err). - WithCode(usefulerror.ErrCodeLifecycle). - WithHumanError("Cloud sync is enabled but failed to initialize"). - WithHelp("Ensure SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables are set"). - WithAdditionalHelp("Disable cloud sync with 'cloud.enabled: false' in config if not needed") + log.Warnf("Cloud sync initialization failed: %v", err) + } else { + sinks = append(sinks, cs) } - sinks = append(sinks, cs) } if cfg.Config.Cloud.Enabled && analytics.IsDisabled() { diff --git a/internal/audit/cloud_client.go b/internal/audit/cloud_client.go new file mode 100644 index 0000000..bf919af --- /dev/null +++ b/internal/audit/cloud_client.go @@ -0,0 +1,131 @@ +package audit + +import ( + "context" + "errors" + "fmt" + + "github.com/safedep/dry/cloud" + "github.com/safedep/dry/cloud/endpointsync" + "github.com/safedep/dry/log" + "github.com/safedep/pmg/config" + appVersion "github.com/safedep/pmg/internal/version" +) + +// SyncClientBundle holds a SyncClient and its underlying cloud client. +// Callers must call Close() when done. +type SyncClientBundle struct { + syncClient *endpointsync.SyncClient + cloudClient *cloud.Client + keychainResolver cloud.CloseableCredentialResolver +} + +// Sync delivers pending events from the WAL to SafeDep Cloud. +func (b *SyncClientBundle) Sync(ctx context.Context) (int, error) { + return b.syncClient.Sync(ctx) +} + +func (b *SyncClientBundle) Close() error { + var errs []error + if b.syncClient != nil { + if err := b.syncClient.Close(); err != nil { + errs = append(errs, err) + } + } + if b.cloudClient != nil { + if err := b.cloudClient.Close(); err != nil { + errs = append(errs, err) + } + } + if b.keychainResolver != nil { + if err := b.keychainResolver.Close(); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// NewSyncClientBundle creates an authenticated SyncClient connected to SafeDep Cloud. +func NewSyncClientBundle(cfg *config.RuntimeConfig) (*SyncClientBundle, error) { + // Build credential resolver chain: keychain first, env fallback. + var resolvers []cloud.CredentialResolver + var keychainResolver cloud.CloseableCredentialResolver + + keychainResolver, err := cloud.NewKeychainCredentialResolver(cloud.CredentialTypeAPIKey) + if err != nil { + log.Debugf("Keychain credential resolver not available, skipping: %v", err) + } else { + resolvers = append(resolvers, keychainResolver) + } + + envResolver, err := cloud.NewEnvCredentialResolver() + if err != nil { + log.Debugf("Env credential resolver not available, skipping: %v", err) + } else { + resolvers = append(resolvers, envResolver) + } + + if len(resolvers) == 0 { + if keychainResolver != nil { + if closeErr := keychainResolver.Close(); closeErr != nil { + log.Warnf("failed to close keychain resolver: %v", closeErr) + } + } + return nil, fmt.Errorf("no credential resolvers available") + } + + chain := cloud.NewChainCredentialResolver(resolvers...) + creds, err := chain.Resolve() + if err != nil { + if keychainResolver != nil { + if closeErr := keychainResolver.Close(); closeErr != nil { + log.Warnf("failed to close keychain resolver: %v", closeErr) + } + } + return nil, fmt.Errorf("failed to resolve cloud credentials: %w", err) + } + + cloudClient, err := cloud.NewDataPlaneClient("pmg", creds) + if err != nil { + if keychainResolver != nil { + if closeErr := keychainResolver.Close(); closeErr != nil { + log.Warnf("failed to close keychain resolver: %v", closeErr) + } + } + return nil, fmt.Errorf("failed to create data plane client: %w", err) + } + + transport := endpointsync.NewGrpcTransport(cloudClient.Connection()) + + var identityOpts []endpointsync.EndpointIdentityOption + if cfg.Config.Cloud.EndpointID != "" { + identityOpts = append(identityOpts, endpointsync.WithEndpointID(cfg.Config.Cloud.EndpointID)) + } + + identity := endpointsync.NewEndpointIdentityResolver(identityOpts...) + + toolVersion := appVersion.Version + if toolVersion == "" { + toolVersion = "dev" + } + + syncClient, err := endpointsync.NewSyncClient("pmg", toolVersion, transport, identity, + endpointsync.WithWALPath(cfg.CloudSyncDBPath())) + if err != nil { + if closeErr := cloudClient.Close(); closeErr != nil { + log.Warnf("failed to close cloud client after sync client init failure: %v", closeErr) + } + if keychainResolver != nil { + if closeErr := keychainResolver.Close(); closeErr != nil { + log.Warnf("failed to close keychain resolver: %v", closeErr) + } + } + return nil, fmt.Errorf("failed to create sync client: %w", err) + } + + return &SyncClientBundle{ + syncClient: syncClient, + cloudClient: cloudClient, + keychainResolver: keychainResolver, + }, nil +} diff --git a/internal/audit/cloud_sink.go b/internal/audit/cloud_sink.go index 777e6ad..c8a36e2 100644 --- a/internal/audit/cloud_sink.go +++ b/internal/audit/cloud_sink.go @@ -6,79 +6,33 @@ import ( "fmt" "github.com/google/uuid" - "github.com/safedep/dry/cloud" "github.com/safedep/dry/cloud/endpointsync" "github.com/safedep/dry/log" "github.com/safedep/pmg/config" - appVersion "github.com/safedep/pmg/internal/version" ) type cloudSink struct { - syncClient *endpointsync.SyncClient - cloudClient *cloud.Client + *SyncClientBundle invocationID string } func newCloudSink(cfg *config.RuntimeConfig) (*cloudSink, error) { - resolver, err := cloud.NewEnvCredentialResolver() + bundle, err := NewSyncClientBundle(cfg) if err != nil { - return nil, fmt.Errorf("failed to create credential resolver: %w", err) - } - - creds, err := resolver.Resolve() - if err != nil { - return nil, fmt.Errorf("failed to resolve cloud credentials: %w", err) - } - - cloudClient, err := cloud.NewDataPlaneClient("pmg", creds) - if err != nil { - return nil, fmt.Errorf("failed to create data plane client: %w", err) - } - - transport := endpointsync.NewGrpcTransport(cloudClient.Connection()) - - sink, err := newCloudSinkWithTransport(transport, cfg.Config.Cloud.EndpointID, cfg.CloudSyncDBPath()) - if err != nil { - if closeErr := cloudClient.Close(); closeErr != nil { - log.Warnf("failed to close cloud client after sink init failure: %v", closeErr) - } return nil, err } - sink.cloudClient = cloudClient - return sink, nil -} - -func newCloudSinkWithTransport(transport endpointsync.EventTransport, endpointID, walPath string) (*cloudSink, error) { - var identityOpts []endpointsync.EndpointIdentityOption - if endpointID != "" { - identityOpts = append(identityOpts, endpointsync.WithEndpointID(endpointID)) - } - - identity := endpointsync.NewEndpointIdentityResolver(identityOpts...) - - toolVersion := appVersion.Version - if toolVersion == "" { - toolVersion = "dev" - } - - syncClient, err := endpointsync.NewSyncClient("pmg", toolVersion, transport, identity, - endpointsync.WithWALPath(walPath)) - if err != nil { - return nil, fmt.Errorf("failed to create sync client: %w", err) - } - invocationID, err := uuid.NewRandom() if err != nil { - if closeErr := syncClient.Close(); closeErr != nil { - log.Warnf("failed to close sync client after invocation ID generation failure: %v", closeErr) + if closeErr := bundle.Close(); closeErr != nil { + log.Warnf("failed to close sync client bundle after invocation ID failure: %v", closeErr) } return nil, fmt.Errorf("failed to generate invocation ID: %w", err) } return &cloudSink{ - syncClient: syncClient, - invocationID: invocationID.String(), + SyncClientBundle: bundle, + invocationID: invocationID.String(), }, nil } @@ -109,17 +63,7 @@ func (s *cloudSink) Handle(ctx context.Context, event AuditEvent) error { return nil } +// Close delegates to the embedded SyncClientBundle.Close(). func (s *cloudSink) Close() error { - var errs []error - if s.syncClient != nil { - if err := s.syncClient.Close(); err != nil { - errs = append(errs, err) - } - } - if s.cloudClient != nil { - if err := s.cloudClient.Close(); err != nil { - errs = append(errs, err) - } - } - return errors.Join(errs...) + return s.SyncClientBundle.Close() } diff --git a/internal/audit/cloud_sink_test.go b/internal/audit/cloud_sink_test.go index 2e528ed..bef9df4 100644 --- a/internal/audit/cloud_sink_test.go +++ b/internal/audit/cloud_sink_test.go @@ -6,6 +6,7 @@ import ( "time" servicev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/dry/cloud/endpointsync" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -29,17 +30,28 @@ func (m *mockTransport) Close() error { return nil } +func newTestCloudSink(t *testing.T, transport endpointsync.EventTransport) *cloudSink { + t.Helper() + walPath := t.TempDir() + "/test-sync.db" + identity := endpointsync.NewEndpointIdentityResolver() + syncClient, err := endpointsync.NewSyncClient("pmg", "test", transport, identity, + endpointsync.WithWALPath(walPath)) + require.NoError(t, err) + return &cloudSink{ + SyncClientBundle: &SyncClientBundle{syncClient: syncClient}, + invocationID: "test-invocation", + } +} + func TestCloudSinkEmitsTranslatableEvents(t *testing.T) { transport := &mockTransport{} - walPath := t.TempDir() + "/test-sync.db" - sink, err := newCloudSinkWithTransport(transport, "", walPath) - require.NoError(t, err) + sink := newTestCloudSink(t, transport) defer func() { require.NoError(t, sink.Close()) }() - err = sink.Handle(context.Background(), AuditEvent{ + err := sink.Handle(context.Background(), AuditEvent{ Type: EventTypeMalwareBlocked, Timestamp: time.Now(), Message: "blocked malware package", @@ -49,15 +61,13 @@ func TestCloudSinkEmitsTranslatableEvents(t *testing.T) { func TestCloudSinkSkipsUntranslatableEvents(t *testing.T) { transport := &mockTransport{} - walPath := t.TempDir() + "/test-sync.db" - sink, err := newCloudSinkWithTransport(transport, "", walPath) - require.NoError(t, err) + sink := newTestCloudSink(t, transport) defer func() { require.NoError(t, sink.Close()) }() - err = sink.Handle(context.Background(), AuditEvent{ + err := sink.Handle(context.Background(), AuditEvent{ Type: EventTypeProxyHostObserved, Timestamp: time.Now(), Message: "observed proxy host", @@ -68,16 +78,14 @@ func TestCloudSinkSkipsUntranslatableEvents(t *testing.T) { func TestCloudSinkEmitAndSync(t *testing.T) { transport := &mockTransport{} - walPath := t.TempDir() + "/test-sync.db" - sink, err := newCloudSinkWithTransport(transport, "", walPath) - require.NoError(t, err) + sink := newTestCloudSink(t, transport) defer func() { require.NoError(t, sink.Close()) }() ctx := context.Background() - err = sink.Handle(ctx, AuditEvent{ + err := sink.Handle(ctx, AuditEvent{ Type: EventTypeMalwareBlocked, Timestamp: time.Now(), Message: "blocked malware package", diff --git a/internal/audit/cloud_translate_test.go b/internal/audit/cloud_translate_test.go index 637c166..acb199a 100644 --- a/internal/audit/cloud_translate_test.go +++ b/internal/audit/cloud_translate_test.go @@ -11,7 +11,7 @@ import ( ) // testCloudSink returns a cloudSink with no real SyncClient, suitable for testing translation. -var testSink = &cloudSink{invocationID: "test-invocation"} +var testSink = &cloudSink{SyncClientBundle: &SyncClientBundle{}, invocationID: "test-invocation"} func TestTranslateMalwareBlocked(t *testing.T) { event := AuditEvent{ diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 22d877f..3030fbc 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -74,14 +74,19 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema startTime := time.Now() audit.LogInstallStarted(f.pm.Name(), args) + + sessionCompleted := false defer func() { + if sessionCompleted { + return + } + // On early error returns (e.g. CA cert, analyzer init), reportData.Outcome - // is still the default (Success). Override to Error for these cases. Don't - // override when outcome was explicitly set (e.g. Blocked) — those paths may - // also return an error from the underlying package manager command. + // is still the default (Success). Override to Error for these cases. if runErr != nil && reportData.Outcome == ui.OutcomeSuccess { reportData.Outcome = ui.OutcomeError } + audit.LogSessionComplete(audit.Outcome(reportData.Outcome.String()), audit.FlowTypeProxy) }() @@ -169,6 +174,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema }() ui.ClearStatus() + log.Infof("Proxy server started on %s", proxyAddr) log.Infof("Running %s with proxy protection enabled", f.pm.Name()) @@ -192,12 +198,16 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema reportData.BlockedCount = stats.BlockedCount reportData.BlockedPackages = statsCollector.GetBlockedPackages() reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages() - reportData.CooldownBlockedPackages = statsCollector.GetCooldownBlocks() // Set outcome based on execution result using shared inference logic reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError) + // Emit session complete before report/exit — handleExecutionResultError may call + // os.Exit which skips defers, so we must emit the session summary here. + audit.LogSessionComplete(audit.Outcome(reportData.Outcome.String()), audit.FlowTypeProxy) + sessionCompleted = true + // Show the report ui.Report(reportData) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6b2faa1..14ce50e 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -9,6 +9,7 @@ import ( "github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/internal/models" + "golang.org/x/term" ) // The UI is internal to PMG and opinionated for the CLI. @@ -135,6 +136,51 @@ func ShowWarning(message string) { fmt.Fprintf(os.Stderr, "PMG: %s\n", Colors.Red(message)) } +// Infof prints an informational message, suppressed in silent mode. +func Infof(msg string, args ...interface{}) { + if verbosityLevel == VerbosityLevelSilent { + return + } + fmt.Println(fmt.Sprintf(msg, args...)) +} + +// Successf prints a green success message, suppressed in silent mode. +func Successf(msg string, args ...interface{}) { + if verbosityLevel == VerbosityLevelSilent { + return + } + fmt.Printf("%s %s\n", Colors.Green("✓"), fmt.Sprintf(msg, args...)) +} + +// PromptInput prints a label and reads a line of visible input from stdin. +func PromptInput(label string) (string, error) { + fmt.Printf("%s %s", Colors.Cyan("›"), Colors.Bold(label)) + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + return strings.TrimSpace(scanner.Text()), nil + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("no input received") +} + +// PromptSecret prints a label and reads input from stdin with echo disabled. +// Returns an error if stdin is not a terminal (e.g. piped input). +func PromptSecret(label string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", fmt.Errorf("interactive terminal required for secure input") + } + + fmt.Printf("%s %s", Colors.Cyan("▪"), Colors.Bold(label)) + raw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() // newline after hidden input + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil +} + func Fatalf(msg string, args ...interface{}) { ClearStatus() diff --git a/main.go b/main.go index 0d00a28..b70dada 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/safedep/dry/log" + "github.com/safedep/pmg/cmd/cloud" "github.com/safedep/pmg/cmd/executors" "github.com/safedep/pmg/cmd/npm" "github.com/safedep/pmg/cmd/pypi" @@ -127,6 +128,7 @@ func main() { cmd.AddCommand(version.NewVersionCommand()) cmd.AddCommand(setup.NewSetupCommand()) cmd.AddCommand(setup.NewRemoveCommand()) + cmd.AddCommand(cloud.NewCloudCommand()) // Print Banner on --help / -h cmd.SetHelpFunc(func(command *cobra.Command, args []string) {