Skip to content

Commit 162675b

Browse files
Merge pull request #2750 from blacklanternsecurity/fix-CVE-2025-10282-again
Fix CVE-2025-10282, again??
2 parents e01df0e + 76a680c commit 162675b

File tree

7 files changed

+285
-212
lines changed

7 files changed

+285
-212
lines changed

bbot/modules/gitlab.py

Lines changed: 0 additions & 141 deletions
This file was deleted.

bbot/modules/gitlab_com.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from bbot.modules.templates.gitlab import GitLabBaseModule
2+
3+
4+
class gitlab_com(GitLabBaseModule):
5+
watched_events = ["SOCIAL"]
6+
produced_events = [
7+
"CODE_REPOSITORY",
8+
]
9+
flags = ["active", "safe", "code-enum"]
10+
meta = {
11+
"description": "Enumerate GitLab SaaS (gitlab.com/org) for projects and groups",
12+
"created_date": "2024-03-11",
13+
"author": "@TheTechromancer",
14+
}
15+
16+
options = {"api_key": ""}
17+
options_desc = {"api_key": "GitLab access token (for gitlab.com/org only)"}
18+
19+
# This is needed because we are consuming SOCIAL events, which aren't in scope
20+
scope_distance_modifier = 2
21+
22+
async def handle_event(self, event):
23+
await self.handle_social(event)
24+
25+
async def filter_event(self, event):
26+
if event.data["platform"] != "gitlab":
27+
return False, "platform is not gitlab"
28+
_, domain = self.helpers.split_domain(event.host)
29+
if domain not in self.saas_domains:
30+
return False, "gitlab instance is not gitlab.com/org"
31+
return True

bbot/modules/gitlab_onprem.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from bbot.modules.templates.gitlab import GitLabBaseModule
2+
3+
4+
class gitlab_onprem(GitLabBaseModule):
5+
watched_events = ["HTTP_RESPONSE", "TECHNOLOGY", "SOCIAL"]
6+
produced_events = [
7+
"TECHNOLOGY",
8+
"SOCIAL",
9+
"CODE_REPOSITORY",
10+
"FINDING",
11+
]
12+
flags = ["active", "safe", "code-enum"]
13+
meta = {
14+
"description": "Detect self-hosted GitLab instances and query them for repositories",
15+
"created_date": "2024-03-11",
16+
"author": "@TheTechromancer",
17+
}
18+
19+
# Optional GitLab access token (only required for gitlab.com, but still
20+
# supported for on-prem installations that expose private projects).
21+
options = {"api_key": ""}
22+
options_desc = {"api_key": "GitLab access token (for self-hosted instances only)"}
23+
24+
# Allow accepting events slightly beyond configured max distance so we can
25+
# discover repos on neighbouring infrastructure.
26+
scope_distance_modifier = 2
27+
28+
async def handle_event(self, event):
29+
if event.type == "HTTP_RESPONSE":
30+
await self.handle_http_response(event)
31+
elif event.type == "TECHNOLOGY":
32+
await self.handle_technology(event)
33+
elif event.type == "SOCIAL":
34+
await self.handle_social(event)
35+
36+
async def filter_event(self, event):
37+
# only accept out-of-scope SOCIAL events
38+
if event.type == "HTTP_RESPONSE":
39+
if event.scope_distance > self.scan.scope_search_distance:
40+
return False, "event is out of scope distance"
41+
elif event.type == "TECHNOLOGY":
42+
if not event.data["technology"].lower().startswith("gitlab"):
43+
return False, "technology is not gitlab"
44+
if not self.helpers.is_ip(event.host) and self.helpers.tldextract(event.host).domain == "gitlab":
45+
return False, "gitlab instance is not self-hosted"
46+
elif event.type == "SOCIAL":
47+
if event.data["platform"] != "gitlab":
48+
return False, "platform is not gitlab"
49+
_, domain = self.helpers.split_domain(event.host)
50+
if domain in self.saas_domains:
51+
return False, "gitlab instance is not self-hosted"
52+
return True
53+
54+
async def handle_http_response(self, event):
55+
"""Identify GitLab servers from HTTP responses."""
56+
headers = event.data.get("header", {})
57+
if "x_gitlab_meta" in headers:
58+
url = event.parsed_url._replace(path="/").geturl()
59+
await self.emit_event(
60+
{"host": str(event.host), "technology": "GitLab", "url": url},
61+
"TECHNOLOGY",
62+
parent=event,
63+
context=f"{{module}} detected {{event.type}}: GitLab at {url}",
64+
)
65+
description = f"GitLab server at {event.host}"
66+
await self.emit_event(
67+
{"host": str(event.host), "description": description},
68+
"FINDING",
69+
parent=event,
70+
context=f"{{module}} detected {{event.type}}: {description}",
71+
)
72+
73+
async def handle_technology(self, event):
74+
"""Enumerate projects & groups once we know a host is GitLab."""
75+
base_url = self.get_base_url(event)
76+
77+
# Projects owned by the authenticated user (or public projects if no
78+
# authentication).
79+
projects_url = self.helpers.urljoin(base_url, "api/v4/projects?simple=true")
80+
await self.handle_projects_url(projects_url, event)
81+
82+
# Group enumeration.
83+
groups_url = self.helpers.urljoin(base_url, "api/v4/groups?simple=true")
84+
await self.handle_groups_url(groups_url, event)

