From f7b72b81ddb599dce3413ab94a0cece459cc8e50 Mon Sep 17 00:00:00 2001 From: Ben Tranter Date: Thu, 12 Feb 2026 17:20:02 -0500 Subject: [PATCH 1/3] security: add commands for cspm scanning preview Adds the commands for the CSPM scanning preview, and pulls in the latest godo changes to support that. --- args.go | 11 + commands/command_config.go | 2 + commands/commands_test.go | 3 + commands/displayers/security.go | 139 ++++++++++ commands/doit.go | 1 + commands/security.go | 247 ++++++++++++++++++ commands/security_test.go | 117 +++++++++ do/mocks/SecurityService.go | 117 +++++++++ do/security.go | 157 +++++++++++ go.mod | 10 +- go.sum | 16 +- .../github.com/digitalocean/godo/CHANGELOG.md | 5 + vendor/github.com/digitalocean/godo/godo.go | 4 +- vendor/github.com/digitalocean/godo/nfs.go | 2 + .../github.com/digitalocean/godo/security.go | 246 +++++++++++++++++ .../google/go-querystring/query/encode.go | 73 +++--- vendor/golang.org/x/oauth2/deviceauth.go | 31 ++- vendor/golang.org/x/oauth2/oauth2.go | 5 +- vendor/golang.org/x/oauth2/pkce.go | 2 +- vendor/golang.org/x/oauth2/token.go | 2 +- vendor/golang.org/x/oauth2/transport.go | 2 +- vendor/golang.org/x/time/rate/rate.go | 2 +- vendor/modules.txt | 14 +- 23 files changed, 1145 insertions(+), 63 deletions(-) create mode 100644 commands/displayers/security.go create mode 100644 commands/security.go create mode 100644 commands/security_test.go create mode 100644 do/mocks/SecurityService.go create mode 100644 do/security.go create mode 100644 vendor/github.com/digitalocean/godo/security.go 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..b7918d407 --- /dev/null +++ b/commands/security_test.go @@ -0,0 +1,117 @@ +/* +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, "scan", "finding") + assertCommandNames(t, cmd.childCommands[0], "create", "get", "latest", "list") + assertCommandNames(t, cmd.childCommands[1], "affected-resources") +} + +func TestSecurityScanCreate(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + request := &godo.CreateScanRequest{ + Resources: []string{"do:droplet"}, + } + tm.security.EXPECT().CreateScan(request).Return(&testSecurityScan, nil) + + config.Doit.Set(config.NS, doctl.ArgSecurityScanResources, []string{"do:droplet"}) + + 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.Scan.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.Doit.Set(config.NS, doctl.ArgSecurityFindingScanUUID, "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 3bb385acb..87b04caa6 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,18 @@ module github.com/digitalocean/doctl -go 1.24 +go 1.24.0 require ( github.com/blang/semver v3.5.1+incompatible github.com/creack/pty v1.1.21 - github.com/digitalocean/godo v1.174.0 + github.com/digitalocean/godo v1.175.0 github.com/docker/cli v24.0.5+incompatible github.com/docker/docker v25.0.6+incompatible github.com/docker/docker-credential-helpers v0.7.0 // indirect 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 9c623e49b..251509e9e 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +91,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/digitalocean/godo v1.174.0 h1:9nVX8WqAPd7ZN9Yn63HeLRAI8m2vi9QeotcDvYmB+ns= -github.com/digitalocean/godo v1.174.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= +github.com/digitalocean/godo v1.175.0 h1:tpfwJFkBzpePxvvFazOn69TXctdxuFlOs7DMVXsI7oU= +github.com/digitalocean/godo v1.175.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v24.0.5+incompatible h1:WeBimjvS0eKdH4Ygx+ihVq1Q++xg36M/rMi4aXAvodc= @@ -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/vendor/github.com/digitalocean/godo/CHANGELOG.md b/vendor/github.com/digitalocean/godo/CHANGELOG.md index e95ce5e38..41fd1e2aa 100644 --- a/vendor/github.com/digitalocean/godo/CHANGELOG.md +++ b/vendor/github.com/digitalocean/godo/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## [1.175.0] - 2026-02-12 + +- #952 - @bentranter - security: add cspm scanning functionality for public preview +- #951 - @v-amanjain-afk - Add performance tier in nfs model + ## [1.174.0] - 2026-02-09 - #946 - @blesswinsamuel - apps: Update app spec to support InactivitySleep configuration diff --git a/vendor/github.com/digitalocean/godo/godo.go b/vendor/github.com/digitalocean/godo/godo.go index c66c6204b..c3fbd9d31 100644 --- a/vendor/github.com/digitalocean/godo/godo.go +++ b/vendor/github.com/digitalocean/godo/godo.go @@ -21,7 +21,7 @@ import ( ) const ( - libraryVersion = "1.174.0" + libraryVersion = "1.175.0" defaultBaseURL = "https://api.digitalocean.com/" userAgent = "godo/" + libraryVersion mediaType = "application/json" @@ -78,6 +78,7 @@ type Client struct { Kubernetes KubernetesService LoadBalancers LoadBalancersService Monitoring MonitoringService + Security SecurityService Nfs NfsService NfsActions NfsActionsService OneClick OneClickService @@ -306,6 +307,7 @@ func NewClient(httpClient *http.Client) *Client { c.Kubernetes = &KubernetesServiceOp{client: c} c.LoadBalancers = &LoadBalancersServiceOp{client: c} c.Monitoring = &MonitoringServiceOp{client: c} + c.Security = &SecurityServiceOp{client: c} c.Nfs = &NfsServiceOp{client: c} c.NfsActions = &NfsActionsServiceOp{client: c} c.VPCNATGateways = &VPCNATGatewaysServiceOp{client: c} diff --git a/vendor/github.com/digitalocean/godo/nfs.go b/vendor/github.com/digitalocean/godo/nfs.go index 016b335fb..da04bb19a 100644 --- a/vendor/github.com/digitalocean/godo/nfs.go +++ b/vendor/github.com/digitalocean/godo/nfs.go @@ -78,6 +78,8 @@ type Nfs struct { Host string `json:"host"` // MountPath is the path at which the share will be available MountPath string `json:"mount_path"` + //PerformanceTier is the performance tier of the NFS share + PerformanceTier string `json:"performance_tier"` } type NfsSnapshot struct { diff --git a/vendor/github.com/digitalocean/godo/security.go b/vendor/github.com/digitalocean/godo/security.go new file mode 100644 index 000000000..47da7b983 --- /dev/null +++ b/vendor/github.com/digitalocean/godo/security.go @@ -0,0 +1,246 @@ +package godo + +import ( + "context" + "fmt" + "net/http" +) + +const ( + securityScansBasePath = "v2/security/scans" + securityScansFindingsPath = "findings" + securityAffectedResources = "affected_resources" +) + +// SecurityService is an interface for interacting with the CSPM endpoints of +// the DigitalOcean API. +type SecurityService interface { + CreateScan(context.Context, *CreateScanRequest) (*Scan, *Response, error) + ListScans(context.Context, *ListOptions) ([]*Scan, *Response, error) + GetScan(context.Context, string, *ScanFindingsOptions) (*Scan, *Response, error) + GetLatestScan(context.Context, *ScanFindingsOptions) (*Scan, *Response, error) + ListFindingAffectedResources(context.Context, *ListFindingAffectedResourcesRequest, *ListOptions) ([]*AffectedResource, *Response, error) +} + +// SecurityServiceOp handles communication with security scan related methods of the DigitalOcean API. +type SecurityServiceOp struct { + client *Client +} + +var _ SecurityService = &SecurityServiceOp{} + +// CreateScanRequest contains the request payload to create a scan. +type CreateScanRequest struct { + Resources []string `json:"resources,omitempty"` +} + +// ScanFindingsOptions contains the query parameters for paginating and +// filtering scan findings. +type ScanFindingsOptions struct { + ListOptions + Type string `url:"type,omitempty"` + Severity string `url:"severity,omitempty"` +} + +// Scan represents a CSPM scan. +type Scan struct { + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Findings []*ScanFinding `json:"findings,omitempty"` +} + +func (s *Scan) Completed() bool { + return s.Status == "COMPLETED" +} + +// ScanFinding represents a finding within a scan. +type ScanFinding struct { + RuleUUID string `json:"rule_uuid,omitempty"` + Name string `json:"name,omitempty"` + Details string `json:"details,omitempty"` + FoundAt string `json:"found_at,omitempty"` + Severity string `json:"severity,omitempty"` + BusinessImpact string `json:"business_impact,omitempty"` + TechnicalDetails string `json:"technical_details,omitempty"` + MitigationSteps []*ScanMitigationStep `json:"mitigation_steps,omitempty"` + AffectedResourcesCount int `json:"affected_resources_count,omitempty"` +} + +// ScanMitigationStep represents a mitigation step for a scan finding. +type ScanMitigationStep struct { + Step int `json:"step,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +// An AffectedResource represents a resource affected by a scan finding. +type AffectedResource struct { + URN string `json:"urn,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` +} + +type scanRoot struct { + Scan *Scan `json:"scan"` +} + +type scansRoot struct { + Scans []*Scan `json:"scans"` + Links *Links `json:"links"` + Meta *Meta `json:"meta"` +} + +type affectedResourcesRoot struct { + AffectedResources []*AffectedResource `json:"affected_resources"` + Links *Links `json:"links"` + Meta *Meta `json:"meta"` +} + +// CreateScan initiates a new CSPM scan. +func (s *SecurityServiceOp) CreateScan(ctx context.Context, createScanRequest *CreateScanRequest) (*Scan, *Response, error) { + if createScanRequest == nil { + return nil, nil, NewArgError("createScanRequest", "cannot be nil") + } + + req, err := s.client.NewRequest(ctx, http.MethodPost, securityScansBasePath, createScanRequest) + if err != nil { + return nil, nil, err + } + + scan := &Scan{} + resp, err := s.client.Do(ctx, req, scan) + if err != nil { + return nil, resp, err + } + + return scan, resp, nil +} + +// ListScans lists all CSPM scans. +func (s *SecurityServiceOp) ListScans(ctx context.Context, opts *ListOptions) ([]*Scan, *Response, error) { + path, err := addOptions(securityScansBasePath, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(scansRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + if l := root.Links; l != nil { + resp.Links = l + } + if m := root.Meta; m != nil { + resp.Meta = m + } + + return root.Scans, resp, nil +} + +// GetScan retrieves a scan by its UUID with optional findings filters. +func (s *SecurityServiceOp) GetScan(ctx context.Context, scanUUID string, opts *ScanFindingsOptions) (*Scan, *Response, error) { + if scanUUID == "" { + return nil, nil, NewArgError("scanUUID", "cannot be empty") + } + + path := fmt.Sprintf("%s/%s", securityScansBasePath, scanUUID) + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := &scanRoot{} + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Scan, resp, nil +} + +// GetLatestScan retrieves the latest scan with optional findings filters. +func (s *SecurityServiceOp) GetLatestScan(ctx context.Context, opts *ScanFindingsOptions) (*Scan, *Response, error) { + path := fmt.Sprintf("%s/latest", securityScansBasePath) + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := &scanRoot{} + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Scan, resp, nil +} + +// ListFindingAffectedResourcesRequest contains the fields to list the +// affected resources for a scan finding. +type ListFindingAffectedResourcesRequest struct { + ScanUUID string + FindingUUID string +} + +// ListFindingAffectedResources lists the affected resources for a scan +// finding. +func (s *SecurityServiceOp) ListFindingAffectedResources(ctx context.Context, listFindingAffectedResourcesRequest *ListFindingAffectedResourcesRequest, opts *ListOptions) ([]*AffectedResource, *Response, error) { + if listFindingAffectedResourcesRequest == nil { + return nil, nil, NewArgError("listFindingAffectedResourcesRequest", "cannot be nil") + } + if listFindingAffectedResourcesRequest.ScanUUID == "" { + return nil, nil, NewArgError("scanUUID", "cannot be empty") + } + if listFindingAffectedResourcesRequest.FindingUUID == "" { + return nil, nil, NewArgError("findingUUID", "cannot be empty") + } + + path := fmt.Sprintf( + "%s/%s/%s/%s/%s", + securityScansBasePath, + listFindingAffectedResourcesRequest.ScanUUID, + securityScansFindingsPath, + listFindingAffectedResourcesRequest.FindingUUID, + securityAffectedResources, + ) + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := &affectedResourcesRoot{} + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + if l := root.Links; l != nil { + resp.Links = l + } + if m := root.Meta; m != nil { + resp.Meta = m + } + + return root.AffectedResources, resp, nil +} 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 a3c0c19e0..2cd3ef0d7 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -61,7 +61,7 @@ github.com/creack/pty # github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc ## explicit github.com/davecgh/go-spew/spew -# github.com/digitalocean/godo v1.174.0 +# github.com/digitalocean/godo v1.175.0 ## explicit; go 1.23.0 github.com/digitalocean/godo github.com/digitalocean/godo/metrics @@ -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 From f0bbe2f8df59e933ccff976325badf5388e4dbf3 Mon Sep 17 00:00:00 2001 From: Ben Tranter Date: Thu, 12 Feb 2026 17:37:07 -0500 Subject: [PATCH 2/3] security: fix failing test cases --- commands/security_test.go | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/commands/security_test.go b/commands/security_test.go index b7918d407..e751ab125 100644 --- a/commands/security_test.go +++ b/commands/security_test.go @@ -50,20 +50,15 @@ var ( func TestSecurityCommand(t *testing.T) { cmd := Security() assert.NotNil(t, cmd) - assertCommandNames(t, cmd, "scan", "finding") - assertCommandNames(t, cmd.childCommands[0], "create", "get", "latest", "list") - assertCommandNames(t, cmd.childCommands[1], "affected-resources") + 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{ - Resources: []string{"do:droplet"}, - } + request := &godo.CreateScanRequest{} tm.security.EXPECT().CreateScan(request).Return(&testSecurityScan, nil) - config.Doit.Set(config.NS, doctl.ArgSecurityScanResources, []string{"do:droplet"}) - err := RunCmdSecurityScanCreate(config) assert.NoError(t, err) }) @@ -74,7 +69,7 @@ func TestSecurityScanGet(t *testing.T) { opts := &godo.ScanFindingsOptions{Severity: "critical", Type: "CSPM"} tm.security.EXPECT().GetScan(testSecurityScan.Scan.ID, opts).Return(&testSecurityScan, nil) - config.Args = append(config.Args, testSecurityScan.Scan.ID) + config.Args = append(config.Args, testSecurityScan.ID) config.Doit.Set(config.NS, doctl.ArgSecurityScanFindingSeverity, "critical") config.Doit.Set(config.NS, doctl.ArgSecurityScanFindingType, "CSPM") @@ -108,7 +103,7 @@ func TestSecurityFindingAffectedResources(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { tm.security.EXPECT().ListFindingAffectedResources("scan-uuid", "finding-uuid").Return(testSecurityAffectedResources, nil) - config.Doit.Set(config.NS, doctl.ArgSecurityFindingScanUUID, "scan-uuid") + config.Args = append(config.Args, "scan-uuid") config.Doit.Set(config.NS, doctl.ArgSecurityFindingUUID, "finding-uuid") err := RunCmdSecurityFindingAffectedResources(config) From 8dd1e517e31906129326660b777d325eda04055c Mon Sep 17 00:00:00 2001 From: Ben Tranter Date: Tue, 17 Feb 2026 16:00:53 -0500 Subject: [PATCH 3/3] security: add integration test --- integration/security_test.go | 118 +++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 integration/security_test.go 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 +` +)