Verified Commit fbd1bcd0 authored by Oscar Tovar's avatar Oscar Tovar Committed by GitLab
Browse files

feat: add security scan profile GraphQL service

Changelog: Improvements
parent ab4922ff
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -307,6 +307,7 @@ type Client struct {
	Search                              SearchServiceInterface
	SecurityAttributes                  SecurityAttributesServiceInterface
	SecurityCategories                  SecurityCategoriesServiceInterface
	SecurityScanProfiles                SecurityScanProfilesServiceInterface
	SecureFiles                         SecureFilesServiceInterface
	Services                            ServicesServiceInterface
	Settings                            SettingsServiceInterface
@@ -634,6 +635,7 @@ func NewAuthSourceClient(as AuthSource, options ...ClientOptionFunc) (*Client, e
	c.Search = &SearchService{client: c}
	c.SecurityAttributes = &SecurityAttributesService{client: c}
	c.SecurityCategories = &SecurityCategoriesService{client: c}
	c.SecurityScanProfiles = &SecurityScanProfilesService{client: c}
	c.SecureFiles = &SecureFilesService{client: c}
	c.Services = &ServicesService{client: c}
	c.Settings = &SettingsService{client: c}
+1 −0
Original line number Diff line number Diff line
@@ -151,6 +151,7 @@ var serviceMap = map[any]any{
	&SecureFilesService{}:                         (*SecureFilesServiceInterface)(nil),
	&SecurityAttributesService{}:                  (*SecurityAttributesServiceInterface)(nil),
	&SecurityCategoriesService{}:                  (*SecurityCategoriesServiceInterface)(nil),
	&SecurityScanProfilesService{}:                (*SecurityScanProfilesServiceInterface)(nil),
	&ServicesService{}:                            (*ServicesServiceInterface)(nil),
	&SettingsService{}:                            (*SettingsServiceInterface)(nil),
	&SidekiqService{}:                             (*SidekiqServiceInterface)(nil),
+262 −0
Original line number Diff line number Diff line
package gitlab

import (
	"errors"
	"fmt"
	"strings"
)

type (
	// SecurityScanProfilesServiceInterface defines all the methods for working
	// with security scan profiles through the GitLab GraphQL API.
	SecurityScanProfilesServiceInterface interface {
		// AttachSecurityScanProfile attaches a security scan profile to the
		// given projects and/or groups.
		//
		// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationsecurityscanprofileattach
		AttachSecurityScanProfile(opt *AttachSecurityScanProfileOptions, options ...RequestOptionFunc) (*Response, error)
		// DetachSecurityScanProfile detaches a security scan profile from the
		// given projects and/or groups.
		//
		// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationsecurityscanprofiledetach
		DetachSecurityScanProfile(opt *DetachSecurityScanProfileOptions, options ...RequestOptionFunc) (*Response, error)
		// ListProjectScanProfileStatuses returns the scan profile statuses for a
		// project. projectFullPath must be the full namespace/project path
		// string, as the GitLab GraphQL project(fullPath:) field does not accept
		// numeric IDs.
		//
		// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#project-scanprofilestatuses
		ListProjectScanProfileStatuses(projectFullPath string, options ...RequestOptionFunc) ([]ScanProfileStatus, *Response, error)
	}

	// SecurityScanProfilesService handles communication with the security scan
	// profile related methods of the GitLab GraphQL API.
	//
	// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationsecurityscanprofileattach
	SecurityScanProfilesService struct {
		client *Client
	}
)

var _ SecurityScanProfilesServiceInterface = (*SecurityScanProfilesService)(nil)

// ScanProfile represents a security scan profile.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#scanprofiletype
type ScanProfile struct {
	// ID is the global ID of the scan profile. Persisted profiles use a
	// numeric identifier; the built-in default profiles use their scan type
	// (for example "dependency_scanning_post_processing"), so this is kept as
	// a string rather than a numeric global ID.
	ID       string `json:"id"`
	Name     string `json:"name"`
	ScanType string `json:"scanType"`
}

// ScanProfileStatus represents the status of a scan profile for a project.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#scanprofileprojectstatus
type ScanProfileStatus struct {
	// Status is the computed display status, one of NOT_CONFIGURED, PENDING,
	// ACTIVE, WARNING, FAILED, or STALE.
	Status      string      `json:"status"`
	ScanProfile ScanProfile `json:"scanProfile"`
}

// graphQLErrors returns a non-nil error if the response carries top-level
// GraphQL errors. These come back with an HTTP 200, so the generic client does
// not flag them; callers must check explicitly.
func graphQLErrors(errs GenericGraphQLErrors) error {
	if len(errs.Errors) == 0 {
		return nil
	}
	msgs := make([]string, 0, len(errs.Errors))
	for _, e := range errs.Errors {
		msgs = append(msgs, e.Message)
	}
	return errors.New(strings.Join(msgs, "; "))
}

// SecurityScanProfileGID builds the GraphQL global ID for a security scan
// profile. Persisted profiles use their numeric database ID; the built-in
// default profiles use their scan type as the identifier, for example
// SecurityScanProfileGID("dependency_scanning_post_processing").
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#scanprofiletype
func SecurityScanProfileGID(identifier string) string {
	return fmt.Sprintf("gid://gitlab/Security::ScanProfile/%s", identifier)
}

// AttachSecurityScanProfileOptions represents the available
// AttachSecurityScanProfile() options.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationsecurityscanprofileattach
type AttachSecurityScanProfileOptions struct {
	// SecurityScanProfileID is the global ID of the scan profile to attach,
	// e.g. gid://gitlab/Security::ScanProfile/dependency_scanning_post_processing.
	// Use SecurityScanProfileGID to build it.
	SecurityScanProfileID string
	// ProjectIDs are the numeric IDs of the projects to attach the profile to.
	ProjectIDs []int64
	// GroupIDs are the numeric IDs of the groups to attach the profile to.
	GroupIDs []int64
}

// DetachSecurityScanProfileOptions represents the available
// DetachSecurityScanProfile() options.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationsecurityscanprofiledetach
type DetachSecurityScanProfileOptions struct {
	SecurityScanProfileID string
	ProjectIDs            []int64
	GroupIDs              []int64
}

// AttachSecurityScanProfile attaches a security scan profile to the given
// projects and/or groups. The caller must be a Maintainer or Owner of the
// targets, and the feature must be enabled on the instance, otherwise the
// mutation returns an error.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationsecurityscanprofileattach
func (s *SecurityScanProfilesService) AttachSecurityScanProfile(opt *AttachSecurityScanProfileOptions, options ...RequestOptionFunc) (*Response, error) {
	if opt == nil {
		return nil, errors.New("opt is required")
	}

	mutation := GraphQLQuery{
		Query: `
			mutation SecurityScanProfileAttach($input: SecurityScanProfileAttachInput!) {
				securityScanProfileAttach(input: $input) {
					errors
				}
			}
		`,
		Variables: map[string]any{
			"input": map[string]any{
				"securityScanProfileId": opt.SecurityScanProfileID,
				"projectIds":            newGIDStrings("Project", opt.ProjectIDs...),
				"groupIds":              newGIDStrings("Group", opt.GroupIDs...),
			},
		},
	}

	var result struct {
		Data struct {
			SecurityScanProfileAttach struct {
				Errors []string `json:"errors"`
			} `json:"securityScanProfileAttach"`
		} `json:"data"`
		GenericGraphQLErrors
	}

	resp, err := s.client.GraphQL.Do(mutation, &result, options...)
	if err != nil {
		return resp, err
	}
	if err := graphQLErrors(result.GenericGraphQLErrors); err != nil {
		return resp, err
	}
	if len(result.Data.SecurityScanProfileAttach.Errors) > 0 {
		return resp, fmt.Errorf("securityScanProfileAttach mutation errors: %v", result.Data.SecurityScanProfileAttach.Errors)
	}

	return resp, nil
}

// DetachSecurityScanProfile detaches a security scan profile from the given
// projects and/or groups.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationsecurityscanprofiledetach
func (s *SecurityScanProfilesService) DetachSecurityScanProfile(opt *DetachSecurityScanProfileOptions, options ...RequestOptionFunc) (*Response, error) {
	if opt == nil {
		return nil, errors.New("opt is required")
	}

	mutation := GraphQLQuery{
		Query: `
			mutation SecurityScanProfileDetach($input: SecurityScanProfileDetachInput!) {
				securityScanProfileDetach(input: $input) {
					errors
				}
			}
		`,
		Variables: map[string]any{
			"input": map[string]any{
				"securityScanProfileId": opt.SecurityScanProfileID,
				"projectIds":            newGIDStrings("Project", opt.ProjectIDs...),
				"groupIds":              newGIDStrings("Group", opt.GroupIDs...),
			},
		},
	}

	var result struct {
		Data struct {
			SecurityScanProfileDetach struct {
				Errors []string `json:"errors"`
			} `json:"securityScanProfileDetach"`
		} `json:"data"`
		GenericGraphQLErrors
	}

	resp, err := s.client.GraphQL.Do(mutation, &result, options...)
	if err != nil {
		return resp, err
	}
	if err := graphQLErrors(result.GenericGraphQLErrors); err != nil {
		return resp, err
	}
	if len(result.Data.SecurityScanProfileDetach.Errors) > 0 {
		return resp, fmt.Errorf("securityScanProfileDetach mutation errors: %v", result.Data.SecurityScanProfileDetach.Errors)
	}

	return resp, nil
}

// ListProjectScanProfileStatuses returns the scan profile statuses for a
// project. projectFullPath must be the full namespace/project path string, as
// the GitLab GraphQL project(fullPath:) field does not accept numeric IDs.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#project-scanprofilestatuses
func (s *SecurityScanProfilesService) ListProjectScanProfileStatuses(projectFullPath string, options ...RequestOptionFunc) ([]ScanProfileStatus, *Response, error) {
	query := GraphQLQuery{
		Query: `
			query($fullPath: ID!) {
				project(fullPath: $fullPath) {
					scanProfileStatuses {
						status
						scanProfile {
							id
							name
							scanType
						}
					}
				}
			}
		`,
		Variables: map[string]any{
			"fullPath": projectFullPath,
		},
	}

	var result struct {
		Data struct {
			Project *struct {
				ScanProfileStatuses []ScanProfileStatus `json:"scanProfileStatuses"`
			} `json:"project"`
		} `json:"data"`
		GenericGraphQLErrors
	}

	resp, err := s.client.GraphQL.Do(query, &result, options...)
	if err != nil {
		return nil, resp, err
	}
	if err := graphQLErrors(result.GenericGraphQLErrors); err != nil {
		return nil, resp, err
	}
	if result.Data.Project == nil {
		return nil, resp, ErrNotFound
	}

	return result.Data.Project.ScanProfileStatuses, resp, nil
}
+223 −0
Original line number Diff line number Diff line
package gitlab

import (
	"fmt"
	"net/http"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestSecurityScanProfiles_AttachSecurityScanProfile(t *testing.T) {
	t.Parallel()

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"securityScanProfileAttach": {
						"errors": []
					}
				}
			}
		`)
	})

	opt := &AttachSecurityScanProfileOptions{
		SecurityScanProfileID: SecurityScanProfileGID("dependency_scanning_post_processing"),
		ProjectIDs:            []int64{1},
	}
	_, err := client.SecurityScanProfiles.AttachSecurityScanProfile(opt)
	require.NoError(t, err)
}

func TestSecurityScanProfiles_AttachSecurityScanProfile_nilOpt(t *testing.T) {
	t.Parallel()

	_, client := setup(t)

	_, err := client.SecurityScanProfiles.AttachSecurityScanProfile(nil)
	assert.ErrorContains(t, err, "opt is required")
}

func TestSecurityScanProfiles_AttachSecurityScanProfile_errors(t *testing.T) {
	t.Parallel()

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"securityScanProfileAttach": {
						"errors": ["The resource that you are attempting to access does not exist"]
					}
				}
			}
		`)
	})

	opt := &AttachSecurityScanProfileOptions{
		SecurityScanProfileID: SecurityScanProfileGID("dependency_scanning_post_processing"),
		ProjectIDs:            []int64{1},
	}
	_, err := client.SecurityScanProfiles.AttachSecurityScanProfile(opt)
	assert.ErrorContains(t, err, "does not exist")
}

