From d029ce40fefe953c0d0b52d7845bf06674b25cf6 Mon Sep 17 00:00:00 2001 From: grconm <167924715+grconm@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:04:48 +0000 Subject: [PATCH] Add tool to report security vulnerability --- README.md | 13 + .../report_security_vulnerability.snap | 105 ++++++++ pkg/github/security_advisories.go | 252 ++++++++++++++++++ pkg/github/security_advisories_test.go | 249 +++++++++++++++++ pkg/github/tools.go | 1 + 5 files changed, 620 insertions(+) create mode 100644 pkg/github/__toolsnaps__/report_security_vulnerability.snap diff --git a/README.md b/README.md index 1a0f6b1c4a..7cd58c3338 100644 --- a/README.md +++ b/README.md @@ -1322,6 +1322,19 @@ The following sets of tools are available: - `sort`: Sort field. (string, optional) - `state`: Filter by advisory state. (string, optional) +- **report_security_vulnerability** - Report security vulnerability + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` + - `cvss_vector_string`: The CVSS vector that calculates the severity of the advisory. You must choose between setting this field or severity. (string, optional) + - `cwe_ids`: A list of Common Weakness Enumeration (CWE) IDs (e.g. ["CWE-79", "CWE-89"]). (string[], optional) + - `description`: A detailed description of what the vulnerability entails. (string, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `severity`: The severity of the advisory. You must choose between setting this field or cvss_vector_string. (string, optional) + - `start_private_fork`: Whether to create a temporary private fork of the repository to collaborate on a fix. Default: false (boolean, optional) + - `summary`: A short summary of the security vulnerability. (string, required) + - `vulnerabilities`: An array of products affected by the vulnerability. (object[], optional) +
diff --git a/pkg/github/__toolsnaps__/report_security_vulnerability.snap b/pkg/github/__toolsnaps__/report_security_vulnerability.snap new file mode 100644 index 0000000000..03029afe70 --- /dev/null +++ b/pkg/github/__toolsnaps__/report_security_vulnerability.snap @@ -0,0 +1,105 @@ +{ + "annotations": { + "title": "Report security vulnerability" + }, + "description": "Report a security vulnerability to the maintainers of a repository. Creates a private security advisory in 'triage' state.", + "inputSchema": { + "type": "object", + "properties": { + "cvss_vector_string": { + "type": "string", + "description": "The CVSS vector that calculates the severity of the advisory. You must choose between setting this field or severity." + }, + "cwe_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of Common Weakness Enumeration (CWE) IDs (e.g. [\"CWE-79\", \"CWE-89\"])." + }, + "description": { + "type": "string", + "description": "A detailed description of what the vulnerability entails." + }, + "owner": { + "type": "string", + "description": "The owner of the repository." + }, + "repo": { + "type": "string", + "description": "The name of the repository." + }, + "severity": { + "type": "string", + "description": "The severity of the advisory. You must choose between setting this field or cvss_vector_string.", + "enum": [ + "critical", + "high", + "medium", + "low" + ] + }, + "start_private_fork": { + "type": "boolean", + "description": "Whether to create a temporary private fork of the repository to collaborate on a fix. Default: false", + "default": false + }, + "summary": { + "type": "string", + "description": "A short summary of the security vulnerability." + }, + "vulnerabilities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "object", + "properties": { + "ecosystem": { + "type": "string", + "description": "The package ecosystem (e.g., npm, pip, maven, rubygems)." + }, + "name": { + "type": "string", + "description": "The package name." + } + }, + "description": "The package affected by the vulnerability.", + "required": [ + "ecosystem", + "name" + ] + }, + "patched_versions": { + "type": "string", + "description": "The versions that patch the vulnerability (e.g., '1.0.1')." + }, + "vulnerable_functions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The names of vulnerable functions in the package." + }, + "vulnerable_version_range": { + "type": "string", + "description": "The range of versions that are vulnerable (e.g., '\u003e= 1.0.0, \u003c 1.0.1')." + } + }, + "required": [ + "package" + ] + }, + "description": "An array of products affected by the vulnerability." + } + }, + "required": [ + "owner", + "repo", + "summary", + "description" + ] + }, + "name": "report_security_vulnerability" +} \ No newline at end of file diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index 7bdb978cdb..914d72e1f2 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -463,3 +463,255 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i }, ) } + +func ReportSecurityVulnerability(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataSecurityAdvisories, + mcp.Tool{ + Name: "report_security_vulnerability", + Description: t("TOOL_REPORT_SECURITY_VULNERABILITY_DESCRIPTION", "Report a security vulnerability to the maintainers of a repository. Creates a private security advisory in 'triage' state."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REPORT_SECURITY_VULNERABILITY_USER_TITLE", "Report security vulnerability"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "summary": { + Type: "string", + Description: "A short summary of the security vulnerability.", + }, + "description": { + Type: "string", + Description: "A detailed description of what the vulnerability entails.", + }, + "severity": { + Type: "string", + Description: "The severity of the advisory. You must choose between setting this field or cvss_vector_string.", + Enum: []any{"critical", "high", "medium", "low"}, + }, + "cvss_vector_string": { + Type: "string", + Description: "The CVSS vector that calculates the severity of the advisory. You must choose between setting this field or severity.", + }, + "cwe_ids": { + Type: "array", + Description: "A list of Common Weakness Enumeration (CWE) IDs (e.g. [\"CWE-79\", \"CWE-89\"]).", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "vulnerabilities": { + Type: "array", + Description: "An array of products affected by the vulnerability.", + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "package": { + Type: "object", + Description: "The package affected by the vulnerability.", + Properties: map[string]*jsonschema.Schema{ + "ecosystem": { + Type: "string", + Description: "The package ecosystem (e.g., npm, pip, maven, rubygems).", + }, + "name": { + Type: "string", + Description: "The package name.", + }, + }, + Required: []string{"ecosystem", "name"}, + }, + "vulnerable_version_range": { + Type: "string", + Description: "The range of versions that are vulnerable (e.g., '>= 1.0.0, < 1.0.1').", + }, + "patched_versions": { + Type: "string", + Description: "The versions that patch the vulnerability (e.g., '1.0.1').", + }, + "vulnerable_functions": { + Type: "array", + Description: "The names of vulnerable functions in the package.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"package"}, + }, + }, + "start_private_fork": { + Type: "boolean", + Description: "Whether to create a temporary private fork of the repository to collaborate on a fix. Default: false", + Default: json.RawMessage(`false`), + }, + }, + Required: []string{"owner", "repo", "summary", "description"}, + }, + }, + []scopes.Scope{scopes.SecurityEvents}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + summary, err := RequiredParam[string](args, "summary") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + description, err := RequiredParam[string](args, "description") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + severity, err := OptionalParam[string](args, "severity") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cvssVectorString, err := OptionalParam[string](args, "cvss_vector_string") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Validate that only one of severity or cvss_vector_string is set + if severity != "" && cvssVectorString != "" { + return utils.NewToolResultError("cannot specify both severity and cvss_vector_string"), nil, nil + } + + cweIDs, err := OptionalStringArrayParam(args, "cwe_ids") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + startPrivateFork, err := OptionalParam[bool](args, "start_private_fork") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Build the request body + type vulnerabilityReport struct { + Summary string `json:"summary"` + Description string `json:"description"` + Severity *string `json:"severity,omitempty"` + CVSSVectorString *string `json:"cvss_vector_string,omitempty"` + CWEIDs *[]string `json:"cwe_ids,omitempty"` + Vulnerabilities *[]*github.AdvisoryVulnerability `json:"vulnerabilities,omitempty"` + StartPrivateFork *bool `json:"start_private_fork,omitempty"` + } + + report := &vulnerabilityReport{ + Summary: summary, + Description: description, + } + + if severity != "" { + report.Severity = &severity + } + if cvssVectorString != "" { + report.CVSSVectorString = &cvssVectorString + } + + if len(cweIDs) > 0 { + report.CWEIDs = &cweIDs + } + + // Handle vulnerabilities array + if vulnsData, ok := args["vulnerabilities"]; ok { + if vulnsArray, ok := vulnsData.([]any); ok { + var vulnerabilities []*github.AdvisoryVulnerability + for _, v := range vulnsArray { + if vulnMap, ok := v.(map[string]any); ok { + vuln := &github.AdvisoryVulnerability{} + + // Parse package + if pkgData, ok := vulnMap["package"].(map[string]any); ok { + pkg := &github.VulnerabilityPackage{} + if ecosystem, ok := pkgData["ecosystem"].(string); ok { + pkg.Ecosystem = &ecosystem + } + if name, ok := pkgData["name"].(string); ok { + pkg.Name = &name + } + vuln.Package = pkg + } + + // Parse other fields + if versionRange, ok := vulnMap["vulnerable_version_range"].(string); ok { + vuln.VulnerableVersionRange = &versionRange + } + if patchedVersions, ok := vulnMap["patched_versions"].(string); ok { + vuln.PatchedVersions = &patchedVersions + } + if vulnFuncs, ok := vulnMap["vulnerable_functions"].([]any); ok { + var functions []string + for _, f := range vulnFuncs { + if funcStr, ok := f.(string); ok { + functions = append(functions, funcStr) + } + } + if len(functions) > 0 { + vuln.VulnerableFunctions = functions + } + } + + vulnerabilities = append(vulnerabilities, vuln) + } + } + report.Vulnerabilities = &vulnerabilities + } + } + + if startPrivateFork { + report.StartPrivateFork = &startPrivateFork + } + + // Make HTTP POST request to the security-advisories/reports endpoint + // The go-github library doesn't have this method yet, so we use NewRequest directly + url := fmt.Sprintf("repos/%s/%s/security-advisories/reports", owner, repo) + req, err := client.NewRequest("POST", url, report) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + + var advisory github.SecurityAdvisory + resp, err := client.Do(ctx, req, &advisory) + if err != nil { + return nil, nil, fmt.Errorf("failed to report security vulnerability: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to report security vulnerability", resp, body), nil, nil + } + + r, err := json.Marshal(advisory) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal advisory response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index bfc4c6985e..ce2007b36d 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -499,3 +499,252 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { }) } } + +func Test_ReportSecurityVulnerability(t *testing.T) { + toolDef := ReportSecurityVulnerability(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "report_security_vulnerability", tool.Name) + assert.NotEmpty(t, tool.Description) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be of type *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "summary") + assert.Contains(t, schema.Properties, "description") + assert.Contains(t, schema.Properties, "severity") + assert.Contains(t, schema.Properties, "cvss_vector_string") + assert.Contains(t, schema.Properties, "cwe_ids") + assert.Contains(t, schema.Properties, "vulnerabilities") + assert.Contains(t, schema.Properties, "start_private_fork") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "summary", "description"}) + + // Setup mock advisory for success case + mockAdvisory := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-xxxx-yyyy-zzzz"), + Summary: github.Ptr("Newly reported vulnerability"), + Description: github.Ptr("A detailed description of the vulnerability."), + Severity: github.Ptr("high"), + State: github.Ptr("triage"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAdvisory *github.SecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful vulnerability report", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/owner/repo/security-advisories/reports": mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "summary": "Newly reported vulnerability", + "description": "A detailed description of the vulnerability.", + "severity": "high", + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "successful report with CWE IDs", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/owner/repo/security-advisories/reports": mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "summary": "XSS vulnerability", + "description": "Cross-site scripting issue in form validation.", + "severity": "medium", + "cwe_ids": []interface{}{"CWE-79", "CWE-20"}, + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "successful report with vulnerabilities", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/owner/repo/security-advisories/reports": mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "summary": "Package vulnerability", + "description": "Security issue in npm package.", + "severity": "critical", + "vulnerabilities": []interface{}{ + map[string]interface{}{ + "package": map[string]interface{}{ + "ecosystem": "npm", + "name": "vulnerable-package", + }, + "vulnerable_version_range": "< 1.0.0", + "patched_versions": "1.0.0", + "vulnerable_functions": []interface{}{"validateInput", "processData"}, + }, + }, + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "successful report with CVSS vector string", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/owner/repo/security-advisories/reports": mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "summary": "Custom CVSS severity", + "description": "Vulnerability with custom CVSS scoring.", + "cvss_vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "successful report with private fork", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/owner/repo/security-advisories/reports": mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "summary": "Vulnerability requiring patch", + "description": "Issue that needs immediate fix.", + "severity": "high", + "start_private_fork": true, + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "error when both severity and cvss_vector_string provided", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "summary": "Test", + "description": "Test description", + "severity": "high", + "cvss_vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + }, + expectError: true, + expectedErrMsg: "cannot specify both severity and cvss_vector_string", + }, + { + name: "missing required owner parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "repo": "repo", + "summary": "Test", + "description": "Test description", + }, + expectError: true, + expectedErrMsg: "owner", + }, + { + name: "API error - forbidden", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/owner/repo/security-advisories/reports": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "summary": "Test", + "description": "Test description", + "severity": "high", + }, + expectError: true, + expectedErrMsg: "failed to report security vulnerability", + }, + { + name: "API error - not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/owner/repo/security-advisories/reports": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent", + "summary": "Test", + "description": "Test description", + "severity": "medium", + }, + expectError: true, + expectedErrMsg: "failed to report security vulnerability", + }, + { + name: "API error - validation failed", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/owner/repo/security-advisories/reports": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "summary": "Test", + "description": "Test description", + "severity": "invalid", + }, + expectError: true, + expectedErrMsg: "failed to report security vulnerability", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + // For validation errors, err is nil but result.IsError is true + // For API errors, err is not nil + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + assert.Contains(t, text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + var returnedAdvisory github.SecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisory) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedAdvisory.GHSAID, *returnedAdvisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisory.Summary, *returnedAdvisory.Summary) + assert.Equal(t, *tc.expectedAdvisory.Description, *returnedAdvisory.Description) + assert.Equal(t, *tc.expectedAdvisory.Severity, *returnedAdvisory.Severity) + if tc.expectedAdvisory.State != nil { + assert.Equal(t, *tc.expectedAdvisory.State, *returnedAdvisory.State) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b15c4fc9a8..342f23da65 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -261,6 +261,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetGlobalSecurityAdvisory(t), ListRepositorySecurityAdvisories(t), ListOrgRepositorySecurityAdvisories(t), + ReportSecurityVulnerability(t), // Gist tools ListGists(t),