diff --git a/docker-compose.yaml b/docker-compose.yaml index 4e4423f0..c58494bb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: context: ./.config args: grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} - grafana_version: ${GRAFANA_VERSION:-10.4.2} + grafana_version: ${GRAFANA_VERSION:-latest} development: ${DEVELOPMENT:-true} ports: - 3000:3000/tcp diff --git a/pkg/github/client/client.go b/pkg/github/client/client.go index ae0444f0..fa7993fa 100644 --- a/pkg/github/client/client.go +++ b/pkg/github/client/client.go @@ -144,6 +144,15 @@ func (client *Client) ListWorkflows(ctx context.Context, owner, repo string, opt return wf, resp, err } +// / ListAlertsForRepo sends a request to the GitHub rest API to list the code scanning alerts in a specific repository. +func (client *Client) ListAlertsForRepo(ctx context.Context, owner, repo string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) { + alerts, resp, err := client.restClient.CodeScanning.ListAlertsForRepo(ctx, owner, repo, opts) + if err != nil { + return nil, nil, addErrorSourceToError(err, resp) + } + return alerts, resp, err +} + // GetWorkflowUsage returns the workflow usage for a specific workflow. func (client *Client) GetWorkflowUsage(ctx context.Context, owner, repo, workflow string, timeRange backend.TimeRange) (models.WorkflowUsage, error) { actors := make(map[string]struct{}, 0) diff --git a/pkg/github/codescanning.go b/pkg/github/codescanning.go new file mode 100644 index 00000000..a63abc7d --- /dev/null +++ b/pkg/github/codescanning.go @@ -0,0 +1,163 @@ +package github + +import ( + "context" + "strings" + "time" + + googlegithub "github.com/google/go-github/v53/github" + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +type CodeScanningWrapper []*googlegithub.Alert + +func (alerts CodeScanningWrapper) Frames() data.Frames { + frames := data.NewFrame("code_scanning_alerts", + data.NewField("number", nil, []*int64{}), + data.NewField("created_at", nil, []time.Time{}), + data.NewField("updated_at", nil, []time.Time{}), + data.NewField("dismissed_at", nil, []*time.Time{}), + data.NewField("url", nil, []string{}), + data.NewField("state", nil, []string{}), + data.NewField("dismissed_by", nil, []string{}), + data.NewField("dismissed_reason", nil, []string{}), + data.NewField("dismissed_comment", nil, []string{}), + data.NewField("rule_id", nil, []string{}), + data.NewField("rule_severity", nil, []string{}), + data.NewField("rule_security_severity_level", nil, []string{}), + data.NewField("rule_description", nil, []string{}), + data.NewField("rule_full_description", nil, []string{}), + data.NewField("rule_tags", nil, []string{}), + data.NewField("rule_help", nil, []string{}), + data.NewField("tool_name", nil, []string{}), + data.NewField("tool_version", nil, []string{}), + data.NewField("tool_guid", nil, []string{}), + ) + + for _, alert := range alerts { + frames.AppendRow( + func() *int64 { + num := int64(alert.GetNumber()) + return &num + }(), + func() time.Time { + if !alert.GetCreatedAt().Time.IsZero() { + return alert.GetCreatedAt().Time + } + return time.Time{} + }(), + func() time.Time { + if !alert.GetUpdatedAt().Time.IsZero() { + return alert.GetUpdatedAt().Time + } + return time.Time{} + }(), + func() *time.Time { + if !alert.GetDismissedAt().Time.IsZero() { + t := alert.GetDismissedAt().Time + return &t + } + return nil + }(), + func() string { + str := alert.GetHTMLURL() + return str + }(), + func() string { + str := alert.GetState() + return str + }(), + func() string { + if alert.GetDismissedBy() != nil { + str := alert.GetDismissedBy().GetLogin() + return str + } + return "" + }(), + func() string { + str := alert.GetDismissedReason() + return str + }(), + func() string { + str := alert.GetDismissedComment() + return str + }(), + func() string { + if alert.GetRule() != nil { + return *alert.GetRule().ID + } + return "" + }(), + func() string { + if alert.GetRule() != nil { + return *alert.GetRule().Severity + } + return "" + }(), + func() string { + if alert.GetRule() != nil { + return *alert.GetRule().SecuritySeverityLevel + } + return "" + }(), + func() string { + if alert.GetRule() != nil && alert.GetRule().Description != nil { + return *alert.GetRule().Description + } + return "" + }(), + func() string { + if alert.GetRule() != nil && alert.GetRule().FullDescription != nil { + return *alert.GetRule().FullDescription + } + return "" + }(), + func() string { + if alert.GetRule() != nil { + str := strings.Join(alert.GetRule().Tags, ", ") + return str + } + return "" + }(), + func() string { + if alert.GetRule() != nil { + return *alert.GetRule().Help + } + return "" + }(), + func() string { + if alert.GetTool() != nil && alert.GetTool().Name != nil { + return *alert.GetTool().Name + } + return "" + }(), + func() string { + if alert.GetTool() != nil && alert.GetTool().Version != nil { + return *alert.GetTool().Version + } + return "" + }(), + func() string { + if alert.GetTool() != nil && alert.GetTool().GUID != nil { + return *alert.GetTool().GUID + } + return "" + }(), + ) + } + + return data.Frames{frames} +} + +// Function to get a list of alerts for a repository +// GET /repos/{owner}/{repo}/code-scanning/alerts +// https://docs.github.com/en/rest/reference/code-scanning#get-a-list-of-code-scanning-alerts-for-a-repository +func GetCodeScanningAlerts(context context.Context, owner, repo string, c models.Client) (CodeScanningWrapper, error) { + alerts, _, err := c.ListAlertsForRepo(context, owner, repo, &googlegithub.AlertListOptions{ListOptions: googlegithub.ListOptions{Page: 1, PerPage: 100}}) + if err != nil { + return nil, err + } + + return CodeScanningWrapper(alerts), nil +} diff --git a/pkg/github/codescanning_handler.go b/pkg/github/codescanning_handler.go new file mode 100644 index 00000000..be8cfaeb --- /dev/null +++ b/pkg/github/codescanning_handler.go @@ -0,0 +1,24 @@ +package github + +import ( + "context" + + "github.com/grafana/github-datasource/pkg/dfutil" + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +func (s *QueryHandler) handleCodeScanningRequests(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.CodeScanningQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandleCodeScanningQuery(ctx, query, q)) +} + +// handleCodeScanning handles the plugin query for github code scanning +func (s *QueryHandler) HandleCodeScanning(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleCodeScanningRequests), + }, nil +} diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index ff1b4c98..41e76d10 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -131,6 +131,16 @@ func (d *Datasource) HandleVulnerabilitiesQuery(ctx context.Context, query *mode return GetAllVulnerabilities(ctx, d.client, opt) } +// ListAlertsForRepo is the query handler for listing GitHub Security Alerts +func (d *Datasource) ListAlertsForRepo(ctx context.Context, query *models.CodeScanningQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.ListCodeScanningOptions{ + Repository: query.Repository, + Owner: query.Owner, + } + + return d.HandleCodeScanningQuery(ctx, &models.CodeScanningQuery{Query: query.Query, Options: opt}, req) +} + // HandleProjectsQuery is the query handler for listing GitHub Projects func (d *Datasource) HandleProjectsQuery(ctx context.Context, query *models.ProjectsQuery, req backend.DataQuery) (dfutil.Framer, error) { opt := models.ProjectOptions{ @@ -179,6 +189,12 @@ func (d *Datasource) HandleWorkflowUsageQuery(ctx context.Context, query *models return GetWorkflowUsage(ctx, d.client, opt, req.TimeRange) } +// HandleCodeScanningQuery is the query handler for listing code scanning alerts of a GitHub repository +func (d *Datasource) HandleCodeScanningQuery(ctx context.Context, query *models.CodeScanningQuery, req backend.DataQuery) (dfutil.Framer, error) { + + return GetCodeScanningAlerts(ctx, query.Owner, query.Repository, d.client) +} + // CheckHealth is the health check for GitHub func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { _, err := GetAllRepositories(ctx, d.client, models.ListRepositoriesOptions{ diff --git a/pkg/github/query_handler.go b/pkg/github/query_handler.go index 333b207e..99a0427f 100644 --- a/pkg/github/query_handler.go +++ b/pkg/github/query_handler.go @@ -57,6 +57,7 @@ func GetQueryHandlers(s *QueryHandler) *datasource.QueryTypeMux { mux.HandleFunc(models.QueryTypeStargazers, s.HandleStargazers) mux.HandleFunc(models.QueryTypeWorkflows, s.HandleWorkflows) mux.HandleFunc(models.QueryTypeWorkflowUsage, s.HandleWorkflowUsage) + mux.HandleFunc(models.QueryTypeCodeScanning, s.HandleCodeScanning) return mux } diff --git a/pkg/models/client.go b/pkg/models/client.go index e1d7165e..cffc1a2f 100644 --- a/pkg/models/client.go +++ b/pkg/models/client.go @@ -13,4 +13,6 @@ type Client interface { Query(ctx context.Context, q interface{}, variables map[string]interface{}) error ListWorkflows(ctx context.Context, owner, repo string, opts *googlegithub.ListOptions) (*googlegithub.Workflows, *googlegithub.Response, error) GetWorkflowUsage(ctx context.Context, owner, repo, workflow string, timeRange backend.TimeRange) (WorkflowUsage, error) + // interface for getting code security alerts + ListAlertsForRepo(ctx context.Context, owner, repo string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) } diff --git a/pkg/models/codescanning.go b/pkg/models/codescanning.go new file mode 100644 index 00000000..216f7a8c --- /dev/null +++ b/pkg/models/codescanning.go @@ -0,0 +1,12 @@ +package models + +type ListCodeScanningOptions struct { + // Owner is the owner of the repository (ex: grafana) + Owner string `json:"owner"` + + // Repository is the name of the repository being queried (ex: grafana) + Repository string `json:"repository"` + + // The field used to check if an entry is in the requested range. + TimeField uint32 `json:"timeField"` +} diff --git a/pkg/models/query.go b/pkg/models/query.go index 09332020..076333e8 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -37,6 +37,8 @@ const ( QueryTypeWorkflows = "Workflows" // QueryTypeWorkflowUsage is used when querying a specific workflow usage QueryTypeWorkflowUsage = "Workflow_Usage" + // QueryTypeCodeScanning is used when querying code scanning alerts for a repository + QueryTypeCodeScanning = "Code_Scanning" ) // Query refers to the structure of a query built using the QueryEditor. @@ -129,3 +131,9 @@ type WorkflowUsageQuery struct { Query Options WorkflowUsageOptions `json:"options"` } + +// CodeScanningQuery is used when querying code scanning alerts for a repository +type CodeScanningQuery struct { + Query + Options ListCodeScanningOptions `json:"options"` +} diff --git a/src/types.ts b/src/types.ts index 30bb2db6..195adcab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,7 @@ export enum GitHubLicenseType { } export enum QueryType { + Code_Scanning = 'Code_Scanning', Commits = 'Commits', Issues = 'Issues', Contributors = 'Contributors', diff --git a/src/validation.ts b/src/validation.ts index 6642f73c..8ede7178 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -15,7 +15,8 @@ export const isValid = (query: GitHubQuery): boolean => { query.queryType === QueryType.Labels || query.queryType === QueryType.Milestones || query.queryType === QueryType.Vulnerabilities || - query.queryType === QueryType.Stargazers + query.queryType === QueryType.Stargazers || + query.queryType === QueryType.Code_Scanning ) { if (isEmpty(query.owner) || isEmpty(query.repository)) { return false; diff --git a/src/views/QueryEditor.tsx b/src/views/QueryEditor.tsx index 1708858b..6ef3c6b4 100644 --- a/src/views/QueryEditor.tsx +++ b/src/views/QueryEditor.tsx @@ -14,6 +14,7 @@ import QueryEditorIssues from './QueryEditorIssues'; import QueryEditorMilestones from './QueryEditorMilestones'; import QueryEditorPullRequests from './QueryEditorPullRequests'; import QueryEditorTags from './QueryEditorTags'; +import QueryEditorCodeScanning from './QueryEditorCodeScanning'; import QueryEditorContributors from './QueryEditorContributors'; import QueryEditorLabels from './QueryEditorLabels'; import QueryEditorPackages from './QueryEditorPackages'; @@ -48,6 +49,9 @@ const queryEditors: { [QueryType.Tags]: { component: (props: Props, _: (val: any) => void) => , }, + [QueryType.Code_Scanning]: { + component: (props: Props, _: (val: any) => void) => , + }, [QueryType.Releases]: { component: (props: Props, _: (val: any) => void) => , }, diff --git a/src/views/QueryEditorCodeScanning.tsx b/src/views/QueryEditorCodeScanning.tsx new file mode 100644 index 00000000..9e73db3e --- /dev/null +++ b/src/views/QueryEditorCodeScanning.tsx @@ -0,0 +1,4 @@ +import React from 'react'; + +const QueryEditorCodeScanning = () => <>; +export default QueryEditorCodeScanning;