github/ github-mcp-server
MCP serverGitHub's official MCP Server
Trust score
Scanned Oct. 22nd, 2026 at b98d115 against 26 rules · How scoring works
What pulled the score down
- HighBinding to 0.0.0.0 Without Auth/TLS ATR-M0004
- HighTransport Security: TLS Everywhere (Remote) ATR-M0009
- MediumDoS Protections: Timeouts, Concurrency, Streaming Limits ATR-C0004
- MediumLogging Hygiene (No Secrets/PII/Tokens) & Audit Trail ATR-C0005
- MediumContainer/Runtime Hardening ATR-C0006
- HighDependency Risk & Supply Chain ATR-C0007
- MediumDenial of Wallet / Cost Controls ATR-C0012
- HighTool Output Sanitization & Injection Containment ATR-C0013
At a glance
- Repository-wide6 of 15
- mcp-server (MCP server)3 of 11
What to fix first
9 issues, most severe first. Each one points to the exact code, with the recommended fix and a prompt you can paste into your coding agent.
- 1HighDependency Risk & Supply ChainRepository-wide · ATR-C0007Trivy identified five CVEs affecting golang.org/x/net v0.59.0, but the only direct imports found in the codebase are the html and httpguts packages, not the HTTP/2 implementation; the advisories establish vulnerable dependency presence rather than a demonstrated HTTP/2 exploitation path reachable from this code. A more concrete supply-chain risk is the release workflow (.github/workflows/registry-releaser.yml), which clones and builds an unversioned/unpinned publisher repository and executes it with OIDC publishing authority, meaning any compromise of that external source could execute arbitrary code with release-publishing credentials. Mutable version tags on actions/cache and actions/setup-node in build-ui/action.yml are a related integrity weakness; the absence of a Dependabot cooldown is a hardening gap rather than demonstrated compromise.
golang.org/x/net v0.59.0 golang.org/x/oauth2 v0.37.0
Show 2 more locations
module github.com/github/github-mcp-server/v2 go 1.27.3
- name: Cache UI artifacts id: cache-ui uses: actions/cache@v6 with: path: | pkg/github/ui_dist/get-me.html pkg/github/ui_dist/issue-write.html pkg/github/ui_dist/pr-write.html pkg/github/ui_dist/pr-edit.html key: ui-dist-v3-${{ hashFiles(...) }} enableCrossOsArchive: true - name: Set up Node.js if: steps.cache-ui.outputs.cache-hit != 'true' …+6 more locations in the finding JSON
Recommended fixBump golang.org/x/net to >=0.60.0; pin actions/cache and actions/setup-node to immutable commit SHAs; add dependabot cooldown entries for gomod/npm/docker ecosystems; and pin the registry-releaser workflow's cloned publisher source to a fixed, verified commit or tag rather than building and executing it unpinned with OIDC publish authority.
- 2HighTool Output Sanitization & Injection ContainmentRepository-wide · ATR-C0013Third-party/user-authored content returned by tools (issue bodies, PR bodies, comments, discussions, etc.) is passed through sanitize.Content before being placed into tool results, but sanitize.Content only filters invisible/zero-width/bidi Unicode characters -- it does not strip HTML or code-fence metadata; only sanitize.PlainText, used for title-type fields, performs that additional filtering. An all-ASCII issue body containing a fabricated role/instruction marker passes through sanitize.Content unchanged. The IFC untrusted-content labeling mechanism provides a real mitigation but is feature-gated and is omitted when a visibility lookup fails, so it does not provide a mandatory, universally-applied containment policy. The JSON structural boundary around tool results is a real but partial mitigation that does not, by itself, neutralize crafted instruction-like text from being included verbatim in model-visible output, though this does not demonstrate that a downstream model actually executes an injected instruction.
func convertToMinimalIssue(issue *github.Issue) MinimalIssue { m := MinimalIssue{ Number: issue.GetNumber(), Title: sanitize.PlainText(issue.GetTitle()), Body: sanitize.Content(issue.GetBody()), State: issue.GetState(),Show 2 more locations
func Content(input string) string { return FilterInvisibleCharacters(input) }} } return input } // filterInvisibleFrom resumes FilterInvisibleCharacters at start, the first byte // that could need filtering. It buffers output only once a rune actually // changes, so input that turns out to be clean is still returned as-is. func filterInvisibleFrom(input string, start int) string { var (+2 more locations in the finding JSON
Recommended fixApply instruction/role-marker-aware filtering, not only invisible-character stripping, to all third-party content routed through sanitize.Content, and make IFC untrusted-content labeling mandatory (fail-closed) rather than omitted on visibility-lookup failure, so externally authored text cannot masquerade as system/tool-level instructions.
- 3HighBinding to 0.0.0.0 Without Auth/TLSmcp-server (MCP server) · ATR-M0004The HTTP server binds to all interfaces by default when --listen-host is unset (resolveListenAddress returns ':8082'), and the default self-hosted deployment serves this listener via plaintext ListenAndServe with no TLS termination enforced in-process or required by configuration. Every request is gated by mandatory bearer-token authentication (ExtractUserToken middleware returns 401 with a WWW-Authenticate challenge when the Authorization header is missing), so an unauthenticated caller cannot invoke any tool merely by reaching the port. However, this check does not protect the bearer credential itself in transit: a caller connecting to this default, all-interface, non-TLS listener from a remote network position can have their Authorization header intercepted on-path, since no TLS or loopback restriction is enforced by default for the self-hosted binary. The documented hosted endpoint (https://api.githubcopilot.com/mcp/) is a separate, TLS-terminated deployment and does not protect operators running the default self-hosted configuration directly.
func resolveListenAddress(host string, port int) string { if host == "" { return fmt.Sprintf(":%d", port) } return net.JoinHostPort(host, strconv.Itoa(port)) }Show 2 more locations
addr := resolveListenAddress(cfg.ListenHost, cfg.Port) httpSvr := http.Server{ Addr: addr, Handler: r, ReadHeaderTimeout: 60 * time.Second, }logger.Info("HTTP server listening", "addr", addr) if err := httpSvr.ListenAndServe(); err != nil && err != http.ErrServerClosed { return fmt.Errorf("HTTP server error: %w", err) }+2 more locations in the finding JSON
Recommended fixDefault --listen-host to 127.0.0.1 so the server does not bind to all interfaces without an explicit operator opt-in, and/or require (not merely document) that the process sit behind a TLS-terminating reverse proxy before accepting bearer-token-bearing requests; refuse to start in all-interface mode without TLS unless explicitly overridden.
- 4HighTransport Security: TLS Everywhere (Remote)mcp-server (MCP server) · ATR-M0009RunHTTPServer binds to all interfaces by default via an address derived from an unset --listen-host and serves the connection via plaintext ListenAndServe -- there is no TLS/loopback requirement enforced, and no TLS-terminating proxy is required by the executable itself. Outbound client traffic to the GitHub API does enforce HTTPS, but that outbound enforcement does not secure the inbound MCP transport that this rule evaluates. The documented hosted endpoint (https://api.githubcopilot.com/mcp/) is a separately TLS-terminated deployment and does not establish TLS protection for this executable's own default, self-hosted startup path; a caller connecting directly to the default HTTP listener transmits its bearer Authorization header in cleartext, which an on-path attacker could intercept.
} return ghhttp.RunHTTPServer(httpConfig) },
Show 2 more locations
addr := resolveListenAddress(cfg.ListenHost, cfg.Port) httpSvr := http.Server{ Addr: addr, Handler: r, ReadHeaderTimeout: 60 * time.Second, }logger.Info("HTTP server listening", "addr", addr) if err := httpSvr.ListenAndServe(); err != nil && err != http.ErrServerClosed { return fmt.Errorf("HTTP server error: %w", err) }+2 more locations in the finding JSON
Recommended fixRequire TLS for the HTTP listener by default (or refuse to serve bearer-token-protected routes over plaintext without an explicit operator override), and/or make a TLS-terminating reverse proxy mandatory rather than merely documented for any non-loopback binding.
- 5MediumDoS Protections: Timeouts, Concurrency, Streaming LimitsRepository-wide · ATR-C0004The HTTP server sets ReadHeaderTimeout (60s), which bounds only the time to receive request headers. The request body is read via io.ReadAll in the handler path with no accompanying read deadline or per-request timeout, so a client that completes headers and then sends a body slowly, staying under the configured max-body-size, can hold the connection and its associated goroutine open indefinitely. Neither the max-body-size limit nor the batch-operation semaphores bound this slow-body read path, and no request-level or body-read deadline is configured distinct from the server's long-lived streaming response policy.
r := newHTTPRouter( func(r chi.Router) { // Register Middleware First, needs to be before route registration handler.RegisterMiddleware(r) // Register MCP server routes handler.RegisterRoutes(r) }, oauthHandler.RegisterRoutes, ) logger.Info("MCP endpoints registered", "baseURL", cfg.BaseURL) logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL) addr := resolveListenAddress(cfg.ListenHost, cfg.Port) httpSvr := http.Server{ …Show 2 more locations
func (h *Handler) RegisterMiddleware(r chi.Router) { r.Use( // Must run first: bounds the body before anything downstream reads it. middleware.WithMaxBodySize(h.maxRequestBodyBytes()), middleware.ExtractUserToken(h.oauthCfg), middleware.WithRequestConfig, middleware.WithMCPParse(), middleware.WithPATScopes(h.logger, h.scopeFetcher), )// Only parse POST requests (MCP uses JSON-RPC over POST) if r.Method != http.MethodPost { next.ServeHTTP(w, r) return } // Read the request body body, err := io.ReadAll(r.Body) if err != nil { if isMaxBytesError(err) { writeRequestTooLarge(w) return }+1 more location in the finding JSON
Recommended fixAdd an explicit read deadline (e.g. a timeout handler, SetReadDeadline, or a per-request context deadline applied before io.ReadAll) for request bodies, separate from the timeout used for long-lived streaming responses, so slow-body connections are closed after a bounded interval.
Show the other 4 issues
- 6MediumLogging Hygiene (No Secrets/PII/Tokens) & Audit TrailRepository-wide · ATR-C0005When command/transport logging is enabled (opt-in), the IOLogger logs every incoming and outgoing JSON-RPC message at Info level, including raw tool inputs and results, without redaction or scrubbing. This can include private tool inputs/results and potentially PII or secrets present in repository content. The log file is created with mode 0600 and the feature is opt-in, which reduces exposure, but the logging still writes unscrubbed payloads at a non-debug level, which does not meet the bar of ensuring sensitive content is scrubbed even when verbose logging is enabled. Scope-name logging on the auth path remains clean, recording only scope names rather than secrets.
in = os.Stdin out = os.Stdout if cfg.EnableCommandLogging { loggedIO := mcplog.NewIOLogger(in, out, logger) in, out = loggedIO, loggedIO } // enable GitHub errors in the context ctx := errors.ContextWithGitHubErrors(ctx) errC <- ghServer.Run(ctx, &mcp.IOTransport{Reader: in, Writer: out}) }()Show 2 more locations
// Read reads data from the underlying io.Reader and logs it. func (l *IOLogger) Read(p []byte) (n int, err error) { if l.reader == nil { return 0, io.EOF } n, err = l.reader.Read(p) if n > 0 { l.logger.Info("[stdin]: received bytes", "count", n, "data", string(p[:n])) } return n, err } // Write writes data to the underlying io.Writer and logs it. func (l *IOLogger) Write(p []byte) (n int, err error) { …if cfg.LogFilePath != "" { file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } logOutput = file slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug}) } else { logOutput = os.Stderr slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) }Recommended fixRedact or scrub tool inputs/results before writing them to the IOLogger, or gate raw payload logging to an explicit debug level with documented scrubbing, rather than logging raw JSON-RPC bytes at Info level.
- 7MediumContainer/Runtime HardeningRepository-wide · ATR-C0006The final runtime stage of the Dockerfile is FROM gcr.io/distroless/base-debian12 with no USER directive and no securityContext hardening, so the ENTRYPOINT process runs as root inside the container. If the server process were compromised via any future vulnerability, it would execute as root within the container, maximizing blast radius for container-breakout-adjacent primitives. The distroless base ships no shell/package manager, narrowing but not eliminating impact.
# Make a stage to run the app FROM gcr.io/distroless/base-debian12@sha256:fabbf1c0c357a3d42550111351daed089b20a2c954df13ee2fcff60602515e84 LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" WORKDIR /server COPY --from=build /bin/github-mcp-server . EXPOSE 8082 ENTRYPOINT ["/server/github-mcp-server"] CMD ["stdio"]
Show 1 more location
FROM node:26-alpine@sha256:e88a35be04478413b7c71c455cd9865de9b9360e1f43456be5951032d7ac1a66 AS ui-build WORKDIR /app COPY ui/package*.json ./ui/ RUN cd ui && npm ci COPY ui/ ./ui/ # Create output directory and build - vite outputs directly to pkg/github/ui_dist/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS buildRecommended fixUse gcr.io/distroless/base-debian12:nonroot (or add an explicit non-root USER/numeric UID) as the final base image so the ENTRYPOINT does not run as root; add a HEALTHCHECK and run with a read-only root filesystem at deployment time.
- 8MediumDenial of Wallet / Cost ControlsRepository-wide · ATR-C0012The server exposes actions_run_trigger, which allows a caller with the requisite repository credential to dispatch or rerun GitHub Actions workflows repeatedly, consuming billable runner time. The handler validates parameters and requires appropriate repo scope before allowing the call, but there is no per-user or per-task dispatch quota or spending check layered on top of that access control. GitHub's API rate limits bound request throughput, not the monetary cost or duration of workflows that are launched, so they do not function as a cost control for this capability. This is a missing cost-control layer on an intentionally exposed capability, not an authentication or authorization bypass.
// Actions tools ActionsList(t), ActionsGet(t), ActionsRunTrigger(t), ActionsGetJobLogs(t),
Show 2 more locations
ToolsetMetadataActions, mcp.Tool{ Name: "actions_run_trigger", OutputSchema: actionsRunTriggerOutputSchema(), Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), Annotations: &mcp.ToolAnnotations{scopes.RequireAll(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsRunTriggerInput) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { owner, repo, method := args.Owner, args.Repo, args.Method workflowID, ref, runID, inputs := args.WorkflowID, args.Ref, args.RunID, args.Inputs // Validate required parameters based on action type if method == actionsMethodRunWorkflow { if workflowID == "" { return utils.NewToolResultError("workflow_id is required for run_workflow action"), nil, nil } if ref == "" { return utils.NewToolResultError("ref is required for run_workflow action"), nil, nil } } else if runID == 0 { …+3 more locations in the finding JSON
Recommended fixAdd a per-user or per-task quota/rate limit on actions_run_trigger dispatch/rerun calls, and consider requiring explicit confirmation or a cost-awareness flag before dispatching workflows, to prevent runaway billable CI usage.
- 9MediumCORS / Origin / Host Validation (DNS Rebinding Defense)mcp-server (MCP server) · ATR-M0003SetCorsHeaders unconditionally sets 'Access-Control-Allow-Origin: *' with no Origin allowlist or validation anywhere in the request path, and the HTTP server binds to all interfaces by default when --listen-host is unset -- together these independently satisfy the rule's missing-Origin-validation-plus-all-interface-binding impact condition. Authentication is enforced via an explicit Authorization bearer header rather than cookies or other ambient credentials, which a cross-origin page cannot cause a browser to attach automatically; this mitigates classic ambient-credential CSRF/DNS-rebinding exploitation, since a rebound-origin page cannot forge the victim's bearer token into its requests without already possessing it. This mitigation reduces exploitability but does not establish a demonstrated browser-token-theft or DNS-rebinding exploit chain -- no explicit Origin or Host allowlist exists to close the gap this rule evaluates.
// SetCorsHeaders is middleware that sets CORS headers to allow browser-based // MCP clients to connect from any origin. This is safe because the server // authenticates via bearer tokens (not cookies), so cross-origin requests // cannot exploit ambient credentials. func SetCorsHeaders(h http.Handler) http.Handler { fixedAllowHeaders := strings.Join(corsAllowedRequestHeaders, ", ") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") w.Header().Set("Access-Control-Max-Age", "86400") w.Header().Add("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate") w.Header().Set("Access-Control-Allow-Headers", fixedAllowHeaders)Show 2 more locations
func resolveListenAddress(host string, port int) string { if host == "" { return fmt.Sprintf(":%d", port) } return net.JoinHostPort(host, strconv.Itoa(port)) }func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { inv, err := h.inventoryFactoryFunc(r) ... ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{ ... })Recommended fixAdd an explicit Origin allowlist (or at minimum validate the Host header matches the expected deployment host) in front of the MCP routes, in addition to the existing bearer-token requirement, to provide defense-in-depth against DNS rebinding for any client that does cache/attach tokens automatically.
What’s inside
BlueRock found 1 component in this repository. Each is checked against the rules for its type.
MCP servermcp-server.38Failing2 high1 medium
Its tools can
What stood out
- Direct dependency golang.org/x/net v0.59.0 carries an unpatched HIGH-severity CVE (CVE-2026-78669, HTTP/2 DoS) per Trivy -- fix available in v0.60.0.
- Final Docker runtime stage (gcr.io/distroless/base-debian12) has no USER directive and runs as root (confirmed by Trivy DS-0002 and Semgrep dockerfile.security.missing-user); narrower blast radius due to distroless having no shell, but still a hardening gap.
- HTTP transport binds to all interfaces by default and sets Access-Control-Allow-Origin:* with no Origin/Host allowlist (ATR-M0003); mitigated in practice by mandatory bearer-token auth, but no defense-in-depth Origin check exists.
- Supply-chain hygiene gaps: mutable (non-SHA) action tags in .github/actions/build-ui/action.yml, and missing Dependabot cooldown for gomod/npm/docker ecosystems.
- mcpcurl (cmd/mcpcurl) is a developer CLI that intentionally executes operator-supplied shell commands (Semgrep dangerous-exec-command); it is a local test/dev tool, not part of the deployed MCP server's attack surface.
MCP server rules 3 of 11 flagged
7 rules passed · 1 did not applyShow
PassedOAuth 2.1 + PKCE Correctness (MCP Authorization)ATR-M0005Details
Why it got this result
Remote (HTTP) transport: the server acts strictly as an OAuth 2.0 Protected Resource (RFC 9728) and never implements token issuance itself -- it serves /.well-known/oauth-protected-resource metadata pointing clients at GitHub's own authorization server, which is the MCP-spec-compliant delegation pattern. Local (stdio) transport: internal/oauth/flow.go implements authorization-code plus PKCE with S256 challenge, a cryptographically random state parameter validated on callback (a 'state mismatch' check), and a local callback listener; native CLI runs bind this callback listener to loopback, while the Docker-container code path explicitly binds it to all interfaces when running in a container, accompanied by an explicit warning instructing operators to publish the port on loopback only. State validation and PKCE verification are enforced regardless of which interface the callback listener binds to, so the container-mode binding does not itself defeat the CSRF/code-interception protections those checks provide.
Why it matters
Misconfig enables token theft and confused-deputy attacks.
Evidence
func (m *Manager) beginPKCE(prompter Prompter) (*flowPlan, error) {
state, err := randomState()
...
verifier := oauth2.GenerateVerifier()
...
authURL := oc.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
run := func(ctx context.Context) (*oauth2.Token, error) {
code, err := cs.wait(ctx)
...
tok, err := oc.Exchange(ctx, code, oauth2.VerifierOption(verifier))
...Show 2 more locations
if q.Get("state") != expectedState {
cs.report(callbackResult{err: fmt.Errorf("state mismatch (possible CSRF)")})
renderError(w, "state mismatch")
return
}func (h *AuthHandler) metadataHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
...
metadata := &oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authorizationServerURL},
...PassedNo Authentication or Authorization Checks (General)ATR-M0010Details
Why it got this result
The HTTP listener requires an Authorization header on every request before tool dispatch: ExtractUserToken middleware parses and validates the header's format/prefix and denies (401 plus WWW-Authenticate challenge) any request with a missing or malformed Authorization header, fail-closed by default; it runs after the request body-size limiting middleware rather than first in the chain. This middleware validates token format/prefix rather than verifying the credential's secret value itself -- the caller's own token is threaded through context and the real authorization/credential check happens when that token is used to call the GitHub API, so every tool action's actual authorization is enforced by GitHub against the caller's own scopes and secret, not by a locally implemented secret-comparison step. There is no shared or ambient service-account identity applied across callers; each request's own credential is what GitHub ultimately verifies.
Why it matters
Prevents unauthorized tool use and data access.
Evidence
func (h *Handler) RegisterMiddleware(r chi.Router) {
r.Use(
middleware.WithMaxBodySize(h.maxRequestBodyBytes()),
middleware.ExtractUserToken(h.oauthCfg),
middleware.WithRequestConfig,
middleware.WithMCPParse(),
middleware.WithPATScopes(h.logger, h.scopeFetcher),
)
if h.config.ScopeChallenge {
r.Use(middleware.WithScopeChallenge(h.oauthCfg, h.scopeFetcher))
}
}Show 1 more location
tokenType, token, err := utils.ParseAuthorizationHeader(r)
if err != nil {
if errors.Is(err, utils.ErrMissingAuthorizationHeader) {
sendAuthChallenge(w, r, oauthCfg)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}PassedTool Definition Integrity & Versioning (ETDI-style)ATR-M0001Details
Why it got this result
All 116+ MCP tools are statically compiled Go code registered via AllTools() in pkg/github/tools.go; there is no runtime fetch or hot-reload of tool manifests from a URL or external file. Releases are built and published via CI workflows carrying signed/attested build provenance, and a dedicated MCP Server Diff workflow runs on every PR to flag unexpected tool schema changes. However, pkg/translations/translations.go does read a github-mcp-server-config.json file from the current working directory and supports environment-variable overrides for tool description text, so not every piece of tool metadata is strictly immutable at runtime, though no attacker-controlled path to reach or modify that local config/environment at runtime was established. The diff workflow's presence demonstrates comparison reporting on schema changes; it does not by itself demonstrate that human approval or branch protection is mandatorily enforced before a changed definition can ship.
Why it matters
Prevents rug-pull tool poisoning.
Evidence
func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []inventory.ServerTool {
cfg := newToolConfig(opts)
return withCSVOutput([]inventory.ServerTool{
// Context tools
GetMe(t),
GetTeams(t),
GetTeamMembers(t),
// Repository tools
SearchRepositories(t),
GetFileContents(t),
...Show 1 more location
name: MCP Server Diff
on:
pull_request:
push:
branches: [main]
tags: ['v*']
permissions:
contents: read
jobs:
mcp-diff:
runs-on: ubuntu-latest
…PassedUnrestricted Tool Permissions (Overprivileged Tools)ATR-M0002Details
Why it got this result
Tools are scoped using GitHub's own OAuth-scope model: pkg/scopes/scopes.go defines RequireAll/PublicRead/DynamicChallenge helpers that gate each tool's visibility/challenge behavior by the OAuth scopes it needs. pkg/http/handler.go's PATScopeFilter narrows the visible tool inventory to the caller's actual PAT scopes, but this filtering is advisory defense-in-depth rather than a universal, fail-closed control: it skips filtering for non-classic tokens and returns the inventory unchanged when a scope-fetch call errors. The control that actually bounds privilege regardless of this filtering is that every tool call is ultimately executed against GitHub's REST/GraphQL API using the caller's own token, so GitHub's server-side permission system, not the scope-filter narrowing, is the final enforcement boundary. Read-only mode (--read-only / X-MCP-Readonly) still enforces an upper bound on tool visibility that per-request headers cannot widen.
Why it matters
Limits blast radius and privilege escalation.
Evidence
func RequireAll(required ...Scope) inventory.ScopeAccess {
required = append([]Scope(nil), required...)
requiredScopes := scopeStrings(required)
return inventory.ScopeAccess{
Scopes: append([]string(nil), requiredScopes...),
Visible: func(activeScopes []string) bool {
return HasAll(activeScopes, required...)
},
Challenge: func(_ map[string]any, activeScopes []string) []string {
if HasAll(activeScopes, required...) {
return nil
}
return append([]string(nil), requiredScopes...)
},
…Show 1 more location
func PATScopeFilter(b *inventory.Builder, r *http.Request, fetcher scopes.FetcherInterface) *inventory.Builder {
ctx := r.Context()
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
if !ok || tokenInfo == nil {
return b
}
if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken {
existingScopes, ok := ghcontext.GetTokenScopes(ctx)
if ok {
return b.WithFilter(github.CreateToolScopeFilter(existingScopes))
}
...PassedCORS Misuse: Wildcard with CredentialsATR-M0008Details
Why it got this result
SetCorsHeaders sets 'Access-Control-Allow-Origin: *' but never sets 'Access-Control-Allow-Credentials: true' anywhere in the codebase. Authentication uses an explicit Authorization bearer header rather than cookies, so the dangerous wildcard-origin-plus-credentials combination this rule targets does not exist.
Why it matters
Prevents credentialed cross-origin abuse.
Evidence
func SetCorsHeaders(h http.Handler) http.Handler {
fixedAllowHeaders := strings.Join(corsAllowedRequestHeaders, ", ")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Max-Age", "86400")
w.Header().Add("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate")
w.Header().Set("Access-Control-Allow-Headers", fixedAllowHeaders)
...Show 1 more location
assert.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials"))PassedTool Name Collision / SpoofingATR-M0006Details
Why it got this result
Enumerated all 116+ registered tool names (e.g. get_file_contents, list_issues, search_code, create_pull_request). None are homographs of, or identical to, a specifically-named tool from another widely-deployed MCP server or client built-in. Names are generic GitHub-domain verbs, which the rule's own negative indicator treats as not itself a positive indicator absent a named collision.
Why it matters
Prevents malicious tool shadowing.
Evidence
func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []inventory.ServerTool {
cfg := newToolConfig(opts)
SearchRepositories(t), GetFileContents(t), ListCommits(t), SearchCode(t), ...PassedPrompt/Metadata Injection in Tool DescriptionsATR-M0007Details
Why it got this result
Searched all tool-description strings in pkg/github/*.go for zero-width/invisible Unicode characters and for common injection phrasing -- no matches found. Tool descriptions are short, functional, English-language strings describing the GitHub operation performed. Separately, the server actively sanitizes untrusted content returned from the GitHub API before it reaches the model, which is a related but distinct control from tool-description integrity.
Why it matters
Prevents covert model manipulation.
Evidence
func Sanitize(input string) string {
filtered := FilterCodeFenceMetadata(FilterInvisibleCharacters(input))
normalized := FilterHTMLTags(filtered)
if normalized == filtered {
return normalized
}
return FilterCodeFenceMetadata(FilterInvisibleCharacters(normalized))
}Does not applyOAuth Proxy Per-Client Consent (Confused Deputy Defense)ATR-M0011Details
Why it got this result
This server is not an OAuth proxy. The HTTP transport only publishes OAuth Protected Resource Metadata pointing MCP clients directly at GitHub's own authorization server -- it never issues, proxies, or mints bridge authorization codes on a client's behalf, and exposes no dynamic MCP-client registration endpoint. The stdio flow is a direct, single-tenant OAuth client rather than a multi-tenant proxy forwarding to a distinct upstream IdP for dynamically registered third-party clients.
Why it matters
Shared upstream client + dynamic MCP-client registration + no per-client consent enables attacker-registered MCP clients to receive bridge codes carrying victim upstream tokens — the documented MCP Confused Deputy attack.
Evidence
func (h *AuthHandler) metadataHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
...
metadata := &oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authorizationServerURL},
ResourceName: "GitHub MCP Server",
...
auth.ProtectedResourceMetadataHandler(metadata).ServeHTTP(w, r)
})
}Show its 116 tools
| Tool | It can |
|---|---|
actions_get | Read dataReturns untrusted content |
actions_list | Read dataReturns untrusted content |
actions_run_trigger | Create or change dataSpend money or metered resourcesDelete or overwrite data |
add_comment_to_pending_review | Create or change data |
add_issue_comment | Create or change data |
add_issue_comment_reaction | Create or change data |
add_issue_reaction | Create or change data |
add_pull_request_review_comment | Create or change data |
add_pull_request_review_comment_reaction | Create or change data |
add_reply_to_pull_request_comment | Create or change data |
assign_copilot_to_issue | Create or change dataSpend money or metered resources |
assign_copilot_to_issue_with_intent | Create or change dataSpend money or metered resources |
create_branch | Create or change data |
create_gist | Create or change data |
create_issue | Create or change data |
create_or_update_file | Create or change data |
create_pull_request | Create or change data |
create_pull_request_review | Create or change data |
create_repository | Create or change data |
create_repository_ruleset | Change settings |
custom_properties_read | Read data |
custom_properties_write | Change settings |
delete_file | Create or change dataDelete or overwrite data |
delete_pending_pull_request_review | Create or change dataDelete or overwrite data |
delete_repository | Delete or overwrite dataChange settings |
discussion_comment_write | Create or change data |
dismiss_notification | Create or change data |
find_duplicate | Read dataReturns untrusted content |
fork_repository | Create or change data |
get_code_quality_finding | Read data |
get_code_scanning_alert | Read data |
get_commit | Read dataReturns untrusted content |
get_dependabot_alert | Read data |
get_discussion | Read dataReturns untrusted content |
get_discussion_comments | Read dataReturns untrusted content |
get_file_blame | Read dataReturns untrusted content |
get_file_contents | Read dataReturns untrusted content |
get_gist | Read dataReturns untrusted content |
get_global_security_advisory | Read data |
get_job_logs | Read dataReturns untrusted content |
get_label | Read data |
get_latest_release | Read dataReturns untrusted content |
get_me | Read data |
get_notification_details | Read dataReturns untrusted content |
get_release_by_tag | Read dataReturns untrusted content |
get_repository_tree | Read data |
get_secret_scanning_alert | Read dataUse secrets and credentials |
get_tag | Read data |
get_team_members | Read data |
get_teams | Read data |
issue_dependency_read | Read data |
issue_dependency_write | Create or change data |
issue_read | Read dataReturns untrusted content |
issue_write | Create or change data |
label_write | Create or change dataDelete or overwrite data |
list_branches | Read data |
list_code_scanning_alerts | Read data |
list_commits | Read dataReturns untrusted content |
list_dependabot_alerts | Read data |
list_discussion_categories | Read data |
list_discussions | Read dataReturns untrusted content |
list_gists | Read dataReturns untrusted content |
list_global_security_advisories | Read data |
list_issue_fields | Read data |
list_issue_types | Read data |
list_issues | Read dataReturns untrusted content |
list_label | Read data |
list_notifications | Read data |
list_org_repository_security_advisories | Read data |
list_pull_requests | Read dataReturns untrusted content |
list_releases | Read dataReturns untrusted content |
list_repository_collaborators | Read data |
list_repository_security_advisories | Read data |
list_secret_scanning_alerts | Read dataUse secrets and credentials |
list_starred_repositories | Read data |
list_tags | Read data |
manage_notification_subscription | Create or change data |
manage_repository_notification_subscription | Create or change data |
mark_all_notifications_read | Create or change data |
merge_pull_request | Create or change data |
projects_get | Read dataReturns untrusted content |
projects_list | Read dataReturns untrusted content |
projects_write | Create or change dataDelete or overwrite data |
pull_request_read | Read dataReturns untrusted content |
pull_request_review_write | Create or change dataDelete or overwrite data |
push_files | Create or change data |
remove_issue_comment_reaction | Create or change data |
remove_issue_reaction | Create or change data |
remove_pull_request_review_comment_reaction | Create or change data |
repository_ruleset_read | Read data |
request_copilot_review | Create or change dataSpend money or metered resources |
request_pull_request_reviewers | Create or change data |
resolve_review_thread | Create or change data |
search_code | Read dataReturns untrusted content |
search_commits | Read dataReturns untrusted content |
search_issues | Read dataReturns untrusted content |
search_orgs | Read data |
search_pull_requests | Read dataReturns untrusted content |
search_repositories | Read dataReturns untrusted content |
search_users | Read data |
set_issue_fields | Create or change data |
star_repository | Post or message on your behalf |
sub_issue_write | Create or change data |
submit_pending_pull_request_review | Create or change data |
ui_get | Read data |
unresolve_review_thread | Create or change data |
unstar_repository | Post or message on your behalf |
update_gist | Create or change data |
update_issue_assignees | Create or change data |
update_issue_comment | Create or change data |
update_issue_labels | Create or change data |
update_issue_state | Create or change data |
update_issue_type | Create or change data |
update_pull_request | Create or change data |
update_pull_request_branch | Create or change data |
update_pull_request_draft_state | Create or change data |
Before you put it to work
- Bump golang.org/x/net to >=0.60.0 and re-run go mod tidy to resolve CVE-2026-78669 and related CVEs.
- Add a non-root USER directive to the final Docker stage (e.g. gcr.io/distroless/base-debian12:nonroot).
- Add an Origin/Host allowlist (or strict same-site check) to the HTTP transport as defense-in-depth alongside the existing bearer-token requirement.
- Default --listen-host to 127.0.0.1 for local/dev usage and document that production deployments must sit behind a TLS-terminating proxy.
- Pin all GitHub Actions to full commit SHAs and add Dependabot cooldown blocks for gomod/npm/docker ecosystems.
- Set explicit Content-Type on all HTTP error responses that echo request-derived strings, and refactor registry-releaser.yml to use env-indirection for github.ref_type/github.ref_name consistent with the rest of the repo.
Repository-wide rules
Checked once for the whole repository: dependencies, secrets, inputs, networking, CI and more. 6 of 15 flagged.
9 rules passedShow
PassedRuntime Command Injection via Process ExecutionATR-C0003Details
Why it got this result
All exec.Command call sites were enumerated: openBrowser() invokes a fixed platform launcher with an internally-constructed OAuth URL as a single argv element, with the URL produced by the server's own OAuth flow code rather than a remote attacker, and argv-vector execution prevents shell metacharacter injection regardless. The mcpcurl developer CLI builds a command from an operator-supplied string but is a standalone developer/test tool, not the shipped MCP server runtime. No exec/shell invocation was found reachable from the actual server runtime's tool-call handling path.
Why it matters
Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.
Evidence
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return errNoDisplay
}
cmd = exec.Command("xdg-open", url)
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)Show 2 more locations
func executeServerCommand(cmdStr, jsonRequest string) (string, error) {
cmdParts := strings.Fields(cmdStr)
if len(cmdParts) == 0 {
return "", fmt.Errorf("empty command")
}
cmd := exec.Command(cmdParts[0], cmdParts[1:]...) //nolint:gosec //mcpcurl is a test command that needs to execute arbitrary shell commands+3 more locations in the finding JSON
PassedWeaponization: Malicious-Purpose Artifact (Offensive Capability by Design)ATR-R0001Details
Why it got this result
This repository is the official github/github-mcp-server: its declared purpose is to let an AI agent interact with GitHub repositories, issues, PRs, CI/CD, and code analysis via the GitHub API. No bundled script, tool description, or instruction body directs generation of malware, exploits, credential theft, or AV/EDR evasion, and no capability evades oversight/attribution. This is unambiguously a defensive/productivity developer tool, not offensive tooling.
Why it matters
An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.
Evidence
# GitHub MCP Server The GitHub MCP Server connects AI tools directly to GitHub's platform. This gives AI agents, assistants, and chatbots the ability to read repositories and code files, manage issues and PRs, analyze code, and automate workflows. All through natural language interactions. ### Use Cases - Repository Management: Browse and query code, search files, analyze commits, and understand project structure across any repository you have access to. - Issue & PR Automation: Create, update, and manage issues and pull requests. Let AI help triage bugs, review code changes, and maintain project boards. - CI/CD & Workflow Intelligence: Monitor GitHub Actions workflow runs, analyze build failures, manage releases, and get insights into your development pipeline. - Code Analysis: Examine security findings, review Dependabot alerts, understand code patterns, and get comprehensive insights into your codebase. - Team Collaboration: Access discussions, manage notifications, analyze team activity, and streamline processes for your team.
PassedUnrestricted Network Fetch / SSRFATR-C0001Details
Why it got this result
The repo exposes no tool that fetches an arbitrary attacker-supplied URL/host. The only outbound HTTP fetch of a dynamic URL is downloadLogContent(), which fetches a short-lived download URL returned by an authenticated GitHub API call, not a user-supplied host. The raw-content client builds URLs by joining a fixed, server-configured base host with owner/repo/ref/path, explicitly rejecting path-traversal segments. All other GitHub API calls go through a fixed-base-URL SDK client. No generic fetch-URL style tool exists.
Why it matters
Enables internal pivoting and data exfiltration.
Evidence
func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) {
prof := profiler.New(nil, profiler.IsProfilingEnabled())
finish := prof.Start(ctx, "log_buffer_processing")
httpResp, err := http.Get(logURL) //nolint:gosec
if err != nil {
return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()Show 2 more locations
func (c *Client) refURL(owner, repo, ref, path string) (string, error) {
if ref == "" {
ref = "HEAD"
}
if err := rejectPathTraversal(owner, repo, ref, path); err != nil {
return "", err
}
return c.url.JoinPath(owner, repo, ref, path).String(), nil
}PassedStatic SecretsATR-C0002Details
Why it got this result
Gitleaks (full git history scan) returned 0 findings. Manual grep for API_KEY/SECRET/PASSWORD/TOKEN literal assignments across Go/YAML/JSON/env files found no committed secret values. The only credential-adjacent code declares empty OAuth client ID/secret package vars populated only at build time via ldflags from build secrets mounted during the Docker build, and these are documented as public OAuth client identifiers kept out of source.
Why it matters
Plaintext secrets are easily exfiltrated.
Evidence
Code not shown: this line may hold a secret.
Show 1 more location
Code not shown: this line may hold a secret.
PassedMissing or Inadequate Input ValidationATR-C0008Details
Why it got this result
Tool parameters are declared with JSON Schema and fetched via typed helper functions that enforce type conversion and return errors on mismatch rather than silently coercing. Pagination and other numeric params go through dedicated schema builders with declared min/max constraints. The MCP SDK performs schema validation of incoming tool-call arguments against each tool's declared InputSchema before the handler runs.
Why it matters
Frontline control to prevent multiple classes of vulns.
Evidence
// OptionalParam is a helper function that can be used to fetch a requested parameter from the request.
func OptionalParam[T any](args map[string]any, p string) (T, error) {PassedInsecure DeserializationATR-C0009Details
Why it got this result
The codebase uses standard encoding/json, which can invoke custom UnmarshalJSON methods during decoding; the custom UnmarshalJSON implementations present in this codebase were inspected and only perform safe, bounded field decoding, with no code-executing or constructor-invoking behavior. Batch mutation handling does build a struct type dynamically via reflect.StructOf, but the fields of that struct have fixed, statically-determined types -- the dynamic construction selects among known result shapes rather than resolving an attacker-supplied type name into an arbitrary executable constructor. No pickle-equivalent, gob, or yaml.Unmarshal-into-interface{} pattern capable of executing code on load exists in the codebase.
Why it matters
Can lead to arbitrary code execution.
Evidence
if err := json.Unmarshal(encoded, schema); err != nil {
return nil, fmt.Errorf(...)
}PassedPath Traversal & Symlink Bypass (Filesystem Tools)ATR-C0011Details
Why it got this result
No MCP tool handler accepts a caller-supplied path and resolves it against the local filesystem; GitHub-facing file tools operate through the GitHub API against a remote repository, not local disk. The raw-content client explicitly rejects path-traversal segments, including recursively percent-decoded '..', before constructing its URL. Local-filesystem reads from a variable path exist only for operator-controlled purposes: a test-only snapshot helper, private-key loading for GitHub App authentication, CLI documentation-generation commands, and configured log file writes -- none of these accept a network/request-controlled path; they are populated from local CLI flags or operator configuration, not from MCP tool-call arguments.
Why it matters
Traversal exposes arbitrary host files; can lead to code execution.
Evidence
func rejectSegment(segment string) error {
if segment == "" {
return nil
}
if segment == ".." {
return errPathTraversal
}
decoded, err := url.PathUnescape(segment)
if err != nil || decoded == segment {
return nil
}
for subsegment := range strings.SplitSeq(decoded, "/") {
if err := rejectSegment(subsegment); err != nil {
return err
…Show 1 more location
PassedCI/CD Workflow Script InjectionATR-C0014Details
Why it got this result
The registry-releaser workflow interpolates github.ref_type and github.ref_name directly into shell run blocks, but it only triggers on push of 'v*' tags or manual workflow_dispatch, both of which require the actor to already hold repository push/write privileges; no protected-environment boundary or lower-privilege trigger exists that would let a less-privileged actor reach this workflow, and the actor pushing the tag can already supply the checked-out executable code that the same job runs. This does not establish that every repository writer necessarily holds all release or secrets privileges beyond what this specific workflow exposes. Other workflows use the safe env-indirection pattern: issue-labeler.yml and docker-publish.yml assign untrusted values to env vars and reference them as quoted shell variables rather than interpolating them directly, and the license-check workflow's PR-number interpolation is a GitHub-enforced numeric value. No issue_comment or pull_request_target workflow interpolates untrusted event content into a shell run block.
Why it matters
CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.
Evidence
on:
push:
tags: ["v*"] # Triggers on version tags like v1.0.0
workflow_dispatch: # Allow manual triggering
...
- name: Wait for Docker image
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
TAG="${{ github.ref_name }}"
elseShow 2 more locations
on:
issues:
types: [reopened, opened]
jobs:
label_issues:
permissions:
issues: write
steps:
- name: Add AI review label to issue
run: gh issue edit "$NUMBER" --add-label "$LABELS"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}
… - name: Wait for Docker image
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
TAG="${{ github.ref_name }}"
else
TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1)
fi
IMAGE="ghcr.io/github/github-mcp-server:$TAG"+1 more location in the finding JSON
PassedLack of Output Encoding/SanitizationATR-C0010Details
Why it got this result
The two non-test direct ResponseWriter.Write call sites were reviewed: one sets an explicit JSON content type before writing, avoiding content-type sniffing/HTML-rendering risk, and the other's error response always begins with a fixed, non-attacker-controlled prefix, which prevents content-sniffing from ever classifying the response as HTML even though no explicit Content-Type is set. Separately, sanitize.Content, used for third-party GitHub content such as issue/PR bodies, only filters invisible/zero-width/bidi Unicode characters -- it does not strip HTML tags or code-fence metadata; sanitize.PlainText is the function that additionally applies HTML stripping and code-fence filtering, used for title-type fields. The UI renders content via React text interpolation rather than raw HTML insertion, providing an additional safe output context.
Why it matters
Prevents UI-level injection and operator compromise.
Evidence
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
inv, err := h.inventoryFactoryFunc(r)
if err != nil {
if errors.Is(err, inventory.ErrUnknownTools) {
w.WriteHeader(http.StatusBadRequest)
if _, writeErr := w.Write([]byte(err.Error())); writeErr != nil {
h.logger.Error("failed to write response", "error", writeErr)
}
return
}Show 1 more location
// ErrUnknownTools is returned when tools specified via WithTools() are not recognized.
ErrUnknownTools = errors.New("unknown tools specified in WithTools")You know what it is. Now see what it does.
A scan helps you understand what you’re building with. When your agent runs, BlueRock shows you what actually happens across tools and systems. Follow actions in context, understand behavior as it unfolds and add controls when you need them.
Results reflect the code at the commit and date shown.