Skip to content

Commit 460b0e6

Browse files
Merge pull request #2515 from mukesh-dream11/feat/graphql-introspection
Add graphql introspection module
2 parents 062b48b + e984b37 commit 460b0e6

File tree

2 files changed

+176
-0
lines changed

2 files changed

+176
-0
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import json
2+
from pathlib import Path
3+
from bbot.modules.base import BaseModule
4+
5+
6+
class graphql_introspection(BaseModule):
7+
watched_events = ["URL"]
8+
produced_events = ["FINDING"]
9+
flags = ["safe", "active", "web-basic"]
10+
meta = {
11+
"description": "Perform GraphQL introspection on a target",
12+
"created_date": "2025-07-01",
13+
"author": "@mukesh-dream11",
14+
}
15+
options = {
16+
"graphql_endpoint_urls": ["/", "/graphql", "/v1/graphql"],
17+
"output_folder": "",
18+
}
19+
options_desc = {
20+
"graphql_endpoint_urls": "List of GraphQL endpoint to suffix to the target URL",
21+
"output_folder": "Folder to save the GraphQL schemas to",
22+
}
23+
24+
async def setup(self):
25+
output_folder = self.config.get("output_folder", "")
26+
if output_folder:
27+
self.output_dir = Path(output_folder) / "graphql-schemas"
28+
else:
29+
self.output_dir = self.scan.home / "graphql-schemas"
30+
self.helpers.mkdir(self.output_dir)
31+
return True
32+
33+
async def filter_event(self, event):
34+
# Dedup by the base URL
35+
base_url = event.parsed_url._replace(path="/", query="", fragment="").geturl()
36+
return hash(base_url)
37+
38+
async def handle_event(self, event):
39+
base_url = event.parsed_url._replace(path="/", query="", fragment="").geturl().rstrip("/")
40+
for endpoint_url in self.config.get("graphql_endpoint_urls", []):
41+
url = f"{base_url}{endpoint_url}"
42+
request_args = {
43+
"url": url,
44+
"method": "POST",
45+
"json": {
46+
"query": """\
47+
query IntrospectionQuery {
48+
__schema {
49+
queryType {
50+
name
51+
}
52+
mutationType {
53+
name
54+
}
55+
types {
56+
name
57+
kind
58+
description
59+
fields(includeDeprecated: true) {
60+
name
61+
description
62+
type {
63+
... TypeRef
64+
}
65+
isDeprecated
66+
deprecationReason
67+
}
68+
interfaces {
69+
... TypeRef
70+
}
71+
possibleTypes {
72+
... TypeRef
73+
}
74+
enumValues(includeDeprecated: true) {
75+
name
76+
description
77+
isDeprecated
78+
deprecationReason
79+
}
80+
ofType {
81+
... TypeRef
82+
}
83+
}
84+
}
85+
}
86+
87+
fragment TypeRef on __Type {
88+
kind
89+
name
90+
ofType {
91+
kind
92+
name
93+
ofType {
94+
kind
95+
name
96+
ofType {
97+
kind
98+
name
99+
ofType {
100+
kind
101+
name
102+
ofType {
103+
kind
104+
name
105+
ofType {
106+
kind
107+
name
108+
ofType {
109+
kind
110+
name
111+
}
112+
}
113+
}
114+
}
115+
}
116+
}
117+
}
118+
}"""
119+
},
120+
}
121+
response = await self.helpers.request(**request_args)
122+
if not response or response.status_code != 200:
123+
self.debug(f"Failed to get GraphQL schema for {url} (status code {response.status_code})")
124+
continue
125+
try:
126+
response_json = response.json()
127+
except json.JSONDecodeError:
128+
self.debug(f"Failed to parse JSON for {url}")
129+
continue
130+
if response_json.get("data", {}).get("__schema", {}).get("types", []):
131+
filename = f"schema-{self.helpers.tagify(url)}.json"
132+
filename = self.output_dir / filename
133+
with open(filename, "w") as f:
134+
json.dump(response_json, f)
135+
await self.emit_event(
136+
{"url": url, "description": "GraphQL schema", "path": str(filename.relative_to(self.scan.home))},
137+
"FINDING",
138+
event,
139+
context=f"{{module}} found GraphQL schema at {url}",
140+
)
141+
# return, because we only want to find one schema per target
142+
return
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from .base import ModuleTestBase
2+
3+
4+
class TestGraphQLIntrospectionNon200(ModuleTestBase):
5+
targets = ["http://127.0.0.1:8888"]
6+
modules_overrides = ["graphql_introspection"]
7+
8+
async def setup_after_prep(self, module_test):
9+
module_test.set_expect_requests(
10+
expect_args={"method": "POST", "uri": "/"},
11+
respond_args={"response_data": "ok"},
12+
)
13+
14+
def check(self, module_test, events):
15+
assert all(e.type != "FINDING" for e in events), "should have raised 0 events"
16+
17+
18+
class TestGraphQLIntrospection(ModuleTestBase):
19+
targets = ["http://127.0.0.1:8888"]
20+
modules_overrides = ["graphql_introspection"]
21+
22+
async def setup_after_prep(self, module_test):
23+
module_test.set_expect_requests(
24+
expect_args={"method": "POST", "uri": "/"},
25+
respond_args={
26+
"response_data": """{"data": {"__schema": {"types": ["dummy"]}}}""",
27+
},
28+
)
29+
30+
def check(self, module_test, events):
31+
finding = [e for e in events if e.type == "FINDING"]
32+
assert finding, "should have raised 1 FINDING event"
33+
assert finding[0].data["url"] == "http://127.0.0.1:8888/"
34+
assert finding[0].data["description"] == "GraphQL schema"

0 commit comments

Comments
 (0)