diff --git a/README.md b/README.md index dff62321b..b57d98404 100644 --- a/README.md +++ b/README.md @@ -1357,6 +1357,21 @@ The following sets of tools are available: shield Security Advisories +- **create_repository_security_advisory** - Create repository security advisory + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` + - `credits`: Users credited for the advisory. (object[], optional) + - `cveId`: The CVE ID to assign to the advisory. (string, optional) + - `cvssVectorString`: The CVSS vector string for the advisory. Exactly one of severity or cvssVectorString is required. (string, optional) + - `cweIds`: Common Weakness Enumeration IDs (for example, ["CWE-79"]). (string[], optional) + - `description`: A detailed description of the security advisory. (string, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `severity`: The severity of the advisory. Exactly one of severity or cvssVectorString is required. (string, optional) + - `startPrivateFork`: Whether to create a temporary private fork for collaborating on a fix. (boolean, optional) + - `summary`: A short summary of the security advisory. (string, required) + - `vulnerabilities`: Affected products and version ranges. (object[], required) + - **get_global_security_advisory** - Get a global security advisory - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` @@ -1394,6 +1409,29 @@ The following sets of tools are available: - `sort`: Sort field. (string, optional) - `state`: Filter by advisory state. (string, optional) +- **request_cve_for_repository_security_advisory** - Request CVE for repository security advisory + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` + - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + +- **update_repository_security_advisory** - Update repository security advisory + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` + - `credits`: Users credited for the advisory. (object[], optional) + - `cveId`: The CVE ID to assign to the advisory. (string, optional) + - `cvssVectorString`: The CVSS vector string for the advisory. Cannot be set together with severity. (string, optional) + - `cweIds`: Common Weakness Enumeration IDs (for example, ["CWE-79"]). (string[], optional) + - `description`: A detailed description of the security advisory. (string, optional) + - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `severity`: The severity of the advisory. Cannot be set together with cvssVectorString. (string, optional) + - `state`: The advisory state. Set to "published" to publish the advisory. (string, optional) + - `summary`: A short summary of the security advisory. (string, optional) + - `vulnerabilities`: Affected products and version ranges. (object[], optional) +
diff --git a/pkg/github/__toolsnaps__/create_repository_security_advisory.snap b/pkg/github/__toolsnaps__/create_repository_security_advisory.snap new file mode 100644 index 000000000..421612191 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_repository_security_advisory.snap @@ -0,0 +1,158 @@ +{ + "annotations": { + "destructiveHint": true, + "openWorldHint": true, + "title": "Create repository security advisory" + }, + "description": "Create a draft repository security advisory. Exactly one of severity or cvssVectorString must be provided. When startPrivateFork is true, a temporary private fork is created for collaborating on a fix.", + "inputSchema": { + "properties": { + "credits": { + "description": "Users credited for the advisory.", + "items": { + "properties": { + "login": { + "description": "The GitHub username of the credited user.", + "type": "string" + }, + "type": { + "description": "The credit type.", + "enum": [ + "analyst", + "finder", + "reporter", + "coordinator", + "remediation_developer", + "remediation_reviewer", + "remediation_verifier", + "tool", + "sponsor", + "other" + ], + "type": "string" + } + }, + "required": [ + "login", + "type" + ], + "type": "object" + }, + "type": "array" + }, + "cveId": { + "description": "The CVE ID to assign to the advisory.", + "type": "string" + }, + "cvssVectorString": { + "description": "The CVSS vector string for the advisory. Exactly one of severity or cvssVectorString is required.", + "type": "string" + }, + "cweIds": { + "description": "Common Weakness Enumeration IDs (for example, [\"CWE-79\"]).", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "A detailed description of the security advisory.", + "type": "string" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + }, + "severity": { + "description": "The severity of the advisory. Exactly one of severity or cvssVectorString is required.", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "startPrivateFork": { + "description": "Whether to create a temporary private fork for collaborating on a fix.", + "type": "boolean" + }, + "summary": { + "description": "A short summary of the security advisory.", + "type": "string" + }, + "vulnerabilities": { + "description": "Affected products and version ranges.", + "items": { + "properties": { + "package": { + "properties": { + "ecosystem": { + "description": "The package ecosystem.", + "enum": [ + "actions", + "composer", + "erlang", + "go", + "maven", + "npm", + "nuget", + "other", + "pip", + "pub", + "rubygems", + "rust", + "swift" + ], + "type": "string" + }, + "name": { + "description": "The package name.", + "type": "string" + } + }, + "required": [ + "ecosystem", + "name" + ], + "type": "object" + }, + "patched_versions": { + "description": "The version that patches the vulnerability.", + "type": "string" + }, + "vulnerable_functions": { + "description": "Functions in the package that are affected.", + "items": { + "type": "string" + }, + "type": "array" + }, + "vulnerable_version_range": { + "description": "The range of affected versions (for example, \"\u003c 2.0.0\").", + "type": "string" + } + }, + "required": [ + "package" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "owner", + "repo", + "summary", + "description", + "vulnerabilities" + ], + "type": "object" + }, + "name": "create_repository_security_advisory" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/request_cve_for_repository_security_advisory.snap b/pkg/github/__toolsnaps__/request_cve_for_repository_security_advisory.snap new file mode 100644 index 000000000..b5d3104ee --- /dev/null +++ b/pkg/github/__toolsnaps__/request_cve_for_repository_security_advisory.snap @@ -0,0 +1,31 @@ +{ + "annotations": { + "destructiveHint": true, + "openWorldHint": true, + "title": "Request CVE for repository security advisory" + }, + "description": "Request a CVE ID from GitHub for a draft repository security advisory.", + "inputSchema": { + "properties": { + "ghsaId": { + "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + "type": "string" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "ghsaId" + ], + "type": "object" + }, + "name": "request_cve_for_repository_security_advisory" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_repository_security_advisory.snap b/pkg/github/__toolsnaps__/update_repository_security_advisory.snap new file mode 100644 index 000000000..4306ac051 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_repository_security_advisory.snap @@ -0,0 +1,166 @@ +{ + "annotations": { + "destructiveHint": true, + "openWorldHint": true, + "title": "Update repository security advisory" + }, + "description": "Update a repository security advisory, including publishing it. Severity and cvssVectorString cannot both be set.", + "inputSchema": { + "properties": { + "credits": { + "description": "Users credited for the advisory.", + "items": { + "properties": { + "login": { + "description": "The GitHub username of the credited user.", + "type": "string" + }, + "type": { + "description": "The credit type.", + "enum": [ + "analyst", + "finder", + "reporter", + "coordinator", + "remediation_developer", + "remediation_reviewer", + "remediation_verifier", + "tool", + "sponsor", + "other" + ], + "type": "string" + } + }, + "required": [ + "login", + "type" + ], + "type": "object" + }, + "type": "array" + }, + "cveId": { + "description": "The CVE ID to assign to the advisory.", + "type": "string" + }, + "cvssVectorString": { + "description": "The CVSS vector string for the advisory. Cannot be set together with severity.", + "type": "string" + }, + "cweIds": { + "description": "Common Weakness Enumeration IDs (for example, [\"CWE-79\"]).", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "A detailed description of the security advisory.", + "type": "string" + }, + "ghsaId": { + "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + "type": "string" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + }, + "severity": { + "description": "The severity of the advisory. Cannot be set together with cvssVectorString.", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "state": { + "description": "The advisory state. Set to \"published\" to publish the advisory.", + "enum": [ + "draft", + "published", + "closed", + "triage" + ], + "type": "string" + }, + "summary": { + "description": "A short summary of the security advisory.", + "type": "string" + }, + "vulnerabilities": { + "description": "Affected products and version ranges.", + "items": { + "properties": { + "package": { + "properties": { + "ecosystem": { + "description": "The package ecosystem.", + "enum": [ + "actions", + "composer", + "erlang", + "go", + "maven", + "npm", + "nuget", + "other", + "pip", + "pub", + "rubygems", + "rust", + "swift" + ], + "type": "string" + }, + "name": { + "description": "The package name.", + "type": "string" + } + }, + "required": [ + "ecosystem", + "name" + ], + "type": "object" + }, + "patched_versions": { + "description": "The version that patches the vulnerability.", + "type": "string" + }, + "vulnerable_functions": { + "description": "Functions in the package that are affected.", + "items": { + "type": "string" + }, + "type": "array" + }, + "vulnerable_version_range": { + "description": "The range of affected versions (for example, \"\u003c 2.0.0\").", + "type": "string" + } + }, + "required": [ + "package" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "owner", + "repo", + "ghsaId" + ], + "type": "object" + }, + "name": "update_repository_security_advisory" +} \ No newline at end of file diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index fdac78ce3..06103196a 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -113,10 +113,13 @@ const ( GetReposDependabotAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/dependabot/alerts/{alert_number}" // Security advisories endpoints - GetAdvisories = "GET /advisories" - GetAdvisoriesByGhsaID = "GET /advisories/{ghsa_id}" - GetReposSecurityAdvisoriesByOwnerByRepo = "GET /repos/{owner}/{repo}/security-advisories" - GetOrgsSecurityAdvisoriesByOrg = "GET /orgs/{org}/security-advisories" + GetAdvisories = "GET /advisories" + GetAdvisoriesByGhsaID = "GET /advisories/{ghsa_id}" + GetReposSecurityAdvisoriesByOwnerByRepo = "GET /repos/{owner}/{repo}/security-advisories" + PostReposSecurityAdvisoriesByOwnerByRepo = "POST /repos/{owner}/{repo}/security-advisories" + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID = "PATCH /repos/{owner}/{repo}/security-advisories/{ghsa_id}" + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID = "POST /repos/{owner}/{repo}/security-advisories/{ghsa_id}/cve" + GetOrgsSecurityAdvisoriesByOrg = "GET /orgs/{org}/security-advisories" // Actions endpoints GetReposActionsWorkflowsByOwnerByRepo = "GET /repos/{owner}/{repo}/actions/workflows" diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index f45c2e421..e38a4e5df 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -499,3 +499,956 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { }) } } +func sampleAdvisoryVulnerabilities() []any { + return []any{ + map[string]any{ + "package": map[string]any{ + "ecosystem": "npm", + "name": "example-package", + }, + "vulnerable_version_range": "< 2.0.0", + "patched_versions": "2.0.0", + }, + } +} + +func mockRepositorySecurityAdvisory() *github.SecurityAdvisory { + return &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), + Summary: github.Ptr("Stored XSS in Core"), + Description: github.Ptr("A stored XSS vulnerability in Core."), + Severity: github.Ptr("high"), + State: github.Ptr("draft"), + } +} + +func Test_CreateRepositorySecurityAdvisory(t *testing.T) { + toolDef := CreateRepositorySecurityAdvisory(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_repository_security_advisory", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.OpenWorldHint) + assert.True(t, *tool.Annotations.OpenWorldHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be of type *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "summary", "description", "vulnerabilities"}) + + mockAdvisory := mockRepositorySecurityAdvisory() + expectedRequestBody := map[string]any{ + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "vulnerabilities": []any{ + map[string]any{ + "package": map[string]any{ + "ecosystem": "npm", + "name": "example-package", + }, + "vulnerable_version_range": "< 2.0.0", + "patched_versions": "2.0.0", + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedAdvisory *github.SecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisory creation", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: expectRequestBody(t, expectedRequestBody).andThen( + mockResponse(t, http.StatusCreated, mockAdvisory), + ), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "successful advisory creation with cvss only", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "cvss_vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "vulnerabilities": []any{ + map[string]any{ + "package": map[string]any{ + "ecosystem": "npm", + "name": "example-package", + }, + "vulnerable_version_range": "< 2.0.0", + "patched_versions": "2.0.0", + }, + }, + }).andThen(mockResponse(t, http.StatusCreated, mockAdvisory)), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "cvssVectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "successful advisory creation with startPrivateFork", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "start_private_fork": true, + "vulnerabilities": []any{ + map[string]any{ + "package": map[string]any{ + "ecosystem": "npm", + "name": "example-package", + }, + "vulnerable_version_range": "< 2.0.0", + "patched_versions": "2.0.0", + }, + }, + }).andThen(mockResponse(t, http.StatusCreated, mockAdvisory)), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "startPrivateFork": true, + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "missing required summary", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "description": "A stored XSS vulnerability in Core.", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: true, + expectedErrMsg: "missing required parameter: summary", + }, + { + name: "missing severity and cvss", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: true, + expectedErrMsg: "exactly one of severity or cvssVectorString must be provided", + }, + { + name: "both severity and cvss set", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "cvssVectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: true, + expectedErrMsg: "severity and cvssVectorString cannot both be set", + }, + { + name: "invalid severity value", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "urgent", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: true, + expectedErrMsg: "severity must be one of: low, medium, high, critical", + }, + { + name: "successful advisory creation with credits and cweIds", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "cve_id": "CVE-2024-12345", + "cwe_ids": []any{"CWE-79"}, + "credits": []any{ + map[string]any{ + "login": "octocat", + "type": "finder", + }, + }, + "vulnerabilities": []any{ + map[string]any{ + "package": map[string]any{ + "ecosystem": "npm", + "name": "example-package", + }, + "vulnerable_version_range": "< 2.0.0", + "patched_versions": "2.0.0", + }, + }, + }).andThen(mockResponse(t, http.StatusCreated, mockAdvisory)), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "cveId": "CVE-2024-12345", + "cweIds": []any{"CWE-79"}, + "credits": []any{ + map[string]any{ + "login": "octocat", + "type": "finder", + }, + }, + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "reject empty vulnerabilities array", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "vulnerabilities": []any{}, + }, + expectError: true, + expectedErrMsg: "invalid vulnerabilities: at least one vulnerability must be provided", + }, + { + name: "API error handling", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: true, + expectedErrMsg: "failed to create repository security advisory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectedErrMsg != "" { + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, tc.expectedErrMsg) + return + } + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + var returnedAdvisory github.SecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisory) + require.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) + }) + } +} + +func Test_UpdateRepositorySecurityAdvisory(t *testing.T) { + toolDef := UpdateRepositorySecurityAdvisory(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_repository_security_advisory", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.OpenWorldHint) + assert.True(t, *tool.Annotations.OpenWorldHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be of type *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "ghsaId"}) + + mockAdvisory := mockRepositorySecurityAdvisory() + mockAdvisory.State = github.Ptr("published") + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedAdvisory *github.SecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisory update", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: expect(t, expectations{ + path: "/repos/octo/hello-world/security-advisories/GHSA-xxxx-xxxx-xxxx", + requestBody: map[string]any{"state": "published", "severity": "high"}, + }).andThen(mockResponse(t, http.StatusOK, mockAdvisory)), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "state": "published", + "severity": "high", + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "lowercase ghsaId normalized in request path", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: expect(t, expectations{ + path: "/repos/octo/hello-world/security-advisories/GHSA-abcd-1234-5678", + requestBody: map[string]any{"state": "published"}, + }).andThen(mockResponse(t, http.StatusOK, mockAdvisory)), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "ghsa-abcd-1234-5678", + "state": "published", + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "invalid ghsaId format", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "invalid/../../path", + "state": "published", + }, + expectError: true, + expectedErrMsg: "invalid ghsaId format: must match GHSA-xxxx-xxxx-xxxx", + }, + { + name: "missing required ghsaId", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "state": "published", + }, + expectError: true, + expectedErrMsg: "missing required parameter: ghsaId", + }, + { + name: "no update fields provided", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: true, + expectedErrMsg: "at least one of summary, description, vulnerabilities, cveId, cweIds, severity, cvssVectorString, credits, or state must be provided for update", + }, + { + name: "both severity and cvss set on update", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "severity": "high", + "cvssVectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + }, + expectError: true, + expectedErrMsg: "severity and cvssVectorString cannot both be set", + }, + { + name: "clear summary with empty string", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: expect(t, expectations{ + path: "/repos/octo/hello-world/security-advisories/GHSA-xxxx-xxxx-xxxx", + requestBody: map[string]any{"summary": ""}, + }).andThen(mockResponse(t, http.StatusOK, mockAdvisory)), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "summary": "", + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "reject empty vulnerabilities array", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "vulnerabilities": []any{}, + }, + expectError: true, + expectedErrMsg: "invalid vulnerabilities: at least one vulnerability must be provided", + }, + { + name: "reject null vulnerabilities", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "vulnerabilities": nil, + }, + expectError: true, + expectedErrMsg: "invalid vulnerabilities: at least one vulnerability must be provided", + }, + { + name: "successful update with credits and cweIds", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: expect(t, expectations{ + path: "/repos/octo/hello-world/security-advisories/GHSA-xxxx-xxxx-xxxx", + requestBody: map[string]any{ + "cve_id": "CVE-2024-12345", + "cwe_ids": []any{"CWE-79"}, + "credits": []any{ + map[string]any{ + "login": "octocat", + "type": "reporter", + }, + }, + }, + }).andThen(mockResponse(t, http.StatusOK, mockAdvisory)), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "cveId": "CVE-2024-12345", + "cweIds": []any{"CWE-79"}, + "credits": []any{ + map[string]any{ + "login": "octocat", + "type": "reporter", + }, + }, + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "invalid state value", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "state": "open", + }, + expectError: true, + expectedErrMsg: "state must be one of: draft, published, closed, triage", + }, + { + name: "API error handling", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "state": "published", + }, + expectError: true, + expectedErrMsg: "failed to update repository security advisory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectedErrMsg != "" { + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, tc.expectedErrMsg) + return + } + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + var returnedAdvisory github.SecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisory) + require.NoError(t, err) + assert.Equal(t, *tc.expectedAdvisory.GHSAID, *returnedAdvisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisory.State, *returnedAdvisory.State) + }) + } +} + +func Test_RequestCVEForRepositorySecurityAdvisory(t *testing.T) { + toolDef := RequestCVEForRepositorySecurityAdvisory(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "request_cve_for_repository_security_advisory", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.OpenWorldHint) + assert.True(t, *tool.Annotations.OpenWorldHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be of type *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "ghsaId"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedText string + expectedErrMsg string + }{ + { + name: "successful CVE request", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, nil), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: false, + expectedText: "CVE request submitted successfully", + }, + { + name: "successful CVE request with accepted status", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }, + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: false, + expectedText: "CVE request submitted successfully", + }, + { + name: "invalid ghsaId format", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, nil), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "not-a-valid-ghsa", + }, + expectError: true, + expectedErrMsg: "invalid ghsaId format: must match GHSA-xxxx-xxxx-xxxx", + }, + { + name: "missing required ghsaId", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, nil), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + }, + expectError: true, + expectedErrMsg: "missing required parameter: ghsaId", + }, + { + name: "API error handling", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: true, + expectedErrMsg: "failed to request CVE for repository security advisory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectedErrMsg != "" { + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, tc.expectedErrMsg) + return + } + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Equal(t, tc.expectedText, textContent.Text) + }) + } +} + +func Test_ParseAdvisoryVulnerabilities(t *testing.T) { + t.Run("required missing parameter", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{}, "vulnerabilities", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing required parameter: vulnerabilities") + }) + + t.Run("invalid parameter type", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": "not-an-array", + }, "vulnerabilities", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid vulnerabilities") + }) + + t.Run("valid vulnerabilities", func(t *testing.T) { + vulns, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, "vulnerabilities", true) + require.NoError(t, err) + require.Len(t, vulns, 1) + assert.Equal(t, "npm", vulns[0].Package.Ecosystem) + require.NotNil(t, vulns[0].Package.Name) + assert.Equal(t, "example-package", *vulns[0].Package.Name) + }) + + t.Run("optional empty vulnerabilities array rejected", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": []any{}, + }, "vulnerabilities", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid vulnerabilities: at least one vulnerability must be provided") + }) + + t.Run("optional null vulnerabilities rejected", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": nil, + }, "vulnerabilities", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid vulnerabilities: at least one vulnerability must be provided") + }) + + t.Run("required empty vulnerabilities array rejected", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": []any{}, + }, "vulnerabilities", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid vulnerabilities: at least one vulnerability must be provided") + }) + + t.Run("missing package ecosystem", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": []any{ + map[string]any{ + "package": map[string]any{ + "name": "example-package", + }, + }, + }, + }, "vulnerabilities", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "vulnerabilities[0].package.ecosystem is required") + }) + + t.Run("missing package name", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": []any{ + map[string]any{ + "package": map[string]any{ + "ecosystem": "npm", + }, + }, + }, + }, "vulnerabilities", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "vulnerabilities[0].package.name is required") + }) + + t.Run("invalid package ecosystem", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": []any{ + map[string]any{ + "package": map[string]any{ + "ecosystem": "invalid-ecosystem", + "name": "example-package", + }, + }, + }, + }, "vulnerabilities", true) + require.Error(t, err) + assert.Contains(t, err.Error(), `vulnerabilities[0].package.ecosystem "invalid-ecosystem" is invalid`) + }) +} + +func Test_ParseAdvisoryCredits(t *testing.T) { + t.Run("valid credits", func(t *testing.T) { + credits, err := parseAdvisoryCredits(map[string]any{ + "credits": []any{ + map[string]any{ + "login": "octocat", + "type": "finder", + }, + }, + }, "credits") + require.NoError(t, err) + require.Len(t, credits, 1) + assert.Equal(t, "octocat", credits[0].Login) + assert.Equal(t, "finder", credits[0].Type) + }) + + t.Run("missing login", func(t *testing.T) { + _, err := parseAdvisoryCredits(map[string]any{ + "credits": []any{ + map[string]any{ + "type": "finder", + }, + }, + }, "credits") + require.Error(t, err) + assert.Contains(t, err.Error(), "credits[0].login is required") + }) + + t.Run("missing type", func(t *testing.T) { + _, err := parseAdvisoryCredits(map[string]any{ + "credits": []any{ + map[string]any{ + "login": "octocat", + }, + }, + }, "credits") + require.Error(t, err) + assert.Contains(t, err.Error(), "credits[0].type is required") + }) + + t.Run("invalid type", func(t *testing.T) { + _, err := parseAdvisoryCredits(map[string]any{ + "credits": []any{ + map[string]any{ + "login": "octocat", + "type": "invalid-type", + }, + }, + }, "credits") + require.Error(t, err) + assert.Contains(t, err.Error(), `credits[0].type "invalid-type" is invalid`) + }) +} + +func Test_validateSeverityOrCVSS(t *testing.T) { + t.Run("create requires exactly one", func(t *testing.T) { + assert.NoError(t, validateSeverityOrCVSS("high", "", true)) + assert.NoError(t, validateSeverityOrCVSS("", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", true)) + assert.Error(t, validateSeverityOrCVSS("", "", true)) + assert.Error(t, validateSeverityOrCVSS("high", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", true)) + }) + + t.Run("update rejects both but allows neither", func(t *testing.T) { + assert.NoError(t, validateSeverityOrCVSS("", "", false)) + assert.NoError(t, validateSeverityOrCVSS("high", "", false)) + assert.NoError(t, validateSeverityOrCVSS("", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", false)) + assert.Error(t, validateSeverityOrCVSS("high", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", false)) + }) + + t.Run("rejects invalid severity", func(t *testing.T) { + assert.Error(t, validateAdvisorySeverity("urgent")) + assert.NoError(t, validateAdvisorySeverity("critical")) + }) + + t.Run("rejects invalid state", func(t *testing.T) { + assert.Error(t, validateAdvisoryState("open")) + assert.NoError(t, validateAdvisoryState("published")) + }) +} + +func Test_validateGHSAID(t *testing.T) { + assert.NoError(t, validateGHSAID("GHSA-xxxx-xxxx-xxxx")) + assert.NoError(t, validateGHSAID("ghsa-abcd-1234-5678")) + assert.Error(t, validateGHSAID("invalid-ghsa-id")) + assert.Error(t, validateGHSAID("GHSA-xxxx-xxxx-xxxx/extra")) + assert.Error(t, validateGHSAID("../etc/passwd")) +} + +func Test_normalizeGHSAID(t *testing.T) { + normalized, err := normalizeGHSAID("ghsa-abcd-1234-5678") + assert.NoError(t, err) + assert.Equal(t, "GHSA-abcd-1234-5678", normalized) + + normalized, err = normalizeGHSAID("GHSA-xxxx-xxxx-xxxx") + assert.NoError(t, err) + assert.Equal(t, "GHSA-xxxx-xxxx-xxxx", normalized) + + _, err = normalizeGHSAID("invalid-ghsa-id") + assert.Error(t, err) +} + +func TestSecurityAdvisoryWriteToolsRegistered(t *testing.T) { + expected := map[string]struct { + readOnly bool + destructive bool + openWorld bool + }{ + "create_repository_security_advisory": { + readOnly: false, + destructive: true, + openWorld: true, + }, + "update_repository_security_advisory": { + readOnly: false, + destructive: true, + openWorld: true, + }, + "request_cve_for_repository_security_advisory": { + readOnly: false, + destructive: true, + openWorld: true, + }, + } + + for _, tool := range AllTools(translations.NullTranslationHelper) { + want, ok := expected[tool.Tool.Name] + if !ok { + continue + } + assert.Equal(t, ToolsetMetadataSecurityAdvisories.ID, tool.Toolset.ID) + require.NotNil(t, tool.Tool.Annotations) + assert.Equal(t, want.readOnly, tool.Tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Tool.Annotations.OpenWorldHint) + assert.Equal(t, want.openWorld, *tool.Tool.Annotations.OpenWorldHint) + if want.destructive { + require.NotNil(t, tool.Tool.Annotations.DestructiveHint) + assert.True(t, *tool.Tool.Annotations.DestructiveHint) + } else { + assert.Nil(t, tool.Tool.Annotations.DestructiveHint) + } + delete(expected, tool.Tool.Name) + } + + assert.Empty(t, expected, "missing security advisory write tools: %v", expected) +} diff --git a/pkg/github/security_advisories_write.go b/pkg/github/security_advisories_write.go new file mode 100644 index 000000000..e914c533e --- /dev/null +++ b/pkg/github/security_advisories_write.go @@ -0,0 +1,734 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var validAdvisoryEcosystems = map[string]struct{}{ + "actions": {}, "composer": {}, "erlang": {}, "go": {}, "maven": {}, "npm": {}, + "nuget": {}, "other": {}, "pip": {}, "pub": {}, "rubygems": {}, "rust": {}, "swift": {}, +} + +var validAdvisoryCreditTypes = map[string]struct{}{ + "analyst": {}, "finder": {}, "reporter": {}, "coordinator": {}, + "remediation_developer": {}, "remediation_reviewer": {}, "remediation_verifier": {}, + "tool": {}, "sponsor": {}, "other": {}, +} + +var validAdvisorySeverities = map[string]struct{}{ + "low": {}, "medium": {}, "high": {}, "critical": {}, +} + +var validAdvisoryStates = map[string]struct{}{ + "draft": {}, "published": {}, "closed": {}, "triage": {}, +} + +var ghsaIDPattern = regexp.MustCompile(`(?i)^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$`) + +func validateGHSAID(ghsaID string) error { + if !ghsaIDPattern.MatchString(ghsaID) { + return fmt.Errorf("invalid ghsaId format: must match GHSA-xxxx-xxxx-xxxx") + } + return nil +} + +func normalizeGHSAID(ghsaID string) (string, error) { + if err := validateGHSAID(ghsaID); err != nil { + return "", err + } + if strings.HasPrefix(strings.ToLower(ghsaID), "ghsa-") { + return "GHSA" + ghsaID[4:], nil + } + return ghsaID, nil +} + +var securityAdvisoryPackageSchema = &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "ecosystem": { + Type: "string", + Description: "The package ecosystem.", + Enum: []any{"actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust", "swift"}, + }, + "name": { + Type: "string", + Description: "The package name.", + }, + }, + Required: []string{"ecosystem", "name"}, +} + +var securityAdvisoryVulnerabilitySchema = &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "package": securityAdvisoryPackageSchema, + "vulnerable_version_range": { + Type: "string", + Description: "The range of affected versions (for example, \"< 2.0.0\").", + }, + "patched_versions": { + Type: "string", + Description: "The version that patches the vulnerability.", + }, + "vulnerable_functions": { + Type: "array", + Description: "Functions in the package that are affected.", + Items: &jsonschema.Schema{Type: "string"}, + }, + }, + Required: []string{"package"}, +} + +var securityAdvisoryCreditSchema = &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "login": { + Type: "string", + Description: "The GitHub username of the credited user.", + }, + "type": { + Type: "string", + Description: "The credit type.", + Enum: []any{"analyst", "finder", "reporter", "coordinator", "remediation_developer", "remediation_reviewer", "remediation_verifier", "tool", "sponsor", "other"}, + }, + }, + Required: []string{"login", "type"}, +} + +type advisoryPackageRequest struct { + Ecosystem string `json:"ecosystem"` + Name *string `json:"name,omitempty"` +} + +type advisoryVulnerabilityRequest struct { + Package advisoryPackageRequest `json:"package"` + VulnerableVersionRange *string `json:"vulnerable_version_range,omitempty"` + PatchedVersions *string `json:"patched_versions,omitempty"` + VulnerableFunctions []string `json:"vulnerable_functions,omitempty"` +} + +type advisoryCreditRequest struct { + Login string `json:"login"` + Type string `json:"type"` +} + +type createRepositorySecurityAdvisoryRequest struct { + Summary string `json:"summary"` + Description string `json:"description"` + CVEID *string `json:"cve_id,omitempty"` + CWEIDs []string `json:"cwe_ids,omitempty"` + Severity *string `json:"severity,omitempty"` + CVSSVectorString *string `json:"cvss_vector_string,omitempty"` + Vulnerabilities []advisoryVulnerabilityRequest `json:"vulnerabilities"` + Credits []advisoryCreditRequest `json:"credits,omitempty"` + StartPrivateFork *bool `json:"start_private_fork,omitempty"` +} + +type updateRepositorySecurityAdvisoryRequest struct { + Summary *string `json:"summary,omitempty"` + Description *string `json:"description,omitempty"` + CVEID *string `json:"cve_id,omitempty"` + CWEIDs []string `json:"cwe_ids,omitempty"` + Severity *string `json:"severity,omitempty"` + CVSSVectorString *string `json:"cvss_vector_string,omitempty"` + Vulnerabilities []advisoryVulnerabilityRequest `json:"vulnerabilities,omitempty"` + Credits []advisoryCreditRequest `json:"credits,omitempty"` + State *string `json:"state,omitempty"` +} + +func parseAdvisoryVulnerabilities(args map[string]any, param string, required bool) ([]advisoryVulnerabilityRequest, error) { + raw, ok := args[param] + if !ok { + if required { + return nil, fmt.Errorf("missing required parameter: %s", param) + } + return nil, nil + } + if raw == nil { + return nil, fmt.Errorf("invalid %s: at least one vulnerability must be provided", param) + } + + data, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", param, err) + } + + var vulns []advisoryVulnerabilityRequest + if err := json.Unmarshal(data, &vulns); err != nil { + return nil, fmt.Errorf("invalid %s: %w", param, err) + } + if len(vulns) == 0 { + return nil, fmt.Errorf("invalid %s: at least one vulnerability must be provided", param) + } + for i, vuln := range vulns { + if vuln.Package.Ecosystem == "" { + return nil, fmt.Errorf("invalid %s: vulnerabilities[%d].package.ecosystem is required", param, i) + } + if _, ok := validAdvisoryEcosystems[vuln.Package.Ecosystem]; !ok { + return nil, fmt.Errorf("invalid %s: vulnerabilities[%d].package.ecosystem %q is invalid", param, i, vuln.Package.Ecosystem) + } + if vuln.Package.Name == nil || *vuln.Package.Name == "" { + return nil, fmt.Errorf("invalid %s: vulnerabilities[%d].package.name is required", param, i) + } + } + + return vulns, nil +} + +func parseAdvisoryCredits(args map[string]any, param string) ([]advisoryCreditRequest, error) { + raw, ok := args[param] + if !ok || raw == nil { + return nil, nil + } + + data, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", param, err) + } + + var credits []advisoryCreditRequest + if err := json.Unmarshal(data, &credits); err != nil { + return nil, fmt.Errorf("invalid %s: %w", param, err) + } + for i, credit := range credits { + if credit.Login == "" { + return nil, fmt.Errorf("invalid %s: credits[%d].login is required", param, i) + } + if credit.Type == "" { + return nil, fmt.Errorf("invalid %s: credits[%d].type is required", param, i) + } + if _, ok := validAdvisoryCreditTypes[credit.Type]; !ok { + return nil, fmt.Errorf("invalid %s: credits[%d].type %q is invalid", param, i, credit.Type) + } + } + + return credits, nil +} + +func optionalStringPtr(value string) *string { + if value == "" { + return nil + } + return &value +} + +func validateAdvisorySeverity(severity string) error { + if severity == "" { + return nil + } + if _, ok := validAdvisorySeverities[severity]; !ok { + return fmt.Errorf("severity must be one of: low, medium, high, critical") + } + return nil +} + +func validateAdvisoryState(state string) error { + if state == "" { + return nil + } + if _, ok := validAdvisoryStates[state]; !ok { + return fmt.Errorf("state must be one of: draft, published, closed, triage") + } + return nil +} + +func validateSeverityOrCVSS(severity, cvssVectorString string, requireOne bool) error { + hasSeverity := severity != "" + hasCVSS := cvssVectorString != "" + if hasSeverity { + if err := validateAdvisorySeverity(severity); err != nil { + return err + } + } + if hasSeverity && hasCVSS { + return fmt.Errorf("severity and cvssVectorString cannot both be set") + } + if requireOne && !hasSeverity && !hasCVSS { + return fmt.Errorf("exactly one of severity or cvssVectorString must be provided") + } + return nil +} + +func marshalRepositorySecurityAdvisoryResponse(advisory *github.SecurityAdvisory) (*mcp.CallToolResult, error) { + r, err := json.Marshal(advisory) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisory: %w", err) + } + return utils.NewToolResultText(string(r)), nil +} + +func repositorySecurityAdvisoryRequest(ctx context.Context, client *github.Client, method, owner, repo, ghsaID string, body any) (*github.SecurityAdvisory, *github.Response, error) { + url := fmt.Sprintf("repos/%s/%s/security-advisories", owner, repo) + if ghsaID != "" { + normalizedGHSAID, err := normalizeGHSAID(ghsaID) + if err != nil { + return nil, nil, err + } + url = fmt.Sprintf("%s/%s", url, normalizedGHSAID) + } + + req, err := client.NewRequest(ctx, method, url, body) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + + advisory := &github.SecurityAdvisory{} + resp, err := client.Do(req, advisory) + if err != nil { + return nil, resp, err + } + + return advisory, resp, nil +} + +func CreateRepositorySecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataSecurityAdvisories, + mcp.Tool{ + Name: "create_repository_security_advisory", + Description: t("TOOL_CREATE_REPOSITORY_SECURITY_ADVISORY_DESCRIPTION", "Create a draft repository security advisory. Exactly one of severity or cvssVectorString must be provided. When startPrivateFork is true, a temporary private fork is created for collaborating on a fix."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_REPOSITORY_SECURITY_ADVISORY_USER_TITLE", "Create repository security advisory"), + ReadOnlyHint: false, + OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: jsonschema.Ptr(true), + }, + 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 advisory.", + }, + "description": { + Type: "string", + Description: "A detailed description of the security advisory.", + }, + "vulnerabilities": { + Type: "array", + Description: "Affected products and version ranges.", + Items: securityAdvisoryVulnerabilitySchema, + }, + "cveId": { + Type: "string", + Description: "The CVE ID to assign to the advisory.", + }, + "cweIds": { + Type: "array", + Description: "Common Weakness Enumeration IDs (for example, [\"CWE-79\"]).", + Items: &jsonschema.Schema{Type: "string"}, + }, + "severity": { + Type: "string", + Description: "The severity of the advisory. Exactly one of severity or cvssVectorString is required.", + Enum: []any{"low", "medium", "high", "critical"}, + }, + "cvssVectorString": { + Type: "string", + Description: "The CVSS vector string for the advisory. Exactly one of severity or cvssVectorString is required.", + }, + "credits": { + Type: "array", + Description: "Users credited for the advisory.", + Items: securityAdvisoryCreditSchema, + }, + "startPrivateFork": { + Type: "boolean", + Description: "Whether to create a temporary private fork for collaborating on a fix.", + }, + }, + Required: []string{"owner", "repo", "summary", "description", "vulnerabilities"}, + }, + }, + []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 + } + vulnerabilities, err := parseAdvisoryVulnerabilities(args, "vulnerabilities", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cveID, err := OptionalParam[string](args, "cveId") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cweIDs, err := OptionalStringArrayParam(args, "cweIds") + 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, "cvssVectorString") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + credits, err := parseAdvisoryCredits(args, "credits") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startPrivateFork, err := OptionalParam[bool](args, "startPrivateFork") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if err := validateSeverityOrCVSS(severity, cvssVectorString, true); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + requestBody := createRepositorySecurityAdvisoryRequest{ + Summary: summary, + Description: description, + CVEID: optionalStringPtr(cveID), + CWEIDs: cweIDs, + Severity: optionalStringPtr(severity), + CVSSVectorString: optionalStringPtr(cvssVectorString), + Vulnerabilities: vulnerabilities, + Credits: credits, + } + if _, ok := args["startPrivateFork"]; ok { + requestBody.StartPrivateFork = &startPrivateFork + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + advisory, resp, err := repositorySecurityAdvisoryRequest(ctx, client, http.MethodPost, owner, repo, "", requestBody) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create repository security advisory", resp, err), nil, nil + } + 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 create repository security advisory", resp, body), nil, nil + } + + result, err := marshalRepositorySecurityAdvisoryResponse(advisory) + if err != nil { + return nil, nil, err + } + return result, nil, nil + }, + ) +} + +func UpdateRepositorySecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataSecurityAdvisories, + mcp.Tool{ + Name: "update_repository_security_advisory", + Description: t("TOOL_UPDATE_REPOSITORY_SECURITY_ADVISORY_DESCRIPTION", "Update a repository security advisory, including publishing it. Severity and cvssVectorString cannot both be set."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_REPOSITORY_SECURITY_ADVISORY_USER_TITLE", "Update repository security advisory"), + ReadOnlyHint: false, + OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: jsonschema.Ptr(true), + }, + 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.", + }, + "ghsaId": { + Type: "string", + Description: "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + }, + "summary": { + Type: "string", + Description: "A short summary of the security advisory.", + }, + "description": { + Type: "string", + Description: "A detailed description of the security advisory.", + }, + "vulnerabilities": { + Type: "array", + Description: "Affected products and version ranges.", + Items: securityAdvisoryVulnerabilitySchema, + }, + "cveId": { + Type: "string", + Description: "The CVE ID to assign to the advisory.", + }, + "cweIds": { + Type: "array", + Description: "Common Weakness Enumeration IDs (for example, [\"CWE-79\"]).", + Items: &jsonschema.Schema{Type: "string"}, + }, + "severity": { + Type: "string", + Description: "The severity of the advisory. Cannot be set together with cvssVectorString.", + Enum: []any{"low", "medium", "high", "critical"}, + }, + "cvssVectorString": { + Type: "string", + Description: "The CVSS vector string for the advisory. Cannot be set together with severity.", + }, + "credits": { + Type: "array", + Description: "Users credited for the advisory.", + Items: securityAdvisoryCreditSchema, + }, + "state": { + Type: "string", + Description: "The advisory state. Set to \"published\" to publish the advisory.", + Enum: []any{"draft", "published", "closed", "triage"}, + }, + }, + Required: []string{"owner", "repo", "ghsaId"}, + }, + }, + []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 + } + ghsaID, err := RequiredParam[string](args, "ghsaId") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ghsaID, err = normalizeGHSAID(ghsaID) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + summary, err := OptionalParam[string](args, "summary") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + description, err := OptionalParam[string](args, "description") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + vulnerabilities, err := parseAdvisoryVulnerabilities(args, "vulnerabilities", false) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cveID, err := OptionalParam[string](args, "cveId") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cweIDs, err := OptionalStringArrayParam(args, "cweIds") + 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, "cvssVectorString") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + credits, err := parseAdvisoryCredits(args, "credits") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if err := validateSeverityOrCVSS(severity, cvssVectorString, false); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if err := validateAdvisoryState(state); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + requestBody := updateRepositorySecurityAdvisoryRequest{} + hasUpdate := false + if _, ok := args["summary"]; ok { + requestBody.Summary = &summary + hasUpdate = true + } + if _, ok := args["description"]; ok { + requestBody.Description = &description + hasUpdate = true + } + if _, ok := args["vulnerabilities"]; ok { + requestBody.Vulnerabilities = vulnerabilities + hasUpdate = true + } + if _, ok := args["cveId"]; ok { + requestBody.CVEID = &cveID + hasUpdate = true + } + if _, ok := args["cweIds"]; ok { + requestBody.CWEIDs = cweIDs + hasUpdate = true + } + if _, ok := args["severity"]; ok { + requestBody.Severity = &severity + hasUpdate = true + } + if _, ok := args["cvssVectorString"]; ok { + requestBody.CVSSVectorString = &cvssVectorString + hasUpdate = true + } + if _, ok := args["credits"]; ok { + requestBody.Credits = credits + hasUpdate = true + } + if _, ok := args["state"]; ok { + requestBody.State = &state + hasUpdate = true + } + + if !hasUpdate { + return utils.NewToolResultError("at least one of summary, description, vulnerabilities, cveId, cweIds, severity, cvssVectorString, credits, or state must be provided for update"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + advisory, resp, err := repositorySecurityAdvisoryRequest(ctx, client, http.MethodPatch, owner, repo, ghsaID, requestBody) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update repository security advisory", resp, err), nil, nil + } + 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 update repository security advisory", resp, body), nil, nil + } + + result, err := marshalRepositorySecurityAdvisoryResponse(advisory) + if err != nil { + return nil, nil, err + } + return result, nil, nil + }, + ) +} + +func RequestCVEForRepositorySecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataSecurityAdvisories, + mcp.Tool{ + Name: "request_cve_for_repository_security_advisory", + Description: t("TOOL_REQUEST_CVE_FOR_REPOSITORY_SECURITY_ADVISORY_DESCRIPTION", "Request a CVE ID from GitHub for a draft repository security advisory."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REQUEST_CVE_FOR_REPOSITORY_SECURITY_ADVISORY_USER_TITLE", "Request CVE for repository security advisory"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + OpenWorldHint: jsonschema.Ptr(true), + }, + 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.", + }, + "ghsaId": { + Type: "string", + Description: "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + }, + }, + Required: []string{"owner", "repo", "ghsaId"}, + }, + }, + []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 + } + ghsaID, err := RequiredParam[string](args, "ghsaId") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ghsaID, err = normalizeGHSAID(ghsaID) + 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) + } + + resp, err := client.SecurityAdvisories.RequestCVE(ctx, owner, repo, ghsaID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to request CVE for repository security advisory", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + 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 request CVE for repository security advisory", resp, body), nil, nil + } + + return utils.NewToolResultText("CVE request submitted successfully"), nil, nil + }, + ) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d1d585b3f..a17f3bf08 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -272,6 +272,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetGlobalSecurityAdvisory(t), ListRepositorySecurityAdvisories(t), ListOrgRepositorySecurityAdvisories(t), + CreateRepositorySecurityAdvisory(t), + UpdateRepositorySecurityAdvisory(t), + RequestCVEForRepositorySecurityAdvisory(t), // Gist tools ListGists(t),