func TestSecurityScanProfiles_AttachSecurityScanProfile_topLevelErrors(t *testing.T) {
	t.Parallel()

	mux, client := setup(t)

	// A disabled feature flag or missing permission comes back as a top-level
	// GraphQL error with HTTP 200 and a null data payload.
	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"errors": [
					{"message": "The resource that you are attempting to access does not exist or you don't have permission to perform this action"}
				],
				"data": {"securityScanProfileAttach": null}
			}
		`)
	})

	opt := &AttachSecurityScanProfileOptions{
		SecurityScanProfileID: SecurityScanProfileGID("dependency_scanning_post_processing"),
		ProjectIDs:            []int64{1},
	}
	_, err := client.SecurityScanProfiles.AttachSecurityScanProfile(opt)
	assert.ErrorContains(t, err, "don't have permission")
}

func TestSecurityScanProfiles_DetachSecurityScanProfile(t *testing.T) {
	t.Parallel()

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"securityScanProfileDetach": {
						"errors": []
					}
				}
			}
		`)
	})

	opt := &DetachSecurityScanProfileOptions{
		SecurityScanProfileID: SecurityScanProfileGID("dependency_scanning_post_processing"),
		ProjectIDs:            []int64{1},
	}
	_, err := client.SecurityScanProfiles.DetachSecurityScanProfile(opt)
	require.NoError(t, err)
}

