Skip to content

Commit e467b2c

Browse files
committed
Update field suggestion detector
1 parent c78cbca commit e467b2c

8 files changed

Lines changed: 510 additions & 145 deletions

File tree

.github/workflows/integration_tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ jobs:
5151
run: |
5252
uv run pytest tests/integration/test_api_security_api.py --exitfirst --verbose --failed-first --cov=. --cov-report html
5353
54+
- name: Run integration test for NoSQL and time-based SQL injection API
55+
run: |
56+
uv run pytest tests/integration/test_nosql_time_sql_api.py --exitfirst --verbose --failed-first --cov=. --cov-report html
57+
5458
- name: Run tests for core
5559
run: |
5660
uv run pytest tests/integration/test_core.py --exitfirst --verbose --failed-first --cov=. --cov-report html

graphqler/fuzzer/engine/detectors/field_fuzzing/id_enumeration_detector.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from graphqler.utils.api import API
3535
from graphqler.utils.objects_bucket import ObjectsBucket
3636
from graphqler.utils.stats import Stats
37+
from graphqler.utils.response_utils import is_non_empty_result
3738
from graphqler.fuzzer.engine.materializers.getter import Getter
3839
from graphqler.fuzzer.engine.materializers.regular_payload_materializer import RegularPayloadMaterializer
3940
from graphqler.fuzzer.engine.detectors.detector import Detector
@@ -220,9 +221,16 @@ def _probe_ids(self, field_name: str) -> tuple[int, list[str]]:
220221
self.api.url, payload
221222
)
222223
Stats().add_http_status_code(self.name, request_response.status_code)
223-
224224
if request_response.status_code == 200 and isinstance(graphql_response.get("data"), dict):
225-
if any(v is not None for v in graphql_response["data"].values()):
225+
data = graphql_response["data"]
226+
field_result = data.get(self.name)
227+
228+
is_hit = (
229+
is_non_empty_result(field_result)
230+
if self.name in data
231+
else any(is_non_empty_result(v) for v in data.values())
232+
)
233+
if is_hit:
226234
success_count += 1
227235
except Exception:
228236
pass

graphqler/fuzzer/engine/detectors/field_suggestion/field_suggestion_detector.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1+
import random
12
from typing import Type
23

34
import requests
45

6+
from graphqler.utils import plugins_handler
7+
from graphqler.utils.stats import Stats
58
from .field_suggestion_materializer import FieldSuggestionMaterializer
69
from ..detector import Detector
710

811

912
class FieldSuggestionsDetector(Detector):
1013
"""Field Suggestions Detector
14+
15+
Iterates over all known query names (shuffled) and sends each as a
16+
misspelled field name. Detection succeeds as soon as any response
17+
contains a "did you mean" hint, making the result independent of which
18+
individual query name happens to be blocked by a deny-list.
1119
"""
20+
1221
@property
1322
def DETECTION_NAME(self) -> str:
1423
return "Field Suggestions Enabled"
@@ -25,9 +34,63 @@ def detect_only_once_for_node(self) -> bool:
2534
def materializer(self) -> Type[FieldSuggestionMaterializer]:
2635
return FieldSuggestionMaterializer
2736

