├── .gitignore ├── README.md ├── pytest_zap.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *.egg-info 3 | *.idea 4 | *.pyc 5 | build 6 | dist 7 | results 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pytest_zap 2 | ========== 3 | 4 | pytest_zap is a plugin for [py.test](http://pytest.org/) that provides support for running [OWASP Zed Attack Proxy](https://www.owasp.org/index.php/OWASP_Zed_Attack_Proxy_Project). 5 | 6 | Requires: 7 | 8 | * py.test 9 | * python-owasp-zap 10 | 11 | Installation 12 | ------------ 13 | 14 | $ python setup.py install 15 | 16 | Usage 17 | ----- 18 | 19 | For full usage details run the following command: 20 | 21 | $ py.test --help 22 | 23 | zap: 24 | --zap-interactive run zap in interactive mode. (default: False) 25 | --zap-path=path location of zap installation. 26 | --zap-log=path location of zap log file (default zap.log) 27 | --zap-home=path location of zap home directory. 28 | --zap-config=path location of zap configuration file. (default: zap.cfg) 29 | --zap-host=str host zap is listening on. (default: localhost) 30 | --zap-port=int port zap is listening on. (default: 8080) 31 | --zap-target=url target url for spider and scan. 32 | --zap-exclude=str exclude urls matching this regex when scanning. 33 | --zap-spider spider the target. (default: False) 34 | --zap-scan scan the target. (default: False) 35 | --zap-save save the zap session in zap.session within home directory. (default: False) 36 | --zap-load=path location of an archived zap session to open. 37 | --zap-ignore=path location of ignored alerts text file. (default: zap_ignore.txt) 38 | --zap-skip-tests skip all tests 39 | --zap-observe enable observation mode to prevent failing when alerts are found. (default False) 40 | -------------------------------------------------------------------------------- /pytest_zap.py: -------------------------------------------------------------------------------- 1 | import glob 2 | from ConfigParser import SafeConfigParser 3 | import copy 4 | import zipfile 5 | import logging 6 | import os 7 | import platform 8 | import subprocess 9 | import time 10 | import urllib 11 | 12 | import py 13 | from zapv2 import ZAPv2 14 | 15 | __version__ = '0.1' 16 | 17 | 18 | def pytest_addoption(parser): 19 | group = parser.getgroup('zap', 'zap') 20 | group._addoption( 21 | '--zap-interactive', 22 | action='store_true', 23 | dest='zap_interactive', 24 | default=False, 25 | help='run zap in interactive mode. (default: %default)') 26 | group._addoption( 27 | '--zap-path', 28 | action='store', 29 | dest='zap_path', 30 | metavar='path', 31 | help='location of zap installation.') 32 | group._addoption( 33 | '--zap-log', 34 | action='store', 35 | dest='zap_log', 36 | default='zap.log', 37 | metavar='path', 38 | help='location of zap log file. (default %default)') 39 | group._addoption( 40 | '--zap-home', 41 | action='store', 42 | dest='zap_home', 43 | metavar='path', 44 | help='location of zap home directory.') 45 | group._addoption( 46 | '--zap-config', 47 | action='store', 48 | dest='zap_config', 49 | default='zap.cfg', 50 | metavar='path', 51 | help='location of zap configuration file. (default: %default)') 52 | group._addoption( 53 | '--zap-host', 54 | action='store', 55 | dest='zap_host', 56 | default='localhost', 57 | metavar='str', 58 | help='host zap is listening on. (default: %default)') 59 | group._addoption( 60 | '--zap-port', 61 | action='store', 62 | dest='zap_port', 63 | metavar='int', 64 | default=8080, 65 | type='int', 66 | help='port zap is listening on. (default: %default)') 67 | group._addoption( 68 | '--zap-target', 69 | action='store', 70 | dest='zap_target', 71 | metavar='url', 72 | help='target url for spider and scan.') 73 | group._addoption( 74 | '--zap-exclude', 75 | action='store', 76 | dest='zap_exclude', 77 | metavar='str', 78 | help='exclude urls matching this regex when scanning.') 79 | group._addoption( 80 | '--zap-spider', 81 | action='store_true', 82 | dest='zap_spider', 83 | default=False, 84 | help='spider the target. (default: %default)') 85 | group._addoption( 86 | '--zap-scan', 87 | action='store_true', 88 | dest='zap_scan', 89 | default=False, 90 | help='scan the target. (default: %default)') 91 | group._addoption( 92 | '--zap-save', 93 | action='store_true', 94 | dest='zap_save_session', 95 | default=False, 96 | help='save the zap session in zap.session within home directory. ' 97 | '(default: %default)') 98 | group._addoption( 99 | '--zap-load', 100 | action='store', 101 | dest='zap_load_session', 102 | metavar='path', 103 | help='location of an archived zap session to open.') 104 | group._addoption( 105 | '--zap-ignore', 106 | action='store', 107 | dest='zap_ignore', 108 | default='zap_ignore.txt', 109 | metavar='path', 110 | help='location of ignored alerts text file. (default: %default)') 111 | group._addoption( 112 | '--zap-skip-tests', 113 | action='store_true', 114 | dest='zap_skip_tests', 115 | default=False, 116 | help='skip all tests') 117 | group._addoption( 118 | '--zap-observe', 119 | action='store_true', 120 | dest='zap_observe', 121 | default=False, 122 | help='enable observation mode to prevent failing when alerts are ' 123 | 'found. (default %default)') 124 | 125 | 126 | def pytest_configure(config): 127 | logger = logging.getLogger(__name__) 128 | logger.setLevel(logging.DEBUG) 129 | 130 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 131 | 132 | console_handler = logging.StreamHandler() 133 | console_handler.setLevel(logging.INFO) 134 | console_handler.setFormatter(formatter) 135 | logger.addHandler(console_handler) 136 | 137 | file_handler = logging.FileHandler('%s.log' % __name__, 'w') 138 | file_handler.setLevel(logging.DEBUG) 139 | file_handler.setFormatter(formatter) 140 | logger.addHandler(file_handler) 141 | 142 | config._zap_config = SafeConfigParser() 143 | config._zap_config.read(config.option.zap_config) 144 | 145 | config.option.zap_target = config.option.zap_target or \ 146 | (hasattr(config.option, 'base_url') and config.option.base_url) 147 | 148 | 149 | #TODO Use py.test fixtures 150 | #See http://pytest.org/latest/fixture.html 151 | def pytest_sessionstart(session): 152 | logger = logging.getLogger(__name__) 153 | if hasattr(session.config, 'slaveinput') or \ 154 | session.config.option.collectonly: 155 | return 156 | 157 | zap_url = 'http://%s:%s' % ( 158 | session.config.option.zap_host, 159 | session.config.option.zap_port) 160 | proxies = {'http': zap_url, 'https': zap_url} 161 | 162 | if not session.config._zap_config.has_option('control', 'start') or\ 163 | session.config._zap_config.getboolean('control', 'start'): 164 | if platform.system() == 'Windows': 165 | zap_script = ['start /b zap.bat'] 166 | else: 167 | zap_script = ['./zap.sh'] 168 | 169 | if not session.config.option.zap_interactive: 170 | # Run as a daemon 171 | zap_script.append('-daemon') 172 | 173 | zap_script.extend(['-port', str(session.config.option.zap_port)]) 174 | 175 | if session.config.option.zap_path: 176 | zap_path = os.path.expanduser(session.config.option.zap_path) 177 | else: 178 | if platform.system() == 'Windows': 179 | # Win 7 default path 180 | zap_path = 'C:\Program Files (x86)\OWASP\Zed Attack Proxy' 181 | if not os.path.exists(zap_path): 182 | # Win XP default path 183 | zap_path = 'C:\Program Files\OWASP\Zed Attack Proxy' 184 | elif 'darwin' in platform.system().lower(): 185 | zap_path = '/Applications/OWASP ZAP.app' 186 | else: 187 | message = 'Installation directory must be set using ' \ 188 | '--zap-path command line option' 189 | logger.error(message) 190 | raise Exception(message) 191 | 192 | if zap_path.rstrip(os.path.sep).endswith('.app'): 193 | zap_path = os.path.join(zap_path, 'Contents', 'Java') 194 | 195 | zap_home = session.config.option.zap_home and \ 196 | os.path.expanduser(session.config.option.zap_home) or \ 197 | os.path.join(zap_path, 'home') 198 | session.config.option.zap_home = zap_home 199 | 200 | if not os.path.exists(zap_home): 201 | logger.info('Creating home directory in %s' % zap_home) 202 | os.makedirs(zap_home) 203 | 204 | license_path = os.path.join(zap_home, 'AcceptedLicense') 205 | if not os.path.exists(license_path): 206 | # Create a blank accepted license file, otherwise will be 207 | # prompted for 208 | logger.info('Creating blank license file in %s' % license_path) 209 | license_file = open(license_path, 'w') 210 | license_file.close() 211 | 212 | if session.config.option.zap_interactive: 213 | zap_script.extend(['-config', 'start.checkForUpdates=false']) 214 | zap_script.extend(['-config', 'start.dayLastChecked=Never']) 215 | 216 | # Set proxy 217 | zap_script.extend([ 218 | '-config', 'proxy.ip=%s' % session.config.option.zap_host]) 219 | 220 | zap_script.extend(['-dir', zap_home]) 221 | 222 | logger.info('Starting ZAP') 223 | #TODO Move all launcher code to Python client 224 | logger.info('Running %s' % ' '.join(zap_script)) 225 | logger.info('From %s' % zap_path) 226 | 227 | # Check if ZAP is already running 228 | if is_zap_running(zap_url): 229 | message = 'ZAP is already running' 230 | logger.error(message) 231 | raise Exception(message) 232 | 233 | # Start ZAP 234 | session.config.log_file = open(os.path.expanduser( 235 | session.config.option.zap_log), 'w') 236 | session.config.zap_process = subprocess.Popen( 237 | zap_script, cwd=zap_path, stdout=session.config.log_file, 238 | stderr=subprocess.STDOUT) 239 | 240 | time.sleep(5) 241 | return_code = session.config.zap_process.poll() 242 | if return_code is not None: 243 | message = 'Failed to start ZAP, check %s for details' % \ 244 | session.config.log_file.name 245 | logger.error(message) 246 | raise Exception(message) 247 | 248 | try: 249 | wait_for_zap_to_start(zap_url) 250 | session.config.zap = ZAPv2(proxies=proxies) 251 | except: 252 | kill_zap_process(session.config.zap_process) 253 | raise 254 | else: 255 | # Check if ZAP is already running 256 | logger.info('Connecting to existing ZAP instance at %s' % zap_url) 257 | if not is_zap_running(zap_url): 258 | message = 'ZAP is not running' 259 | logger.error(message) 260 | raise Exception(message) 261 | session.config.zap = ZAPv2(proxies=proxies) 262 | 263 | # Save session 264 | if session.config.option.zap_save_session: 265 | session_path = os.path.join(os.path.abspath( 266 | session.config.option.zap_home), 'zap') 267 | logger.info('Saving session in %s' % session_path) 268 | 269 | if not session.config.option.zap_home: 270 | logger.error('Home directory must be set using --zap-home command ' 271 | 'line option') 272 | 273 | session.config.zap.core.save_session(session_path) 274 | else: 275 | logger.info('Skipping save session') 276 | 277 | logger.info('Generating a root CA certificate') 278 | session.config.zap.core.generate_root_ca() 279 | 280 | if session.config.option.zap_load_session: 281 | try: 282 | #TODO Remove this when the archived sessions 283 | # are supported by default 284 | # Blocked by http://code.google.com/p/zaproxy/issues/detail?id=373 285 | load_session_zip_path = os.path.expanduser( 286 | session.config.option.zap_load_session) 287 | logger.info('Extracting session from %s' % load_session_zip_path) 288 | load_session_zip = zipfile.ZipFile(load_session_zip_path) 289 | load_session_path = os.path.abspath(os.path.join( 290 | session.config.option.zap_home, 'load_session')) 291 | load_session_zip.extractall(load_session_path) 292 | load_session_file = glob.glob(os.path.join( 293 | load_session_path, '*.session'))[0] 294 | logger.info('Loading session from %s' % load_session_file) 295 | session.config.zap.core.load_session(load_session_file) 296 | except (IOError, zipfile.BadZipfile) as e: 297 | logger.error('Failed to load session. %s' % e) 298 | kill_zap_process(session.config.zap_process) 299 | raise 300 | 301 | 302 | def pytest_runtest_setup(item): 303 | if item.config.option.zap_skip_tests: 304 | py.test.skip() 305 | 306 | 307 | def pytest_sessionfinish(session): 308 | logger = logging.getLogger(__name__) 309 | if hasattr(session.config, 'slaveinput') or \ 310 | session.config.option.collectonly: 311 | return 312 | 313 | print '\n' 314 | zap = session.config.zap 315 | 316 | # Passive scan 317 | wait_for_passive_scan(zap) 318 | 319 | zap_urls = copy.deepcopy(zap.core.urls) 320 | logger.info('Got %s URLs' % len(zap_urls)) 321 | 322 | # Spider 323 | if session.config.option.zap_spider and session.config.option.zap_target: 324 | if session.config.option.zap_exclude: 325 | zap.spider.exclude_from_scan(session.config.option.zap_exclude) 326 | logger.info('Spider progress: 0%') 327 | zap.spider.scan(session.config.option.zap_target) 328 | status = int(zap.spider.status) 329 | while status < 100: 330 | new_status = int(zap.spider.status) 331 | if new_status > status: 332 | level = logging.INFO 333 | status = new_status 334 | else: 335 | level = logging.DEBUG 336 | logger.log(level, 'Spider progress: %s%%' % new_status) 337 | time.sleep(5) 338 | logger.info('Spider progress: 100%') 339 | #TODO API call for new URLs discovered by spider 340 | # Blocked by http://code.google.com/p/zaproxy/issues/detail?id=368 341 | new_urls = copy.deepcopy(zap.core.urls) 342 | logger.info('Spider found %s additional URLs' % ( 343 | len(new_urls) - len(zap_urls))) 344 | wait_for_passive_scan(zap) 345 | else: 346 | logger.info('Skipping spider') 347 | 348 | zap_alerts = get_alerts(zap) 349 | 350 | # Active scan 351 | if session.config.option.zap_scan and session.config.option.zap_target: 352 | if session.config.option.zap_exclude: 353 | zap.ascan.exclude_from_scan(session.config.option.zap_exclude) 354 | logger.info('Scan progress: 0%') 355 | zap.ascan.scan(session.config.option.zap_target) 356 | status = int(zap.ascan.status) 357 | while status < 100: 358 | new_status = int(zap.ascan.status) 359 | if new_status > status: 360 | level = logging.INFO 361 | status = new_status 362 | else: 363 | level = logging.DEBUG 364 | logger.log(level, 'Scan progress: %s%%' % new_status) 365 | time.sleep(5) 366 | logger.info('Scan progress: 100%') 367 | zap_alerts.extend(get_alerts(zap, start=len(zap_alerts))) 368 | else: 369 | logger.info('Skipping scan') 370 | 371 | # Filter alerts 372 | ignored_alerts = [] 373 | alerts = [] 374 | if zap_alerts and os.path.exists(session.config.option.zap_ignore): 375 | with open(session.config.option.zap_ignore, 'r') as f: 376 | zap_ignores = f.readlines() 377 | for alert in zap_alerts: 378 | if '%s\n' % alert['alert'] in zap_ignores: 379 | ignored_alerts.append(alert) 380 | else: 381 | alerts.append(alert) 382 | if ignored_alerts: 383 | for alert in set(['%s [%s]' % (i['alert'], i['risk']) for i in 384 | ignored_alerts]): 385 | logger.info('Ignored alert: %s' % alert) 386 | else: 387 | alerts.extend(zap_alerts) 388 | 389 | if alerts: 390 | for alert in set(['%s [%s]' % (i['alert'], i['risk']) for i in 391 | alerts]): 392 | logger.warn('Alert: %s' % alert) 393 | 394 | #TODO Save alerts report 395 | #TODO Save JUnit style report 396 | # Blocked by http://code.google.com/p/zaproxy/issues/detail?id=371 397 | #TODO Save URLs report 398 | # Blocked by http://code.google.com/p/zaproxy/issues/detail?id=368 399 | 400 | if not session.config._zap_config.has_option('control', 'stop') or \ 401 | session.config._zap_config.getboolean('control', 'stop'): 402 | logger.info('Stopping ZAP') 403 | try: 404 | zap.core.shutdown() 405 | except: 406 | pass 407 | try: 408 | zap_url = 'http://%s:%s' % ( 409 | session.config.option.zap_host, 410 | session.config.option.zap_port) 411 | wait_for_zap_to_stop(zap_url) 412 | except: 413 | if hasattr(session.config, 'zap_process'): 414 | kill_zap_process(session.config.zap_process) 415 | 416 | # Close log file 417 | if hasattr(session.config, 'log_file'): 418 | session.config.log_file.close() 419 | 420 | # Archive session 421 | #TODO Remove this when the session is archived by default 422 | # Blocked by http://code.google.com/p/zaproxy/issues/detail?id=373 423 | if session.config.option.zap_save_session: 424 | wait_for_lock_file_removed(os.path.join( 425 | session.config.option.zap_home, 'zap.session.lck')) 426 | session_files = glob.glob(os.path.join( 427 | session.config.option.zap_home, 'zap.session*')) 428 | if len(session_files) > 0: 429 | try: 430 | import zlib # NOQA 431 | mode = zipfile.ZIP_DEFLATED 432 | except: 433 | mode = zipfile.ZIP_STORED 434 | session_zip = zipfile.ZipFile(os.path.join( 435 | session.config.option.zap_home, 'zap_session.zip'), 'w', mode) 436 | for session_file in session_files: 437 | session_zip.write(session_file, session_file.rpartition( 438 | os.path.sep)[2]) 439 | session_zip.close() 440 | logger.info('Session archived in %s' % session_zip.filename) 441 | else: 442 | logger.warn('No session files to archive') 443 | 444 | #TODO Fail if alerts were raised (unless in observation mode) 445 | if not session.config.option.zap_observe and len(alerts) > 0: 446 | logger.error('Alerts raised') 447 | session.exitstatus = 1 448 | 449 | 450 | def get_alerts(api, start=0): 451 | logger = logging.getLogger(__name__) 452 | alerts_per_request = 1000 453 | alerts = [] 454 | while True: 455 | logger.info('Getting alerts: %s-%s' % (start, 456 | (start + alerts_per_request))) 457 | new_alerts = api.core.alerts(start=start, count=alerts_per_request) 458 | alerts.extend(new_alerts) 459 | if len(new_alerts) == alerts_per_request: 460 | start += alerts_per_request 461 | else: 462 | logger.info('Got %s alerts' % len(alerts)) 463 | return alerts 464 | 465 | 466 | def is_zap_running(url): 467 | logger = logging.getLogger(__name__) 468 | try: 469 | proxies = {'http': url, 'https': url} 470 | response = urllib.urlopen('http://zap/', proxies=proxies) 471 | if 'ZAP-Header' in response.headers.get( 472 | 'Access-Control-Allow-Headers', []): 473 | return True 474 | else: 475 | message = 'Service running at %s is not ZAP' % url 476 | logger.error(message) 477 | raise Exception(message) 478 | except IOError: 479 | return False 480 | 481 | 482 | def wait_for_passive_scan(api): 483 | logger = logging.getLogger(__name__) 484 | logger.info('Waiting for passive scan') 485 | logger.info('Records to scan: %s' % api.pscan.records_to_scan) 486 | while int(api.pscan.records_to_scan) > 0: 487 | time.sleep(5) 488 | logger.info('Records to scan: %s' % api.pscan.records_to_scan) 489 | logger.info('Finished passive scan') 490 | 491 | 492 | def wait_for_lock_file_removed(path): 493 | logger = logging.getLogger(__name__) 494 | timeout = 60 495 | end_time = time.time() + timeout 496 | while os.path.exists(path): 497 | time.sleep(1) 498 | if time.time() > end_time: 499 | message = 'Timeout after %s seconds waiting for lock file to be ' \ 500 | 'removed: %s' % (timeout, path) 501 | logger.error(message) 502 | raise Exception(message) 503 | 504 | 505 | def wait_for_zap_to_start(url): 506 | logger = logging.getLogger(__name__) 507 | logger.info('Waiting for ZAP to start') 508 | timeout = 60 509 | end_time = time.time() + timeout 510 | while not is_zap_running(url): 511 | time.sleep(1) 512 | if time.time() > end_time: 513 | message = 'Timeout after %s seconds waiting for ZAP' % timeout 514 | logger.error(message) 515 | raise Exception(message) 516 | logger.info('ZAP has successfully started') 517 | 518 | 519 | def wait_for_zap_to_stop(url): 520 | logger = logging.getLogger(__name__) 521 | logger.info('Waiting for ZAP to shutdown') 522 | timeout = 60 523 | end_time = time.time() + timeout 524 | while is_zap_running(url): 525 | time.sleep(1) 526 | if time.time() > end_time: 527 | message = 'Timeout after %s seconds waiting for ZAP to ' \ 528 | 'shutdown' % timeout 529 | logger.error(message) 530 | raise Exception(message) 531 | logger.info('ZAP has successfully shutdown') 532 | 533 | 534 | def kill_zap_process(process): 535 | logger = logging.getLogger(__name__) 536 | try: 537 | process.kill() 538 | except: 539 | logger.error('Unable to kill ZAP process') 540 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup(name='pytest-zap', 4 | version='0.2', 5 | description='OWASP ZAP plugin for py.test.', 6 | author='Dave Hunt', 7 | author_email='dhunt@mozilla.com', 8 | url='https://github.com/davehunt/pytest-zap', 9 | py_modules=['pytest_zap'], 10 | install_requires=['pytest', 'python-owasp-zap-v2'], 11 | entry_points={'pytest11': ['pytest_zap = pytest_zap']}, 12 | license='Mozilla Public License 2.0 (MPL 2.0)', 13 | keywords='py.test pytest owasp zap test security mozilla', 14 | classifiers=[ 15 | 'Development Status :: 4 - Beta', 16 | 'Intended Audience :: Developers', 17 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 18 | 'Operating System :: POSIX', 19 | 'Operating System :: Microsoft :: Windows', 20 | 'Operating System :: MacOS :: MacOS X', 21 | 'Topic :: Software Development :: Quality Assurance', 22 | 'Topic :: Software Development :: Testing', 23 | 'Topic :: Utilities', 24 | 'Programming Language :: Python', 25 | 'Programming Language :: Python :: 2.6', 26 | 'Programming Language :: Python :: 2.7']) 27 | --------------------------------------------------------------------------------