diff --git a/args.go b/args.go index 25a72b3a3..6575a9ad3 100644 --- a/args.go +++ b/args.go @@ -661,6 +661,17 @@ const ( // ArgAlertPolicySlackURLs are the Slack URLs to send alerts to. ArgAlertPolicySlackURLs = "slack-urls" + // Security Args + + // ArgSecurityScanResources are the resources to scan. + ArgSecurityScanResources = "resources" + // ArgSecurityScanFindingSeverity filters findings by severity. + ArgSecurityScanFindingSeverity = "severity" + // ArgSecurityScanFindingType filters findings by type. + ArgSecurityScanFindingType = "type" + // ArgSecurityFindingUUID is the finding UUID for finding operations. + ArgSecurityFindingUUID = "finding-uuid" + // ArgTokenValidationServer is the server used to validate an OAuth token ArgTokenValidationServer = "token-validation-server" diff --git a/commands/command_config.go b/commands/command_config.go index e2f0e49d5..b46fb1507 100644 --- a/commands/command_config.go +++ b/commands/command_config.go @@ -86,6 +86,7 @@ type CmdConfig struct { GradientAI func() do.GradientAIService Nfs func() do.NfsService NfsActions func() do.NfsActionsService + Security func() do.SecurityService } // NewCmdConfig creates an instance of a CmdConfig. @@ -152,6 +153,7 @@ func NewCmdConfig(ns string, dc doctl.Config, out io.Writer, args []string, init c.GradientAI = func() do.GradientAIService { return do.NewGradientAIService(godoClient) } c.Nfs = func() do.NfsService { return do.NewNfsService(godoClient) } c.NfsActions = func() do.NfsActionsService { return do.NewNfsActionsService(godoClient) } + c.Security = func() do.SecurityService { return do.NewSecurityService(godoClient) } return nil }, diff --git a/commands/commands_test.go b/commands/commands_test.go index 5ca575779..55d6a5481 100644 --- a/commands/commands_test.go +++ b/commands/commands_test.go @@ -294,6 +294,7 @@ type tcMocks struct { gradientAI *domocks.MockGradientAIService nfs *domocks.MockNfsService nfsActions *domocks.MockNfsActionsService + security *domocks.MockSecurityService } func withTestClient(t *testing.T, tFn testFn) { @@ -351,6 +352,7 @@ func withTestClient(t *testing.T, tFn testFn) { gradientAI: domocks.NewMockGradientAIService(ctrl), nfs: domocks.NewMockNfsService(ctrl), nfsActions: domocks.NewMockNfsActionsService(ctrl), + security: domocks.NewMockSecurityService(ctrl), } testConfig := doctl.NewTestConfig() @@ -416,6 +418,7 @@ func withTestClient(t *testing.T, tFn testFn) { GradientAI: func() do.GradientAIService { return tm.gradientAI }, Nfs: func() do.NfsService { return tm.nfs }, NfsActions: func() do.NfsActionsService { return tm.nfsActions }, + Security: func() do.SecurityService { return tm.security }, } tFn(config, tm) diff --git a/commands/displayers/security.go b/commands/displayers/security.go new file mode 100644 index 000000000..a3857b312 --- /dev/null +++ b/commands/displayers/security.go @@ -0,0 +1,139 @@ +/* +Copyright 2026 The Doctl Authors All rights reserved. +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 displayers + +import ( + "io" + + "github.com/digitalocean/doctl/do" +) + +// A SecurityScan is the displayer for showing the results of a single CSPM +// scan. +type SecurityScan struct { + Scan do.Scan +} + +var _ Displayable = &SecurityScan{} + +func (s *SecurityScan) JSON(out io.Writer) error { + return writeJSON(s.Scan, out) +} + +func (s *SecurityScan) Cols() []string { + return []string{"Rule ID", "Name", "Affected Resources", "Found At", "Severity"} +} + +func (s *SecurityScan) ColMap() map[string]string { + return map[string]string{ + "Rule ID": "Rule ID", + "Name": "Name", + "Affected Resources": "Affected Resources", + "Found At": "Found At", + "Severity": "Severity", + } +} + +func (s *SecurityScan) KV() []map[string]any { + out := make([]map[string]any, 0, len(s.Scan.Findings)) + + for _, finding := range s.Scan.Findings { + o := map[string]any{ + "Rule ID": finding.RuleUUID, + "Name": finding.Name, + "Affected Resources": finding.AffectedResourcesCount, + "Found At": finding.FoundAt, + "Severity": finding.Severity, + } + out = append(out, o) + } + + return out +} + +// SecurityScans is the displayer for showing the results of multiple CSPM +// scans. +type SecurityScans struct { + Scans do.Scans +} + +var _ Displayable = &SecurityScans{} + +func (s *SecurityScans) JSON(out io.Writer) error { + return writeJSON(s.Scans, out) +} + +func (s *SecurityScans) Cols() []string { + return []string{"ID", "Status", "Created At"} +} + +func (s *SecurityScans) ColMap() map[string]string { + return map[string]string{ + "ID": "ID", + "Status": "Status", + "Created At": "Created At", + } +} + +func (s *SecurityScans) KV() []map[string]any { + out := make([]map[string]any, 0, len(s.Scans)) + + for _, scan := range s.Scans { + o := map[string]any{ + "ID": scan.ID, + "Status": scan.Status, + "Created At": scan.CreatedAt, + } + out = append(out, o) + } + + return out +} + +type SecurityAffectedResource struct { + AffectedResources do.AffectedResources +} + +var _ Displayable = &SecurityAffectedResource{} + +func (s *SecurityAffectedResource) JSON(out io.Writer) error { + return writeJSON(s.AffectedResources, out) +} + +func (s *SecurityAffectedResource) Cols() []string { + return []string{"URN", "Name", "Type"} +} + +func (s *SecurityAffectedResource) ColMap() map[string]string { + return map[string]string{ + "URN": "URN", + "Name": "Name", + "Type": "Type", + } +} + +func (s *SecurityAffectedResource) KV() []map[string]any { + out := make([]map[string]any, 0, len(s.AffectedResources)) + + for _, resource := range s.AffectedResources { + o := map[string]any{ + "URN": resource.URN, + "Name": resource.Name, + "Type": resource.Type, + } + out = append(out, o) + } + + return out +} diff --git a/commands/doit.go b/commands/doit.go index 3c0ce4314..6645e4e93 100644 --- a/commands/doit.go +++ b/commands/doit.go @@ -193,6 +193,7 @@ func addCommands() { DoitCmd.AddCommand(Spaces()) DoitCmd.AddCommand(GradientAI()) DoitCmd.AddCommand(Nfs()) + DoitCmd.AddCommand(Security()) } func computeCmd() *Command { diff --git a/commands/security.go b/commands/security.go new file mode 100644 index 000000000..754607f8c --- /dev/null +++ b/commands/security.go @@ -0,0 +1,247 @@ +/* +Copyright 2026 The Doctl Authors All rights reserved. +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 commands + +import ( + "fmt" + "os" + "time" + + "github.com/digitalocean/doctl" + "github.com/digitalocean/doctl/commands/displayers" + "github.com/digitalocean/doctl/do" + "github.com/digitalocean/godo" + + "github.com/spf13/cobra" +) + +// Security creates the security command hierarchy. +func Security() *Command { + cmd := &Command{ + Command: &cobra.Command{ + Use: "security", + Short: "Display commands to manage CSPM scans", + Long: `The sub-commands of ` + "`" + `doctl security` + "`" + ` manage CSPM scans. + +You can create scans, view existing scans, and list resources affected by scan findings.`, + GroupID: manageResourcesGroup, + }, + } + + cmd.AddCommand(SecurityScan()) + + return cmd +} + +func SecurityScan() *Command { + cmd := &Command{ + Command: &cobra.Command{ + Use: "scans", + Aliases: []string{"scan"}, + Short: "Display commands for managing CSPM scans", + Long: `The commands under ` + "`" + `doctl security scans` + "`" + ` are for managing CSPM scans.`, + }, + } + + cmdScanCreate := CmdBuilder(cmd, RunCmdSecurityScanCreate, "create", "Create a CSPM scan", `Creates a new CSPM scan.`, Writer, + aliasOpt("c"), displayerType(&displayers.SecurityScan{})) + AddBoolFlag(cmdScanCreate, doctl.ArgCommandWait, "", false, "Boolean that specifies whether to wait for a scan to complete before returning control to the terminal") + cmdScanCreate.Example = `The following example creates a CSPM scan for all droplets: doctl security scan create` + + cmdScanGet := CmdBuilder(cmd, RunCmdSecurityScanGet, "get ", "Get a CSPM scan", `Retrieves a CSPM scan and its findings.`, Writer, + aliasOpt("g"), displayerType(&displayers.SecurityScan{})) + AddStringFlag(cmdScanGet, doctl.ArgSecurityScanFindingType, "", "", "Filter findings by type") + AddStringFlag(cmdScanGet, doctl.ArgSecurityScanFindingSeverity, "", "", "Filter findings by severity") + cmdScanGet.Example = `The following example retrieves a CSPM scan with findings filtered by severity: doctl security scan get 497dcba3-ecbf-4587-a2dd-5eb0665e6880 --severity critical` + + cmdScanLatest := CmdBuilder(cmd, RunCmdSecurityScanLatest, "latest", "Get the latest CSPM scan", `Retrieves the latest CSPM scan and its findings.`, Writer, + displayerType(&displayers.SecurityScan{})) + AddStringFlag(cmdScanLatest, doctl.ArgSecurityScanFindingType, "", "", "Filter findings by type") + AddStringFlag(cmdScanLatest, doctl.ArgSecurityScanFindingSeverity, "", "", "Filter findings by severity") + cmdScanLatest.Example = `The following example retrieves the latest CSPM scan with high severity findings: doctl security scans latest --severity high` + + cmdScanList := CmdBuilder(cmd, RunCmdSecurityScanList, "list", "List CSPM scans", `Retrieves a list of CSPM scans.`, Writer, + aliasOpt("ls"), displayerType(&displayers.SecurityScans{})) + cmdScanList.Example = `The following example lists all CSPM scans: doctl security scan list` + + cmdScanFindingAffectedResources := CmdBuilder(cmd, RunCmdSecurityFindingAffectedResources, "affected-resources ", "List scan finding affected resources", `Retrieves the resources affected by the issue identified in a scan finding.`, Writer, displayerType(&displayers.SecurityAffectedResource{})) + AddStringFlag(cmdScanFindingAffectedResources, doctl.ArgSecurityFindingUUID, "", "", "Finding UUID to show affected resources for", requiredOpt()) + cmdScanFindingAffectedResources.Example = `The following example lists affected resources for a finding: doctl security scans affected-resources --finding-uuid 50e14f43-dd4e-412f-864d-78943ea28d91 497dcba3-ecbf-4587-a2dd-5eb0665e6880 ` + + return cmd +} + +// RunCmdSecurityScanCreate creates a CSPM scan. +func RunCmdSecurityScanCreate(c *CmdConfig) error { + scan, err := c.Security().CreateScan(&godo.CreateScanRequest{}) + if err != nil { + return err + } + + wait, err := c.Doit.GetBool(c.NS, doctl.ArgCommandWait) + if err != nil { + return err + } + + if wait { + security := c.Security() + notice("Scan in progress, waiting for scan to complete") + + err := waitForScanComplete(security, scan.ID) + if err != nil { + return fmt.Errorf( + "scan did not complete: %v", + err, + ) + } + + scan, _ = security.GetScan(scan.ID, nil) + } + + notice("Scan completed") + + item := &displayers.SecurityScan{Scan: *scan} + return c.Display(item) +} + +// RunCmdSecurityScanGet retrieves a CSPM scan by UUID. +func RunCmdSecurityScanGet(c *CmdConfig) error { + if len(c.Args) == 0 { + return doctl.NewMissingArgsErr(c.NS) + } + + opts, err := securityScanFindingOptions(c) + if err != nil { + return err + } + + scan, err := c.Security().GetScan(c.Args[0], opts) + if err != nil { + return err + } + + item := &displayers.SecurityScan{Scan: *scan} + return c.Display(item) +} + +// RunCmdSecurityScanLatest retrieves the latest CSPM scan. +func RunCmdSecurityScanLatest(c *CmdConfig) error { + opts, err := securityScanFindingOptions(c) + if err != nil { + return err + } + + scan, err := c.Security().GetLatestScan(opts) + if err != nil { + return err + } + + item := &displayers.SecurityScan{Scan: *scan} + return c.Display(item) +} + +// RunCmdSecurityScanList lists CSPM scans. +func RunCmdSecurityScanList(c *CmdConfig) error { + scans, err := c.Security().ListScans() + if err != nil { + return err + } + + item := &displayers.SecurityScans{Scans: scans} + return c.Display(item) +} + +// RunCmdSecurityFindingAffectedResources lists affected resources for a finding. +func RunCmdSecurityFindingAffectedResources(c *CmdConfig) error { + if len(c.Args) == 0 { + return doctl.NewMissingArgsErr(c.NS) + } + scanUUID := c.Args[0] + + findingUUID, err := c.Doit.GetString(c.NS, doctl.ArgSecurityFindingUUID) + if err != nil { + return err + } + + resources, err := c.Security().ListFindingAffectedResources(scanUUID, findingUUID) + if err != nil { + return err + } + + item := &displayers.SecurityAffectedResource{AffectedResources: resources} + return c.Display(item) +} + +func securityScanFindingOptions(c *CmdConfig) (*godo.ScanFindingsOptions, error) { + severity, err := c.Doit.GetString(c.NS, doctl.ArgSecurityScanFindingSeverity) + if err != nil { + return nil, err + } + + findingType, err := c.Doit.GetString(c.NS, doctl.ArgSecurityScanFindingType) + if err != nil { + return nil, err + } + + if severity == "" && findingType == "" { + return nil, nil + } + + return &godo.ScanFindingsOptions{ + Severity: severity, + Type: findingType, + }, nil +} + +func waitForScanComplete(scans do.SecurityService, id string) error { + const maxAttempts = 16 + const wantStatus = "complete" + const errStatus = "error" + attempts := 0 + printNewLineSet := false + + for range maxAttempts { + if attempts != 0 { + fmt.Fprint(os.Stderr, ".") + if !printNewLineSet { + printNewLineSet = true + defer fmt.Fprintln(os.Stderr) + } + } + + scan, err := scans.GetScan(id, nil) + if err != nil { + return err + } + + if scan.Status == errStatus { + return fmt.Errorf( + "scan (%s) entered status `ERROR`", + id, + ) + } + + if scan.Status == wantStatus { + return nil + } + + attempts++ + time.Sleep(10 * time.Second) + } + + return fmt.Errorf( + "timeout waiting for scan (%s) to complete", + id, + ) +} diff --git a/commands/security_test.go b/commands/security_test.go new file mode 100644 index 000000000..e751ab125 --- /dev/null +++ b/commands/security_test.go @@ -0,0 +1,112 @@ +/* +Copyright 2026 The Doctl Authors All rights reserved. +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 commands + +import ( + "testing" + + "github.com/digitalocean/doctl" + "github.com/digitalocean/doctl/do" + "github.com/digitalocean/godo" + "github.com/stretchr/testify/assert" +) + +var ( + testSecurityScan = do.Scan{ + Scan: &godo.Scan{ + ID: "497dcba3-ecbf-4587-a2dd-5eb0665e6880", + Status: "COMPLETED", + CreatedAt: "2025-12-04T00:00:00Z", + Findings: []*godo.ScanFinding{ + {RuleUUID: "rule-1", Name: "test", Severity: "critical", AffectedResourcesCount: 2}, + }, + }, + } + + testSecurityScanList = do.Scans{testSecurityScan} + + testSecurityAffectedResource = do.AffectedResource{ + AffectedResource: &godo.AffectedResource{ + URN: "do:droplet:1", + Name: "droplet-1", + Type: "Droplet", + }, + } + + testSecurityAffectedResources = do.AffectedResources{testSecurityAffectedResource} +) + +func TestSecurityCommand(t *testing.T) { + cmd := Security() + assert.NotNil(t, cmd) + assertCommandNames(t, cmd, "scans") + assertCommandNames(t, cmd.childCommands[0], "affected-resources", "create", "get", "latest", "list") +} + +func TestSecurityScanCreate(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + request := &godo.CreateScanRequest{} + tm.security.EXPECT().CreateScan(request).Return(&testSecurityScan, nil) + + err := RunCmdSecurityScanCreate(config) + assert.NoError(t, err) + }) +} + +func TestSecurityScanGet(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + opts := &godo.ScanFindingsOptions{Severity: "critical", Type: "CSPM"} + tm.security.EXPECT().GetScan(testSecurityScan.Scan.ID, opts).Return(&testSecurityScan, nil) + + config.Args = append(config.Args, testSecurityScan.ID) + config.Doit.Set(config.NS, doctl.ArgSecurityScanFindingSeverity, "critical") + config.Doit.Set(config.NS, doctl.ArgSecurityScanFindingType, "CSPM") + + err := RunCmdSecurityScanGet(config) + assert.NoError(t, err) + }) +} + +func TestSecurityScanLatest(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + opts := &godo.ScanFindingsOptions{Severity: "high"} + tm.security.EXPECT().GetLatestScan(opts).Return(&testSecurityScan, nil) + + config.Doit.Set(config.NS, doctl.ArgSecurityScanFindingSeverity, "high") + + err := RunCmdSecurityScanLatest(config) + assert.NoError(t, err) + }) +} + +func TestSecurityScanList(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + tm.security.EXPECT().ListScans().Return(testSecurityScanList, nil) + + err := RunCmdSecurityScanList(config) + assert.NoError(t, err) + }) +} + +func TestSecurityFindingAffectedResources(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + tm.security.EXPECT().ListFindingAffectedResources("scan-uuid", "finding-uuid").Return(testSecurityAffectedResources, nil) + + config.Args = append(config.Args, "scan-uuid") + config.Doit.Set(config.NS, doctl.ArgSecurityFindingUUID, "finding-uuid") + + err := RunCmdSecurityFindingAffectedResources(config) + assert.NoError(t, err) + }) +} diff --git a/do/mocks/SecurityService.go b/do/mocks/SecurityService.go new file mode 100644 index 000000000..98984073f --- /dev/null +++ b/do/mocks/SecurityService.go @@ -0,0 +1,117 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: security.go +// +// Generated by this command: +// +// mockgen -source security.go -package=mocks SecurityService +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + do "github.com/digitalocean/doctl/do" + godo "github.com/digitalocean/godo" + gomock "go.uber.org/mock/gomock" +) + +// MockSecurityService is a mock of SecurityService interface. +type MockSecurityService struct { + ctrl *gomock.Controller + recorder *MockSecurityServiceMockRecorder + isgomock struct{} +} + +// MockSecurityServiceMockRecorder is the mock recorder for MockSecurityService. +type MockSecurityServiceMockRecorder struct { + mock *MockSecurityService +} + +// NewMockSecurityService creates a new mock instance. +func NewMockSecurityService(ctrl *gomock.Controller) *MockSecurityService { + mock := &MockSecurityService{ctrl: ctrl} + mock.recorder = &MockSecurityServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSecurityService) EXPECT() *MockSecurityServiceMockRecorder { + return m.recorder +} + +// CreateScan mocks base method. +func (m *MockSecurityService) CreateScan(request *godo.CreateScanRequest) (*do.Scan, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateScan", request) + ret0, _ := ret[0].(*do.Scan) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateScan indicates an expected call of CreateScan. +func (mr *MockSecurityServiceMockRecorder) CreateScan(request any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateScan", reflect.TypeOf((*MockSecurityService)(nil).CreateScan), request) +} + +// GetLatestScan mocks base method. +func (m *MockSecurityService) GetLatestScan(opts *godo.ScanFindingsOptions) (*do.Scan, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestScan", opts) + ret0, _ := ret[0].(*do.Scan) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestScan indicates an expected call of GetLatestScan. +func (mr *MockSecurityServiceMockRecorder) GetLatestScan(opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestScan", reflect.TypeOf((*MockSecurityService)(nil).GetLatestScan), opts) +} + +// GetScan mocks base method. +func (m *MockSecurityService) GetScan(scanUUID string, opts *godo.ScanFindingsOptions) (*do.Scan, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetScan", scanUUID, opts) + ret0, _ := ret[0].(*do.Scan) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetScan indicates an expected call of GetScan. +func (mr *MockSecurityServiceMockRecorder) GetScan(scanUUID, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetScan", reflect.TypeOf((*MockSecurityService)(nil).GetScan), scanUUID, opts) +} + +// ListFindingAffectedResources mocks base method. +func (m *MockSecurityService) ListFindingAffectedResources(scanUUID, findingUUID string) (do.AffectedResources, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListFindingAffectedResources", scanUUID, findingUUID) + ret0, _ := ret[0].(do.AffectedResources) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListFindingAffectedResources indicates an expected call of ListFindingAffectedResources. +func (mr *MockSecurityServiceMockRecorder) ListFindingAffectedResources(scanUUID, findingUUID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFindingAffectedResources", reflect.TypeOf((*MockSecurityService)(nil).ListFindingAffectedResources), scanUUID, findingUUID) +} + +// ListScans mocks base method. +func (m *MockSecurityService) ListScans() (do.Scans, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListScans") + ret0, _ := ret[0].(do.Scans) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListScans indicates an expected call of ListScans. +func (mr *MockSecurityServiceMockRecorder) ListScans() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListScans", reflect.TypeOf((*MockSecurityService)(nil).ListScans)) +} diff --git a/do/security.go b/do/security.go new file mode 100644 index 000000000..e411dcc4d --- /dev/null +++ b/do/security.go @@ -0,0 +1,157 @@ +/* +Copyright 2026 The Doctl Authors All rights reserved. +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 do + +import ( + "context" + "errors" + + "github.com/digitalocean/godo" +) + +// Scan wraps a godo.Scan. +type Scan struct { + *godo.Scan +} + +// Scans is a slice of Scan. +type Scans []Scan + +// AffectedResource wraps a godo.AffectedResource. +type AffectedResource struct { + *godo.AffectedResource +} + +// AffectedResources is a slice of AffectedResource. +type AffectedResources []AffectedResource + +// SecurityService is an interface for interacting with DigitalOcean's CSPM API. +type SecurityService interface { + CreateScan(*godo.CreateScanRequest) (*Scan, error) + ListScans() (Scans, error) + GetScan(string, *godo.ScanFindingsOptions) (*Scan, error) + GetLatestScan(*godo.ScanFindingsOptions) (*Scan, error) + ListFindingAffectedResources(string, string) (AffectedResources, error) +} + +type securityService struct { + client *godo.Client +} + +var _ SecurityService = (*securityService)(nil) + +// NewSecurityService builds a SecurityService instance. +func NewSecurityService(godoClient *godo.Client) SecurityService { + return &securityService{ + client: godoClient, + } +} + +func (ss *securityService) CreateScan(request *godo.CreateScanRequest) (*Scan, error) { + scan, _, err := ss.client.Security.CreateScan(context.TODO(), request) + if err != nil { + return nil, err + } + + return &Scan{Scan: scan}, nil +} + +func (ss *securityService) ListScans() (Scans, error) { + f := func(opt *godo.ListOptions) ([]any, *godo.Response, error) { + list, resp, err := ss.client.Security.ListScans(context.TODO(), opt) + if err != nil { + return nil, nil, err + } + + si := make([]any, len(list)) + for i := range list { + si[i] = list[i] + } + + return si, resp, err + } + + si, err := PaginateResp(f) + if err != nil { + return nil, err + } + + list := make(Scans, len(si)) + for i := range si { + scan, ok := si[i].(*godo.Scan) + if !ok { + return nil, errors.New("unexpected value in response") + } + list[i] = Scan{Scan: scan} + } + + return list, nil +} + +func (ss *securityService) GetScan(scanUUID string, opts *godo.ScanFindingsOptions) (*Scan, error) { + scan, _, err := ss.client.Security.GetScan(context.TODO(), scanUUID, opts) + if err != nil { + return nil, err + } + + return &Scan{Scan: scan}, nil +} + +func (ss *securityService) GetLatestScan(opts *godo.ScanFindingsOptions) (*Scan, error) { + scan, _, err := ss.client.Security.GetLatestScan(context.TODO(), opts) + if err != nil { + return nil, err + } + + return &Scan{Scan: scan}, nil +} + +func (ss *securityService) ListFindingAffectedResources(scanUUID string, findingUUID string) (AffectedResources, error) { + f := func(opt *godo.ListOptions) ([]any, *godo.Response, error) { + list, resp, err := ss.client.Security.ListFindingAffectedResources( + context.TODO(), + &godo.ListFindingAffectedResourcesRequest{ + ScanUUID: scanUUID, + FindingUUID: findingUUID, + }, + opt, + ) + if err != nil { + return nil, nil, err + } + + si := make([]any, len(list)) + for i := range list { + si[i] = list[i] + } + + return si, resp, err + } + + si, err := PaginateResp(f) + if err != nil { + return nil, err + } + + list := make(AffectedResources, len(si)) + for i := range si { + resource, ok := si[i].(*godo.AffectedResource) + if !ok { + return nil, errors.New("unexpected value in response") + } + list[i] = AffectedResource{AffectedResource: resource} + } + + return list, nil +} diff --git a/go.mod b/go.mod index 96e80de0a..87b04caa6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/digitalocean/doctl -go 1.24 +go 1.24.0 require ( github.com/blang/semver v3.5.1+incompatible @@ -12,7 +12,7 @@ require ( github.com/dustin/go-humanize v1.0.0 github.com/fatih/color v1.16.0 github.com/gobwas/glob v0.2.3 - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/google/uuid v1.4.0 github.com/gorilla/websocket v1.5.0 github.com/hashicorp/go-multierror v1.1.1 @@ -30,7 +30,7 @@ require ( github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.36.0 golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.30.0 + golang.org/x/oauth2 v0.35.0 golang.org/x/sys v0.31.0 gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.26.2 @@ -123,7 +123,7 @@ require ( go.opentelemetry.io/otel/trace v1.21.0 // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/text v0.23.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.26.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.66.4 // indirect diff --git a/go.sum b/go.sum index 05766b2db..251509e9e 100644 --- a/go.sum +++ b/go.sum @@ -188,8 +188,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -520,8 +520,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -602,8 +602,8 @@ golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/integration/security_test.go b/integration/security_test.go new file mode 100644 index 000000000..af2fd2351 --- /dev/null +++ b/integration/security_test.go @@ -0,0 +1,118 @@ +package integration + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "os/exec" + "strings" + "testing" + + "github.com/sclevine/spec" + "github.com/stretchr/testify/require" +) + +var _ = suite("security/cspm", func(t *testing.T, when spec.G, it spec.S) { + const scanID = "497dcba3-ecbf-4587-a2dd-5eb0665e6880" + var ( + expect *require.Assertions + server *httptest.Server + ) + + it.Before(func() { + expect = require.New(t) + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/v2/security/scans": + auth := req.Header.Get("Authorization") + if auth != "Bearer some-magic-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + reqBody, err := io.ReadAll(req.Body) + expect.NoError(err) + expect.JSONEq(securityScanCreateRequest, string(reqBody)) + + w.Write([]byte(securityScanCreateResponse)) + case "/v2/security/scans/" + scanID: + auth := req.Header.Get("Authorization") + if auth != "Bearer some-magic-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + w.Write([]byte(securityScanGetResponse)) + default: + dump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Fatal("failed to dump request") + } + + t.Fatalf("received unknown request: %s", dump) + } + })) + }) + + when("the wait flag is passed", func() { + it("creates a scan and waits for completion", func() { + cmd := exec.Command(builtBinaryPath, + "-t", "some-magic-token", + "-u", server.URL, + "security", + "scans", + "create", + "--wait", + ) + + output, err := cmd.CombinedOutput() + expect.NoError(err, fmt.Sprintf("received error output: %s", output)) + expect.Equal(strings.TrimSpace(securityScanWaitCreateOutput), strings.TrimSpace(string(output))) + }) + }) +}) + +const ( + securityScanCreateRequest = `{}` + securityScanCreateResponse = `{ + "id": "497dcba3-ecbf-4587-a2dd-5eb0665e6880", + "status": "in_progress", + "created_at": "2025-12-04T00:00:00Z", + "findings": [] +}` + securityScanGetResponse = `{ + "scan": { + "id": "497dcba3-ecbf-4587-a2dd-5eb0665e6880", + "status": "complete", + "created_at": "2025-12-04T00:00:00Z", + "findings": [ + { + "rule_uuid": "rule-1", + "name": "test", + "found_at": "2025-12-04T00:00:00Z", + "severity": "critical", + "affected_resources_count": 2 + } + ] + } +}` + securityScanWaitCreateOutput = ` +Notice: Scan in progress, waiting for scan to complete +Notice: Scan completed +Rule ID Name Affected Resources Found At Severity +rule-1 test 2 2025-12-04T00:00:00Z critical +` +) diff --git a/vendor/github.com/google/go-querystring/query/encode.go b/vendor/github.com/google/go-querystring/query/encode.go index 91198f819..c93695403 100644 --- a/vendor/github.com/google/go-querystring/query/encode.go +++ b/vendor/github.com/google/go-querystring/query/encode.go @@ -6,22 +6,21 @@ // // As a simple example: // -// type Options struct { -// Query string `url:"q"` -// ShowAll bool `url:"all"` -// Page int `url:"page"` -// } +// type Options struct { +// Query string `url:"q"` +// ShowAll bool `url:"all"` +// Page int `url:"page"` +// } // -// opt := Options{ "foo", true, 2 } -// v, _ := query.Values(opt) -// fmt.Print(v.Encode()) // will output: "q=foo&all=true&page=2" +// opt := Options{ "foo", true, 2 } +// v, _ := query.Values(opt) +// fmt.Print(v.Encode()) // will output: "q=foo&all=true&page=2" // // The exact mapping between Go values and url.Values is described in the // documentation for the Values() function. package query import ( - "bytes" "fmt" "net/url" "reflect" @@ -47,8 +46,8 @@ type Encoder interface { // // Each exported struct field is encoded as a URL parameter unless // -// - the field's tag is "-", or -// - the field is empty and its tag specifies the "omitempty" option +// - the field's tag is "-", or +// - the field is empty and its tag specifies the "omitempty" option // // The empty values are false, 0, any nil pointer or interface value, any array // slice, map, or string of length zero, and any type (such as time.Time) that @@ -59,19 +58,19 @@ type Encoder interface { // field's tag value is the key name, followed by an optional comma and // options. For example: // -// // Field is ignored by this package. -// Field int `url:"-"` +// // Field is ignored by this package. +// Field int `url:"-"` // -// // Field appears as URL parameter "myName". -// Field int `url:"myName"` +// // Field appears as URL parameter "myName". +// Field int `url:"myName"` // -// // Field appears as URL parameter "myName" and the field is omitted if -// // its value is empty -// Field int `url:"myName,omitempty"` +// // Field appears as URL parameter "myName" and the field is omitted if +// // its value is empty +// Field int `url:"myName,omitempty"` // -// // Field appears as URL parameter "Field" (the default), but the field -// // is skipped if empty. Note the leading comma. -// Field int `url:",omitempty"` +// // Field appears as URL parameter "Field" (the default), but the field +// // is skipped if empty. Note the leading comma. +// Field int `url:",omitempty"` // // For encoding individual field values, the following type-dependent rules // apply: @@ -88,8 +87,8 @@ type Encoder interface { // "url" tag) will use the value of the "layout" tag as a layout passed to // time.Format. For example: // -// // Encode a time.Time as YYYY-MM-DD -// Field time.Time `layout:"2006-01-02"` +// // Encode a time.Time as YYYY-MM-DD +// Field time.Time `layout:"2006-01-02"` // // Slice and Array values default to encoding as multiple URL values of the // same name. Including the "comma" option signals that the field should be @@ -103,9 +102,9 @@ type Encoder interface { // from the "url" tag) will use the value of the "del" tag as the delimiter. // For example: // -// // Encode a slice of bools as ints ("1" for true, "0" for false), -// // separated by exclamation points "!". -// Field []bool `url:",int" del:"!"` +// // Encode a slice of bools as ints ("1" for true, "0" for false), +// // separated by exclamation points "!". +// Field []bool `url:",int" del:"!"` // // Anonymous struct fields are usually encoded as if their inner exported // fields were fields in the outer struct, subject to the standard Go @@ -114,10 +113,10 @@ type Encoder interface { // // Non-nil pointer values are encoded as the value pointed to. // -// Nested structs are encoded including parent fields in value names for -// scoping. e.g: +// Nested structs have their fields processed recursively and are encoded +// including parent fields in value names for scoping. For example, // -// "user[name]=acme&user[addr][postcode]=1234&user[addr][city]=SFO" +// "user[name]=acme&user[addr][postcode]=1234&user[addr][city]=SFO" // // All other values are encoded using their default string representation. // @@ -125,6 +124,11 @@ type Encoder interface { // as multiple URL values of the same name. func Values(v interface{}) (url.Values, error) { values := make(url.Values) + + if v == nil { + return values, nil + } + val := reflect.ValueOf(v) for val.Kind() == reflect.Ptr { if val.IsNil() { @@ -133,10 +137,6 @@ func Values(v interface{}) (url.Values, error) { val = val.Elem() } - if v == nil { - return values, nil - } - if val.Kind() != reflect.Struct { return nil, fmt.Errorf("query: Values() expects struct input. Got %v", val.Kind()) } @@ -209,6 +209,11 @@ func reflectValue(values url.Values, val reflect.Value, scope string) error { } if sv.Kind() == reflect.Slice || sv.Kind() == reflect.Array { + if sv.Len() == 0 { + // skip if slice or array is empty + continue + } + var del string if opts.Contains("comma") { del = "," @@ -223,7 +228,7 @@ func reflectValue(values url.Values, val reflect.Value, scope string) error { } if del != "" { - s := new(bytes.Buffer) + s := new(strings.Builder) first := true for i := 0; i < sv.Len(); i++ { if first { diff --git a/vendor/golang.org/x/oauth2/deviceauth.go b/vendor/golang.org/x/oauth2/deviceauth.go index e99c92f39..e783a9437 100644 --- a/vendor/golang.org/x/oauth2/deviceauth.go +++ b/vendor/golang.org/x/oauth2/deviceauth.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "net/url" "strings" @@ -116,10 +117,38 @@ func retrieveDeviceAuth(ctx context.Context, c *Config, v url.Values) (*DeviceAu return nil, fmt.Errorf("oauth2: cannot auth device: %v", err) } if code := r.StatusCode; code < 200 || code > 299 { - return nil, &RetrieveError{ + retrieveError := &RetrieveError{ Response: r, Body: body, } + + content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + switch content { + case "application/x-www-form-urlencoded", "text/plain": + // some endpoints return a query string + vals, err := url.ParseQuery(string(body)) + if err != nil { + return nil, retrieveError + } + retrieveError.ErrorCode = vals.Get("error") + retrieveError.ErrorDescription = vals.Get("error_description") + retrieveError.ErrorURI = vals.Get("error_uri") + default: + var tj struct { + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorURI string `json:"error_uri"` + } + if json.Unmarshal(body, &tj) != nil { + return nil, retrieveError + } + retrieveError.ErrorCode = tj.ErrorCode + retrieveError.ErrorDescription = tj.ErrorDescription + retrieveError.ErrorURI = tj.ErrorURI + } + + return nil, retrieveError } da := &DeviceAuthResponse{} diff --git a/vendor/golang.org/x/oauth2/oauth2.go b/vendor/golang.org/x/oauth2/oauth2.go index de34feb84..5c527d31f 100644 --- a/vendor/golang.org/x/oauth2/oauth2.go +++ b/vendor/golang.org/x/oauth2/oauth2.go @@ -9,7 +9,6 @@ package oauth2 // import "golang.org/x/oauth2" import ( - "bytes" "context" "errors" "net/http" @@ -99,7 +98,7 @@ const ( // in the POST body as application/x-www-form-urlencoded parameters. AuthStyleInParams AuthStyle = 1 - // AuthStyleInHeader sends the client_id and client_password + // AuthStyleInHeader sends the client_id and client_secret // using HTTP Basic Authorization. This is an optional style // described in the OAuth2 RFC 6749 section 2.3.1. AuthStyleInHeader AuthStyle = 2 @@ -158,7 +157,7 @@ func SetAuthURLParam(key, value string) AuthCodeOption { // PKCE), https://www.oauth.com/oauth2-servers/pkce/ and // https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-09.html#name-cross-site-request-forgery (describing both approaches) func (c *Config) AuthCodeURL(state string, opts ...AuthCodeOption) string { - var buf bytes.Buffer + var buf strings.Builder buf.WriteString(c.Endpoint.AuthURL) v := url.Values{ "response_type": {"code"}, diff --git a/vendor/golang.org/x/oauth2/pkce.go b/vendor/golang.org/x/oauth2/pkce.go index cea8374d5..f99384f0f 100644 --- a/vendor/golang.org/x/oauth2/pkce.go +++ b/vendor/golang.org/x/oauth2/pkce.go @@ -51,7 +51,7 @@ func S256ChallengeFromVerifier(verifier string) string { return base64.RawURLEncoding.EncodeToString(sha[:]) } -// S256ChallengeOption derives a PKCE code challenge derived from verifier with +// S256ChallengeOption derives a PKCE code challenge from the verifier with // method S256. It should be passed to [Config.AuthCodeURL] or [Config.DeviceAuth] // only. func S256ChallengeOption(verifier string) AuthCodeOption { diff --git a/vendor/golang.org/x/oauth2/token.go b/vendor/golang.org/x/oauth2/token.go index 239ec3296..e995eebb5 100644 --- a/vendor/golang.org/x/oauth2/token.go +++ b/vendor/golang.org/x/oauth2/token.go @@ -103,7 +103,7 @@ func (t *Token) WithExtra(extra any) *Token { } // Extra returns an extra field. -// Extra fields are key-value pairs returned by the server as a +// Extra fields are key-value pairs returned by the server as // part of the token retrieval response. func (t *Token) Extra(key string) any { if raw, ok := t.raw.(map[string]any); ok { diff --git a/vendor/golang.org/x/oauth2/transport.go b/vendor/golang.org/x/oauth2/transport.go index 8bbebbac9..9922ec331 100644 --- a/vendor/golang.org/x/oauth2/transport.go +++ b/vendor/golang.org/x/oauth2/transport.go @@ -58,7 +58,7 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { var cancelOnce sync.Once // CancelRequest does nothing. It used to be a legacy cancellation mechanism -// but now only it only logs on first use to warn that it's deprecated. +// but now only logs on first use to warn that it's deprecated. // // Deprecated: use contexts for cancellation instead. func (t *Transport) CancelRequest(req *http.Request) { diff --git a/vendor/golang.org/x/time/rate/rate.go b/vendor/golang.org/x/time/rate/rate.go index 794b2e32b..563270c15 100644 --- a/vendor/golang.org/x/time/rate/rate.go +++ b/vendor/golang.org/x/time/rate/rate.go @@ -195,7 +195,7 @@ func (r *Reservation) CancelAt(t time.Time) { // update state r.lim.last = t r.lim.tokens = tokens - if r.timeToAct == r.lim.lastEvent { + if r.timeToAct.Equal(r.lim.lastEvent) { prevEvent := r.timeToAct.Add(r.limit.durationFromTokens(float64(-r.tokens))) if !prevEvent.Before(t) { r.lim.lastEvent = prevEvent diff --git a/vendor/modules.txt b/vendor/modules.txt index 179c29f40..2cd3ef0d7 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -164,8 +164,8 @@ github.com/gobwas/glob/util/strings ## explicit; go 1.15 github.com/gogo/protobuf/proto github.com/gogo/protobuf/sortkeys -# github.com/google/go-querystring v1.1.0 -## explicit; go 1.10 +# github.com/google/go-querystring v1.2.0 +## explicit; go 1.13 github.com/google/go-querystring/query # github.com/google/gofuzz v1.2.0 ## explicit; go 1.12 @@ -443,8 +443,8 @@ golang.org/x/net/idna golang.org/x/net/internal/httpcommon golang.org/x/net/internal/socks golang.org/x/net/proxy -# golang.org/x/oauth2 v0.30.0 -## explicit; go 1.23.0 +# golang.org/x/oauth2 v0.35.0 +## explicit; go 1.24.0 golang.org/x/oauth2 golang.org/x/oauth2/internal # golang.org/x/sync v0.12.0 @@ -466,8 +466,8 @@ golang.org/x/text/secure/bidirule golang.org/x/text/transform golang.org/x/text/unicode/bidi golang.org/x/text/unicode/norm -# golang.org/x/time v0.12.0 -## explicit; go 1.23.0 +# golang.org/x/time v0.14.0 +## explicit; go 1.24.0 golang.org/x/time/rate # golang.org/x/tools v0.26.0 ## explicit; go 1.22.0