1use async_trait::async_trait;
4use rand::seq::SliceRandom;
5use serde_json::{json, Value};
6use tracing::debug;
7use url::Url;
8
9use crate::{
10 config::Config,
11 error::CapturedError,
12 http_client::HttpClient,
13 reports::{Finding, Severity},
14};
15
16use super::Scanner;
17
18pub struct GraphqlScanner;
19
20impl GraphqlScanner {
21 pub fn new(_config: &Config) -> Self {
22 Self
23 }
24}
25
26static GQL_PATHS: &[&str] = &[
27 "/graphql",
28 "/graphiql",
29 "/api/graphql",
30 "/v1/graphql",
31 "/query",
32 "/gql",
33];
34
35fn introspection_payload() -> Value {
38 json!({
39 "query": "{ __schema { queryType { name } types { kind name \
40 fields { name args { name type { name kind } } } } } }"
41 })
42}
43
44fn field_suggestion_payload() -> Value {
45 json!({ "query": "{ __typ }" })
46}
47
48fn batch_payload() -> Value {
49 json!([
50 { "query": "{ __typename }" },
51 { "query": "{ __typename }" }
52 ])
53}
54
55fn alias_dos_payload() -> Value {
56 let aliases: String = (0..10).map(|i| format!("a{i}: __typename ")).collect();
57 json!({ "query": format!("{{ {aliases} }}") })
58}
59
60static SENSITIVE_TYPES: &[&str] = &[
62 "user",
63 "users",
64 "admin",
65 "password",
66 "secret",
67 "token",
68 "apikey",
69 "api_key",
70 "credential",
71 "auth",
72 "session",
73 "privatekey",
74 "ssn",
75 "creditcard",
76 "payment",
77 "billing",
78];
79
80#[async_trait]
81impl Scanner for GraphqlScanner {
82 fn name(&self) -> &'static str {
83 "graphql"
84 }
85
86 async fn scan(
87 &self,
88 url: &str,
89 client: &HttpClient,
90 _config: &Config,
91 ) -> (Vec<Finding>, Vec<CapturedError>) {
92 let mut findings = Vec::new();
93 let mut errors = Vec::new();
94
95 let lower = url.to_ascii_lowercase();
97 let already_gql = GQL_PATHS.iter().any(|p| lower.ends_with(p));
98 let base_path_looks_graphql = seed_path_looks_graphql(url);
99
100 let mut candidates: Vec<String> = Vec::new();
101 if already_gql || base_path_looks_graphql {
102 candidates.push(url.to_string());
103 }
104 if !already_gql {
105 let base = url.trim_end_matches('/');
106 let mut paths: Vec<&str> = GQL_PATHS.to_vec();
107 let mut rng = rand::thread_rng();
108 paths.shuffle(&mut rng);
109 for path in paths {
110 candidates.push(format!("{base}{path}"));
111 }
112 }
113
114 for candidate in &candidates {
115 probe_endpoint(candidate, client, &mut findings, &mut errors).await;
116 }
117
118 (findings, errors)
119 }
120}
121
122async fn probe_endpoint(
125 url: &str,
126 client: &HttpClient,
127 findings: &mut Vec<Finding>,
128 errors: &mut Vec<CapturedError>,
129) {
130 let payload = introspection_payload();
132 let resp = match client.post_json(url, &payload).await {
133 Ok(r) => r,
134 Err(e) => {
135 errors.push(e);
136 return;
137 }
138 };
139
140 if resp.status >= 500 || resp.status == 401 || resp.status == 403 || resp.status == 429 {
143 return;
144 }
145 if resp.status == 404 {
146 return;
147 }
148
149 let body: Value = match serde_json::from_str(&resp.body) {
150 Ok(v) => v,
151 Err(_) => return,
152 };
153
154 let types_ptr = body
156 .pointer("/data/__schema/types")
157 .or_else(|| body.pointer("/__schema/types"));
158
159 if let Some(types_val) = types_ptr {
160 findings.push(
162 Finding::new(
163 url,
164 "graphql/introspection-enabled",
165 "GraphQL introspection enabled",
166 Severity::Medium,
167 "GraphQL introspection is enabled. Full schema is publicly discoverable.",
168 "graphql",
169 )
170 .with_evidence(format!(
171 "POST {url}\nPayload: {payload}\nStatus: {}",
172 resp.status
173 ))
174 .with_remediation(
175 "Disable introspection in production or restrict it to authenticated/admin users.",
176 ),
177 );
178
179 if let Some(types) = types_val.as_array() {
181 let mut matched: Vec<String> = Vec::new();
182
183 for t in types {
184 let type_name = t["name"].as_str().unwrap_or("").to_ascii_lowercase();
185
186 if is_sensitive(&type_name) && !type_name.starts_with("__") {
187 matched.push(format!("type:{type_name}"));
188 }
189
190 if let Some(fields) = t["fields"].as_array() {
191 for field in fields {
192 let fname = field["name"].as_str().unwrap_or("").to_ascii_lowercase();
193 if is_sensitive(&fname) {
194 matched.push(format!(
195 "{}::{}",
196 t["name"].as_str().unwrap_or("?"),
197 fname
198 ));
199 }
200 }
201 }
202 }
203
204 if !matched.is_empty() {
205 findings.push(Finding::new(
206 url,
207 "graphql/sensitive-schema-fields",
208 "Sensitive GraphQL schema fields",
209 Severity::High,
210 format!(
211 "Schema exposes potentially sensitive types/fields: {}",
212 matched.join(", ")
213 ),
214 "graphql",
215 )
216 .with_evidence(format!("Matched names: {}", matched.join(", ")))
217 .with_remediation(
218 "Review schema for sensitive fields and enforce authorization on resolvers.",
219 ));
220 }
221 }
222 } else if let Some(errors_val) = body.get("errors") {
223 debug!("[graphql] introspection disabled at {url}: {errors_val}");
224 findings.push(
225 Finding::new(
226 url,
227 "graphql/endpoint-detected",
228 "GraphQL endpoint detected",
229 Severity::Info,
230 "GraphQL endpoint detected; introspection is disabled (good).",
231 "graphql",
232 )
233 .with_evidence(format!("Errors: {errors_val}"))
234 .with_remediation(
235 "Ensure the endpoint requires authentication and applies query cost limits.",
236 ),
237 );
238 }
239
240 let sugg_payload = field_suggestion_payload();
242 if let Ok(sr) = client.post_json(url, &sugg_payload).await {
243 if let Ok(sb) = serde_json::from_str::<Value>(&sr.body) {
244 let has_suggestion = sb["errors"]
245 .as_array()
246 .map(|errs| {
247 errs.iter().any(|e| {
248 e["message"]
249 .as_str()
250 .map(|m| m.contains("Did you mean") || m.contains("did you mean"))
251 .unwrap_or(false)
252 })
253 })
254 .unwrap_or(false);
255
256 if has_suggestion {
257 findings.push(
258 Finding::new(
259 url,
260 "graphql/field-suggestions",
261 "GraphQL field suggestions enabled",
262 Severity::Low,
263 "Server returns field-name suggestions in errors, leaking schema \
264 information even with introspection disabled.",
265 "graphql",
266 )
267 .with_evidence(sr.body.chars().take(512).collect::<String>())
268 .with_remediation(
269 "Disable field suggestions or hide detailed error messages in production.",
270 ),
271 );
272 }
273 }
274 }
275
276 let batch = batch_payload();
278 if let Ok(br) = client.post_json(url, &batch).await {
279 if let Ok(bv) = serde_json::from_str::<Value>(&br.body) {
280 if bv.as_array().map(|a| a.len() >= 2).unwrap_or(false) {
281 findings.push(
282 Finding::new(
283 url,
284 "graphql/batching-enabled",
285 "GraphQL query batching enabled",
286 Severity::Low,
287 "GraphQL query batching is enabled. This can amplify DoS impact \
288 and may bypass rate limiting applied per-request.",
289 "graphql",
290 )
291 .with_evidence(br.body.chars().take(256).collect::<String>())
292 .with_remediation(
293 "Disable batching or enforce per-operation rate limits and cost controls.",
294 ),
295 );
296 }
297 }
298 }
299
300 let alias = alias_dos_payload();
302 if let Ok(ar) = client.post_json(url, &alias).await {
303 if let Ok(av) = serde_json::from_str::<Value>(&ar.body) {
304 let resolved = (0..10)
305 .filter(|i| av.pointer(&format!("/data/a{i}")).is_some())
306 .count();
307 if resolved >= 10 {
308 findings.push(
309 Finding::new(
310 url,
311 "graphql/alias-amplification",
312 "GraphQL alias amplification possible",
313 Severity::Low,
314 "Server resolves all query aliases without restriction. \
315 Malicious clients can craft deeply aliased queries to amplify \
316 server-side work (alias-based DoS).",
317 "graphql",
318 )
319 .with_evidence(format!("{resolved}/10 aliases resolved"))
320 .with_remediation(
321 "Enforce query depth/complexity limits and alias count caps.",
322 ),
323 );
324 }
325 }
326 }
327
328 if let Ok(gr) = client.get(url).await {
330 let body_lower = gr.body.to_ascii_lowercase();
331 if body_lower.contains("graphiql") || body_lower.contains("graphql playground") {
332 findings.push(
333 Finding::new(
334 url,
335 "graphql/playground-exposed",
336 "GraphQL IDE exposed",
337 Severity::Low,
338 "GraphQL IDE (GraphiQL / Playground) is exposed. Attackers can \
339 interactively explore and query the API.",
340 "graphql",
341 )
342 .with_evidence(format!("GET {url} → HTML contains IDE marker"))
343 .with_remediation(
344 "Disable GraphiQL/Playground in production or restrict access to admins.",
345 ),
346 );
347 }
348 }
349}
350
351fn is_sensitive(name: &str) -> bool {
354 SENSITIVE_TYPES
355 .iter()
356 .any(|&s| name == s || name.contains(s))
357}
358
359fn seed_path_looks_graphql(url: &str) -> bool {
360 let Ok(parsed) = Url::parse(url) else {
361 return false;
362 };
363 let path = parsed.path().to_ascii_lowercase();
364 path.contains("graphql") || path.ends_with("/gql")
365}