Skip to content

Commit ff69eb9

Browse files
authored
Replay log fixes and add test coverage. (#503)
1. Fixed replay logic, which was broken after shared connection perf improvements. 2. Added end to end test for FuzzLean and Replay that caught the original regression. 3. After adding the two new tests, 2 other issues were found and fixed. a) The demo_server did not consume all the bytes of the request on all failure paths. This was only caught during FuzzLean when invalid payloads were specified in the path and body. The fix was to provide a teardown function that consumed the entire request. The following issue has more details: pallets/flask#2188. b) test-quick-start hung in CI because the process output from demo_server was not consumed. This was not caught previously because there is more output in Fuzz modes.
1 parent 88e41f0 commit ff69eb9

File tree

8 files changed

+253
-62
lines changed

8 files changed

+253
-62
lines changed

demo_server/demo_server/app.py

+14
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ def initialize_app(flask_app):
5252

5353
db.init_app(flask_app)
5454

55+
@app.teardown_request
56+
def teardown(stream):
57+
if not stream:
58+
return
59+
exhaust = getattr(stream, "exhaust", None)
60+
61+
if exhaust is not None:
62+
exhaust()
63+
else:
64+
while True:
65+
chunk = stream.read(1024 * 64)
66+
67+
if not chunk:
68+
break
5569

5670
def main():
5771
initialize_app(app)

docs/user-guide/SettingsFile.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ the examples instead of just the first one.
162162

