@zereight/mcp-gitlab 2.1.42 → 2.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +3 -3
- package/README.md +9 -3
- package/README.zh-CN.md +3 -3
- package/build/downloads/proxy.js +3 -2
- package/build/index.js +286 -42
- package/build/oauth-proxy.js +2 -2
- package/build/oauth.js +2 -5
- package/build/schemas.js +67 -0
- package/build/scripts/check-skill-sync.js +1 -0
- package/build/scripts/generate-tool-docs.js +6 -1
- package/build/test/dynamic-api-url-allowlist.test.js +18 -1
- package/build/test/test-toolset-filtering.js +2 -0
- package/build/test/test-vulnerabilities.js +399 -0
- package/build/tools/registry.js +34 -1
- package/build/utils/logger.js +51 -0
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
80
80
|
|
|
81
81
|
예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
|
|
82
82
|
|
|
83
|
-
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.
|
|
83
|
+
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.42`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
|
|
84
84
|
|
|
85
85
|
#### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
|
|
86
86
|
|
|
@@ -479,7 +479,7 @@ node build/index.js
|
|
|
479
479
|
AI 에이전트가 skill/instruction 로딩을 지원하는 경우(Claude Code, GitHub Copilot, Cursor 등), [`skills/gitlab-mcp/`](./skills/gitlab-mcp/)에 미리 작성된 skill 파일을 사용할 수 있습니다.
|
|
480
480
|
|
|
481
481
|
- **[SKILL.md](./skills/gitlab-mcp/SKILL.md)** — 도구셋 개요, 핵심 워크플로우, 파라미터 힌트를 담은 핵심 가이드
|
|
482
|
-
- **[reference/](./skills/gitlab-mcp/reference/)** — 코드 리뷰, 머지 리퀘스트, 이슈,
|
|
482
|
+
- **[reference/](./skills/gitlab-mcp/reference/)** — 코드 리뷰, 머지 리퀘스트, 이슈, 파이프라인, 취약점 트리아지용 상세 워크플로우 문서
|
|
483
483
|
|
|
484
484
|
`skills` CLI로 설치:
|
|
485
485
|
|
|
@@ -491,7 +491,7 @@ AI 클라이언트에 skill 디렉터리를 등록하면 전체 ListTools 응답
|
|
|
491
491
|
|
|
492
492
|
## 도구 🛠️
|
|
493
493
|
|
|
494
|
-
전체 도구 목록은 영어 README의 [Tools 섹션](./README.md#tools-%EF%B8%8F)을 참고하세요. 현재 서버는 머지 리퀘스트, 이슈, 파이프라인, 배포, 환경, 아티팩트, 마일스톤, 위키, 저장소, 릴리스, 사용자, 이벤트, work item, 웹훅, 코드 검색, GraphQL 실행 도구를 제공합니다.
|
|
494
|
+
전체 도구 목록은 영어 README의 [Tools 섹션](./README.md#tools-%EF%B8%8F)을 참고하세요. 현재 서버는 머지 리퀘스트, 이슈, 파이프라인, 배포, 환경, 아티팩트, 마일스톤, 위키, 저장소, 릴리스, 사용자, 이벤트, work item, 웹훅, 코드 검색, CI/CD 변수, dependency proxy, 취약점 트리아지, GraphQL 실행 도구를 제공합니다.
|
|
495
495
|
|
|
496
496
|
### Wiki 페이지 제목과 slug
|
|
497
497
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# GitLab MCP Server
|
|
2
2
|
|
|
3
|
+
[](https://mcptoplist.com/server/io.github.zereight%2Fgitlab-mcp)
|
|
4
|
+
|
|
3
5
|
[English](./README.md) | [한국어](./README.ko.md) | [简体中文](./README.zh-CN.md)
|
|
4
6
|
|
|
5
7
|
📖 **[Documentation →](https://zereight.github.io/gitlab-mcp/)** Setup guides, environment variables, and the full tool reference live on the hosted docs site.
|
|
@@ -80,7 +82,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
80
82
|
|
|
81
83
|
The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
|
|
82
84
|
|
|
83
|
-
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.
|
|
85
|
+
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.42`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
84
86
|
|
|
85
87
|
#### Using CLI Arguments (for clients with env var issues)
|
|
86
88
|
|
|
@@ -523,7 +525,7 @@ No `headers` field is needed — Claude.ai obtains the token via OAuth automatic
|
|
|
523
525
|
Pre-built skill files are available in [`skills/gitlab-mcp/`](./skills/gitlab-mcp/) for AI agents that support skill/instruction loading (Claude Code, GitHub Copilot, Cursor, etc.).
|
|
524
526
|
|
|
525
527
|
- **[SKILL.md](./skills/gitlab-mcp/SKILL.md)** — Core guide (~800 tokens) with toolset overview, key workflows, and parameter hints
|
|
526
|
-
- **[reference/](./skills/gitlab-mcp/reference/)** — Detailed workflow docs for code review, merge requests, issues, and
|
|
528
|
+
- **[reference/](./skills/gitlab-mcp/reference/)** — Detailed workflow docs for code review, merge requests, issues, pipelines, and vulnerability triage
|
|
527
529
|
|
|
528
530
|
Install with the `skills` CLI:
|
|
529
531
|
|
|
@@ -751,7 +753,11 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
|
|
|
751
753
|
209. `update_dependency_proxy_settings` - Update dependency proxy settings for a group (enable/disable, credentials for authenticated Docker Hub pulls)
|
|
752
754
|
210. `list_dependency_proxy_blobs` - List cached dependency proxy blobs for a group with cursor-based pagination
|
|
753
755
|
211. `purge_dependency_proxy_cache` - Schedule purge of all cached dependency proxy blobs for a group
|
|
754
|
-
212. `
|
|
756
|
+
212. `list_project_vulnerabilities` - List vulnerabilities for a project with optional state, severity, and report type filters (GraphQL-backed, cursor pagination)
|
|
757
|
+
213. `get_vulnerability` - Get full details of a specific vulnerability
|
|
758
|
+
214. `dismiss_vulnerability` - Dismiss a vulnerability with a reason (acceptable_risk, false_positive, used_in_tests, mitigating_control, not_applicable) and optional comment
|
|
759
|
+
215. `confirm_vulnerability` - Confirm a vulnerability as a real finding requiring remediation
|
|
760
|
+
216. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities. Already-active categories are listed in the response.
|
|
755
761
|
|
|
756
762
|
<!-- TOOLS-END -->
|
|
757
763
|
|
package/README.zh-CN.md
CHANGED
|
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
80
80
|
|
|
81
81
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
82
82
|
|
|
83
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.
|
|
83
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.42`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
84
84
|
|
|
85
85
|
#### 使用 CLI 参数(适用于环境变量有问题的客户端)
|
|
86
86
|
|
|
@@ -479,7 +479,7 @@ node build/index.js
|
|
|
479
479
|
对于支持 skill/instruction 加载的 AI 代理(Claude Code、GitHub Copilot、Cursor 等),[`skills/gitlab-mcp/`](./skills/gitlab-mcp/) 中提供了预构建 skill 文件。
|
|
480
480
|
|
|
481
481
|
- **[SKILL.md](./skills/gitlab-mcp/SKILL.md)** — 核心指南,包含工具集概览、关键工作流和参数提示
|
|
482
|
-
- **[reference/](./skills/gitlab-mcp/reference/)** —
|
|
482
|
+
- **[reference/](./skills/gitlab-mcp/reference/)** — 代码审查、合并请求、议题、流水线和漏洞分类的详细工作流文档
|
|
483
483
|
|
|
484
484
|
使用 `skills` CLI 安装:
|
|
485
485
|
|
|
@@ -491,7 +491,7 @@ npx skills add zereight/gitlab-mcp --skill gitlab-mcp-skill
|
|
|
491
491
|
|
|
492
492
|
## 工具 🛠️
|
|
493
493
|
|
|
494
|
-
完整工具列表请参考英文 README 的 [Tools 部分](./README.md#tools-%EF%B8%8F)。当前服务器提供合并请求、议题、流水线、部署、环境、制品、里程碑、Wiki、仓库、发布、用户、事件、work item、webhook
|
|
494
|
+
完整工具列表请参考英文 README 的 [Tools 部分](./README.md#tools-%EF%B8%8F)。当前服务器提供合并请求、议题、流水线、部署、环境、制品、里程碑、Wiki、仓库、发布、用户、事件、work item、webhook、代码搜索、CI/CD 变量、dependency proxy、漏洞分类和 GraphQL 执行相关工具。
|
|
495
495
|
|
|
496
496
|
### Wiki 页面标题与 slug
|
|
497
497
|
|
package/build/downloads/proxy.js
CHANGED
|
@@ -100,8 +100,9 @@ export function registerDownloadProxy(app, deps) {
|
|
|
100
100
|
try {
|
|
101
101
|
apiUrl = deps.resolveTrustedGitLabApiUrl(requestedApiUrl);
|
|
102
102
|
}
|
|
103
|
-
catch {
|
|
104
|
-
|
|
103
|
+
catch (error) {
|
|
104
|
+
const message = error instanceof Error ? error.message : "Invalid X-GitLab-API-URL";
|
|
105
|
+
res.status(400).json({ error: message });
|
|
105
106
|
return;
|
|
106
107
|
}
|
|
107
108
|
}
|
package/build/index.js
CHANGED
|
@@ -103,47 +103,11 @@ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMerg
|
|
|
103
103
|
GitLabDiscussionNoteSchema, // Added
|
|
104
104
|
GitLabDiscussionSchema,
|
|
105
105
|
// Draft Notes Schemas
|
|
106
|
-
GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
106
|
+
GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
107
107
|
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
|
|
108
108
|
import { randomUUID, timingSafeEqual, } from "node:crypto";
|
|
109
|
-
import {
|
|
110
|
-
const logger =
|
|
111
|
-
level: process.env.LOG_LEVEL || "info",
|
|
112
|
-
// Redact sensitive values that must never appear in logs. Covers both
|
|
113
|
-
// typical auth-context property names and common HTTP header-bag shapes
|
|
114
|
-
// that tool/fetch wrappers may include when logging errors.
|
|
115
|
-
redact: {
|
|
116
|
-
paths: [
|
|
117
|
-
"token",
|
|
118
|
-
"*.token",
|
|
119
|
-
"ctx.token",
|
|
120
|
-
"context.token",
|
|
121
|
-
"authData.token",
|
|
122
|
-
"auth.token",
|
|
123
|
-
"headers.authorization",
|
|
124
|
-
"headers.Authorization",
|
|
125
|
-
'headers["private-token"]',
|
|
126
|
-
'headers["Private-Token"]',
|
|
127
|
-
'headers["job-token"]',
|
|
128
|
-
'headers["JOB-TOKEN"]',
|
|
129
|
-
'headers["mcp-session-id"]',
|
|
130
|
-
'headers["Mcp-Session-Id"]',
|
|
131
|
-
"sessionId",
|
|
132
|
-
"*.sessionId",
|
|
133
|
-
"ctx.sessionId",
|
|
134
|
-
"context.sessionId",
|
|
135
|
-
],
|
|
136
|
-
censor: "[REDACTED]",
|
|
137
|
-
},
|
|
138
|
-
transport: {
|
|
139
|
-
target: "pino-pretty",
|
|
140
|
-
options: {
|
|
141
|
-
colorize: true,
|
|
142
|
-
levelFirst: true,
|
|
143
|
-
destination: 2,
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
});
|
|
109
|
+
import { createLogger } from "./utils/logger.js";
|
|
110
|
+
const logger = createLogger();
|
|
147
111
|
/**
|
|
148
112
|
* Read version from package.json
|
|
149
113
|
*/
|
|
@@ -1146,7 +1110,8 @@ function resolveTrustedGitLabApiUrl(value) {
|
|
|
1146
1110
|
}
|
|
1147
1111
|
const allowedApiUrl = GITLAB_ALLOWED_API_URLS_BY_HOST.get(parsed.host);
|
|
1148
1112
|
if (!allowedApiUrl) {
|
|
1149
|
-
throw new Error(`GitLab API URL host is not allowed: ${parsed.host}`
|
|
1113
|
+
throw new Error(`GitLab API URL host is not allowed: ${parsed.host}. ` +
|
|
1114
|
+
"Add the host to GITLAB_ALLOWED_HOSTS or GITLAB_API_URL.");
|
|
1150
1115
|
}
|
|
1151
1116
|
return allowedApiUrl;
|
|
1152
1117
|
}
|
|
@@ -6453,6 +6418,262 @@ async function purgeDependencyProxyCache(groupId) {
|
|
|
6453
6418
|
const response = await fetch(url.toString(), { ...getFetchConfig(), method: "DELETE" });
|
|
6454
6419
|
await handleGitLabError(response);
|
|
6455
6420
|
}
|
|
6421
|
+
// --- Vulnerability functions (GraphQL) ---
|
|
6422
|
+
//
|
|
6423
|
+
// The REST Vulnerabilities API is deprecated and silently ignores filter
|
|
6424
|
+
// params on list and reason/comment on dismiss. GitLab recommends the
|
|
6425
|
+
// GraphQL API for vulnerability management, which supports all of these.
|
|
6426
|
+
/** Convert a numeric vulnerability ID (or an existing Vulnerability GID) to a GraphQL global ID. */
|
|
6427
|
+
function toVulnerabilityGid(vulnerabilityId) {
|
|
6428
|
+
if (/^\d+$/.test(vulnerabilityId)) {
|
|
6429
|
+
return `gid://gitlab/Vulnerability/${vulnerabilityId}`;
|
|
6430
|
+
}
|
|
6431
|
+
if (/^gid:\/\/gitlab\/Vulnerability\/\d+$/.test(vulnerabilityId)) {
|
|
6432
|
+
return vulnerabilityId;
|
|
6433
|
+
}
|
|
6434
|
+
throw new Error(`Invalid vulnerability ID "${vulnerabilityId}": expected a numeric ID or a gid://gitlab/Vulnerability/<id> global ID`);
|
|
6435
|
+
}
|
|
6436
|
+
/**
|
|
6437
|
+
* Resolve a project ID or path to the full namespace path required by
|
|
6438
|
+
* GraphQL's `project(fullPath:)` query. Numeric IDs are resolved via a
|
|
6439
|
+
* REST lookup; paths are returned as-is (decoded).
|
|
6440
|
+
*/
|
|
6441
|
+
async function resolveProjectFullPath(projectId) {
|
|
6442
|
+
const decoded = decodeURIComponent(getEffectiveProjectId(decodeURIComponent(projectId)));
|
|
6443
|
+
if (!/^\d+$/.test(decoded)) {
|
|
6444
|
+
return decoded;
|
|
6445
|
+
}
|
|
6446
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(decoded)}`);
|
|
6447
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
6448
|
+
await handleGitLabError(response);
|
|
6449
|
+
const project = (await response.json());
|
|
6450
|
+
if (!project.path_with_namespace) {
|
|
6451
|
+
throw new Error(`Could not resolve full path for project ${decoded}`);
|
|
6452
|
+
}
|
|
6453
|
+
return project.path_with_namespace;
|
|
6454
|
+
}
|
|
6455
|
+
/**
|
|
6456
|
+
* Enforce GITLAB_ALLOWED_PROJECT_IDS for vulnerability tools. Vulnerability
|
|
6457
|
+
* GIDs are globally unique rather than project-scoped, so the boundary must
|
|
6458
|
+
* be checked against the project the vulnerability actually belongs to —
|
|
6459
|
+
* mirroring what getEffectiveProjectId does for project-scoped REST calls.
|
|
6460
|
+
* Allowlist entries may be numeric IDs or full namespace paths.
|
|
6461
|
+
*/
|
|
6462
|
+
function assertVulnerabilityProjectAllowed(vulnerabilityId, project) {
|
|
6463
|
+
if (GITLAB_ALLOWED_PROJECT_IDS.length === 0) {
|
|
6464
|
+
return;
|
|
6465
|
+
}
|
|
6466
|
+
const fullPath = project?.fullPath ?? undefined;
|
|
6467
|
+
const numericId = project?.id?.match(/^gid:\/\/gitlab\/Project\/(\d+)$/)?.[1];
|
|
6468
|
+
const allowed = (fullPath !== undefined && GITLAB_ALLOWED_PROJECT_IDS.includes(fullPath)) ||
|
|
6469
|
+
(numericId !== undefined && GITLAB_ALLOWED_PROJECT_IDS.includes(numericId));
|
|
6470
|
+
if (!allowed) {
|
|
6471
|
+
throw new Error(`Access denied: Vulnerability ${vulnerabilityId} belongs to project ${fullPath ?? numericId ?? "unknown"}, which is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}`);
|
|
6472
|
+
}
|
|
6473
|
+
}
|
|
6474
|
+
/**
|
|
6475
|
+
* Pre-flight allowlist check for vulnerability mutations: resolves the
|
|
6476
|
+
* vulnerability's project and verifies it against GITLAB_ALLOWED_PROJECT_IDS
|
|
6477
|
+
* before any write is issued. No-op (no extra request) when the allowlist
|
|
6478
|
+
* is not configured.
|
|
6479
|
+
*/
|
|
6480
|
+
async function ensureVulnerabilityProjectAllowed(vulnerabilityId) {
|
|
6481
|
+
if (GITLAB_ALLOWED_PROJECT_IDS.length === 0) {
|
|
6482
|
+
return;
|
|
6483
|
+
}
|
|
6484
|
+
const data = await executeGraphQL(`query getVulnerabilityProject($id: VulnerabilityID!) {
|
|
6485
|
+
vulnerability(id: $id) {
|
|
6486
|
+
project {
|
|
6487
|
+
id
|
|
6488
|
+
fullPath
|
|
6489
|
+
}
|
|
6490
|
+
}
|
|
6491
|
+
}`, { id: toVulnerabilityGid(vulnerabilityId) });
|
|
6492
|
+
if (!data.vulnerability) {
|
|
6493
|
+
throw new Error(`Vulnerability not found: ${vulnerabilityId}`);
|
|
6494
|
+
}
|
|
6495
|
+
assertVulnerabilityProjectAllowed(vulnerabilityId, data.vulnerability.project);
|
|
6496
|
+
}
|
|
6497
|
+
/** Shared GraphQL selection set for vulnerability objects. */
|
|
6498
|
+
const VULNERABILITY_FIELDS = `
|
|
6499
|
+
id
|
|
6500
|
+
title
|
|
6501
|
+
description
|
|
6502
|
+
state
|
|
6503
|
+
severity
|
|
6504
|
+
reportType
|
|
6505
|
+
detectedAt
|
|
6506
|
+
confirmedAt
|
|
6507
|
+
resolvedAt
|
|
6508
|
+
dismissedAt
|
|
6509
|
+
dismissalReason
|
|
6510
|
+
webUrl
|
|
6511
|
+
scanner {
|
|
6512
|
+
name
|
|
6513
|
+
externalId
|
|
6514
|
+
vendor
|
|
6515
|
+
}
|
|
6516
|
+
identifiers {
|
|
6517
|
+
externalType
|
|
6518
|
+
externalId
|
|
6519
|
+
name
|
|
6520
|
+
url
|
|
6521
|
+
}
|
|
6522
|
+
links {
|
|
6523
|
+
name
|
|
6524
|
+
url
|
|
6525
|
+
}
|
|
6526
|
+
location {
|
|
6527
|
+
... on VulnerabilityLocationSast {
|
|
6528
|
+
file
|
|
6529
|
+
startLine
|
|
6530
|
+
endLine
|
|
6531
|
+
}
|
|
6532
|
+
... on VulnerabilityLocationSecretDetection {
|
|
6533
|
+
file
|
|
6534
|
+
startLine
|
|
6535
|
+
endLine
|
|
6536
|
+
}
|
|
6537
|
+
... on VulnerabilityLocationDependencyScanning {
|
|
6538
|
+
file
|
|
6539
|
+
dependency {
|
|
6540
|
+
package {
|
|
6541
|
+
name
|
|
6542
|
+
}
|
|
6543
|
+
version
|
|
6544
|
+
}
|
|
6545
|
+
}
|
|
6546
|
+
... on VulnerabilityLocationContainerScanning {
|
|
6547
|
+
image
|
|
6548
|
+
operatingSystem
|
|
6549
|
+
dependency {
|
|
6550
|
+
package {
|
|
6551
|
+
name
|
|
6552
|
+
}
|
|
6553
|
+
version
|
|
6554
|
+
}
|
|
6555
|
+
}
|
|
6556
|
+
... on VulnerabilityLocationDast {
|
|
6557
|
+
path
|
|
6558
|
+
hostname
|
|
6559
|
+
}
|
|
6560
|
+
}
|
|
6561
|
+
`;
|
|
6562
|
+
async function listProjectVulnerabilities(projectId, options = {}) {
|
|
6563
|
+
const fullPath = await resolveProjectFullPath(projectId);
|
|
6564
|
+
const variables = {
|
|
6565
|
+
fullPath,
|
|
6566
|
+
first: Math.min(options.first ?? 20, 100),
|
|
6567
|
+
};
|
|
6568
|
+
if (options.state)
|
|
6569
|
+
variables.state = [options.state.toUpperCase()];
|
|
6570
|
+
if (options.severity)
|
|
6571
|
+
variables.severity = [options.severity.toUpperCase()];
|
|
6572
|
+
if (options.report_type)
|
|
6573
|
+
variables.reportType = [options.report_type.toUpperCase()];
|
|
6574
|
+
if (options.after)
|
|
6575
|
+
variables.after = options.after;
|
|
6576
|
+
const data = await executeGraphQL(`query listProjectVulnerabilities(
|
|
6577
|
+
$fullPath: ID!
|
|
6578
|
+
$state: [VulnerabilityState!]
|
|
6579
|
+
$severity: [VulnerabilitySeverity!]
|
|
6580
|
+
$reportType: [VulnerabilityReportType!]
|
|
6581
|
+
$first: Int
|
|
6582
|
+
$after: String
|
|
6583
|
+
) {
|
|
6584
|
+
project(fullPath: $fullPath) {
|
|
6585
|
+
vulnerabilities(
|
|
6586
|
+
state: $state
|
|
6587
|
+
severity: $severity
|
|
6588
|
+
reportType: $reportType
|
|
6589
|
+
first: $first
|
|
6590
|
+
after: $after
|
|
6591
|
+
) {
|
|
6592
|
+
nodes {
|
|
6593
|
+
${VULNERABILITY_FIELDS}
|
|
6594
|
+
}
|
|
6595
|
+
pageInfo {
|
|
6596
|
+
endCursor
|
|
6597
|
+
hasNextPage
|
|
6598
|
+
}
|
|
6599
|
+
}
|
|
6600
|
+
}
|
|
6601
|
+
}`, variables);
|
|
6602
|
+
if (!data.project) {
|
|
6603
|
+
throw new Error(`Project not found or not accessible: ${fullPath}`);
|
|
6604
|
+
}
|
|
6605
|
+
return {
|
|
6606
|
+
vulnerabilities: data.project.vulnerabilities.nodes,
|
|
6607
|
+
pageInfo: data.project.vulnerabilities.pageInfo,
|
|
6608
|
+
};
|
|
6609
|
+
}
|
|
6610
|
+
async function getVulnerability(vulnerabilityId) {
|
|
6611
|
+
const data = await executeGraphQL(`query getVulnerability($id: VulnerabilityID!) {
|
|
6612
|
+
vulnerability(id: $id) {
|
|
6613
|
+
${VULNERABILITY_FIELDS}
|
|
6614
|
+
project {
|
|
6615
|
+
id
|
|
6616
|
+
name
|
|
6617
|
+
fullPath
|
|
6618
|
+
}
|
|
6619
|
+
}
|
|
6620
|
+
}`, { id: toVulnerabilityGid(vulnerabilityId) });
|
|
6621
|
+
if (!data.vulnerability) {
|
|
6622
|
+
throw new Error(`Vulnerability not found: ${vulnerabilityId}`);
|
|
6623
|
+
}
|
|
6624
|
+
assertVulnerabilityProjectAllowed(vulnerabilityId, data.vulnerability.project);
|
|
6625
|
+
return data.vulnerability;
|
|
6626
|
+
}
|
|
6627
|
+
async function dismissVulnerability(vulnerabilityId, reason, comment) {
|
|
6628
|
+
await ensureVulnerabilityProjectAllowed(vulnerabilityId);
|
|
6629
|
+
const input = {
|
|
6630
|
+
id: toVulnerabilityGid(vulnerabilityId),
|
|
6631
|
+
dismissalReason: reason.toUpperCase(),
|
|
6632
|
+
};
|
|
6633
|
+
if (comment)
|
|
6634
|
+
input.comment = comment;
|
|
6635
|
+
const data = await executeGraphQL(`mutation dismissVulnerability($input: VulnerabilityDismissInput!) {
|
|
6636
|
+
vulnerabilityDismiss(input: $input) {
|
|
6637
|
+
vulnerability {
|
|
6638
|
+
id
|
|
6639
|
+
state
|
|
6640
|
+
dismissedAt
|
|
6641
|
+
dismissalReason
|
|
6642
|
+
}
|
|
6643
|
+
errors
|
|
6644
|
+
}
|
|
6645
|
+
}`, { input });
|
|
6646
|
+
if (data.vulnerabilityDismiss.errors?.length) {
|
|
6647
|
+
throw new Error(`Failed to dismiss vulnerability: ${data.vulnerabilityDismiss.errors.join(", ")}`);
|
|
6648
|
+
}
|
|
6649
|
+
if (!data.vulnerabilityDismiss.vulnerability) {
|
|
6650
|
+
throw new Error(`Vulnerability not returned after dismissal (id: ${vulnerabilityId})`);
|
|
6651
|
+
}
|
|
6652
|
+
return data.vulnerabilityDismiss.vulnerability;
|
|
6653
|
+
}
|
|
6654
|
+
async function confirmVulnerability(vulnerabilityId, comment) {
|
|
6655
|
+
await ensureVulnerabilityProjectAllowed(vulnerabilityId);
|
|
6656
|
+
const input = { id: toVulnerabilityGid(vulnerabilityId) };
|
|
6657
|
+
if (comment)
|
|
6658
|
+
input.comment = comment;
|
|
6659
|
+
const data = await executeGraphQL(`mutation confirmVulnerability($input: VulnerabilityConfirmInput!) {
|
|
6660
|
+
vulnerabilityConfirm(input: $input) {
|
|
6661
|
+
vulnerability {
|
|
6662
|
+
id
|
|
6663
|
+
state
|
|
6664
|
+
confirmedAt
|
|
6665
|
+
}
|
|
6666
|
+
errors
|
|
6667
|
+
}
|
|
6668
|
+
}`, { input });
|
|
6669
|
+
if (data.vulnerabilityConfirm.errors?.length) {
|
|
6670
|
+
throw new Error(`Failed to confirm vulnerability: ${data.vulnerabilityConfirm.errors.join(", ")}`);
|
|
6671
|
+
}
|
|
6672
|
+
if (!data.vulnerabilityConfirm.vulnerability) {
|
|
6673
|
+
throw new Error(`Vulnerability not returned after confirmation (id: ${vulnerabilityId})`);
|
|
6674
|
+
}
|
|
6675
|
+
return data.vulnerabilityConfirm.vulnerability;
|
|
6676
|
+
}
|
|
6456
6677
|
/**
|
|
6457
6678
|
* Upload a file to a GitLab project for use in markdown content.
|
|
6458
6679
|
*
|
|
@@ -8869,6 +9090,28 @@ async function handleToolCall(params) {
|
|
|
8869
9090
|
],
|
|
8870
9091
|
};
|
|
8871
9092
|
}
|
|
9093
|
+
// --- Vulnerability tools ---
|
|
9094
|
+
case "list_project_vulnerabilities": {
|
|
9095
|
+
const args = ListProjectVulnerabilitiesSchema.parse(params.arguments);
|
|
9096
|
+
const { project_id, ...options } = args;
|
|
9097
|
+
const result = await listProjectVulnerabilities(project_id, options);
|
|
9098
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9099
|
+
}
|
|
9100
|
+
case "get_vulnerability": {
|
|
9101
|
+
const args = GetVulnerabilitySchema.parse(params.arguments);
|
|
9102
|
+
const result = await getVulnerability(args.vulnerability_id);
|
|
9103
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9104
|
+
}
|
|
9105
|
+
case "dismiss_vulnerability": {
|
|
9106
|
+
const args = DismissVulnerabilitySchema.parse(params.arguments);
|
|
9107
|
+
const result = await dismissVulnerability(args.vulnerability_id, args.reason, args.comment);
|
|
9108
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9109
|
+
}
|
|
9110
|
+
case "confirm_vulnerability": {
|
|
9111
|
+
const args = ConfirmVulnerabilitySchema.parse(params.arguments);
|
|
9112
|
+
const result = await confirmVulnerability(args.vulnerability_id, args.comment);
|
|
9113
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9114
|
+
}
|
|
8872
9115
|
case "upload_markdown": {
|
|
8873
9116
|
if (IS_REMOTE) {
|
|
8874
9117
|
const args = MarkdownUploadRemoteSchema.parse(params.arguments);
|
|
@@ -9496,8 +9739,9 @@ async function startStreamableHTTPServer() {
|
|
|
9496
9739
|
try {
|
|
9497
9740
|
apiUrl = resolveTrustedGitLabApiUrl(dynamicApiUrl);
|
|
9498
9741
|
}
|
|
9499
|
-
catch {
|
|
9500
|
-
|
|
9742
|
+
catch (error) {
|
|
9743
|
+
const reason = error instanceof Error ? error.message : "Invalid X-GitLab-API-URL";
|
|
9744
|
+
logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. ${reason}`);
|
|
9501
9745
|
return null; // Reject if URL is malformed or not allowed
|
|
9502
9746
|
}
|
|
9503
9747
|
}
|
package/build/oauth-proxy.js
CHANGED
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
import { InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
48
48
|
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
49
49
|
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
50
|
-
import { pino } from "pino";
|
|
51
50
|
import { looksLikeStatelessClientId, mintClientId, openClientId, } from "./stateless/client-id.js";
|
|
52
51
|
import { looksLikeStatelessState, mintPendingAuthState, openPendingAuthState, } from "./stateless/pending-auth.js";
|
|
53
52
|
import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, } from "./stateless/stored-tokens.js";
|
|
54
|
-
|
|
53
|
+
import { createLogger } from "./utils/logger.js";
|
|
54
|
+
const logger = createLogger("gitlab-mcp-oauth-proxy");
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
// GitLab OAuth Server Provider
|
|
57
57
|
// ---------------------------------------------------------------------------
|
package/build/oauth.js
CHANGED
|
@@ -9,11 +9,8 @@ import { execFile } from "child_process";
|
|
|
9
9
|
import { promisify } from "util";
|
|
10
10
|
import open from "open";
|
|
11
11
|
import pkceChallenge from "pkce-challenge";
|
|
12
|
-
import {
|
|
13
|
-
const logger =
|
|
14
|
-
name: "gitlab-mcp-oauth",
|
|
15
|
-
level: process.env.LOG_LEVEL || "info",
|
|
16
|
-
}, pino.destination(2));
|
|
12
|
+
import { createLogger } from "./utils/logger.js";
|
|
13
|
+
const logger = createLogger("gitlab-mcp-oauth");
|
|
17
14
|
const execFileAsync = promisify(execFile);
|
|
18
15
|
function escapeHtml(str) {
|
|
19
16
|
const map = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
package/build/schemas.js
CHANGED
|
@@ -4023,3 +4023,70 @@ export const ListDependencyProxyBlobsSchema = z.object({
|
|
|
4023
4023
|
export const PurgeDependencyProxyCacheSchema = z.object({
|
|
4024
4024
|
group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
|
|
4025
4025
|
});
|
|
4026
|
+
// --- Vulnerability schemas ---
|
|
4027
|
+
// Schema for listing project vulnerabilities with optional filters (GraphQL-backed)
|
|
4028
|
+
export const ListProjectVulnerabilitiesSchema = z.object({
|
|
4029
|
+
project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
|
|
4030
|
+
state: z
|
|
4031
|
+
.enum(["detected", "confirmed", "resolved", "dismissed"])
|
|
4032
|
+
.optional()
|
|
4033
|
+
.describe("Filter by vulnerability state"),
|
|
4034
|
+
severity: z
|
|
4035
|
+
.enum(["critical", "high", "medium", "low", "info", "unknown"])
|
|
4036
|
+
.optional()
|
|
4037
|
+
.describe("Filter by severity level"),
|
|
4038
|
+
report_type: z
|
|
4039
|
+
.enum([
|
|
4040
|
+
"sast",
|
|
4041
|
+
"dast",
|
|
4042
|
+
"dependency_scanning",
|
|
4043
|
+
"container_scanning",
|
|
4044
|
+
"secret_detection",
|
|
4045
|
+
"coverage_fuzzing",
|
|
4046
|
+
"api_fuzzing",
|
|
4047
|
+
"cluster_image_scanning",
|
|
4048
|
+
"generic",
|
|
4049
|
+
])
|
|
4050
|
+
.optional()
|
|
4051
|
+
.describe("Filter by scan/report type (e.g. secret_detection, sast, dast)"),
|
|
4052
|
+
first: z.coerce
|
|
4053
|
+
.number()
|
|
4054
|
+
.int()
|
|
4055
|
+
.min(1)
|
|
4056
|
+
.max(100)
|
|
4057
|
+
.optional()
|
|
4058
|
+
.describe("Number of vulnerabilities to return (max: 100, default: 20)"),
|
|
4059
|
+
after: z
|
|
4060
|
+
.string()
|
|
4061
|
+
.optional()
|
|
4062
|
+
.describe("Cursor for pagination; use the endCursor from a previous response"),
|
|
4063
|
+
});
|
|
4064
|
+
// Schema for getting a single vulnerability by ID
|
|
4065
|
+
export const GetVulnerabilitySchema = z.object({
|
|
4066
|
+
vulnerability_id: z.coerce
|
|
4067
|
+
.string()
|
|
4068
|
+
.describe("The vulnerability ID (numeric or GraphQL global ID)"),
|
|
4069
|
+
});
|
|
4070
|
+
// Schema for dismissing a vulnerability with required reason and optional comment
|
|
4071
|
+
export const DismissVulnerabilitySchema = z.object({
|
|
4072
|
+
vulnerability_id: z.coerce
|
|
4073
|
+
.string()
|
|
4074
|
+
.describe("The ID of the vulnerability to dismiss (numeric or GraphQL global ID)"),
|
|
4075
|
+
reason: z
|
|
4076
|
+
.enum([
|
|
4077
|
+
"acceptable_risk",
|
|
4078
|
+
"false_positive",
|
|
4079
|
+
"used_in_tests",
|
|
4080
|
+
"mitigating_control",
|
|
4081
|
+
"not_applicable",
|
|
4082
|
+
])
|
|
4083
|
+
.describe("Reason for dismissal"),
|
|
4084
|
+
comment: z.string().optional().describe("Optional comment explaining the dismissal"),
|
|
4085
|
+
});
|
|
4086
|
+
// Schema for confirming a vulnerability with optional comment
|
|
4087
|
+
export const ConfirmVulnerabilitySchema = z.object({
|
|
4088
|
+
vulnerability_id: z.coerce
|
|
4089
|
+
.string()
|
|
4090
|
+
.describe("The ID of the vulnerability to confirm (numeric or GraphQL global ID)"),
|
|
4091
|
+
comment: z.string().optional().describe("Optional comment explaining the confirmation"),
|
|
4092
|
+
});
|
|
@@ -14,6 +14,7 @@ const SKILL_PATH = join(REPO_ROOT, "skills", "gitlab-mcp", "SKILL.md");
|
|
|
14
14
|
const REF_DIR = join(REPO_ROOT, "skills", "gitlab-mcp", "reference");
|
|
15
15
|
// Response field names in reference docs — not MCP tools.
|
|
16
16
|
const REFERENCE_FIELD_ALLOWLIST = new Set([
|
|
17
|
+
"after",
|
|
17
18
|
"allow_multiple",
|
|
18
19
|
"deleted_file",
|
|
19
20
|
"excluded_file_patterns",
|
|
@@ -115,6 +115,10 @@ const GROUP_META = {
|
|
|
115
115
|
title: "Dependency Proxy",
|
|
116
116
|
blurb: "Inspect and manage the GitLab dependency proxy cache settings, blob storage, and purge operations.",
|
|
117
117
|
},
|
|
118
|
+
vulnerabilities: {
|
|
119
|
+
title: "Vulnerabilities",
|
|
120
|
+
blurb: "AI-assisted vulnerability triage — list findings, inspect details, dismiss with reason, or confirm for remediation. Backed by the GitLab GraphQL API; requires GitLab Ultimate.",
|
|
121
|
+
},
|
|
118
122
|
};
|
|
119
123
|
const GROUP_ORDER = [
|
|
120
124
|
"projects",
|
|
@@ -136,6 +140,7 @@ const GROUP_ORDER = [
|
|
|
136
140
|
"webhooks",
|
|
137
141
|
"search",
|
|
138
142
|
"dependency_proxy",
|
|
143
|
+
"vulnerabilities",
|
|
139
144
|
];
|
|
140
145
|
// Authoritative classification — uses the `readOnlyTools` set from
|
|
141
146
|
// tools/registry.ts. That set is what the server itself consults to decide
|
|
@@ -210,7 +215,7 @@ function buildGroupPage(id, toolNames) {
|
|
|
210
215
|
lines.push("## Tools in this group");
|
|
211
216
|
lines.push("");
|
|
212
217
|
for (const name of toolNames) {
|
|
213
|
-
lines.push(`- [\`${name}\`](#${name
|
|
218
|
+
lines.push(`- [\`${name}\`](#${name}) — ${rwBadge(name)}`);
|
|
214
219
|
}
|
|
215
220
|
lines.push("");
|
|
216
221
|
lines.push("---");
|
|
@@ -26,6 +26,7 @@ describe("Dynamic API URL allowlist", () => {
|
|
|
26
26
|
let secondaryGitLab;
|
|
27
27
|
let attackerServer;
|
|
28
28
|
let mcpServer;
|
|
29
|
+
let mcpPort;
|
|
29
30
|
let mcpUrl;
|
|
30
31
|
let secondaryHit = false;
|
|
31
32
|
let getAttackerHits = () => 0;
|
|
@@ -44,7 +45,7 @@ describe("Dynamic API URL allowlist", () => {
|
|
|
44
45
|
const attacker = await startAttackerServer(attackerPort);
|
|
45
46
|
attackerServer = attacker.server;
|
|
46
47
|
getAttackerHits = attacker.getHits;
|
|
47
|
-
|
|
48
|
+
mcpPort = await findAvailablePort(3100);
|
|
48
49
|
mcpServer = await launchServer({
|
|
49
50
|
mode: TransportMode.STREAMABLE_HTTP,
|
|
50
51
|
port: mcpPort,
|
|
@@ -101,4 +102,20 @@ describe("Dynamic API URL allowlist", () => {
|
|
|
101
102
|
assert.strictEqual(connected, false, "untrusted dynamic host should not initialize a session");
|
|
102
103
|
assert.strictEqual(getAttackerHits(), 0, "token-bearing requests must not reach untrusted hosts");
|
|
103
104
|
});
|
|
105
|
+
test("returns remediation text when download proxy rejects untrusted dynamic hosts", async () => {
|
|
106
|
+
const server = attackerServer;
|
|
107
|
+
assert.ok(server, "attacker server should be running");
|
|
108
|
+
const attackerUrl = `http://${HOST}:${server.address().port}/api/v4`;
|
|
109
|
+
const response = await fetch(`http://${HOST}:${mcpPort}/downloads/job-artifacts?project_id=1&job_id=1`, {
|
|
110
|
+
headers: {
|
|
111
|
+
"Private-Token": MOCK_TOKEN,
|
|
112
|
+
"x-gitlab-api-url": attackerUrl,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
assert.strictEqual(response.status, 400);
|
|
116
|
+
const body = (await response.json());
|
|
117
|
+
assert.match(body.error, /not allowed/);
|
|
118
|
+
assert.match(body.error, /GITLAB_ALLOWED_HOSTS/);
|
|
119
|
+
assert.strictEqual(getAttackerHits(), 0, "download proxy must not forward to untrusted hosts");
|
|
120
|
+
});
|
|
104
121
|
});
|
|
@@ -35,6 +35,7 @@ const TOOLSET_TOOL_COUNTS = {
|
|
|
35
35
|
groups: 1,
|
|
36
36
|
variables: 10,
|
|
37
37
|
dependency_proxy: 4,
|
|
38
|
+
vulnerabilities: 4,
|
|
38
39
|
};
|
|
39
40
|
const LEGACY_PIPELINE_CI_TOOL_COUNT = 2;
|
|
40
41
|
const LEGACY_PIPELINE_TOOL_COUNT = TOOLSET_TOOL_COUNTS.pipelines + LEGACY_PIPELINE_CI_TOOL_COUNT;
|
|
@@ -60,6 +61,7 @@ const NON_DEFAULT_TOOLSETS = [
|
|
|
60
61
|
"search",
|
|
61
62
|
"variables",
|
|
62
63
|
"dependency_proxy",
|
|
64
|
+
"vulnerabilities",
|
|
63
65
|
];
|
|
64
66
|
// discover_tools meta-tool is always force-injected (Step 5.5)
|
|
65
67
|
const DISCOVER_TOOLS_COUNT = 1;
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { describe, test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
5
|
+
const MOCK_TOKEN = "glpat-mock-token-12345";
|
|
6
|
+
const TEST_PROJECT_ID = "456";
|
|
7
|
+
const TEST_PROJECT_FULL_PATH = "test-group/test-project";
|
|
8
|
+
const TEST_VULNERABILITY_ID = "789";
|
|
9
|
+
const TEST_VULNERABILITY_GID = `gid://gitlab/Vulnerability/${TEST_VULNERABILITY_ID}`;
|
|
10
|
+
const mockVulnerability = {
|
|
11
|
+
id: TEST_VULNERABILITY_GID,
|
|
12
|
+
title: "Hard-coded credential detected",
|
|
13
|
+
description: "A hard-coded credential was found in source code",
|
|
14
|
+
state: "DETECTED",
|
|
15
|
+
severity: "CRITICAL",
|
|
16
|
+
reportType: "SECRET_DETECTION",
|
|
17
|
+
detectedAt: "2024-06-01T10:00:00Z",
|
|
18
|
+
confirmedAt: null,
|
|
19
|
+
resolvedAt: null,
|
|
20
|
+
dismissedAt: null,
|
|
21
|
+
dismissalReason: null,
|
|
22
|
+
webUrl: "https://gitlab.mock/test-group/test-project/-/security/vulnerabilities/789",
|
|
23
|
+
scanner: { name: "GitLeaks", externalId: "gitleaks", vendor: "GitLab" },
|
|
24
|
+
identifiers: [
|
|
25
|
+
{ externalType: "cve", externalId: "CVE-2024-0001", name: "CVE-2024-0001", url: null },
|
|
26
|
+
],
|
|
27
|
+
links: [{ name: "More info", url: "https://example.com/vuln/789" }],
|
|
28
|
+
location: { file: "config/secrets.yml", startLine: 42, endLine: 42 },
|
|
29
|
+
};
|
|
30
|
+
const mockVulnerabilityList = [
|
|
31
|
+
mockVulnerability,
|
|
32
|
+
{
|
|
33
|
+
...mockVulnerability,
|
|
34
|
+
id: "gid://gitlab/Vulnerability/790",
|
|
35
|
+
title: "SQL Injection in login form",
|
|
36
|
+
state: "CONFIRMED",
|
|
37
|
+
severity: "HIGH",
|
|
38
|
+
reportType: "SAST",
|
|
39
|
+
confirmedAt: "2024-06-03T09:00:00Z",
|
|
40
|
+
location: { file: "src/auth/login.ts", startLine: 18, endLine: 22 },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
...mockVulnerability,
|
|
44
|
+
id: "gid://gitlab/Vulnerability/791",
|
|
45
|
+
title: "Outdated dependency with known CVE",
|
|
46
|
+
state: "DISMISSED",
|
|
47
|
+
severity: "MEDIUM",
|
|
48
|
+
reportType: "DEPENDENCY_SCANNING",
|
|
49
|
+
dismissedAt: "2024-05-20T14:00:00Z",
|
|
50
|
+
dismissalReason: "ACCEPTABLE_RISK",
|
|
51
|
+
location: { file: "package.json", dependency: { package: { name: "foo" }, version: "1.2.3" } },
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
async function callTool(toolName, args, env) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
57
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
58
|
+
env: {
|
|
59
|
+
...process.env,
|
|
60
|
+
...env,
|
|
61
|
+
USE_PIPELINE: "true",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
let output = "";
|
|
65
|
+
let errorOutput = "";
|
|
66
|
+
proc.stdout?.on("data", (d) => (output += d));
|
|
67
|
+
proc.stderr?.on("data", (d) => (errorOutput += d));
|
|
68
|
+
proc.on("close", code => {
|
|
69
|
+
if (code !== 0) {
|
|
70
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
71
|
+
}
|
|
72
|
+
const line = output.split("\n").find(l => l.startsWith("{"));
|
|
73
|
+
if (!line) {
|
|
74
|
+
return reject(new Error("No JSON output found"));
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const response = JSON.parse(line);
|
|
78
|
+
if (response.error) {
|
|
79
|
+
reject(response.error);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const content = response.result?.content?.[0]?.text;
|
|
83
|
+
if (content) {
|
|
84
|
+
try {
|
|
85
|
+
resolve(JSON.parse(content));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
resolve(content);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
resolve(response.result);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
reject(e);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
proc.stdin?.end(JSON.stringify({
|
|
101
|
+
jsonrpc: "2.0",
|
|
102
|
+
id: 1,
|
|
103
|
+
method: "tools/call",
|
|
104
|
+
params: { name: toolName, arguments: args },
|
|
105
|
+
}) + "\n");
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
describe("vulnerability tools", () => {
|
|
109
|
+
let mockGitLab;
|
|
110
|
+
let mockGitLabUrl;
|
|
111
|
+
// Capture variables for verifying GraphQL payloads
|
|
112
|
+
let lastListVariables = null;
|
|
113
|
+
let lastDismissInput = null;
|
|
114
|
+
let lastConfirmInput = null;
|
|
115
|
+
let lastGetId = null;
|
|
116
|
+
const baseEnv = () => ({
|
|
117
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
118
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
119
|
+
GITLAB_TOOLSETS: "vulnerabilities",
|
|
120
|
+
});
|
|
121
|
+
before(async () => {
|
|
122
|
+
const mockPort = await findMockServerPort();
|
|
123
|
+
mockGitLab = new MockGitLabServer({
|
|
124
|
+
port: mockPort,
|
|
125
|
+
validTokens: [MOCK_TOKEN],
|
|
126
|
+
});
|
|
127
|
+
// All four tools go through POST /api/graphql; dispatch on operation name
|
|
128
|
+
mockGitLab.addRootHandler("post", "/api/graphql", (req, res) => {
|
|
129
|
+
const query = req.body.query || "";
|
|
130
|
+
const variables = req.body.variables || {};
|
|
131
|
+
if (query.includes("vulnerabilityDismiss")) {
|
|
132
|
+
lastDismissInput = variables.input;
|
|
133
|
+
res.json({
|
|
134
|
+
data: {
|
|
135
|
+
vulnerabilityDismiss: {
|
|
136
|
+
vulnerability: {
|
|
137
|
+
id: TEST_VULNERABILITY_GID,
|
|
138
|
+
state: "DISMISSED",
|
|
139
|
+
dismissedAt: "2024-06-10T12:00:00Z",
|
|
140
|
+
dismissalReason: variables.input?.dismissalReason || null,
|
|
141
|
+
},
|
|
142
|
+
errors: [],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (query.includes("vulnerabilityConfirm")) {
|
|
149
|
+
lastConfirmInput = variables.input;
|
|
150
|
+
res.json({
|
|
151
|
+
data: {
|
|
152
|
+
vulnerabilityConfirm: {
|
|
153
|
+
vulnerability: {
|
|
154
|
+
id: TEST_VULNERABILITY_GID,
|
|
155
|
+
state: "CONFIRMED",
|
|
156
|
+
confirmedAt: "2024-06-10T12:00:00Z",
|
|
157
|
+
},
|
|
158
|
+
errors: [],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (query.includes("listProjectVulnerabilities")) {
|
|
165
|
+
lastListVariables = variables;
|
|
166
|
+
let nodes = mockVulnerabilityList;
|
|
167
|
+
if (variables.state) {
|
|
168
|
+
nodes = nodes.filter(v => variables.state.includes(v.state));
|
|
169
|
+
}
|
|
170
|
+
if (variables.severity) {
|
|
171
|
+
nodes = nodes.filter(v => variables.severity.includes(v.severity));
|
|
172
|
+
}
|
|
173
|
+
if (variables.reportType) {
|
|
174
|
+
nodes = nodes.filter(v => variables.reportType.includes(v.reportType));
|
|
175
|
+
}
|
|
176
|
+
res.json({
|
|
177
|
+
data: {
|
|
178
|
+
project: {
|
|
179
|
+
vulnerabilities: {
|
|
180
|
+
nodes,
|
|
181
|
+
pageInfo: { endCursor: "cursor-abc", hasNextPage: false },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (query.includes("getVulnerability")) {
|
|
189
|
+
lastGetId = variables.id;
|
|
190
|
+
res.json({
|
|
191
|
+
data: {
|
|
192
|
+
vulnerability: {
|
|
193
|
+
...mockVulnerability,
|
|
194
|
+
project: {
|
|
195
|
+
id: "gid://gitlab/Project/456",
|
|
196
|
+
name: "Test Project",
|
|
197
|
+
fullPath: TEST_PROJECT_FULL_PATH,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
res
|
|
205
|
+
.status(400)
|
|
206
|
+
.json({ errors: [{ message: `Unexpected GraphQL query: ${query.slice(0, 80)}` }] });
|
|
207
|
+
});
|
|
208
|
+
await mockGitLab.start();
|
|
209
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
210
|
+
});
|
|
211
|
+
after(async () => {
|
|
212
|
+
await mockGitLab.stop();
|
|
213
|
+
});
|
|
214
|
+
test("list_project_vulnerabilities returns vulnerabilities with pageInfo", async () => {
|
|
215
|
+
const result = await callTool("list_project_vulnerabilities", { project_id: TEST_PROJECT_ID }, baseEnv());
|
|
216
|
+
assert.ok(Array.isArray(result.vulnerabilities), "Response should contain vulnerabilities array");
|
|
217
|
+
assert.strictEqual(result.vulnerabilities.length, 3, "Should return 3 vulnerabilities");
|
|
218
|
+
assert.strictEqual(result.vulnerabilities[0].id, TEST_VULNERABILITY_GID);
|
|
219
|
+
assert.strictEqual(result.vulnerabilities[0].title, "Hard-coded credential detected");
|
|
220
|
+
assert.ok(result.pageInfo, "Response should include pageInfo for cursor pagination");
|
|
221
|
+
assert.strictEqual(result.pageInfo.hasNextPage, false);
|
|
222
|
+
});
|
|
223
|
+
test("list_project_vulnerabilities resolves numeric project ID to full path", async () => {
|
|
224
|
+
lastListVariables = null;
|
|
225
|
+
await callTool("list_project_vulnerabilities", { project_id: TEST_PROJECT_ID }, baseEnv());
|
|
226
|
+
assert.ok(lastListVariables, "GraphQL variables should have been captured");
|
|
227
|
+
assert.strictEqual(lastListVariables.fullPath, TEST_PROJECT_FULL_PATH, "Numeric project ID should be resolved to the project full path");
|
|
228
|
+
});
|
|
229
|
+
test("list_project_vulnerabilities passes state filter as GraphQL enum", async () => {
|
|
230
|
+
lastListVariables = null;
|
|
231
|
+
const result = await callTool("list_project_vulnerabilities", { project_id: TEST_PROJECT_ID, state: "detected" }, baseEnv());
|
|
232
|
+
assert.ok(lastListVariables, "GraphQL variables should have been captured");
|
|
233
|
+
assert.deepStrictEqual(lastListVariables.state, ["DETECTED"], "state should be uppercased enum array");
|
|
234
|
+
assert.strictEqual(result.vulnerabilities.length, 1, "Mock should filter to only DETECTED");
|
|
235
|
+
});
|
|
236
|
+
test("list_project_vulnerabilities passes severity filter as GraphQL enum", async () => {
|
|
237
|
+
lastListVariables = null;
|
|
238
|
+
const result = await callTool("list_project_vulnerabilities", { project_id: TEST_PROJECT_ID, severity: "critical" }, baseEnv());
|
|
239
|
+
assert.ok(lastListVariables, "GraphQL variables should have been captured");
|
|
240
|
+
assert.deepStrictEqual(lastListVariables.severity, ["CRITICAL"]);
|
|
241
|
+
assert.strictEqual(result.vulnerabilities.length, 1, "Mock should filter to only CRITICAL");
|
|
242
|
+
});
|
|
243
|
+
test("list_project_vulnerabilities passes report_type filter as GraphQL enum", async () => {
|
|
244
|
+
lastListVariables = null;
|
|
245
|
+
const result = await callTool("list_project_vulnerabilities", { project_id: TEST_PROJECT_ID, report_type: "secret_detection" }, baseEnv());
|
|
246
|
+
assert.ok(lastListVariables, "GraphQL variables should have been captured");
|
|
247
|
+
assert.deepStrictEqual(lastListVariables.reportType, ["SECRET_DETECTION"]);
|
|
248
|
+
assert.strictEqual(result.vulnerabilities.length, 1, "Mock should filter to only SECRET_DETECTION");
|
|
249
|
+
});
|
|
250
|
+
test("list_project_vulnerabilities forwards cursor pagination", async () => {
|
|
251
|
+
lastListVariables = null;
|
|
252
|
+
await callTool("list_project_vulnerabilities", { project_id: TEST_PROJECT_ID, first: 50, after: "cursor-abc" }, baseEnv());
|
|
253
|
+
assert.ok(lastListVariables, "GraphQL variables should have been captured");
|
|
254
|
+
assert.strictEqual(lastListVariables.first, 50);
|
|
255
|
+
assert.strictEqual(lastListVariables.after, "cursor-abc");
|
|
256
|
+
});
|
|
257
|
+
test("get_vulnerability returns full vulnerability object via GID", async () => {
|
|
258
|
+
lastGetId = null;
|
|
259
|
+
const result = await callTool("get_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID }, baseEnv());
|
|
260
|
+
assert.strictEqual(lastGetId, TEST_VULNERABILITY_GID, "Numeric ID should be converted to a GraphQL global ID");
|
|
261
|
+
assert.strictEqual(result.id, TEST_VULNERABILITY_GID);
|
|
262
|
+
assert.strictEqual(result.title, "Hard-coded credential detected");
|
|
263
|
+
assert.strictEqual(result.description, "A hard-coded credential was found in source code");
|
|
264
|
+
assert.strictEqual(result.state, "DETECTED");
|
|
265
|
+
assert.strictEqual(result.severity, "CRITICAL");
|
|
266
|
+
assert.strictEqual(result.reportType, "SECRET_DETECTION");
|
|
267
|
+
assert.strictEqual(result.location.file, "config/secrets.yml");
|
|
268
|
+
assert.strictEqual(result.location.startLine, 42);
|
|
269
|
+
assert.strictEqual(result.identifiers[0].name, "CVE-2024-0001");
|
|
270
|
+
assert.strictEqual(result.links[0].url, "https://example.com/vuln/789");
|
|
271
|
+
assert.strictEqual(result.project.fullPath, TEST_PROJECT_FULL_PATH);
|
|
272
|
+
});
|
|
273
|
+
test("dismiss_vulnerability sends dismissalReason in mutation input", async () => {
|
|
274
|
+
lastDismissInput = null;
|
|
275
|
+
const result = await callTool("dismiss_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID, reason: "false_positive" }, baseEnv());
|
|
276
|
+
assert.ok(lastDismissInput, "Mutation input should have been captured");
|
|
277
|
+
assert.strictEqual(lastDismissInput.id, TEST_VULNERABILITY_GID);
|
|
278
|
+
assert.strictEqual(lastDismissInput.dismissalReason, "FALSE_POSITIVE", "Reason should be mapped to the GraphQL enum");
|
|
279
|
+
assert.strictEqual(result.state, "DISMISSED");
|
|
280
|
+
assert.strictEqual(result.dismissalReason, "FALSE_POSITIVE");
|
|
281
|
+
});
|
|
282
|
+
test("dismiss_vulnerability includes comment when provided", async () => {
|
|
283
|
+
lastDismissInput = null;
|
|
284
|
+
await callTool("dismiss_vulnerability", {
|
|
285
|
+
vulnerability_id: TEST_VULNERABILITY_ID,
|
|
286
|
+
reason: "acceptable_risk",
|
|
287
|
+
comment: "Reviewed by security team, risk accepted",
|
|
288
|
+
}, baseEnv());
|
|
289
|
+
assert.ok(lastDismissInput, "Mutation input should have been captured");
|
|
290
|
+
assert.strictEqual(lastDismissInput.dismissalReason, "ACCEPTABLE_RISK");
|
|
291
|
+
assert.strictEqual(lastDismissInput.comment, "Reviewed by security team, risk accepted");
|
|
292
|
+
});
|
|
293
|
+
test("dismiss_vulnerability accepts mitigating_control and not_applicable reasons", async () => {
|
|
294
|
+
lastDismissInput = null;
|
|
295
|
+
await callTool("dismiss_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID, reason: "mitigating_control" }, baseEnv());
|
|
296
|
+
assert.strictEqual(lastDismissInput.dismissalReason, "MITIGATING_CONTROL");
|
|
297
|
+
lastDismissInput = null;
|
|
298
|
+
await callTool("dismiss_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID, reason: "not_applicable" }, baseEnv());
|
|
299
|
+
assert.strictEqual(lastDismissInput.dismissalReason, "NOT_APPLICABLE");
|
|
300
|
+
});
|
|
301
|
+
test("confirm_vulnerability sends mutation with GID", async () => {
|
|
302
|
+
lastConfirmInput = null;
|
|
303
|
+
const result = await callTool("confirm_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID }, baseEnv());
|
|
304
|
+
assert.ok(lastConfirmInput, "Mutation input should have been captured");
|
|
305
|
+
assert.strictEqual(lastConfirmInput.id, TEST_VULNERABILITY_GID);
|
|
306
|
+
assert.strictEqual(result.state, "CONFIRMED");
|
|
307
|
+
assert.strictEqual(result.confirmedAt, "2024-06-10T12:00:00Z");
|
|
308
|
+
});
|
|
309
|
+
test("confirm_vulnerability includes comment when provided", async () => {
|
|
310
|
+
lastConfirmInput = null;
|
|
311
|
+
await callTool("confirm_vulnerability", {
|
|
312
|
+
vulnerability_id: TEST_VULNERABILITY_ID,
|
|
313
|
+
comment: "Confirmed after manual verification",
|
|
314
|
+
}, baseEnv());
|
|
315
|
+
assert.ok(lastConfirmInput, "Mutation input should have been captured");
|
|
316
|
+
assert.strictEqual(lastConfirmInput.comment, "Confirmed after manual verification");
|
|
317
|
+
});
|
|
318
|
+
// Read-only mode tests
|
|
319
|
+
test("read-only mode: list_project_vulnerabilities succeeds", async () => {
|
|
320
|
+
const result = await callTool("list_project_vulnerabilities", { project_id: TEST_PROJECT_ID }, { ...baseEnv(), GITLAB_PERMISSION_MODE: "readonly" });
|
|
321
|
+
assert.ok(Array.isArray(result.vulnerabilities), "Should return vulnerabilities in readonly mode");
|
|
322
|
+
assert.strictEqual(result.vulnerabilities.length, 3);
|
|
323
|
+
});
|
|
324
|
+
test("read-only mode: get_vulnerability succeeds", async () => {
|
|
325
|
+
const result = await callTool("get_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID }, { ...baseEnv(), GITLAB_PERMISSION_MODE: "readonly" });
|
|
326
|
+
assert.strictEqual(result.id, TEST_VULNERABILITY_GID);
|
|
327
|
+
assert.strictEqual(result.title, "Hard-coded credential detected");
|
|
328
|
+
});
|
|
329
|
+
test("read-only mode: dismiss_vulnerability is rejected", async () => {
|
|
330
|
+
await assert.rejects(() => callTool("dismiss_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID, reason: "false_positive" }, { ...baseEnv(), GITLAB_PERMISSION_MODE: "readonly" }), (err) => {
|
|
331
|
+
const msg = typeof err === "string" ? err : err?.message || JSON.stringify(err);
|
|
332
|
+
assert.ok(msg.toLowerCase().includes("not allowed") ||
|
|
333
|
+
msg.toLowerCase().includes("read-only") ||
|
|
334
|
+
msg.toLowerCase().includes("readonly") ||
|
|
335
|
+
msg.toLowerCase().includes("not available"), `Expected read-only rejection, got: ${msg}`);
|
|
336
|
+
return true;
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
test("read-only mode: confirm_vulnerability is rejected", async () => {
|
|
340
|
+
await assert.rejects(() => callTool("confirm_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID }, { ...baseEnv(), GITLAB_PERMISSION_MODE: "readonly" }), (err) => {
|
|
341
|
+
const msg = typeof err === "string" ? err : err?.message || JSON.stringify(err);
|
|
342
|
+
assert.ok(msg.toLowerCase().includes("not allowed") ||
|
|
343
|
+
msg.toLowerCase().includes("read-only") ||
|
|
344
|
+
msg.toLowerCase().includes("readonly") ||
|
|
345
|
+
msg.toLowerCase().includes("not available"), `Expected read-only rejection, got: ${msg}`);
|
|
346
|
+
return true;
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
// GITLAB_ALLOWED_PROJECT_IDS tests: the mock vulnerability belongs to
|
|
350
|
+
// project 456 (test-group/test-project), so an allowlist of "999" must
|
|
351
|
+
// deny all id-only vulnerability tools.
|
|
352
|
+
const assertAccessDenied = (err) => {
|
|
353
|
+
const msg = typeof err === "string" ? err : err?.message || JSON.stringify(err);
|
|
354
|
+
assert.ok(msg.includes("Access denied"), `Expected allowlist rejection, got: ${msg}`);
|
|
355
|
+
return true;
|
|
356
|
+
};
|
|
357
|
+
test("allowlist: get_vulnerability is rejected for a vulnerability outside allowed projects", async () => {
|
|
358
|
+
await assert.rejects(() => callTool("get_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID }, { ...baseEnv(), GITLAB_ALLOWED_PROJECT_IDS: "999" }), assertAccessDenied);
|
|
359
|
+
});
|
|
360
|
+
test("allowlist: get_vulnerability succeeds when project is allowed by numeric ID", async () => {
|
|
361
|
+
const result = await callTool("get_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID }, { ...baseEnv(), GITLAB_ALLOWED_PROJECT_IDS: TEST_PROJECT_ID });
|
|
362
|
+
assert.strictEqual(result.id, TEST_VULNERABILITY_GID);
|
|
363
|
+
});
|
|
364
|
+
test("allowlist: get_vulnerability succeeds when project is allowed by full path", async () => {
|
|
365
|
+
const result = await callTool("get_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID }, { ...baseEnv(), GITLAB_ALLOWED_PROJECT_IDS: `999,${TEST_PROJECT_FULL_PATH}` });
|
|
366
|
+
assert.strictEqual(result.id, TEST_VULNERABILITY_GID);
|
|
367
|
+
});
|
|
368
|
+
test("allowlist: dismiss_vulnerability is rejected before the mutation is sent", async () => {
|
|
369
|
+
lastDismissInput = null;
|
|
370
|
+
await assert.rejects(() => callTool("dismiss_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID, reason: "false_positive" }, { ...baseEnv(), GITLAB_ALLOWED_PROJECT_IDS: "999" }), assertAccessDenied);
|
|
371
|
+
assert.strictEqual(lastDismissInput, null, "vulnerabilityDismiss mutation must not be sent for a denied project");
|
|
372
|
+
});
|
|
373
|
+
test("allowlist: confirm_vulnerability is rejected before the mutation is sent", async () => {
|
|
374
|
+
lastConfirmInput = null;
|
|
375
|
+
await assert.rejects(() => callTool("confirm_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID }, { ...baseEnv(), GITLAB_ALLOWED_PROJECT_IDS: "999" }), assertAccessDenied);
|
|
376
|
+
assert.strictEqual(lastConfirmInput, null, "vulnerabilityConfirm mutation must not be sent for a denied project");
|
|
377
|
+
});
|
|
378
|
+
test("allowlist: dismiss_vulnerability succeeds when the vulnerability's project is allowed", async () => {
|
|
379
|
+
lastDismissInput = null;
|
|
380
|
+
const result = await callTool("dismiss_vulnerability", { vulnerability_id: TEST_VULNERABILITY_ID, reason: "false_positive" }, { ...baseEnv(), GITLAB_ALLOWED_PROJECT_IDS: TEST_PROJECT_ID });
|
|
381
|
+
assert.ok(lastDismissInput, "Mutation should be sent after the allowlist check passes");
|
|
382
|
+
assert.strictEqual(lastDismissInput.id, TEST_VULNERABILITY_GID);
|
|
383
|
+
assert.strictEqual(result.state, "DISMISSED");
|
|
384
|
+
});
|
|
385
|
+
// GID validation tests: only numeric IDs and Vulnerability GIDs are accepted
|
|
386
|
+
const assertInvalidVulnerabilityId = (err) => {
|
|
387
|
+
const msg = typeof err === "string" ? err : err?.message || JSON.stringify(err);
|
|
388
|
+
assert.ok(msg.includes("Invalid vulnerability ID"), `Expected invalid-ID rejection, got: ${msg}`);
|
|
389
|
+
return true;
|
|
390
|
+
};
|
|
391
|
+
test("gid validation: get_vulnerability rejects non-Vulnerability GIDs", async () => {
|
|
392
|
+
await assert.rejects(() => callTool("get_vulnerability", { vulnerability_id: "gid://gitlab/Project/456" }, baseEnv()), assertInvalidVulnerabilityId);
|
|
393
|
+
});
|
|
394
|
+
test("gid validation: dismiss_vulnerability rejects a non-numeric ID before any request", async () => {
|
|
395
|
+
lastDismissInput = null;
|
|
396
|
+
await assert.rejects(() => callTool("dismiss_vulnerability", { vulnerability_id: "not-a-vuln-id", reason: "false_positive" }, baseEnv()), assertInvalidVulnerabilityId);
|
|
397
|
+
assert.strictEqual(lastDismissInput, null, "vulnerabilityDismiss mutation must not be sent for an invalid ID");
|
|
398
|
+
});
|
|
399
|
+
});
|
package/build/tools/registry.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
2
2
|
import { toJSONSchema } from "../utils/schema.js";
|
|
3
3
|
import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, SSE, STREAMABLE_HTTP, } from "../config.js";
|
|
4
|
-
import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateGroupMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, UnprotectBranchSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupMilestoneSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, EditGroupMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetGroupMilestoneSchema, GetGroupMilestoneIssuesSchema, GetGroupMilestoneMergeRequestsSchema, GetGroupMilestoneBurndownEventsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, PipelineJobControlSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupMilestonesSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, } from "../schemas.js";
|
|
4
|
+
import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateGroupMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, UnprotectBranchSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupMilestoneSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, EditGroupMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetGroupMilestoneSchema, GetGroupMilestoneIssuesSchema, GetGroupMilestoneMergeRequestsSchema, GetGroupMilestoneBurndownEventsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, PipelineJobControlSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupMilestonesSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, } from "../schemas.js";
|
|
5
5
|
const IS_REMOTE = SSE || STREAMABLE_HTTP;
|
|
6
6
|
// Define all available tools
|
|
7
7
|
export const allTools = [
|
|
@@ -1083,6 +1083,27 @@ export const allTools = [
|
|
|
1083
1083
|
description: "Schedule purge of all cached dependency proxy blobs for a group",
|
|
1084
1084
|
inputSchema: toJSONSchema(PurgeDependencyProxyCacheSchema),
|
|
1085
1085
|
},
|
|
1086
|
+
// --- Vulnerability tools ---
|
|
1087
|
+
{
|
|
1088
|
+
name: "list_project_vulnerabilities",
|
|
1089
|
+
description: "List vulnerabilities for a project with optional state, severity, and report type filters (GraphQL-backed, cursor pagination)",
|
|
1090
|
+
inputSchema: toJSONSchema(ListProjectVulnerabilitiesSchema),
|
|
1091
|
+
},
|
|
1092
|
+
{
|
|
1093
|
+
name: "get_vulnerability",
|
|
1094
|
+
description: "Get full details of a specific vulnerability",
|
|
1095
|
+
inputSchema: toJSONSchema(GetVulnerabilitySchema),
|
|
1096
|
+
},
|
|
1097
|
+
{
|
|
1098
|
+
name: "dismiss_vulnerability",
|
|
1099
|
+
description: "Dismiss a vulnerability with a reason (acceptable_risk, false_positive, used_in_tests, mitigating_control, not_applicable) and optional comment",
|
|
1100
|
+
inputSchema: toJSONSchema(DismissVulnerabilitySchema),
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
name: "confirm_vulnerability",
|
|
1104
|
+
description: "Confirm a vulnerability as a real finding requiring remediation",
|
|
1105
|
+
inputSchema: toJSONSchema(ConfirmVulnerabilitySchema),
|
|
1106
|
+
},
|
|
1086
1107
|
// --- Meta tool: Dynamic tool discovery ---
|
|
1087
1108
|
{
|
|
1088
1109
|
name: "discover_tools",
|
|
@@ -1217,6 +1238,8 @@ export const readOnlyTools = new Set([
|
|
|
1217
1238
|
"get_group_variable",
|
|
1218
1239
|
"get_dependency_proxy_settings",
|
|
1219
1240
|
"list_dependency_proxy_blobs",
|
|
1241
|
+
"list_project_vulnerabilities",
|
|
1242
|
+
"get_vulnerability",
|
|
1220
1243
|
]);
|
|
1221
1244
|
// Define which tools are destructive (data loss potential)
|
|
1222
1245
|
export const destructiveTools = new Set([
|
|
@@ -1653,6 +1676,16 @@ export const TOOLSET_DEFINITIONS = [
|
|
|
1653
1676
|
"purge_dependency_proxy_cache",
|
|
1654
1677
|
]),
|
|
1655
1678
|
},
|
|
1679
|
+
{
|
|
1680
|
+
id: "vulnerabilities",
|
|
1681
|
+
isDefault: false,
|
|
1682
|
+
tools: new Set([
|
|
1683
|
+
"list_project_vulnerabilities",
|
|
1684
|
+
"get_vulnerability",
|
|
1685
|
+
"dismiss_vulnerability",
|
|
1686
|
+
"confirm_vulnerability",
|
|
1687
|
+
]),
|
|
1688
|
+
},
|
|
1656
1689
|
];
|
|
1657
1690
|
// Derived lookup: tool name → toolset ID
|
|
1658
1691
|
export const TOOLSET_BY_TOOL_NAME = new Map();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { pino } from "pino";
|
|
2
|
+
const REDACT_PATHS = [
|
|
3
|
+
"token",
|
|
4
|
+
"*.token",
|
|
5
|
+
"ctx.token",
|
|
6
|
+
"context.token",
|
|
7
|
+
"authData.token",
|
|
8
|
+
"auth.token",
|
|
9
|
+
"headers.authorization",
|
|
10
|
+
"headers.Authorization",
|
|
11
|
+
'headers["private-token"]',
|
|
12
|
+
'headers["Private-Token"]',
|
|
13
|
+
'headers["job-token"]',
|
|
14
|
+
'headers["JOB-TOKEN"]',
|
|
15
|
+
'headers["mcp-session-id"]',
|
|
16
|
+
'headers["Mcp-Session-Id"]',
|
|
17
|
+
"sessionId",
|
|
18
|
+
"*.sessionId",
|
|
19
|
+
"ctx.sessionId",
|
|
20
|
+
"context.sessionId",
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Create a pino logger with consistent redaction, level, format, and
|
|
24
|
+
* destination across the entire codebase.
|
|
25
|
+
*
|
|
26
|
+
* - LOG_LEVEL (default "info") controls the minimum severity.
|
|
27
|
+
* - LOG_FORMAT=json disables pino-pretty and outputs newline-delimited JSON
|
|
28
|
+
* to stderr.
|
|
29
|
+
* - All loggers redact the same set of sensitive fields.
|
|
30
|
+
*/
|
|
31
|
+
export function createLogger(name) {
|
|
32
|
+
const isJson = process.env.LOG_FORMAT === "json";
|
|
33
|
+
return pino({
|
|
34
|
+
...(name && { name }),
|
|
35
|
+
level: process.env.LOG_LEVEL || "info",
|
|
36
|
+
redact: {
|
|
37
|
+
paths: REDACT_PATHS,
|
|
38
|
+
censor: "[REDACTED]",
|
|
39
|
+
},
|
|
40
|
+
...(!isJson && {
|
|
41
|
+
transport: {
|
|
42
|
+
target: "pino-pretty",
|
|
43
|
+
options: {
|
|
44
|
+
colorize: true,
|
|
45
|
+
levelFirst: true,
|
|
46
|
+
destination: 2,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
}, ...(isJson ? [pino.destination(2)] : []));
|
|
51
|
+
}
|
package/package.json
CHANGED