diff --git a/components/producers/github-dependabot/README.md b/components/producers/github-dependabot/README.md new file mode 100644 index 000000000..fa5c8d869 --- /dev/null +++ b/components/producers/github-dependabot/README.md @@ -0,0 +1,17 @@ +# Producer: GitHub Code Scanning + + + +This producer [queries the GitHub Code Scanning API](https://docs.github.com/en/rest/code-scanning/code-scanning?apiVersion=2022-11-28#list-code-scanning-alerts-for-a-repository) to produce SAST findings. + +## Parameters + +All parameters are **required**. + +| Name | Type | Default | Description | +| ------------------------------------------------ | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `producer-github-code-scanning-repository-owner` | `string` | N/A | The owner of the repository to scan. | +| `producer-github-code-scanning-repository-name` | `string` | N/A | The name of the repository to scan. | +| `producer-github-code-scanning-github-token` | `string` | N/A | The GitHub token to use for scanning. Must have "Code scanning alerts" repository permissions (read) ([More Information](https://docs.github.com/en/rest/code-scanning/code-scanning?apiVersion=2022-11-28#list-code-scanning-alerts-for-a-repository)). | + + diff --git a/components/producers/github-dependabot/main.go b/components/producers/github-dependabot/main.go new file mode 100644 index 000000000..8bef5df56 --- /dev/null +++ b/components/producers/github-dependabot/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "flag" + "log" + "log/slog" + "strconv" + "strings" + + "github.com/google/go-github/v65/github" + "github.com/package-url/packageurl-go" + + v1 "github.com/ocurity/dracon/api/proto/v1" + "github.com/ocurity/dracon/components/producers" + wrapper "github.com/ocurity/dracon/pkg/github" +) + +var ( + // RepositoryOwner is the owner of the GitHub repository + RepositoryOwner string + + // RepositoryName is the name of the GitHub repository + RepositoryName string + + // GitHubToken is the GitHub token used to authenticate + GitHubToken string + + // Ref is the Ref/branch to get alerts for + Ref string + + // Severity, if specified, only code scanning alerts with this severity will be returned. Possible values are: critical, high, medium, low, warning, note, error + Severity string + + // Ecosystem is a comma separated list of at least one of composer, go, maven, npm, nuget, pip, pub, rubygems, rust + Ecosystem string +) + +func main() { + flag.StringVar(&RepositoryOwner, "repository-owner", "", "The owner of the GitHub repository") + flag.StringVar(&RepositoryName, "repository-name", "", "The name of the GitHub repository") + flag.StringVar(&GitHubToken, "github-token", "", "The GitHub token used to authenticate with the API") + flag.StringVar(&Ref, "reference", "", "The Ref/branch to get alerts for") + flag.StringVar(&Severity, "severity", "", "If specified, only code scanning alerts with this severity will be returned. Possible values are: critical, high, medium, low, warning, note, error") + flag.StringVar(&Ecosystem, "ecosystem", "", "If specified, a comma separated list of at least one of composer, go, maven, npm, nuget, pip, pub, rubygems, rust") + if err := producers.ParseFlags(); err != nil { + log.Fatal(err) + } + + alerts, err := listAlertsForRepo(RepositoryOwner, RepositoryName, GitHubToken) + if err != nil { + log.Fatal(err) + } + + issues := parseIssues(alerts) + + if err := producers.WriteDraconOut( + "github-code-scanning", + issues, + ); err != nil { + log.Fatal(err) + } +} + +func listAlertsForRepo(owner, repo, token string) ([]*github.DependabotAlert, error) { + apiClient := wrapper.NewClient(token) + open := "open" + opt := &github.ListAlertsOptions{ + State: &open, + Severity: &Severity, + Ecosystem: &Ecosystem, + + ListOptions: github.ListOptions{ + PerPage: 30, + }, + } + + var allAlerts []*github.DependabotAlert + for { + alerts, resp, err := apiClient.ListRepoDependabotAlerts(context.Background(), owner, repo, opt) + if err != nil { + return nil, err + } + + allAlerts = append(allAlerts, alerts...) + + if resp.NextPage == 0 { + break + } + opt.ListOptions.Page = resp.NextPage + } + + slog.Info("Successfully fetched alerts", "count", len(allAlerts), "repository", owner+"/"+repo) + + return allAlerts, nil +} + +func parseIssues(alerts []*github.DependabotAlert) []*v1.Issue { + issues := []*v1.Issue{} + for _, alert := range alerts { + ecosystem := *(alert.GetSecurityVulnerability().Package.Ecosystem) + if ecosystem == "pip" { + ecosystem = "pypi" + } + cwe := []int32{} + for _, c := range alert.SecurityAdvisory.CWEs { + numberOnly := strings.ReplaceAll(*c.CWEID, "CWE-", "") + cweNum, err := strconv.Atoi(numberOnly) + + if err != nil { + slog.Error("could not extract cwe number from ", slog.String("cweID", *c.CWEID)) + continue + } + cwe = append(cwe, int32(cweNum)) + } + issue := &v1.Issue{ + Target: producers.GetPURLTarget(ecosystem, "", *alert.GetSecurityVulnerability().Package.Name, "", packageurl.Qualifiers{}, ""), + Cve: *alert.GetSecurityAdvisory().CVEID, + Title: *alert.GetSecurityAdvisory().Summary, + Description: *alert.GetSecurityAdvisory().Description, + Severity: parseGitHubSeverity(*alert.GetSecurityAdvisory().Severity), + Cvss: *alert.SecurityAdvisory.GetCVSS().Score, + Cwe: cwe, + } + issues = append(issues, issue) + } + + return issues +} + +func parseGitHubSeverity(severity string) v1.Severity { + switch severity { + case "low": + return v1.Severity_SEVERITY_LOW + case "medium": + return v1.Severity_SEVERITY_MEDIUM + case "high": + return v1.Severity_SEVERITY_HIGH + case "critical": + return v1.Severity_SEVERITY_CRITICAL + default: + return v1.Severity_SEVERITY_UNSPECIFIED + } +} diff --git a/components/producers/github-dependabot/main_test.go b/components/producers/github-dependabot/main_test.go new file mode 100644 index 000000000..968719db9 --- /dev/null +++ b/components/producers/github-dependabot/main_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "testing" + + "github.com/google/go-github/v65/github" + "github.com/stretchr/testify/require" + + v1 "github.com/ocurity/dracon/api/proto/v1" +) + +func TestParseIssues(t *testing.T) { + require.Fail(t, "unimplemented") + + alerts := []*github.DependabotAlert{} + + issues := parseIssues(alerts) + + expected := []*v1.Issue{ + { + Target: "file://spec-main/api-session-spec.ts:917-918", + Type: "1", + Title: "Test description", + Severity: v1.Severity_SEVERITY_LOW, + Cvss: 0, + Confidence: v1.Confidence_CONFIDENCE_UNSPECIFIED, + Description: "Test message", + Source: "https://example.com", + Cwe: []int32{22}, + }, + } + + require.Equal(t, expected, issues) +} + +func TestParseGitHubSeverity(t *testing.T) { + testCases := []struct { + name string + severity string + expected v1.Severity + }{ + { + name: "low severity", + severity: "low", + expected: v1.Severity_SEVERITY_LOW, + }, + { + name: "medium severity", + severity: "medium", + expected: v1.Severity_SEVERITY_MEDIUM, + }, + { + name: "high severity", + severity: "high", + expected: v1.Severity_SEVERITY_HIGH, + }, + { + name: "critical severity", + severity: "critical", + expected: v1.Severity_SEVERITY_CRITICAL, + }, + { + name: "unspecified severity", + severity: "unknown", + expected: v1.Severity_SEVERITY_UNSPECIFIED, + }, + { + name: "empty severity", + severity: "", + expected: v1.Severity_SEVERITY_UNSPECIFIED, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + severity := parseGitHubSeverity(tc.severity) + require.Equal(t, tc.expected, severity) + }) + } + +} + +func TestListAlertsForRepo(t *testing.T) { + require.Fail(t, "unimplemented") +} diff --git a/components/producers/github-dependabot/task.yaml b/components/producers/github-dependabot/task.yaml new file mode 100644 index 000000000..6cd0cb74e --- /dev/null +++ b/components/producers/github-dependabot/task.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: producer-github-code-scanning + labels: + v1.dracon.ocurity.com/component: producer + v1.dracon.ocurity.com/test-type: sast +spec: + description: Retrieve a GitHub Code Scanning report from a GitHub repository. + params: + - name: producer-github-code-scanning-repository-owner + description: The owner of the repository to scan. + type: string + - name: producer-github-code-scanning-repository-name + description: The name of the repository to scan. + type: string + - name: producer-github-code-scanning-github-token + description: The GitHub token to use for scanning. Must have "Code scanning alerts" repository permissions (read). + type: string + volumes: + - name: scratch + emptyDir: {} + workspaces: + - name: output + description: The workspace containing the source-code to scan. + steps: + - name: produce-issues + imagePullPolicy: IfNotPresent + image: '{{ default "ghcr.io/ocurity/dracon" .Values.image.registry }}/components/producers/github-code-scanning:{{ .Chart.AppVersion }}' + command: ["/app/components/producers/github-code-scanning/github-code-scanning-parser"] + args: + - "-in=/scratch/out.json" + - "-out=$(workspaces.output.path)/.dracon/producers/github-code-scanning.pb" + - "-github-token=$(params.producer-github-code-scanning-github-token)" + - "-repository-owner=$(params.producer-github-code-scanning-repository-owner)" + - "-repository-name=$(params.producer-github-code-scanning-repository-name)" + volumeMounts: + - mountPath: /scratch + name: scratch