diff --git a/README.md b/README.md index dff62321b8..2d54358558 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. (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. (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. (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. (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 0000000000..8e8c981146 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_repository_security_advisory.snap @@ -0,0 +1,155 @@ +{ + "annotations": { + "title": "Create repository security advisory" + }, + "description": "Create a draft repository security advisory.", + "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.", + "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.", + "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" + ], + "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 0000000000..5d069b8259 --- /dev/null +++ b/pkg/github/__toolsnaps__/request_cve_for_repository_security_advisory.snap @@ -0,0 +1,29 @@ +{ + "annotations": { + "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 0000000000..97057570aa --- /dev/null +++ b/pkg/github/__toolsnaps__/update_repository_security_advisory.snap @@ -0,0 +1,163 @@ +{ + "annotations": { + "title": "Update repository security advisory" + }, + "description": "Update a repository security advisory, including publishing it.", + "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.", + "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.", + "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" + ], + "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 fdac78ce3f..06103196ab 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_write.go b/pkg/github/security_advisories_write.go new file mode 100644 index 0000000000..a65a40e6ff --- /dev/null +++ b/pkg/github/security_advisories_write.go @@ -0,0 +1,600 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + 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 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"}, +} + +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 || raw == nil { + if required { + return nil, fmt.Errorf("missing required parameter: %s", param) + } + return nil, nil + } + + 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 required && len(vulns) == 0 { + return nil, fmt.Errorf("missing required parameter: %s", param) + } + + 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) + } + + return credits, nil +} + +func optionalStringPtr(value string) *string { + if value == "" { + return nil + } + return &value +} + +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 != "" { + url = fmt.Sprintf("%s/%s", url, ghsaID) + } + + 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."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_REPOSITORY_SECURITY_ADVISORY_USER_TITLE", "Create repository security advisory"), + 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 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.", + Enum: []any{"low", "medium", "high", "critical"}, + }, + "cvssVectorString": { + Type: "string", + Description: "The CVSS vector string for the advisory.", + }, + "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 + } + + 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."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_REPOSITORY_SECURITY_ADVISORY_USER_TITLE", "Update repository security advisory"), + 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.", + }, + "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.", + Enum: []any{"low", "medium", "high", "critical"}, + }, + "cvssVectorString": { + Type: "string", + Description: "The CVSS vector string for the advisory.", + }, + "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 + } + 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 + } + + requestBody := updateRepositorySecurityAdvisoryRequest{} + if summary != "" { + requestBody.Summary = &summary + } + if description != "" { + requestBody.Description = &description + } + if len(vulnerabilities) > 0 { + requestBody.Vulnerabilities = vulnerabilities + } + if cveID != "" { + requestBody.CVEID = &cveID + } + if len(cweIDs) > 0 { + requestBody.CWEIDs = cweIDs + } + if severity != "" { + requestBody.Severity = &severity + } + if cvssVectorString != "" { + requestBody.CVSSVectorString = &cvssVectorString + } + if len(credits) > 0 { + requestBody.Credits = credits + } + if state != "" { + requestBody.State = &state + } + + if requestBody.Summary == nil && requestBody.Description == nil && len(requestBody.Vulnerabilities) == 0 && + requestBody.CVEID == nil && len(requestBody.CWEIDs) == 0 && requestBody.Severity == nil && + requestBody.CVSSVectorString == nil && len(requestBody.Credits) == 0 && requestBody.State == nil { + 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, + }, + 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 + } + + 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 { + if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { + return utils.NewToolResultText("CVE request accepted and is being processed"), nil, 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/security_advisories_write_test.go b/pkg/github/security_advisories_write_test.go new file mode 100644 index 0000000000..9c51fea1bb --- /dev/null +++ b/pkg/github/security_advisories_write_test.go @@ -0,0 +1,420 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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) + + 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: "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: "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.", + "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) + + 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: "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: "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) + + 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: "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) + }) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d1d585b3fa..a17f3bf084 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),