37+
# ── multi-query detect override ──────────────────────────────────────────
38+
39+
def detect(self) -> tuple[bool, bool]:
40+
query_names = list(self.api.queries.keys())
41+
random.shuffle(query_names)
42+
43+
for query_name in query_names:
44+
misspelled = query_name + "abc"
45+
payload = f"query {{\n {misspelled} {{\n id\n }}\n}}"
46+
47+
graphql_response, request_response = (
48+
plugins_handler.get_request_utils().send_graphql_request(
49+
self.api.url, payload
50+
)
51+
)
52+
Stats().add_http_status_code(self.name, request_response.status_code)
53+
54+
if self._is_vulnerable(graphql_response, request_response):
55+
self.payload = payload
56+
self.confirmed_vulnerable = True
57+
self.potentially_vulnerable = True
58+
evidence = self._get_evidence(graphql_response, request_response)
59+
Stats().add_vulnerability(
60+
self.DETECTION_NAME,
61+
self.name,
62+
self.confirmed_vulnerable,
63+
self.potentially_vulnerable,
64+
payload=payload,
65+
evidence=evidence,
66+
)
67+
self.detector_logger.info(
68+
f"Detector {self.DETECTION_NAME} finished detecting - "
69+
f"is_vulnerable: True - potentially_vulnerable: True"
70+
)
71+
return (True, True)
72+
73+
self.detector_logger.info(
74+
f"Detector {self.DETECTION_NAME} finished detecting - "
75+
f"is_vulnerable: False - potentially_vulnerable: False"
76+
)
77+
Stats().add_vulnerability(
78+
self.DETECTION_NAME,
79+
self.name,
80+
False,
81+
False,
82+
payload="",
83+
evidence="",
84+
)
85+
return (False, False)
86+
87+
# ── helpers ──────────────────────────────────────────────────────────────
88+
2889
def _is_vulnerable(self, graphql_response: dict, request_response: requests.Response) -> bool:
29-
return ("did you mean" in str(graphql_response['errors'][0]['message']).lower()
30-
or "did you mean" in request_response.text.lower())
90+
errors = graphql_response.get("errors") or []
91+
if errors and "did you mean" in str(errors[0].get("message", "")).lower():
92+
return True
93+
return "did you mean" in request_response.text.lower()
3194

3295
def _is_potentially_vulnerable(self, graphql_response: dict, request_response: requests.Response) -> bool:
3396
return self._is_vulnerable(graphql_response, request_response)

graphqler/utils/response_utils.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from typing import Any
2+
3+
def is_non_empty_result(value: Any) -> bool:
4+
"""Checks if a GraphQL result contains any non-empty fields recursively.
5+
6+
Args:
7+
value (Any): The value to check (dict, list, str, int, float, bool, etc.)
8+
9+
Returns:
10+
bool: True if the result contains actual data, False if entirely empty/null.
11+
"""
12+
if value is None:
13+
return False
14+
15+
if isinstance(value, str):
16+
# bool("") is False, bool("text") is True.
17+
# Note: use bool(value.strip()) if you want spaces like " " to count as empty.
18+
return bool(value)
19+
20+
if isinstance(value, dict):
21+
# any() automatically returns False if the dict is empty
22+
return any(is_non_empty_result(v) for v in value.values())
23+
24+
if isinstance(value, list):
25+
# any() automatically returns False if the list is empty
26+
return any(is_non_empty_result(item) for item in value)
27+
28+
# Fallback for ints, floats, and booleans (e.g., 0, 0.0, False).
29+
# In GraphQL/JSON, these represent actual data points, so they are non-empty.
30+
return True

tests/integration/test_nosql_time_sql_api.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,14 @@ def test_fuzz_generates_stats_file(self):
6666

6767
# ── Injection detection ───────────────────────────────────────────────────
6868

69+
_cached_vulns = None
70+
6971
def _run_and_get_vulns(self):
70-
__main__.run_compile_mode(self.PATH, self.URL)
71-
__main__.run_fuzz_mode(self.PATH, self.URL)
72-
return get_vulnerabilities_from_stats(self.PATH)
72+
if self.__class__._cached_vulns is None:
73+
__main__.run_compile_mode(self.PATH, self.URL)
74+
__main__.run_fuzz_mode(self.PATH, self.URL)
75+
self.__class__._cached_vulns = get_vulnerabilities_from_stats(self.PATH)
76+
return self.__class__._cached_vulns
7377

7478
def test_nosql_injection_detected(self):
7579
vulns = self._run_and_get_vulns()

0 commit comments

Comments
 (0)