163163
```json
164164
"test_combinations_settings": {
165-
"example_payloadds" : {
165+
"example_payloads" : {
166166
"payload_kind": "all"
167167
}
168168
}

restler-quick-start.py

+63-18
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,45 @@ def compile_spec(api_spec_path, restler_dll_path):
3939

4040
with usedir(RESTLER_TEMP_DIR):
4141
command=f"dotnet \"{restler_dll_path}\" compile --api_spec \"{api_spec_path}\""
42-
print(command)
42+
print(f"command: {command}")
4343
subprocess.run(command, shell=True)
4444

45-
def test_spec(ip, port, host, use_ssl, restler_dll_path):
45+
def add_common_settings(ip, port, host, use_ssl, command):
46+
if not use_ssl:
47+
command = f"{command} --no_ssl"
48+
if ip is not None:
49+
command = f"{command} --target_ip {ip}"
50+
if port is not None:
51+
command = f"{command} --target_port {port}"
52+
if host is not None:
53+
command = f"{command} --host {host}"
54+
return command
55+
56+
def replay_bug(ip, port, host, use_ssl, restler_dll_path, replay_log):
57+
""" Runs RESTler's replay mode on the specified replay file
58+
"""
59+
with usedir(RESTLER_TEMP_DIR):
60+
command = (
61+
f"dotnet \"{restler_dll_path}\" replay --replay_log \"{replay_log}\""
62+
)
63+
command = add_common_settings(ip, port, host, use_ssl, command)
64+
print(f"command: {command}\n")
65+
subprocess.run(command, shell=True)
66+
67+
def replay_from_dir(ip, port, host, use_ssl, restler_dll_path, replay_dir):
68+
import glob
69+
from pathlib import Path
70+
# get all the 500 replay files in the bug buckets directory
71+
bug_buckets = glob.glob(os.path.join(replay_dir, 'RestlerResults', '**/bug_buckets/*500*'))
72+
print(f"buckets: {bug_buckets}")
73+
for file_path in bug_buckets:
74+
if "bug_buckets" in os.path.basename(file_path):
75+
continue
76+
print(f"Testing replay file: {file_path}")
77+
replay_bug(ip, port, host, use_ssl, restler_dll_path, Path(file_path).absolute())
78+
pass
79+
80+
def test_spec(ip, port, host, use_ssl, restler_dll_path, task):
4681
""" Runs RESTler's test mode on a specified Compile directory
4782
4883
@param ip: The IP of the service to test
@@ -60,33 +95,28 @@ def test_spec(ip, port, host, use_ssl, restler_dll_path):
6095
@rtype : None
6196
6297
"""
98+
import json
6399
with usedir(RESTLER_TEMP_DIR):
64100
compile_dir = Path(f'Compile')
65101
grammar_file_path = compile_dir.joinpath('grammar.py')
66102
dictionary_file_path = compile_dir.joinpath('dict.json')
67103
settings_file_path = compile_dir.joinpath('engine_settings.json')
104+
68105
command = (
69-
f"dotnet \"{restler_dll_path}\" test --grammar_file \"{grammar_file_path}\" --dictionary_file \"{dictionary_file_path}\""
106+
f"dotnet \"{restler_dll_path}\" {task} --grammar_file \"{grammar_file_path}\" --dictionary_file \"{dictionary_file_path}\""
70107
f" --settings \"{settings_file_path}\""
71108
)
72-
if not use_ssl:
73-
command = f"{command} --no_ssl"
74-
if ip is not None:
75-
command = f"{command} --target_ip {ip}"
76-
if port is not None:
77-
command = f"{command} --target_port {port}"
78-
if host is not None:
79-
command = f"{command} --host {host}"
80-
81-
print(command)
109+
print(f"command: {command}\n")
110+
command = add_common_settings(ip, port, host, use_ssl, command)
111+
82112
subprocess.run(command, shell=True)
83113

84114
if __name__ == '__main__':
85115

86116
parser = argparse.ArgumentParser()
87117
parser.add_argument('--api_spec_path',
88118
help='The API Swagger specification to compile and test',
89-
type=str, required=True)
119+
type=str, required=False, default=None)
90120
parser.add_argument('--ip',
91121
help='The IP of the service to test',
92122
type=str, required=False, default=None)
@@ -102,12 +132,27 @@ def test_spec(ip, port, host, use_ssl, restler_dll_path):
102132
parser.add_argument('--host',
103133
help='The hostname of the service to test',
104134
type=str, required=False, default=None)
135+
parser.add_argument('--task',
136+
help='The task to run (test, fuzz-lean, fuzz, or replay)'
137+
'For test, fuzz-lean, and fuzz, the spec is compiled first.'
138+
'For replay, bug buckets from the specified task directory are re-played.',
139+
type=str, required=False, default='test')
140+
parser.add_argument('--replay_bug_buckets_dir',
141+
help='For the replay task, specifies the directory in which to search for bug buckets.',
142+
type=str, required=False, default=None)
105143

106144
args = parser.parse_args()
107-
108-
api_spec_path = os.path.abspath(args.api_spec_path)
109145
restler_dll_path = Path(os.path.abspath(args.restler_drop_dir)).joinpath('restler', 'Restler.dll')
110-
compile_spec(api_spec_path, restler_dll_path.absolute())
111-
test_spec(args.ip, args.port, args.host, args.use_ssl, restler_dll_path.absolute())
146+
print(f"\nrestler_dll_path: {restler_dll_path}\n")
147+
148+
if args.task == "replay":
149+
replay_from_dir(args.ip, args.port, args.host, args.use_ssl, restler_dll_path.absolute(), args.replay_bug_buckets_dir)
150+
else:
151+
if args.api_spec_path is None:
152+
print("api_spec_path is required for all tasks except the replay task.")
153+
exit(-1)
154+
api_spec_path = os.path.abspath(args.api_spec_path)
155+
compile_spec(api_spec_path, restler_dll_path.absolute())
156+
test_spec(args.ip, args.port, args.host, args.use_ssl, restler_dll_path.absolute(), args.task)
112157

113158
print(f"Test complete.\nSee {os.path.abspath(RESTLER_TEMP_DIR)} for results.")

restler/end_to_end_tests/test_quick_start.py

+156-34
Original file line numberDiff line numberDiff line change
@@ -14,66 +14,188 @@
1414
import shutil
1515
import glob
1616
from pathlib import Path
17+
from threading import Thread
1718

1819
RESTLER_WORKING_DIR = 'restler_working_dir'
1920

2021
class QuickStartFailedException(Exception):
2122
pass
2223

24+
def check_output_errors(output):
25+
if output.stderr:
26+
raise QuickStartFailedException(f"Failing because stderr was detected after running restler-quick-start:\n{output.stderr!s}")
27+
try:
28+
output.check_returncode()
29+
except subprocess.CalledProcessError:
30+
raise QuickStartFailedException(f"Failing because restler-quick-start exited with a non-zero return code: {output.returncode!s}")
31+
32+
def check_expected_output(restler_working_dir, expected_strings, output, task_dir_name):
33+
stdout = str(output.stdout)
34+
35+
for expected_str in expected_strings:
36+
if expected_str not in stdout:
37+
stdout = stdout.replace('\\r\\n', '\r\n')
38+
39+
# Print the engine logs to the console
40+
out_file_path = os.path.join(restler_working_dir, task_dir_name, 'EngineStdOut.txt')
41+
err_file_path = os.path.join(restler_working_dir, task_dir_name, 'EngineStdErr.txt')
42+
results_dir = os.path.join(restler_working_dir, task_dir_name, 'RestlerResults')
43+
# Return the newest experiments directory in RestlerResults
44+
net_log_dir = max(glob.glob(os.path.join(results_dir, 'experiment*/')), key=os.path.getmtime)
45+
net_log_path = glob.glob(os.path.join(net_log_dir, 'logs', f'network.testing.*.1.txt'))[0]
46+
with open(out_file_path) as of, open(err_file_path) as ef, open(net_log_path) as nf:
47+
out = of.read()
48+
err = ef.read()
49+
net_log = nf.read()
50+
raise QuickStartFailedException(f"Failing because expected output '{expected_str}' was not found:\n{stdout}{out}{err}{net_log}")
51+
52+
def test_test_task(restler_working_dir, swagger_path, restler_drop_dir):
53+
# Run the quick start script
54+
output = subprocess.run(
55+
f'python ./restler-quick-start.py --api_spec_path {swagger_path} --restler_drop_dir {restler_drop_dir} --task test',
56+
shell=True, capture_output=True
57+
)
58+
expected_strings = [
59+
'Request coverage (successful / total): 6 / 6',
60+
'No bugs were found.' ,
61+
'Task Test succeeded.'
62+
]
63+
check_output_errors(output)
64+
check_expected_output(restler_working_dir, expected_strings, output, "Test")
65+
66+
def test_fuzzlean_task(restler_working_dir, swagger_path, restler_drop_dir):
67+
# Run the quick start script
68+
output = subprocess.run(
69+
f'python ./restler-quick-start.py --api_spec_path {swagger_path} --restler_drop_dir {restler_drop_dir} --task fuzz-lean',
70+
shell=True, capture_output=True
71+
)
72+
expected_strings = [
73+
'Request coverage (successful / total): 6 / 6',
74+
'Bugs were found!' ,
75+
'InvalidDynamicObjectChecker_20x: 2',
76+
'InvalidDynamicObjectChecker_500: 1',
77+
'PayloadBodyChecker_500: 1',
78+
'Task FuzzLean succeeded.'
79+
]
80+
check_output_errors(output)
81+
check_expected_output(restler_working_dir, expected_strings, output, "FuzzLean")
82+
83+
def test_fuzz_task(restler_working_dir, swagger_path, restler_drop_dir):
84+
import json
85+
compile_dir = Path(restler_working_dir, f'Compile')
86+
settings_file_path = compile_dir.joinpath('engine_settings.json')
87+
# Set the maximum number of generations (i.e. sequence length) to limit fuzzing
88+
settings_json=json.load(open(settings_file_path))
89+
settings_json["max_sequence_length"] = 5
90+
json.dump(settings_json, open(settings_file_path, "w", encoding='utf-8'))
91+
92+
expected_strings = [
93+
'Request coverage (successful / total): 6 / 6',
94+
'Bugs were found!' ,
95+
'InvalidDynamicObjectChecker_20x: 2',
96+
'InvalidDynamicObjectChecker_500: 1',
97+
'PayloadBodyChecker_500: 1',
98+
'Task Fuzz succeeded.'
99+
]
100+
output = subprocess.run(
101+
f'python ./restler-quick-start.py --api_spec_path {swagger_path} --restler_drop_dir {restler_drop_dir} --task fuzz',
102+
shell=True, capture_output=True
103+
)
104+
check_output_errors(output)
105+
# check_expected_output(restler_working_dir, expected_strings, output)
106+
107+
def test_replay_task(restler_working_dir, task_output_dir, restler_drop_dir):
108+
# Run the quick start script
109+
print(f"Testing replay for bugs found in task output dir: {task_output_dir}")
110+
output = subprocess.run(
111+
f'python ./restler-quick-start.py --replay_bug_buckets_dir {task_output_dir} --restler_drop_dir {restler_drop_dir} --task replay',
112+
shell=True, capture_output=True
113+
)
114+
check_output_errors(output)
115+
# Check that the Replay directory is present and that it contains a bug bucket with the
116+
# same bug.
117+
original_bug_buckets_file_path = glob.glob(os.path.join(task_output_dir, 'RestlerResults/*/bug_buckets/bug_buckets.txt'))[0]
118+
119+
# TODO: it would be better if the replay command also produced a bug bucket, so they could be
120+
# diff'ed with the original bug buckets.
121+
# Until this is implemented, check that a 500 is found in the log.
122+
# replay_buckets = glob.glob(os.path.join(restler_working_dir, 'Replay/RestlerResults/*/bug_buckets/bug_buckets.txt'))
123+
network_log = glob.glob(os.path.join(restler_working_dir, 'Replay/RestlerResults/**/logs/network.*.txt'))
124+
if network_log:
125+
network_log = network_log[0]
126+
else:
127+
output = str(output.stdout)
128+
raise QuickStartFailedException(f"No bug buckets were found after replay. Output: {output}")
129+
130+
with open(network_log) as rf, open(original_bug_buckets_file_path) as of:
131+
orig_buckets = of.read()
132+
log_contents = rf.read()
133+
if 'HTTP/1.1 500 INTERNAL SERVER ERROR' not in log_contents:
134+
raise QuickStartFailedException(f"Failing because bug buckets {orig_buckets} were not reproduced. Replay log: {log_contents}.")
135+
else:
136+
print("500 error was reproduced.")
137+
138+
demo_server_output=[]
139+
140+
def get_demo_server_output(demo_server_process):
141+
demo_server_output.clear()
142+
while True:
143+
output = demo_server_process.stdout.readline()
144+
if output:
145+
demo_server_output.append(output)
146+
else:
147+
result = demo_server_process.poll()
148+
if result is not None:
149+
break
150+
151+
return result
152+
23153
if __name__ == '__main__':
24154
curr = os.getcwd()
155+
25156
# Run demo server in background
157+
# Note: demo_server must be started in its directory
26158
os.chdir('demo_server')
27159
demo_server_path = Path('demo_server', 'app.py')
28160
demo_server_process = subprocess.Popen([sys.executable, demo_server_path],
29161
stdout=subprocess.PIPE,
30162
stderr=subprocess.STDOUT)
31163

164+
thread = Thread(target = get_demo_server_output, args = (demo_server_process, ))
165+
thread.start()
166+
32167
os.chdir(curr)
33168

34169
swagger_path = Path('demo_server', 'swagger.json')
35170
# argv 1 = path to RESTler drop
36171
restler_drop_dir = sys.argv[1]
37-
172+
restler_working_dir = os.path.join(curr, RESTLER_WORKING_DIR)
173+
test_failed = False
38174
try:
39-
# Run the quick start script
40-
output = subprocess.run(
41-
f'python ./restler-quick-start.py --api_spec_path {swagger_path} --restler_drop_dir {restler_drop_dir}',
42-
shell=True, capture_output=True
43-
)
44-
# Kill demo server
45-
demo_server_process.terminate()
46-
demo_server_out, _ = demo_server_process.communicate()
47-
# Check if restler-quick-start succeeded
48-
if output.stderr:
49-
raise QuickStartFailedException(f"Failing because stderr was detected after running restler-quick-start:\n{output.stderr!s}")
50-
try:
51-
output.check_returncode()
52-
except subprocess.CalledProcessError:
53-
raise QuickStartFailedException(f"Failing because restler-quick-start exited with a non-zero return code: {output.returncode!s}")
175+
print("+++++++++++++++++++++++++++++test...")
176+
test_test_task(restler_working_dir, swagger_path, restler_drop_dir)
54177

55-
stdout = str(output.stdout)
178+
print("+++++++++++++++++++++++++++++fuzzlean...")
179+
test_fuzzlean_task(restler_working_dir, swagger_path, restler_drop_dir)
56180

57-
if 'Request coverage (successful / total): 6 / 6' not in stdout or\
58-
'No bugs were found.' not in stdout or\
59-
'Task Test succeeded.' not in stdout:
60-
print(f"Demo server output: {demo_server_out}")
181+
print("+++++++++++++++++++++++++++++replay...")
182+
fuzzlean_task_dir = os.path.join(curr, RESTLER_WORKING_DIR, 'FuzzLean')
183+
test_replay_task(restler_working_dir, fuzzlean_task_dir, restler_drop_dir)
61184

62-
stdout = stdout.replace('\\r\\n', '\r\n')
63-
# Print the engine logs to the console
64-
out_file_path = os.path.join(curr, RESTLER_WORKING_DIR, 'Test', 'EngineStdOut.txt')
65-
err_file_path = os.path.join(curr, RESTLER_WORKING_DIR, 'Test', 'EngineStdErr.txt')
66-
results_dir = os.path.join(curr, RESTLER_WORKING_DIR, 'Test', 'RestlerResults')
67-
# Return the newest experiments directory in RestlerResults
68-
net_log_dir = max(glob.glob(os.path.join(results_dir, 'experiment*/')), key=os.path.getmtime)
69-
net_log_path = glob.glob(os.path.join(net_log_dir, 'logs', f'network.testing.*.1.txt'))[0]
70-
with open(out_file_path) as of, open(err_file_path) as ef, open(net_log_path) as nf:
71-
out = of.read()
72-
err = ef.read()
73-
net_log = nf.read()
185+
#print("+++++++++++++++++++++++++++++fuzz...")
186+
#test_fuzz_task(restler_working_dir, swagger_path, restler_drop_dir)
74187

75-
raise QuickStartFailedException(f"Failing because expected output was not found:\n{stdout}{out}{err}{net_log}")
188+
except Exception:
189+
test_failed = True
190+
raise
76191
finally:
192+
# Kill demo server
193+
demo_server_process.terminate()
194+
thread.join()
195+
demo_server_out, _ = demo_server_process.communicate()
196+
if test_failed:
197+
print(f"Demo server output: {demo_server_output} {demo_server_out}")
198+
77199
# Delete the working directory that was created during restler quick start
78200
shutil.rmtree(RESTLER_WORKING_DIR)
79201

restler/engine/core/driver.py

+11
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,14 @@ def generate_sequences(fuzzing_requests, checkers, fuzzing_jobs=1):
758758

759759
return num_total_sequences
760760

761+
def get_host_and_port(hostname):
762+
if ':' in hostname:
763+
# If hostname includes port, split it out
764+
host_split = hostname.split(':')
765+
return host_split[0], host_split[1]
766+
else:
767+
return hostname, None
768+
761769
def replay_sequence_from_log(replay_log_filename, token_refresh_cmd):
762770
""" Replays a sequence of requests from a properly formed log file
763771
@@ -790,6 +798,9 @@ def replay_sequence_from_log(replay_log_filename, token_refresh_cmd):
790798
hostname = get_hostname_from_line(line)
791799
if hostname is None:
792800
raise Exception("Host not found in request. The replay log may be corrupted.")
801+
hostname, port = get_host_and_port(hostname)
802+
if Settings().connection_settings.target_port is None and port is not None:
803+
Settings().set_port(port)
793804
Settings().set_hostname(hostname)
794805

795806
# Append the request data to the list

0 commit comments

Comments
 (0)