func TestSecurityScanProfiles_DetachSecurityScanProfile_nilOpt(t *testing.T) {
	t.Parallel()

	_, client := setup(t)

	_, err := client.SecurityScanProfiles.DetachSecurityScanProfile(nil)
	assert.ErrorContains(t, err, "opt is required")
}

func TestSecurityScanProfiles_DetachSecurityScanProfile_errors(t *testing.T) {
	t.Parallel()

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"securityScanProfileDetach": {
						"errors": ["Record not found"]
					}
				}
			}
		`)
	})

	opt := &DetachSecurityScanProfileOptions{
		SecurityScanProfileID: SecurityScanProfileGID("dependency_scanning_post_processing"),
		ProjectIDs:            []int64{1},
	}
	_, err := client.SecurityScanProfiles.DetachSecurityScanProfile(opt)
	assert.ErrorContains(t, err, "Record not found")
}

func TestSecurityScanProfiles_ListProjectScanProfileStatuses(t *testing.T) {
	t.Parallel()

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"project": {
						"scanProfileStatuses": [
							{
								"status": "ACTIVE",
								"scanProfile": {
									"id": "gid://gitlab/Security::ScanProfile/dependency_scanning_post_processing",
									"name": "Dependency Scanning Auto-Remediation (default)",
									"scanType": "DEPENDENCY_SCANNING_POST_PROCESSING"
								}
							}
						]
					}
				}
			}
		`)
	})

	statuses, _, err := client.SecurityScanProfiles.ListProjectScanProfileStatuses("mygroup/myproject")
	require.NoError(t, err)

	want := []ScanProfileStatus{
		{
			Status: "ACTIVE",
			ScanProfile: ScanProfile{
				ID:       "gid://gitlab/Security::ScanProfile/dependency_scanning_post_processing",
				Name:     "Dependency Scanning Auto-Remediation (default)",
				ScanType: "DEPENDENCY_SCANNING_POST_PROCESSING",
			},
		},
	}
	assert.Equal(t, want, statuses)
}

