Skip to content

Commit

Permalink
Add UNION type support
Browse files Browse the repository at this point in the history
  • Loading branch information
omar2535 committed Jul 15, 2024
1 parent 3dc9109 commit 820c165
Show file tree
Hide file tree
Showing 10 changed files with 114 additions and 33 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,5 @@ There are also varaibles that can be modified with the `--config` flag as a TOML
| MAX_FUZZING_ITERATIONS | Maximum number of fuzzing payloads to run on a node | Integer | 5 |
| MAX_TIME | The maximum time to run in seconds | Integer | 3600 |
| TIME_BETWEEN_REQUESTS | Max time to wait between requests in seconds | Integer | 0.001 |
| DEBUG | Debug mode | Boolean | False |
| Custom Headers| Custom headers to be sent along with each request | Object | `Accept = "application/json"` |
10 changes: 8 additions & 2 deletions graphqler/compiler/parsers/union_list_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,17 @@ def extract_union_values(self, union_values: List[dict]) -> List[dict]:
union_valies (List[dict]): List of possible values of the UNION object
Returns:
List[dict]: List of possible balues of the UNION object but filtered for only relavent fields
List[dict]: List of possible balues of the UNION object but filtered for only relavent fields.
We also add the type field to the object if the kind if an object
"""
list_of_union_values = []
for union_value in union_values:
filtered_union_value = {"kind": union_value["kind"], "name": union_value["name"], "ofType": union_value["ofType"]}
filtered_union_value = {
"kind": union_value["kind"],
"name": union_value["name"],
"ofType": union_value["ofType"],
"type": union_value["name"] if union_value["kind"] == "OBJECT" else None,
}
list_of_union_values.append(filtered_union_value)
return list_of_union_values

Expand Down
4 changes: 2 additions & 2 deletions graphqler/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
BUILT_IN_TYPE_KINDS = ["SCALAR", "OBJECT", "INTERFACE", "UNION", "ENUM", "INPUT_OBJECT", "LIST", "NON_NULL"]

"""For materializers"""
MAX_OBJECT_CYCLES = 3
MAX_OUTPUT_SELECTOR_DEPTH = 3
MAX_OBJECT_CYCLES = 5
MAX_OUTPUT_SELECTOR_DEPTH = 5
HARD_CUTOFF_DEPTH = 20
MAX_INPUT_DEPTH = 20

Expand Down
14 changes: 13 additions & 1 deletion graphqler/fuzzer/fengine/fengine.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@

@singleton
class FEngine(object):
def __init__(self, queries: dict, objects: dict, mutations: dict, input_objects: dict, enums: dict, url: str, save_path: str):
def __init__(self,
queries: dict,
objects: dict,
mutations: dict,
input_objects: dict,
enums: dict,
unions: dict,
url: str,
save_path: str):
"""The intiialization of the FEnginer
Args:
Expand All @@ -36,6 +44,7 @@ def __init__(self, queries: dict, objects: dict, mutations: dict, input_objects:
mutations (dict): The possible mutations
input_objects (dict): The possible input_objects
enums (dict): The possible enums
unions (dict): The possible union types
url (str): The string of the URL
save_path (str): The path the user is currently working with
"""
Expand All @@ -44,6 +53,7 @@ def __init__(self, queries: dict, objects: dict, mutations: dict, input_objects:
self.mutations = mutations
self.input_objects = input_objects
self.enums = enums
self.unions = unions
self.url = url
self.logger = Logger().get_fuzzer_logger()

Expand All @@ -65,6 +75,7 @@ def run_regular_payload(self, name: str, objects_bucket: dict, graphql_type: str
self.mutations,
self.input_objects,
self.enums,
self.unions,
fail_on_hard_dependency_not_met=check_hard_depends_on
)
return self.__run_payload(name, objects_bucket, materializer, graphql_type)
Expand All @@ -87,6 +98,7 @@ def run_dos_payload(self, name: str, objects_bucket: dict, graphql_type: str, ma
self.mutations,
self.input_objects,
self.enums,
self.unions,
fail_on_hard_dependency_not_met=False,
max_depth=max_depth
)
Expand Down
14 changes: 11 additions & 3 deletions graphqler/fuzzer/fengine/materializers/dos_payload_materializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,22 @@


class DOSPayloadMaterializer(Materializer):
def __init__(self, objects: dict, queries: dict, mutations: dict, input_objects: dict, enums: dict, fail_on_hard_dependency_not_met: bool = False, max_depth: int = 20):
super().__init__(objects, mutations, input_objects, enums)
def __init__(self,
objects: dict,
queries: dict,
mutations: dict,
input_objects: dict,
enums: dict,
unions: dict,
fail_on_hard_dependency_not_met: bool = False,
max_depth: int = 20):
super().__init__(objects, mutations, input_objects, enums, unions)
self.objects = objects
self.queries = queries
self.mutations = mutations
self.input_objects = input_objects
self.enums = enums

self.unions = unions
self.fail_on_hard_dependency_not_met = fail_on_hard_dependency_not_met
self.max_depth = max_depth

Expand Down
61 changes: 39 additions & 22 deletions graphqler/fuzzer/fengine/materializers/materializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,31 @@
Base class for a regular materializer
"""

from .utils import get_random_scalar, get_random_enum_value
from graphqler import constants
from .utils import get_random_scalar, get_random_enum_value, clean_output_selectors
from graphqler.utils.logging_utils import Logger
from ..exceptions.hard_dependency_not_met_exception import HardDependencyNotMetException

import random
import logging
from graphqler import constants


class Materializer:
def __init__(self, objects: dict, operator_info: dict, input_objects: dict, enums: dict, fail_on_hard_dependency_not_met: bool = True):
def __init__(self,
objects: dict,
operator_info: dict,
input_objects: dict,
enums: dict,
unions: dict,
fail_on_hard_dependency_not_met: bool = True):
"""Default constructor for a regular materializer
Args:
objects (dict): The objects that exist in the Graphql schema
operator_info (dict): All information about the operator (either all QUERYs or all MUTATIONs) that we want to materialize
input_objects (dict): The input objects that exist in the Graphql schema
enums (dict): The enums that exist in the Graphql schema
unions (dict): The unions that exist in the Graphql schema
logger (logging.Logger): The logger
"""
self.objects = objects
Expand All @@ -42,7 +50,7 @@ def get_payload(self, name: str, objects_bucket: dict, graphql_type: str = '') -
"""
pass

def materialize_output(self, output_info: dict, used_objects: list[str], include_name: bool, max_depth: int = 3) -> str:
def materialize_output(self, output_info: dict, used_objects: list[str], include_name: bool, max_depth: int = 5) -> str:
"""Materializes the output. If returns empty string,
then tries to get at least something, bypassing the max depth until the hard cutoff.
Expand All @@ -64,7 +72,8 @@ def materialize_output(self, output_info: dict, used_objects: list[str], include
if max_depth > constants.HARD_CUTOFF_DEPTH:
break
max_depth += 1
return output_selectors
cleaned_output_selectors = clean_output_selectors(output_selectors)
return cleaned_output_selectors

def materialize_output_recursive(self, output: dict, used_objects: list[str], include_name: bool, max_depth: int, current_depth: int = 0) -> str:
"""Materializes the output recursively. Some interesting cases:
Expand All @@ -82,34 +91,42 @@ def materialize_output_recursive(self, output: dict, used_objects: list[str], in
Returns:
str: The built output payload
"""
built_str = ""
# Case: if we are already at max depth, just return none
if current_depth >= max_depth:
return ""
return built_str

# When we are including names (IE. fields of an object), we need to include the name
if include_name:
built_str += output["name"]

built_str = ""
if output["kind"] == "OBJECT":
materialized_object_fields = self.materialize_output_object_fields(output["type"], used_objects, max_depth, current_depth)
if materialized_object_fields != "":
if include_name:
built_str += output["name"]
built_str += " {"
built_str += materialized_object_fields
built_str += "},"
elif output["kind"] == "NON_NULL" or output["kind"] == "LIST":
elif output["kind"] == "UNION": # For a UNION type, loop through all the UNION types and materialize them into fragments
union_types = self.unions[output["type"]]["possibleTypes"]
built_str += " {"
for union_type in union_types:
materialized_fragment = self.materialize_output_recursive(union_type, used_objects, False, max_depth, current_depth)
if materialized_fragment != "":
built_str += f"... on {union_type['name']} " + materialized_fragment
built_str += "},"
elif output["kind"] == "NON_NULL" or output["kind"] == "LIST": # For a NON_NULL / LIST kind: Don't +1 here because it is an oftype (which doesn't add depth), or else we will double count
oftype = output["ofType"]
if oftype["kind"] == "SCALAR":
built_str += f"{output['name']}, "
else:
# Don't +1 here because it is an oftype (which doesn't add depth), or else we will double count
materialized_output = self.materialize_output_recursive(oftype, used_objects, False, max_depth, current_depth)
if materialized_output != "":
if include_name:
built_str += f"{output['name']}" + materialized_output + ", "
else:
built_str += materialized_output
materialized_output = self.materialize_output_recursive(oftype, used_objects, False, max_depth, current_depth)
if materialized_output != "":
built_str += materialized_output + ", "
else:
if include_name:
built_str += f"{output['name']}, "
built_str += ","

# A bit of post processing on the built payload
if include_name and built_str[-1] != ",":
built_str += ","
elif not include_name and built_str.strip() == "{}":
built_str = ""
return built_str

def materialize_output_object_fields(self, object_name: str, used_objects: list[str], max_depth: int, current_depth: int) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ def __init__(self,
mutations: dict,
input_objects: dict,
enums: dict,
unions: dict,
fail_on_hard_dependency_not_met: bool = True):
super().__init__(objects, mutations, input_objects, enums)
super().__init__(objects, mutations, input_objects, enums, unions)
self.objects = objects
self.queries = queries
self.mutations = mutations
self.input_objects = input_objects
self.enums = enums
self.unions = unions
self.fail_on_hard_dependency_not_met = fail_on_hard_dependency_not_met

def get_payload(self, name: str, objects_bucket: dict, graphql_type: str) -> tuple[str, dict]:
Expand Down
23 changes: 23 additions & 0 deletions graphqler/fuzzer/fengine/materializers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,26 @@ def prettify_graphql_payload(payload: str) -> str:
parsed_query = parse(payload)
formatted_query = print_ast(parsed_query).strip()
return formatted_query


def clean_output_selectors(output_selectors: str) -> str:
"""Cleans the output selectors by doing the following:L
- Removing any extra commas
- Removing Removing keys that don't have an object (ie. {stuff {}, otherstuff} -> {otherstuff})
Args:
output_selectors (str): _description_
Returns:
str: _description_
"""
# Removing any extra commas
while ',,' in output_selectors:
output_selectors = output_selectors.replace(",,", ",")

# Removing keys that don't have an object
while "{}" in output_selectors:
output_selectors = output_selectors.replace("{},", "")
output_selectors = output_selectors.replace(",{}", "")

return output_selectors
13 changes: 12 additions & 1 deletion graphqler/fuzzer/fuzzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,26 @@ def __init__(self, save_path: str, url: str):
self.compiled_mutations_save_path = Path(save_path) / constants.COMPILED_MUTATIONS_FILE_NAME
self.extracted_enums_save_path = Path(save_path) / constants.ENUM_LIST_FILE_NAME
self.extracted_input_objects_save_path = Path(save_path) / constants.INPUT_OBJECT_LIST_FILE_NAME
self.extracted_unions_save_path = Path(save_path) / constants.UNION_LIST_FILE_NAME

self.queries = read_yaml_to_dict(self.compiled_queries_save_path)
self.objects = read_yaml_to_dict(self.compiled_objects_save_path)
self.mutations = read_yaml_to_dict(self.compiled_mutations_save_path)
self.input_objects = read_yaml_to_dict(self.extracted_input_objects_save_path)
self.enums = read_yaml_to_dict(self.extracted_enums_save_path)
self.unions = read_yaml_to_dict(self.extracted_unions_save_path)

self.dependency_graph = GraphGenerator(save_path).get_dependency_graph()
self.fengine = FEngine(self.queries, self.objects, self.mutations, self.input_objects, self.enums, self.url, self.save_path)
self.fengine = FEngine(
self.queries,
self.objects,
self.mutations,
self.input_objects,
self.enums,
self.unions,
self.url,
self.save_path
)

self.objects_bucket = {}

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ exclude = '''

[tool.poetry]
name = "GraphQLer"
version = "2.1.3"
version = "2.1.5"
description = "A dependency-aware GraphQL API fuzzing tool"
authors = ["Omar2535 <omar2535@alumni.ubc.ca>"]
license = "MIT"
Expand Down

0 comments on commit 820c165

Please # to comment.