├── .gitignore ├── README.md ├── graphqler.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .ipynb_checkpoints 2 | *.ipynb 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphqler 2 | 3 | Helps in security testing graphql applications 4 | 5 | ## Description 6 | 7 | Slides for my talk "GraphQL application security testing automatization" on ZeroNights 2019: https://clck.ru/KDZB3 8 | 9 | ## Setup 10 | 11 | ``` 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | ## Modes 16 | 17 | - elementary: issue all elementary queries 18 | - all_args: find all types with fields with parameters and issue query for each 19 | - loops: find defined number of loops and issue query for each 20 | - alt_path: find all pathes to given type 21 | - single_query: issue single query 22 | 23 | 24 | ``` 25 | usage: graphqler.py [-h] [-u URL] [-f FILE] [-m MODE] [-v] [-c COOKIE] 26 | [--loop-depth LOOP_DEPTH] [--loop-number LOOP_NUMBER] 27 | [--skip-nullable SKIP_NULLABLE] 28 | [--target-class TARGET_CLASS] [-p PROXY] 29 | [--max-requests-per-call MAX_REQUESTS_PER_CALL] 30 | [--header HEADER] [--mutation] [--path PATH] 31 | 32 | optional arguments: 33 | -h, --help show this help message and exit 34 | -u URL, --url URL GraphQL endpoint url 35 | -f FILE, --file FILE file with introspection query response 36 | -m MODE, --mode MODE mode from 37 | [elementary,all_args,loops,alt_path,single_query] 38 | -v, --verbose increase output verbosity 39 | -c COOKIE, --cookie COOKIE 40 | auth cookie 41 | --loop-depth LOOP_DEPTH 42 | define depth for loops (loops mode only) 43 | --loop-number LOOP_NUMBER 44 | number of loops requests to issue (loops mode only) 45 | --skip-nullable SKIP_NULLABLE 46 | set none to nullable variables 47 | --target-class TARGET_CLASS 48 | target class name (for alt_path mode only) 49 | -p PROXY, --proxy PROXY 50 | proxy in python requests format 51 | --max-requests-per-call MAX_REQUESTS_PER_CALL 52 | limit number of issued requests with different 53 | parameter formats 54 | --header HEADER HTTP header 55 | --mutation set to use mutation queries (May be dangerous) 56 | --path PATH path to run single call, example: Query|getUsers|posts 57 | (single_query mode only)``` 58 | -------------------------------------------------------------------------------- /graphqler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import json 4 | import itertools 5 | import string 6 | import random 7 | import pyjq 8 | import requests 9 | import re 10 | import urllib3 11 | import pandas as pd 12 | import argparse 13 | from copy import copy 14 | 15 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 16 | 17 | introspection_query = '{"operationName":"IntrospectionQuery","variables":{},"query":" query IntrospectionQuery {\n __schema {\n queryType { name }\n mutationType { name }\n subscriptionType { name }\n types {\n ...FullType\n }\n directives {\n name\n description\n locations\n args {\n ...InputValue\n }\n }\n }\n }\n\n fragment FullType on __Type {\n kind\n name\n description\n fields(includeDeprecated: true) {\n name\n description\n args {\n ...InputValue\n }\n type {\n ...TypeRef\n }\n isDeprecated\n deprecationReason\n }\n inputFields {\n ...InputValue\n }\n interfaces {\n ...TypeRef\n }\n enumValues(includeDeprecated: true) {\n name\n description\n isDeprecated\n deprecationReason\n }\n possibleTypes {\n ...TypeRef\n }\n }\n\n fragment InputValue on __InputValue {\n name\n description\n type { ...TypeRef }\n defaultValue\n }\n\n fragment TypeRef on __Type {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n }\n }\n }\n }\n }\n }\n }\n }\n "}'.replace('\n','\\n') 18 | 19 | class QueryRunner: 20 | 21 | @staticmethod 22 | def run_query(query): 23 | url = QueryRunner.url 24 | cookies = None 25 | proxyDict = None 26 | headers = None 27 | if 'cookies' in dir(QueryRunner): 28 | cookies = QueryRunner.cookies 29 | if 'headers' in dir(QueryRunner): 30 | headers = QueryRunner.headers 31 | if 'proxy' in dir(QueryRunner): 32 | proxy = QueryRunner.proxy 33 | proxyDict = { 34 | "https" : proxy, 35 | "http" : proxy 36 | } 37 | 38 | request = requests.post(url, json=query, proxies=proxyDict,verify=False,cookies=cookies,headers=headers) 39 | if request.status_code == 200: 40 | return request.json() 41 | else: 42 | return None 43 | 44 | # Вершины графа будут такие: 45 | # - идентификатор 46 | # - имя типа 47 | # 48 | # Ребра графа будут такие: 49 | # - идентификатор вершины 50 | # - ребенок 51 | # - модификаторы ребер (имя для запроса, NON_NULL, LIST) 52 | # 53 | # При обходе будет такая структура: 54 | # - идентификатор 1 вершины 55 | # - идентификатор 2 вершины 56 | # - идентификатор 3 вершины 57 | # ... 58 | # - модификаторы 1-2 59 | # - модификаторы 2-3 и т.д. 60 | 61 | # ## Loops 62 | 63 | def check_loop_for_list(loop): 64 | loop_begin = loop.ids.index(loop.id_to) 65 | for i in range(loop_begin,len(loop.ids)-1): 66 | if loop['LIST_%d_%d'%(i,i+1)] == True: 67 | return True 68 | return False 69 | 70 | def get_full_type(t,modifiers=None): 71 | if modifiers is None: 72 | modifiers = [] 73 | if t['kind'] in ['NON_NULL','LIST']: 74 | return get_full_type(t['ofType'],modifiers+[t['kind']]) 75 | return t['name'],modifiers 76 | 77 | def build_graph(schema): 78 | types = schema['data']['__schema']['types'] 79 | vertexes = [] 80 | 81 | for i,t in enumerate(types): 82 | name = t['name'] 83 | vertexes.append([i,name]) 84 | 85 | vertexes = pd.DataFrame.from_records(vertexes,columns = ['id','name']) 86 | edges = [] 87 | for i,t in enumerate(types): 88 | if ('fields' in t) and (t['fields']!=None): 89 | for f in t['fields']: 90 | child_name, modifiers = get_full_type(f['type']) 91 | edges.append([i,vertexes[vertexes.name==child_name].id.values[0],f['name'],'NON_NULL' in modifiers, 'LIST' in modifiers]) 92 | edges = pd.DataFrame.from_records(edges,columns = ['id_from','id_to','arg_name','NON_NULL','LIST']) 93 | return vertexes,edges 94 | 95 | def find_loops(graph,query_type,mutation_type,loops_to_find=100): 96 | vertexes,edges = graph 97 | start = vertexes[vertexes.name.apply(lambda x: x in [query_type,mutation_type])][['id']] 98 | start.columns=['id_0'] 99 | start['ids'] = start.id_0.apply(lambda x: [x]) 100 | start['start_word'] = [query_type,mutation_type] 101 | move = 0 102 | current = start 103 | loops = [] 104 | while True: 105 | print ('%d iteration'% (move+1)) 106 | result = current.merge(edges,left_on='id_%d'%move,right_on='id_from') 107 | if result.shape[0] == 0: 108 | break 109 | result.drop('id_from',axis=1,inplace=True) 110 | loops_recs = result.apply(lambda x: x.id_to in x.ids,axis=1) 111 | if loops_recs.any(): 112 | print ('loops found') 113 | for name,row in result[loops_recs].iterrows(): 114 | if check_loop_for_list(row): 115 | loops.append(row) 116 | if len(loops)>= loops_to_find: 117 | return loops 118 | result.drop(result[loops_recs].index,inplace=True) 119 | result['ids'] = result.ids.apply(lambda x: x.copy()) 120 | result.apply(lambda x: x.ids.append(x.id_to),axis=1) 121 | result.rename(columns = {'id_to':'id_%d'%(move+1), 122 | 'NON_NULL':'NON_NULL_%d_%d'%(move,move+1), 123 | 'arg_name':'arg_name_%d_%d'%(move,move+1), 124 | 'LIST':'LIST_%d_%d'%(move,move+1)},inplace=True) 125 | 126 | current = result 127 | #print (current.shape) 128 | move +=1 129 | 130 | return loops 131 | 132 | def run_loops(schema,loops,loop_depth = 3,not_more_than=16): 133 | for loop in loops: 134 | loop_begin = loop.ids.index(loop.id_to) 135 | start_args = ['arg_name_%d_%d'%(i,i+1) for i in range(loop_begin)] 136 | prolog_strs = list(loop[start_args].values) 137 | loop_args = ['arg_name_%d_%d'%(i,i+1) for i in range(loop_begin,len(loop.ids)-1)] 138 | loop_strs = list(loop[loop_args].values) + [loop.arg_name] 139 | path = '|'.join([loop.start_word] + prolog_strs + loop_strs*loop_depth) 140 | #print(path) 141 | run_queries_by_path(schema,path,not_more_than=not_more_than) 142 | 143 | def find_alt_paths(graph,target,query_type,mutation_type): 144 | vertexes,edges = graph 145 | start = vertexes[vertexes.name == target][['id']] 146 | finish = vertexes[vertexes.name.apply(lambda x: x in [query_type,mutation_type])].id.values 147 | start.columns=['id_0'] 148 | start['ids'] = start.id_0.apply(lambda x: [x]) 149 | move = 0 150 | current = start 151 | found_pathes = [] 152 | while True: 153 | print ('%d iteration'% (move+1)) 154 | result = current.merge(edges,left_on='id_%d'%move,right_on='id_to') 155 | if result.shape[0] == 0: 156 | break 157 | result.drop('id_to',axis=1,inplace=True) 158 | loops_recs = result.apply(lambda x: x.id_from in x.ids,axis=1) 159 | if loops_recs.any(): 160 | result.drop(result[loops_recs].index,inplace=True) 161 | 162 | 163 | found = result.id_from.apply(lambda x: x in finish) 164 | if found.any(): 165 | found_pathes.append(result[found]) 166 | result.drop(result[found].index,inplace=True) 167 | 168 | result['ids'] = result.ids.apply(lambda x: x.copy()) 169 | result.apply(lambda x: x.ids.append(x.id_from),axis=1) 170 | result.rename(columns = {'id_from':'id_%d'%(move+1), 171 | 'NON_NULL':'NON_NULL_%d_%d'%(move,move+1), 172 | 'arg_name':'arg_name_%d_%d'%(move,move+1), 173 | 'LIST':'LIST_%d_%d'%(move,move+1)},inplace=True) 174 | 175 | current = result 176 | print (current.shape) 177 | move +=1 178 | 179 | real_pathes = [] 180 | for l in found_pathes: 181 | for name,row in l.iterrows(): 182 | ids=[] 183 | for i in range(len(row.ids)-1): 184 | ids.append( 'arg_name_%d_%d'%(i,i+1)) 185 | path = list(row[ids].values) + [row.arg_name] 186 | path += [vertexes[vertexes.id == row.id_from].name.values[0]] 187 | real_pathes.append('|'.join(path[::-1])) 188 | return real_pathes 189 | 190 | 191 | def find_shortest_paths(graph,target,query_type,mutation_type): 192 | if target in [query_type,mutation_type]: 193 | return [target] 194 | vertexes,edges = graph 195 | start = vertexes[vertexes.name == target][['id']] 196 | finish = vertexes[vertexes.name.apply(lambda x: x in [query_type,mutation_type])].id.values 197 | start.columns=['id_0'] 198 | start['ids'] = start.id_0.apply(lambda x: [x]) 199 | move = 0 200 | current = start 201 | found_pathes = [] 202 | while True: 203 | result = current.merge(edges,left_on='id_%d'%move,right_on='id_to') 204 | if result.shape[0] == 0: 205 | break 206 | result.drop('id_to',axis=1,inplace=True) 207 | loops_recs = result.apply(lambda x: x.id_from in x.ids,axis=1) 208 | if loops_recs.any(): 209 | result.drop(result[loops_recs].index,inplace=True) 210 | 211 | 212 | found = result.id_from.apply(lambda x: x in finish) 213 | if found.any(): 214 | found_pathes.append(result[found]) 215 | break 216 | 217 | result['ids'] = result.ids.apply(lambda x: x.copy()) 218 | result.apply(lambda x: x.ids.append(x.id_from),axis=1) 219 | result.rename(columns = {'id_from':'id_%d'%(move+1), 220 | 'NON_NULL':'NON_NULL_%d_%d'%(move,move+1), 221 | 'arg_name':'arg_name_%d_%d'%(move,move+1), 222 | 'LIST':'LIST_%d_%d'%(move,move+1)},inplace=True) 223 | 224 | current = result 225 | move +=1 226 | 227 | real_pathes = [] 228 | for l in found_pathes: 229 | for name,row in l.iterrows(): 230 | ids=[] 231 | for i in range(len(row.ids)-1): 232 | ids.append( 'arg_name_%d_%d'%(i,i+1)) 233 | path = list(row[ids].values) + [row.arg_name] 234 | path += [vertexes[vertexes.id == row.id_from].name.values[0]] 235 | real_pathes.append('|'.join(path[::-1])) 236 | return( real_pathes) 237 | 238 | 239 | def find_all_paths_with_args(schema): 240 | graph = build_graph(schema) 241 | query_type,mutation_type = get_query_and_mutation_types(schema) 242 | 243 | types = schema['data']['__schema']['types'] 244 | result = [] 245 | for t in types: 246 | if ('fields' not in t) or (t['fields'] is None): 247 | continue 248 | args = [] 249 | for f in t['fields']: 250 | if len(f['args']) > 0: 251 | args.append(f['name']) 252 | if len(args) > 0: 253 | path_to_type = find_shortest_paths(graph,t['name'],query_type,mutation_type) 254 | if len(path_to_type)==0: #type unaccessible, bug in schema? 255 | continue 256 | path_to_type = path_to_type[0] 257 | for arg in args: 258 | result.append (path_to_type+'|'+arg) 259 | return result 260 | 261 | def build_arg_definition_strings(args): 262 | if len(args)==0: 263 | return '' 264 | real_names = pyjq.all('.[].real_name',args) 265 | real_types = [build_type_string(x['type']) for x in args] 266 | arg_def_str = ', '.join(['$%s: %s' %(real_name,real_type) for real_name,real_type in zip(real_names,real_types)]) 267 | return '(%s)'%arg_def_str 268 | 269 | def build_arg_call_strings(args): 270 | if len(args)==0: 271 | return '' 272 | names = pyjq.all('.[].name',args) 273 | real_names = pyjq.all('.[].real_name',args) 274 | real_types = [build_type_string(x['type']) for x in args] 275 | call_args_str = ', '.join(['%s: $%s' %(name,real_name) for (name,real_name) in zip(names,real_names)]) 276 | return '(%s)' % call_args_str 277 | 278 | def build_arg_var(schema,arg_type,skip_nullable_vars): 279 | real_type = arg_type 280 | not_null = False 281 | if real_type['kind'] == 'NON_NULL': 282 | real_type = real_type['ofType'] 283 | not_null = True 284 | if not not_null and skip_nullable_vars: 285 | return None 286 | if real_type['kind'] == 'LIST': 287 | if skip_nullable_vars: 288 | return [] 289 | return [build_arg_var(schema,real_type['ofType'],skip_nullable_vars)] 290 | if real_type['kind'] == 'SCALAR': 291 | if real_type['name'] not in default_table: 292 | print ('%s not in default_table' % real_type['name']) 293 | return placehoder_table['String'] 294 | return placehoder_table[real_type['name']] 295 | if real_type['kind'] == 'INPUT_OBJECT': 296 | obj_type = get_type_by_name(schema,real_type['name']) 297 | res = {} 298 | for f in obj_type['inputFields']: 299 | res[f['name']] = build_arg_var(schema,f['type'],skip_nullable_vars) 300 | return res 301 | 302 | def get_query_and_mutation_types(schema): 303 | query_type=None 304 | mutation_type=None 305 | if schema['data']['__schema']['queryType'] is not None: 306 | query_type = schema['data']['__schema']['queryType']['name'] 307 | if schema['data']['__schema']['mutationType'] is not None: 308 | mutation_type = schema['data']['__schema']['mutationType']['name'] 309 | return query_type,mutation_type 310 | 311 | #Note: Date and DateTime are not graphQL scpecified. It is typical scalars, but format could be different 312 | default_table = {'String':['"test_string"'], 313 | 'ID':['1','"5ed496cc-c971-11dc-93cd-15767af24309"'], 314 | 'Int':['1'], 315 | 'DateTime':['"2017-07-09T11:54:42"'], 316 | 'Date':['"2017-07-09"'], 317 | 'Float':['3.1415'], 318 | 'Boolean':['true'], 319 | 'URI':['"http://example.com/"']} 320 | 321 | placehoder_table = {'String':'|String|', 322 | 'ID':'|ID|', 323 | 'Int':'|Int|', 324 | 'DateTime':'|DateTime|', 325 | 'Date':'|Date|', 326 | 'Float':'|Float|', 327 | 'Boolean':'|Boolean|', 328 | 'URI':'|URI|'} 329 | 330 | def build_variables(schema,args,skip_nullable_vars): 331 | variables = {} 332 | for arg in args: 333 | variables[arg['real_name']] = build_arg_var(schema,arg['type'],skip_nullable_vars) 334 | 335 | variables_str = json.dumps(variables) 336 | 337 | variables_types = re.findall('\"\|([a-zA-Z]*)\|\"',variables_str) 338 | variables_values = [default_table[var_type] for var_type in variables_types] 339 | variables_values_all = itertools.product(*variables_values) 340 | 341 | results = [] 342 | requests = 0 343 | for variables_values in variables_values_all: 344 | variables_str_for_test = variables_str 345 | for var_type,var_val in zip(variables_types,variables_values): 346 | variables_str_for_test = re.subn('\"\|([a-zA-Z]*)\|\"',var_val,variables_str_for_test,count=1)[0] 347 | results.append(json.loads(variables_str_for_test)) 348 | requests += 1 349 | #if requests >= max_requests_per_operation: 350 | # break 351 | return results 352 | 353 | def find_scalar_fields(json_type): 354 | get_fields = [] 355 | for f in json_type['fields']: 356 | field_type,field_name = get_return_type_name(f['type']) 357 | if field_type == 'SCALAR': 358 | get_fields.append(f['name']) 359 | return get_fields 360 | 361 | 362 | def get_first_static_field(schema,typename): 363 | t = get_type_by_name(schema,typename) 364 | if ('fields' not in t) or (t['fields'] is None): 365 | return None 366 | for f in t['fields']: 367 | r_type = get_return_type_name(f['type']) 368 | if r_type[0] == 'SCALAR': 369 | return f['name'] 370 | 371 | pathes = [] 372 | for f in t['fields']: 373 | r_type = get_return_type_name(f['type']) 374 | subpath = get_first_static_field(schema,r_type[1]) 375 | if subpath is None: 376 | continue 377 | pathes.append((f['name'],subpath,subpath.count('|'))) 378 | 379 | pathes.sort(key=lambda x: x[1]) 380 | return pathes[0][0] + '|' + pathes[0][1] 381 | 382 | def build_query_by_path(schema,path): 383 | pattern = '''%s %s%s{%s}''' 384 | 385 | query_type,mutation_type = get_query_and_mutation_types(schema) 386 | 387 | in_path = path 388 | path = path.split('|') 389 | first_word = 'query' if path[0]==query_type else 'mutation' 390 | query_type = get_type_by_name(schema,path[0]) 391 | query_name = '_'.join(path) 392 | current_type = query_type 393 | all_args = [] 394 | indent = 0 395 | header = '' 396 | footer = '' 397 | for i,param in enumerate(path[1:]): 398 | target_field = copy(pyjq.all('.[] | select(.name == "%s")'%param,current_type['fields'])[0]) 399 | for arg in target_field['args']: 400 | arg['real_name'] = arg['name'] + '_%d' % i 401 | param_type,param_kind = get_valuable_type(target_field['type']) 402 | 403 | call_args = build_arg_call_strings(target_field['args']) 404 | all_args += target_field['args'] 405 | 406 | indent += 4 407 | header += '\n' + ' ' * indent 408 | header += param+call_args 409 | header += '{' 410 | footer = ' ' * indent + '}\n' + footer 411 | 412 | current_type = get_type_by_name(schema,param_type) 413 | header = header[:-1] 414 | footer = '\n'.join(footer.split('\n')[1:]) 415 | 416 | 417 | 418 | return_type_type,return_type_name = get_return_type_name(target_field['type']) 419 | if return_type_type in ['SCALAR','ENUM']: 420 | return_data = '' 421 | else: 422 | return_type = get_type_by_name(schema,return_type_name) 423 | json_type = return_type 424 | 425 | if json_type['kind'] == 'UNION': 426 | get_fields = ['__typename'] 427 | else: 428 | field_names = pyjq.all('.fields[].name',json_type) 429 | get_fields = find_scalar_fields(json_type) 430 | 431 | # if 'edges' in field_names: 432 | # print( build_query_by_path(schema,in_path+'|edges|node')) 433 | # edges = pyjq.all('.[] | select(.name == "edges")',json_type['fields'])[0] 434 | # edges_type = get_valuable_type(edges['type'])[0] 435 | # edges_type = get_type_by_name(schema,edges_type) 436 | # nodes = pyjq.all('.[] | select(.name == "node")',edges_type['fields'])[0] 437 | # nodes = pyjq.all('.[] | select(.name == "node")',edges_type['fields'])[0] 438 | # real_type = get_valuable_type(nodes['type'])[0] 439 | # real_type = get_type_by_name(schema,real_type) 440 | # params = find_scalar_fields(real_type) 441 | # edges_pattern_head = '''edges{ 442 | # node{\n''' 443 | # edges_pattern_footer=''' } 444 | # }''' 445 | # nodes_fields = '\n'.join([' '*4*2 + param for param in params]) + '\n' 446 | # edges_str = edges_pattern_head + nodes_fields + edges_pattern_footer 447 | # get_fields += edges_str.split('\n') 448 | 449 | if len(get_fields) == 0: 450 | ## find inner path 451 | new_path = get_first_static_field(schema,return_type_name) 452 | full_new_path = in_path+ '|' + new_path 453 | return (build_query_by_path(schema,full_new_path)) 454 | 455 | fields = '' 456 | for f in get_fields: 457 | fields+= ' '*4*(len(path)+1) + f + '\n' 458 | fields+= ' '*4*(len(path)) 459 | return_data = '{\n%s}\n'%fields 460 | 461 | head_args = build_arg_definition_strings(all_args) 462 | query_str = (pattern%(first_word,query_name,head_args,header+return_data+footer)) 463 | query_vars = build_variables(schema,all_args,True) 464 | return query_str,query_vars,query_name 465 | 466 | 467 | def run_queries_by_path(schema,path,not_more_than=None): 468 | print (path) 469 | query_str,query_vars,query_name = build_query_by_path(schema,path) 470 | 471 | requests_set = [{'operation':query_name, 472 | 'variables':v, 473 | 'query':query_str} for v in query_vars] 474 | for r in requests_set[:not_more_than]: 475 | QueryRunner.run_query(r) 476 | 477 | 478 | def get_type_by_name(schema,type_name): 479 | types = schema['data']['__schema']['types'] 480 | for t in types: 481 | if t['name'] == type_name: 482 | return t 483 | 484 | def get_operations_in_type(schema,json_type,types_in_list = [],no_recursion=True): 485 | # types_in_list protects agains bad recursion 486 | results = [] 487 | if ('fields' not in json_type) or (json_type['fields'] == None): 488 | return [] 489 | for f in json_type['fields']: 490 | is_func = None 491 | if len(f['args'])>0: 492 | is_func = True 493 | elif no_recursion: 494 | is_func = False 495 | elif f['type']['name'] is None: 496 | is_func = True 497 | elif f['type']['ofType'] is None: 498 | is_func = False 499 | else: 500 | sub_type = get_type_by_name(schema,f['type']['name']) 501 | field_names = pyjq.all('.fields[].name',sub_type) 502 | if 'id' in field_names: 503 | is_func=True 504 | else: 505 | is_func = False 506 | if is_func == False: 507 | if f['type']['name'] in types_in_list: 508 | continue 509 | sub_type = get_type_by_name(schema,f['type']['name']) 510 | if sub_type is None: 511 | continue 512 | 513 | res = get_operations_in_type(schema,sub_type,types_in_list+[f['type']['name']]) 514 | for r in res: 515 | r['full_name'] = f['name']+'|'+r['full_name'] 516 | results.append(r) 517 | else: 518 | f['full_name'] = f['name'] 519 | results.append(f) 520 | return results 521 | 522 | def build_type_string(t): 523 | if t['kind'] == 'NON_NULL': 524 | return build_type_string(t['ofType']) + '!' 525 | if t['kind'] == 'LIST': 526 | return '[' + build_type_string(t['ofType']) + ']' 527 | return t['name'] 528 | 529 | def build_arg_strings(args): 530 | if len(args)==0: 531 | return None,None 532 | names = pyjq.all('.[].name',args) 533 | real_types = [build_type_string(x['type']) for x in args] 534 | first_args = ', '.join(['$%s: %s' %(name,real_type) for name,real_type in zip(names,real_types)]) 535 | second_args = ', '.join(['%s: $%s' %(name,name) for name in names]) 536 | return first_args,second_args 537 | 538 | def get_return_type_name(query): 539 | if query['name'] is not None: 540 | return query['kind'],query['name'] 541 | else: 542 | return get_return_type_name(query['ofType']) 543 | 544 | def get_valuable_type(Type): 545 | if Type['kind']=='NON_NULL': 546 | return get_valuable_type(Type['ofType']) 547 | if Type['kind']=='LIST': 548 | return get_valuable_type(Type['ofType']) 549 | return Type['name'],Type['kind'] 550 | 551 | def main(): 552 | parser = argparse.ArgumentParser() 553 | parser.add_argument("-u","--url", 554 | help="GraphQL endpoint url") 555 | parser.add_argument("-f","--file", 556 | help = "file with introspection query response"); 557 | parser.add_argument("-m","--mode", 558 | help="mode from [elementary,all_args,loops,alt_path,single_query]") 559 | parser.add_argument("-v", "--verbose", action="store_true", 560 | help="increase output verbosity",) 561 | parser.add_argument("-c","--cookie",action="append", 562 | help="auth cookie") 563 | parser.add_argument("--loop-depth",help = "define depth for loops (loops mode only)",type=int,default=3) 564 | parser.add_argument("--loop-number",help = "number of loops requests to issue (loops mode only)",type=int,default=100) 565 | parser.add_argument("--skip-nullable",help = "set none to nullable variables") 566 | parser.add_argument("--target-class",help = "target class name (for alt_path mode only)") 567 | parser.add_argument("--find-queries",help = "if specified, graphqler try to find real operations, else use first level of type `Query` or `Mutation` as operations",action="store_true") 568 | parser.add_argument("-p","--proxy",help = "proxy in python requests format") 569 | parser.add_argument("--max-requests-per-call", help = "limit number of issued requests with different parameter formats",default=16) 570 | parser.add_argument('--header', help = "HTTP header",action="append") 571 | parser.add_argument('--mutation', action="store_true",help = "set to use mutation queries (May be dangerous)") 572 | parser.add_argument('--path', help = "path to run single call, example: Query|getUsers|posts (single_query mode only)") 573 | args = parser.parse_args() 574 | schema = None 575 | 576 | if args.url is None: 577 | print ("provide graphql endpoint url (-u)") 578 | exit(1) 579 | 580 | QueryRunner.url = args.url 581 | 582 | if args.file is not None: 583 | schema = open(args.file).read() 584 | 585 | if args.cookie is not None: 586 | cookie_dir = {} 587 | for cookie in args.cookie: 588 | splits = cookie.split('=',1) 589 | if len(splits)==1: 590 | cookie_dir[splits[0]]='' 591 | else: 592 | cookie_dir[splits[0]]=splits[1] 593 | QueryRunner.cookies = cookie_dir 594 | 595 | if args.header is not None: 596 | header_dir = {} 597 | for header in args.header: 598 | splits = header.split('=',1) 599 | if len(splits)==1: 600 | header_dir[splits[0]]='' 601 | else: 602 | header_dir[splits[0]]=splits[1] 603 | QueryRunner.headers = header_dir 604 | 605 | if args.proxy is not None: 606 | QueryRunner.proxy = args.proxy 607 | 608 | if (args.mode is None) or (args.mode not in 'elementary,all_args,loops,alt_path,single_query'.split(',')): 609 | print ("provide -m with one of [elementary,all_args,loops,alt_path,single_query] value") 610 | exit(1) 611 | mode = args.mode 612 | mutation = args.mutation == True 613 | 614 | if schema is None: 615 | schema = QueryRunner.run_query(json.loads(introspection_query)) 616 | 617 | query_type_name,mutation_type_name = get_query_and_mutation_types(schema) 618 | 619 | 620 | if mode == 'elementary': 621 | query_type = get_type_by_name(schema,query_type_name) 622 | mutation_type = get_type_by_name(schema,mutation_type_name) 623 | queries = get_operations_in_type(schema,query_type,no_recursion=not args.find_queries) 624 | for q in queries: 625 | path = query_type_name + '|'+q['full_name'] 626 | run_queries_by_path(schema,path,not_more_than=args.max_requests_per_call) 627 | if mutation: 628 | mutations = get_operations_in_type(schema,mutation_type) 629 | for m in mutations: 630 | path = mutation_type_name + '|'+q['full_name'] 631 | run_queries_by_path(schema,path,not_more_than=args.max_requests_per_call) 632 | print ('Done') 633 | exit(0) 634 | 635 | if mode == 'all_args': 636 | paths = find_all_paths_with_args(schema) 637 | if not mutation: 638 | paths = list(filter(lambda x: x[:len(mutation_type_name)]!=mutation_type_name,paths)) 639 | for path in paths: 640 | run_queries_by_path(schema,path,not_more_than=args.max_requests_per_call) 641 | 642 | if mode == 'loops': 643 | graph = build_graph(schema) 644 | loops = find_loops(graph,query_type_name,mutation_type_name,loops_to_find = args.loop_number) 645 | 646 | run_loops(schema,loops,loop_depth = args.loop_depth,not_more_than=args.max_requests_per_call) 647 | print ('Done') 648 | exit(0) 649 | 650 | if mode == 'single_query': 651 | run_queries_by_path(schema,args.path,not_more_than = args.max_requests_per_call) 652 | print ('Done') 653 | exit(0) 654 | 655 | if mode == 'alt_path': 656 | if args.target_class is None: 657 | print ("Provide --target-class for alt_paths mode") 658 | exit(1) 659 | graph = build_graph(schema) 660 | print (find_alt_paths(graph,args.target_class,query_type_name,mutation_type_name)) 661 | print ('Done') 662 | exit(0) 663 | 664 | 665 | if __name__=='__main__': 666 | main() 667 | 668 | 669 | 670 | 671 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyjq==2.3.1 2 | requests==2.22.0 3 | urllib3==1.26.5 4 | pandas==0.25.1 --------------------------------------------------------------------------------