@zereight/mcp-gitlab 2.2.0 → 2.2.2

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.
@@ -4,25 +4,35 @@
4
4
  * never be forwarded to the model.
5
5
  */
6
6
  const SENSITIVE_GITLAB_FIELDS = ["runners_token", "import_url", "token"];
7
+ function isPlainRecord(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
7
10
  /**
8
11
  * Strip known sensitive fields from a GitLab API response object before it is
9
12
  * returned as an MCP tool result. Mutates and returns the same object.
10
- * Recurses into arrays (e.g. list responses) since those hold one object per item.
13
+ * Walks arrays and nested objects so a runner's embedded project cannot leak
14
+ * runners_token or token.
11
15
  *
12
16
  * Used for raw passthrough responses (get_project / update_project,
13
- * list/get_pipeline_trigger) that do not go through a Zod schema, since Zod
14
- * would otherwise strip unknown keys for us. Do NOT apply to
17
+ * list/get_pipeline_trigger, runner tools) that do not go through a Zod schema,
18
+ * since Zod would otherwise strip unknown keys for us. Do NOT apply to
15
19
  * create_pipeline_trigger: its response carries the newly issued token once.
16
20
  */
17
21
  export function redactSensitiveGitLabFields(data) {
18
22
  if (Array.isArray(data)) {
19
- for (const item of data)
23
+ for (const item of data) {
20
24
  redactSensitiveGitLabFields(item);
21
- }
22
- else if (data && typeof data === "object") {
23
- for (const key of SENSITIVE_GITLAB_FIELDS) {
24
- delete data[key];
25
25
  }
26
+ return data;
27
+ }
28
+ if (!isPlainRecord(data)) {
29
+ return data;
30
+ }
31
+ for (const key of SENSITIVE_GITLAB_FIELDS) {
32
+ delete data[key];
33
+ }
34
+ for (const value of Object.values(data)) {
35
+ redactSensitiveGitLabFields(value);
26
36
  }
27
37
  return data;
28
38
  }
@@ -0,0 +1,141 @@
1
+ const SENSITIVE_QUERY_NAMES = new Set([
2
+ "access_token",
3
+ "private_token",
4
+ "job_token",
5
+ "password",
6
+ "token",
7
+ ]);
8
+ /**
9
+ * Removes userinfo from a GitLab API URL before it is returned to the client.
10
+ * A password with an unencoded slash is not a valid WHATWG URL, so that form
11
+ * uses the same text redaction as transport error messages.
12
+ */
13
+ export function redactGitLabUrlCredentials(url) {
14
+ try {
15
+ const parsed = new URL(url);
16
+ const hadUserinfo = parsed.username.length > 0 || parsed.password.length > 0;
17
+ if (hadUserinfo) {
18
+ parsed.username = "";
19
+ parsed.password = "";
20
+ }
21
+ const serialized = hadUserinfo ? parsed.toString() : url;
22
+ return redactSensitiveQuery(serialized);
23
+ }
24
+ catch {
25
+ return redactSensitiveQuery(redactUrlUserinfoInText(url));
26
+ }
27
+ }
28
+ /**
29
+ * Log text can contain several URLs. A parseable URL keeps `[REDACTED]@`
30
+ * where userinfo was. redactGitLabUrlCredentials strips that userinfo instead,
31
+ * because the value is returned to the client as a URL.
32
+ */
33
+ export function redactUrlSecretsInText(text) {
34
+ return redactSensitiveQuery(redactUrlUserinfoInText(redactParseableUrlUserinfo(text)));
35
+ }
36
+ function redactSensitiveQuery(url) {
37
+ return url.replace(/([?&])([^=&\s#]+)=[^&\s#]+/g, (match, separator, rawName) => {
38
+ if (!isSensitiveQueryName(rawName)) {
39
+ return match;
40
+ }
41
+ return `${separator}${rawName}=[REDACTED]`;
42
+ });
43
+ }
44
+ function isSensitiveQueryName(rawName) {
45
+ return SENSITIVE_QUERY_NAMES.has(decodeQueryName(rawName).toLowerCase());
46
+ }
47
+ function decodeQueryName(rawName) {
48
+ try {
49
+ return decodeURIComponent(rawName);
50
+ }
51
+ catch {
52
+ return rawName;
53
+ }
54
+ }
55
+ function redactUrlUserinfoInText(message) {
56
+ const withoutCompactUserinfo = message.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, urlToken => redactCompactUserinfo(urlToken));
57
+ return withoutCompactUserinfo.replace(/\b([a-z][a-z0-9+.-]*:\/\/)([^\s/:@.]+(?::[^\s/:@.]+)+(?:\s+[^\s/:@.]+)+)@/gi, (match, scheme, userinfo, offset, source) => {
58
+ if (looksLikeHostPort(userinfo, source.slice(offset + match.length))) {
59
+ return match;
60
+ }
61
+ return `${scheme}[REDACTED]@`;
62
+ });
63
+ }
64
+ function redactParseableUrlUserinfo(text) {
65
+ const trimmed = text.trim();
66
+ const parsed = tryParseUrl(trimmed);
67
+ if (parsed === null || (parsed.username.length === 0 && parsed.password.length === 0)) {
68
+ return text;
69
+ }
70
+ const split = splitAuthorityUserinfo(trimmed);
71
+ if (split === null) {
72
+ return text;
73
+ }
74
+ const leadingLength = text.length - text.trimStart().length;
75
+ const trailing = text.slice(text.trimEnd().length);
76
+ return `${text.slice(0, leadingLength)}${split.scheme}[REDACTED]@${split.afterAt}${trailing}`;
77
+ }
78
+ function tryParseUrl(value) {
79
+ try {
80
+ return new URL(value);
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ function splitAuthorityUserinfo(url) {
87
+ // WHATWG special schemes still expose userinfo for "https:user:pass@host".
88
+ const schemeMatch = /^([a-z][a-z0-9+.-]*:)(\/\/)?/i.exec(url);
89
+ if (schemeMatch === null) {
90
+ return null;
91
+ }
92
+ const schemeEnd = schemeMatch[0].length;
93
+ const rest = url.slice(schemeEnd);
94
+ const slash = rest.indexOf("/");
95
+ const query = rest.indexOf("?");
96
+ const hash = rest.indexOf("#");
97
+ const ends = [slash, query, hash].filter(index => index >= 0);
98
+ const authorityEnd = ends.length === 0 ? rest.length : Math.min(...ends);
99
+ const authority = rest.slice(0, authorityEnd);
100
+ const atIndex = authority.lastIndexOf("@");
101
+ if (atIndex <= 0) {
102
+ return null;
103
+ }
104
+ return {
105
+ scheme: url.slice(0, schemeEnd),
106
+ afterAt: `${authority.slice(atIndex + 1)}${rest.slice(authorityEnd)}`,
107
+ };
108
+ }
109
+ function redactCompactUserinfo(urlToken) {
110
+ const schemeIndex = urlToken.indexOf("://");
111
+ const rest = urlToken.slice(schemeIndex + 3);
112
+ const atIndex = rest.lastIndexOf("@");
113
+ if (atIndex <= 0) {
114
+ return urlToken;
115
+ }
116
+ return `${urlToken.slice(0, schemeIndex + 3)}[REDACTED]@${rest.slice(atIndex + 1)}`;
117
+ }
118
+ // "user:pa ss@host" is a password. "localhost:3000 a@b.com" is a host, then an email.
119
+ // A numeric port counts only when one token follows it and the text after "@" is an email domain.
120
+ function looksLikeHostPort(userinfo, afterAt) {
121
+ const colon = userinfo.indexOf(":");
122
+ if (colon <= 0) {
123
+ return false;
124
+ }
125
+ const host = userinfo.slice(0, colon);
126
+ const portMatch = /^([^\s]+)/.exec(userinfo.slice(colon + 1));
127
+ const port = portMatch?.[1] ?? "";
128
+ if (port.length === 0) {
129
+ return false;
130
+ }
131
+ if (/^\d+$/.test(port)) {
132
+ const remainder = userinfo.slice(colon + 1 + port.length).trim();
133
+ const firstTokenEnd = afterAt.search(/\s/);
134
+ const firstToken = firstTokenEnd === -1 ? afterAt : afterAt.slice(0, firstTokenEnd);
135
+ return (remainder.length > 0 &&
136
+ !/\s/.test(remainder) &&
137
+ firstToken.length > 0 &&
138
+ !/[/:@]/.test(firstToken));
139
+ }
140
+ return host.toLowerCase() === "localhost";
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "mcpName": "io.github.zereight/gitlab-mcp",
5
5
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
6
6
  "keywords": [