diff --git a/README.md b/README.md index 432541f434..22e3554901 100644 --- a/README.md +++ b/README.md @@ -1569,7 +1569,8 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat - **get_global_security_advisory** - Get a global security advisory - **OAuth Challenge Scopes**: `security_events` - - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) + - `cveId`: CVE ID of the advisory (format: CVE-YYYY-NNNNN). Provide this or ghsaId. (string, optional) + - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). Provide this or cveId. (string, optional) - **list_global_security_advisories** - List global security advisories - **OAuth Challenge Scopes**: `security_events` diff --git a/pkg/github/__toolsnaps__/get_global_security_advisory.snap b/pkg/github/__toolsnaps__/get_global_security_advisory.snap index 647701421b..5c3c645fb4 100644 --- a/pkg/github/__toolsnaps__/get_global_security_advisory.snap +++ b/pkg/github/__toolsnaps__/get_global_security_advisory.snap @@ -4,17 +4,18 @@ "readOnlyHint": true, "title": "Get a global security advisory" }, - "description": "Get a global security advisory", + "description": "Get a global security advisory by GHSA ID or CVE ID", "inputSchema": { "properties": { + "cveId": { + "description": "CVE ID of the advisory (format: CVE-YYYY-NNNNN). Provide this or ghsaId.", + "type": "string" + }, "ghsaId": { - "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). Provide this or cveId.", "type": "string" } }, - "required": [ - "ghsaId" - ], "type": "object" }, "name": "get_global_security_advisory", diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index 42eb505f77..62f50f76df 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" "github.com/github/github-mcp-server/v2/pkg/ifc" @@ -268,7 +269,7 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "get_global_security_advisory", - Description: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory"), + Description: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory by GHSA ID or CVE ID"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_USER_TITLE", "Get a global security advisory"), ReadOnlyHint: true, @@ -278,10 +279,13 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S Properties: map[string]*jsonschema.Schema{ "ghsaId": { Type: "string", - Description: "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + Description: "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). Provide this or cveId.", + }, + "cveId": { + Type: "string", + Description: "CVE ID of the advisory (format: CVE-YYYY-NNNNN). Provide this or ghsaId.", }, }, - Required: []string{"ghsaId"}, }, }, scopes.RequireAll(scopes.SecurityEvents), @@ -291,22 +295,19 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - if args.GHSAID == "" { - return utils.NewToolResultError("invalid ghsaId: missing required parameter: ghsaId"), nil, nil + switch { + case args.GHSAID == "" && args.CVEID == "": + return utils.NewToolResultError("missing required parameter: provide either ghsaId or cveId"), nil, nil + case args.GHSAID != "" && args.CVEID != "": + return utils.NewToolResultError("provide only one of ghsaId or cveId, not both"), nil, nil } - advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, args.GHSAID) + advisory, errResult, err := findGlobalSecurityAdvisory(ctx, client, args) if err != nil { - return nil, nil, fmt.Errorf("failed to get advisory: %w", err) + return nil, nil, err } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - 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 get advisory", resp, body), nil, nil + if errResult != nil { + return errResult, nil, nil } r, err := json.Marshal(advisory) @@ -323,6 +324,67 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S ) } +// findGlobalSecurityAdvisory resolves a GHSA ID directly and a CVE ID through the +// advisory list filter GitHub exposes for it. A CVE that maps to several advisories +// is reported instead of returning an arbitrary one, since the tool returns a single +// advisory and the caller needs the GHSA ID to pick. +func findGlobalSecurityAdvisory( + ctx context.Context, + client *github.Client, + args GetGlobalSecurityAdvisoryInput, +) (*github.GlobalSecurityAdvisory, *mcp.CallToolResult, error) { + if args.GHSAID != "" { + advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, args.GHSAID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get advisory: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return nil, ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get advisory", resp, body), nil + } + return advisory, nil, nil + } + + advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, &github.ListGlobalSecurityAdvisoriesOptions{ + CVEID: &args.CVEID, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to list global security advisories: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return nil, ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get advisory", resp, body), nil + } + + switch len(advisories) { + case 0: + return nil, utils.NewToolResultError(fmt.Sprintf("no global security advisory found for %s", args.CVEID)), nil + case 1: + return advisories[0], nil, nil + default: + ghsaIDs := make([]string, 0, len(advisories)) + for _, advisory := range advisories { + if advisory != nil && advisory.GHSAID != nil { + ghsaIDs = append(ghsaIDs, *advisory.GHSAID) + } + } + return nil, utils.NewToolResultError(fmt.Sprintf( + "%s matches %d global security advisories (%s); call get_global_security_advisory with the ghsaId you need", + args.CVEID, len(advisories), strings.Join(ghsaIDs, ", "), + )), nil + } +} + func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool[ListOrgRepositorySecurityAdvisoriesInput, []*SecurityAdvisoryOutput]( ToolsetMetadataSecurityAdvisories, diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index 70186e0ccf..f851195a59 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -138,11 +138,13 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be of type *jsonschema.Schema") assert.Contains(t, schema.Properties, "ghsaId") - assert.ElementsMatch(t, schema.Required, []string{"ghsaId"}) + assert.Contains(t, schema.Properties, "cveId") + assert.Empty(t, schema.Required, "ghsaId and cveId are alternatives, so neither is required on its own") // Setup mock advisory for success case mockAdvisory := &github.GlobalSecurityAdvisory{ GHSAID: new("GHSA-xxxx-xxxx-xxxx"), + CVEID: new("CVE-2026-0001"), Summary: new("Test advisory"), Description: new("This is a test advisory."), Severity: new("high"), @@ -153,6 +155,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { mockedClient *http.Client requestArgs map[string]any expectError bool + expectToolError bool expectedAdvisory *github.GlobalSecurityAdvisory expectedErrMsg string }{ @@ -195,6 +198,62 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { expectError: true, expectedErrMsg: "failed to get advisory", }, + { + name: "successful advisory fetch by cveId", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetAdvisories: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "CVE-2026-0001", r.URL.Query().Get("cve_id"), "the CVE ID must reach the API as a filter") + mockResponse(t, http.StatusOK, []*github.GlobalSecurityAdvisory{mockAdvisory})(w, r) + }, + }), + requestArgs: map[string]any{ + "cveId": "CVE-2026-0001", + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "cveId matching no advisory", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetAdvisories: mockResponse(t, http.StatusOK, []*github.GlobalSecurityAdvisory{}), + }), + requestArgs: map[string]any{ + "cveId": "CVE-2026-9999", + }, + expectToolError: true, + expectedErrMsg: "no global security advisory found for CVE-2026-9999", + }, + { + name: "cveId matching several advisories", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetAdvisories: mockResponse(t, http.StatusOK, []*github.GlobalSecurityAdvisory{ + mockAdvisory, + {GHSAID: new("GHSA-yyyy-yyyy-yyyy"), CVEID: new("CVE-2026-0001")}, + }), + }), + requestArgs: map[string]any{ + "cveId": "CVE-2026-0001", + }, + expectToolError: true, + expectedErrMsg: "matches 2 global security advisories (GHSA-xxxx-xxxx-xxxx, GHSA-yyyy-yyyy-yyyy)", + }, + { + name: "neither identifier provided", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{}, + expectToolError: true, + expectedErrMsg: "provide either ghsaId or cveId", + }, + { + name: "both identifiers provided", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "cveId": "CVE-2026-0001", + }, + expectToolError: true, + expectedErrMsg: "provide only one of ghsaId or cveId", + }, } for _, tc := range tests { @@ -219,6 +278,12 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { require.NoError(t, err) + if tc.expectToolError { + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + // Parse the result and get the text content if no error textContent := getTextResult(t, result) diff --git a/pkg/github/security_outputs.go b/pkg/github/security_outputs.go index 88fb34f12b..7ce9a3fbe6 100644 --- a/pkg/github/security_outputs.go +++ b/pkg/github/security_outputs.go @@ -77,7 +77,8 @@ type ListRepositorySecurityAdvisoriesInput struct { } type GetGlobalSecurityAdvisoryInput struct { - GHSAID string `json:"ghsaId"` + GHSAID string `json:"ghsaId,omitempty"` + CVEID string `json:"cveId,omitempty"` } type ListOrgRepositorySecurityAdvisoriesInput struct {