func TestSecurityScanProfiles_ListProjectScanProfileStatuses_projectNotFound(t *testing.T) {
	t.Parallel()

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `{"data": {"project": null}}`)
	})

	_, _, err := client.SecurityScanProfiles.ListProjectScanProfileStatuses("nonexistent/project")
	assert.ErrorIs(t, err, ErrNotFound)
}
+1 −0
Original line number Diff line number Diff line
@@ -151,6 +151,7 @@ package testing
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -typed -destination=secure_files_mock.go -write_package_comment=false -package=testing gitlab.com/gitlab-org/api/client-go/v2 SecureFilesServiceInterface
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -typed -destination=security_attributes_mock.go -write_package_comment=false -package=testing gitlab.com/gitlab-org/api/client-go/v2 SecurityAttributesServiceInterface
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -typed -destination=security_categories_mock.go -write_package_comment=false -package=testing gitlab.com/gitlab-org/api/client-go/v2 SecurityCategoriesServiceInterface
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -typed -destination=security_scan_profiles_mock.go -write_package_comment=false -package=testing gitlab.com/gitlab-org/api/client-go/v2 SecurityScanProfilesServiceInterface
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -typed -destination=services_mock.go -write_package_comment=false -package=testing gitlab.com/gitlab-org/api/client-go/v2 ServicesServiceInterface
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -typed -destination=settings_mock.go -write_package_comment=false -package=testing gitlab.com/gitlab-org/api/client-go/v2 SettingsServiceInterface
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -typed -destination=sidekiq_metrics_mock.go -write_package_comment=false -package=testing gitlab.com/gitlab-org/api/client-go/v2 SidekiqServiceInterface
Loading