bbot/modules/templates/gitlab.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from bbot.modules.base import BaseModule
2+
3+
4+
class GitLabBaseModule(BaseModule):
5+
"""Common functionality for interacting with GitLab instances.
6+
7+
This template is intended to be inherited by two concrete modules:
8+
1. ``gitlab_com`` – Handles public SaaS instances (gitlab.com / gitlab.org).
9+
2. ``gitlab_onprem`` – Handles self-hosted, on-premises GitLab servers.
10+
11+
Both child modules share identical behaviour when talking to the GitLab
12+
REST API; they only differ in which events they are willing to accept.
13+
"""
14+
15+
# domains owned by GitLab
16+
saas_domains = ["gitlab.com", "gitlab.org"]
17+
18+
async def setup(self):
19+
if self.options.get("api_key") is not None:
20+
await self.require_api_key()
21+
return True
22+
23+
async def handle_social(self, event):
24+
"""Enumerate projects belonging to a user or group profile."""
25+
username = event.data.get("profile_name", "")
26+
if not username:
27+
return
28+
base_url = self.get_base_url(event)
29+
urls = [
30+
# User-owned projects
31+
self.helpers.urljoin(base_url, f"api/v4/users/{username}/projects?simple=true"),
32+
# Group-owned projects
33+
self.helpers.urljoin(base_url, f"api/v4/groups/{username}/projects?simple=true"),
34+
]
35+
for url in urls:
36+
await self.handle_projects_url(url, event)
37+
38+
async def handle_projects_url(self, projects_url, event):
39+
for project in await self.gitlab_json_request(projects_url):
40+
project_url = project.get("web_url", "")
41+
if project_url:
42+
code_event = self.make_event({"url": project_url}, "CODE_REPOSITORY", tags="git", parent=event)
43+
await self.emit_event(
44+
code_event,
45+
context=f"{{module}} enumerated projects and found {{event.type}} at {project_url}",
46+
)
47+
namespace = project.get("namespace", {})
48+
if namespace:
49+
await self.handle_namespace(namespace, event)
50+
51+
async def handle_groups_url(self, groups_url, event):
52+
for group in await self.gitlab_json_request(groups_url):
53+
await self.handle_namespace(group, event)
54+
55+
async def gitlab_json_request(self, url):
56+
"""Helper that performs an HTTP request and safely returns JSON list."""
57+
response = await self.api_request(url)
58+
if response is not None:
59+
try:
60+
json_data = response.json()
61+
except Exception:
62+
return []
63+
if json_data and isinstance(json_data, list):
64+
return json_data
65+
return []
66+
67+
async def handle_namespace(self, namespace, event):
68+
namespace_name = namespace.get("path", "")
69+
namespace_url = namespace.get("web_url", "")
70+
namespace_path = namespace.get("full_path", "")
71+
72+
if not (namespace_name and namespace_url and namespace_path):
73+
return
74+
75+
namespace_url = self.helpers.parse_url(namespace_url)._replace(path=f"/{namespace_path}").geturl()
76+
77+
social_event = self.make_event(
78+
{
79+
"platform": "gitlab",
80+
"profile_name": namespace_path,
81+
"url": namespace_url,
82+
},
83+
"SOCIAL",
84+
parent=event,
85+
)
86+
await self.emit_event(
87+
social_event,
88+
context=f'{{module}} found GitLab namespace ({{event.type}}) "{namespace_name}" at {namespace_url}',
89+
)
90+
91+
# ------------------------------------------------------------------
92+
# Utility helpers
93+
# ------------------------------------------------------------------
94+
def get_base_url(self, event):
95+
base_url = event.data.get("url", "")
96+
if not base_url:
97+
base_url = f"https://{event.host}"
98+
return self.helpers.urlparse(base_url)._replace(path="/").geturl()

0 commit comments

Comments
 (0)