From a7d79f29401ec42cfed0db1de126ad50ac5e6638 Mon Sep 17 00:00:00 2001 From: Simon Bennetts Date: Wed, 4 Feb 2026 10:06:07 +0000 Subject: [PATCH] Docker: Add --plan-only to baseline scan Signed-off-by: Simon Bennetts --- docker/CHANGELOG.md | 4 + .../fixtures/baseline_plan_supported.yaml | 31 +++ docker/tests/suite.py | 2 +- docker/tests/test_zap_baseline_plan.py | 120 +++++++++++ docker/zap-baseline.py | 188 +++++++++++------- 5 files changed, 268 insertions(+), 77 deletions(-) create mode 100644 docker/tests/fixtures/baseline_plan_supported.yaml create mode 100644 docker/tests/test_zap_baseline_plan.py diff --git a/docker/CHANGELOG.md b/docker/CHANGELOG.md index 59e7fa5deb0..0d9c4ea7561 100644 --- a/docker/CHANGELOG.md +++ b/docker/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog All notable changes to the docker containers will be documented in this file. +### 2026-02-04 +- Added --plan-only option to the baseline scan. +- Fixed the directory used for the plan. + ### 2025-12-11 - Update `Alert_on_HTTP_Response_Code_Errors.js` and `Alert_on_Unexpected_Content_Types.js` to reduce classloading (Issue 9187). diff --git a/docker/tests/fixtures/baseline_plan_supported.yaml b/docker/tests/fixtures/baseline_plan_supported.yaml new file mode 100644 index 00000000000..787c2798bfb --- /dev/null +++ b/docker/tests/fixtures/baseline_plan_supported.yaml @@ -0,0 +1,31 @@ +env: + contexts: + - name: baseline + urls: + - https://example.com/path + - https://example.com/ + excludePaths: [] + parameters: + failOnError: true + progressToStdout: false +jobs: +- type: passiveScan-config + parameters: + enableTags: false + maxAlertsPerRule: 10 +- type: spider + parameters: + url: https://example.com/ + maxDuration: 5 +- type: spiderAjax + parameters: + url: https://example.com/ + maxDuration: 5 +- type: passiveScan-wait + parameters: + maxDuration: 10 +- type: outputSummary + parameters: + format: Short + summaryFile: {SUMMARY_FILE} + rules: [] diff --git a/docker/tests/suite.py b/docker/tests/suite.py index 484605f4b6e..8b20ef63007 100644 --- a/docker/tests/suite.py +++ b/docker/tests/suite.py @@ -12,7 +12,7 @@ def module_name_to_class(module_name): def get_test_cases(directory): - directory = directory[0:-1] if directory[-1:] is '/' else directory + directory = directory[0:-1] if directory[-1:] == '/' else directory tests = glob(directory + '/test_*.py') test_list = [] for module_path in tests: diff --git a/docker/tests/test_zap_baseline_plan.py b/docker/tests/test_zap_baseline_plan.py new file mode 100644 index 00000000000..fc6efb5d83b --- /dev/null +++ b/docker/tests/test_zap_baseline_plan.py @@ -0,0 +1,120 @@ +import importlib.util +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import yaml + + +class TestZapBaselinePlan(unittest.TestCase): + def load_module(self): + docker_dir = Path(__file__).resolve().parents[1] + module_path = docker_dir / "zap-baseline.py" + module_name = "zap_baseline_test" + + if module_name in sys.modules: + del sys.modules[module_name] + + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + def load_fixture_plan(self, fixture_name, summary_file): + fixtures_dir = Path(__file__).resolve().parent / "fixtures" + fixture_path = fixtures_dir / fixture_name + raw = fixture_path.read_text(encoding="utf-8") + raw = raw.replace("{SUMMARY_FILE}", summary_file) + return yaml.safe_load(raw) + + def test_plan_only_supported_options(self): + zap_baseline = self.load_module() + target = "https://example.com/path" + + with tempfile.TemporaryDirectory() as home_dir: + summary_file = os.path.join(home_dir, "zap_out.json") + plan_path = os.path.join(home_dir, "zap.yaml") + args = [ + "--plan-only", + "-t", target, + "-m", "5", + "-j", + "-T", "10", + "-s" + ] + + original_cwd = os.getcwd() + os.chdir(home_dir) + try: + with patch.dict(os.environ, {"HOME": home_dir}, clear=True): + with patch.object(zap_baseline, "check_zap_client_version"): + with patch.object(zap_baseline, "running_in_docker", return_value=False): + with patch.object(zap_baseline.Path, "home", return_value=Path(home_dir)): + with self.assertRaises(SystemExit) as exc: + zap_baseline.main(args) + self.assertEqual(0, exc.exception.code) + finally: + os.chdir(original_cwd) + + self.assertTrue(os.path.exists(plan_path)) + generated_plan = yaml.safe_load(Path(plan_path).read_text(encoding="utf-8")) + expected_plan = self.load_fixture_plan("baseline_plan_supported.yaml", summary_file) + self.assertEqual(expected_plan, generated_plan) + + def test_plan_only_unsupported_option(self): + zap_baseline = self.load_module() + target = "https://example.com/" + + with tempfile.TemporaryDirectory() as home_dir: + plan_path = os.path.join(home_dir, "zap.yaml") + + args = [ + "--plan-only", + "-t", target, + "-D", "5" + ] + + original_cwd = os.getcwd() + os.chdir(home_dir) + try: + with patch.dict(os.environ, {"HOME": home_dir}, clear=True): + with patch.object(zap_baseline, "check_zap_client_version"): + with patch.object(zap_baseline, "running_in_docker", return_value=False): + with patch.object(zap_baseline.Path, "home", return_value=Path(home_dir)): + with self.assertLogs(level="WARNING") as log_capture: + with self.assertRaises(SystemExit) as exc: + zap_baseline.main(args) + self.assertEqual(3, exc.exception.code) + finally: + os.chdir(original_cwd) + + self.assertTrue(any("-D" in message for message in log_capture.output)) + self.assertFalse(os.path.exists(plan_path)) + + def test_plan_only_requires_mounted_workdir_in_docker(self): + zap_baseline = self.load_module() + target = "https://example.com/" + args = [ + "--plan-only", + "-t", target, + ] + + real_exists = os.path.exists + + def exists_side_effect(path): + if path == "/zap/wrk/": + return False + return real_exists(path) + + with patch.dict(os.environ, {"IS_CONTAINERIZED": "true"}): + with patch("os.path.exists", side_effect=exists_side_effect): + with self.assertLogs(level="WARNING") as log_capture: + with self.assertRaises(SystemExit) as exc: + zap_baseline.main(args) + self.assertEqual(3, exc.exception.code) + + self.assertTrue(any("/zap/wrk" in message for message in log_capture.output)) diff --git a/docker/zap-baseline.py b/docker/zap-baseline.py index 73f92f569d8..6d9201151e8 100755 --- a/docker/zap-baseline.py +++ b/docker/zap-baseline.py @@ -103,6 +103,7 @@ def usage(): print(' --hook path to python file that define your custom hooks') print(' --auto use the automation framework if supported for the given parameters (this is now the default)') print(' --autooff do not use the automation framework even if supported for the given parameters') + print(' --plan-only generate an automation framework plan but do not run it') print('') print('For more details see https://www.zaproxy.org/docs/docker/baseline-scan/') @@ -128,6 +129,11 @@ def usage(): -T mins -z zap_options + The following option can be used to just generate an Authentication Framework plan but not run it. + It will fail if you specify any unsupported parameters. + + --plan-only + The following parameters are partially supported. If you specify the '--auto' flag _before_ using them then the Automation Framework will be used: @@ -148,6 +154,74 @@ def usage(): ''' +def generate_af_plan(yaml_file, summary_file, target, out_of_scope_dict, debug, mins, ajax, timeout, + detailed_output, config_dict, config_msg, report_html, report_md, report_xml, + report_json, base_dir): + with open(yaml_file, 'w') as yf: + + # Add the top level to the scope for backwards compatibility + top_levels = [ target ] + if target.count('/') > 2: + # The url can include a valid path, but always reset to spider the host (backwards compatibility) + t2 = target[0:target.index('/', 8)+1] + if not t2 == target: + target = t2 + top_levels.append(target) + + yaml.dump(get_af_env(top_levels, out_of_scope_dict, debug), yf) + + alertFilters = [] + + # Handle id specific alertFilters - rules that apply to all IDs are excluded from the env + for id in out_of_scope_dict: + if id != '*': + for regex in out_of_scope_dict[id]: + alertFilters.append({'ruleId': id, 'newRisk': 'False Positive', 'url': regex.pattern, 'urlRegex': True}) + + jobs = [get_af_pscan_config()] + + if len(alertFilters) > 0: + jobs.append(get_af_alertFilter(alertFilters)) + + jobs.append(get_af_spider(target, mins)) + + if ajax: + jobs.append(get_af_spiderAjax(target, mins)) + + jobs.append(get_af_pscan_wait(timeout)) + jobs.append(get_af_output_summary(('Short', 'Long')[detailed_output], summary_file, config_dict, config_msg)) + + if report_html: + jobs.append(get_af_report('traditional-html', base_dir, report_html, 'ZAP Scanning Report', '')) + + if report_md: + jobs.append(get_af_report('traditional-md', base_dir, report_md, 'ZAP Scanning Report', '')) + + if report_xml: + jobs.append(get_af_report('traditional-xml', base_dir, report_xml, 'ZAP Scanning Report', '')) + + if report_json: + jobs.append(get_af_report('traditional-json', base_dir, report_json, 'ZAP Scanning Report', '')) + + yaml.dump({'jobs': jobs}, yf) + + if os.path.exists('/zap/wrk'): + yaml_copy_file = '/zap/wrk/zap.yaml' + try: + # Write the yaml file to the mapped directory, if there is one + if os.path.abspath(yaml_file) != os.path.abspath(yaml_copy_file): + copyfile(yaml_file, yaml_copy_file) + except OSError as err: + logging.warning('Unable to copy yaml file to ' + yaml_copy_file + ' ' + str(err)) + +def add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, opt, reason): + af_supported = False + if opt not in af_unsupported_opts: + af_unsupported_opts.append(opt) + if not no_af_reason: + no_af_reason = reason + return af_supported, no_af_reason + def main(argv): global min_level global in_progress_issues @@ -180,6 +254,8 @@ def main(argv): af_supported = True af_override = False no_af_reason = '' + plan_only = False + af_unsupported_opts = [] pass_count = 0 warn_count = 0 @@ -192,7 +268,7 @@ def main(argv): debug = False try: - opts, args = getopt.getopt(argv, "t:c:u:g:m:n:r:J:w:x:l:hdaijp:sz:P:D:T:IU:", ["hook=", "auto", "autooff"]) + opts, args = getopt.getopt(argv, "t:c:u:g:m:n:r:J:w:x:l:hdaijp:sz:P:D:T:IU:", ["hook=", "auto", "autooff", "plan-only"]) except getopt.GetoptError as exc: logging.warning('Invalid option ' + exc.opt + ' : ' + exc.msg) usage() @@ -211,8 +287,7 @@ def main(argv): config_url = arg elif opt == '-g': generate = arg - af_supported = False - no_af_reason = 'gen' + af_supported, no_af_reason = add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, '-g', 'gen') elif opt == '-d': logging.getLogger().setLevel(logging.DEBUG) debug = True @@ -222,16 +297,13 @@ def main(argv): port = int(arg) elif opt == '-D': delay = int(arg) - af_supported = False - no_af_reason = 'delay' + af_supported, no_af_reason = add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, '-D', 'delay') elif opt == '-n': context_file = arg - af_supported = False - no_af_reason = 'context' + af_supported, no_af_reason = add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, '-n', 'context') elif opt == '-p': progress_file = arg - af_supported = False - no_af_reason = 'progress' + af_supported, no_af_reason = add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, '-p', 'progress') elif opt == '-r': report_html = arg elif opt == '-J': @@ -244,8 +316,7 @@ def main(argv): zap_alpha = True elif opt == '-i': info_unspecified = True - af_supported = False - no_af_reason = 'info' + af_supported, no_af_reason = add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, '-i', 'info') elif opt == '-I': ignore_warn = True elif opt == '-j': @@ -257,8 +328,7 @@ def main(argv): logging.warning('Level must be one of ' + str(zap_conf_lvls)) usage() sys.exit(3) - af_supported = False - no_af_reason = 'level' + af_supported, no_af_reason = add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, '-l', 'level') elif opt == '-z': zap_options = arg elif opt == '-s': @@ -267,18 +337,18 @@ def main(argv): timeout = int(arg) elif opt == '-U': user = arg - af_supported = False - no_af_reason = 'user' + af_supported, no_af_reason = add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, '-U', 'user') elif opt == '--hook': hook_file = arg - af_supported = False - no_af_reason = 'hook' + af_supported, no_af_reason = add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, '--hook', 'hook') elif opt == '--auto': use_af = True af_override = True elif opt == '--autooff': use_af = False - no_af_reason = 'optout' + af_supported, no_af_reason = add_af_unsupported(af_supported, no_af_reason, af_unsupported_opts, '--autooff', 'optout') + elif opt == '--plan-only': + plan_only = True check_zap_client_version() @@ -301,7 +371,7 @@ def main(argv): if running_in_docker(): base_dir = '/zap/wrk/' - if config_file or generate or report_html or report_xml or report_json or report_md or progress_file or context_file: + if config_file or generate or report_html or report_xml or report_json or report_md or progress_file or context_file or plan_only: # Check directory has been mounted if not os.path.exists(base_dir): logging.warning('A file based option has been specified but the directory \'/zap/wrk\' is not mounted ') @@ -350,70 +420,36 @@ def main(argv): if issue["state"] == "inprogress": in_progress_issues[issue["id"]] = issue + if plan_only: + use_af = True + if not af_supported: + logging.warning('Automation Framework does not support the following options with --plan-only: ' + + ', '.join(af_unsupported_opts)) + sys.exit(3) + + home_dir = str(Path.home()) + yaml_file = os.path.join(base_dir, 'zap.yaml') + summary_file = os.path.join(home_dir, 'zap_out.json') + + print('Generating the Automation Framework plan only: zap.yaml') + + generate_af_plan(yaml_file, summary_file, target, out_of_scope_dict, debug, mins, ajax, timeout, + detailed_output, config_dict, config_msg, report_html, report_md, report_xml, + report_json, base_dir) + + sys.exit(0) + if running_in_docker(): if use_af and af_supported: print('Using the Automation Framework') # Generate the yaml file home_dir = str(Path.home()) - yaml_file = os.path.join(home_dir, 'zap.yaml') + yaml_file = os.path.join(base_dir, 'zap.yaml') summary_file = os.path.join(home_dir, 'zap_out.json') - - with open(yaml_file, 'w') as yf: - - # Add the top level to the scope for backwards compatibility - top_levels = [ target ] - if target.count('/') > 2: - # The url can include a valid path, but always reset to spider the host (backwards compatibility) - t2 = target[0:target.index('/', 8)+1] - if not t2 == target: - target = t2 - top_levels.append(target) - - yaml.dump(get_af_env(top_levels, out_of_scope_dict, debug), yf) - - alertFilters = [] - - # Handle id specific alertFilters - rules that apply to all IDs are excluded from the env - for id in out_of_scope_dict: - if id != '*': - for regex in out_of_scope_dict[id]: - alertFilters.append({'ruleId': id, 'newRisk': 'False Positive', 'url': regex.pattern, 'urlRegex': True}) - - jobs = [get_af_pscan_config()] - - if len(alertFilters) > 0: - jobs.append(get_af_alertFilter(alertFilters)) - - jobs.append(get_af_spider(target, mins)) - - if ajax: - jobs.append(get_af_spiderAjax(target, mins)) - - jobs.append(get_af_pscan_wait(timeout)) - jobs.append(get_af_output_summary(('Short', 'Long')[detailed_output], summary_file, config_dict, config_msg)) - - if report_html: - jobs.append(get_af_report('traditional-html', base_dir, report_html, 'ZAP Scanning Report', '')) - - if report_md: - jobs.append(get_af_report('traditional-md', base_dir, report_md, 'ZAP Scanning Report', '')) - - if report_xml: - jobs.append(get_af_report('traditional-xml', base_dir, report_xml, 'ZAP Scanning Report', '')) - - if report_json: - jobs.append(get_af_report('traditional-json', base_dir, report_json, 'ZAP Scanning Report', '')) - - yaml.dump({'jobs': jobs}, yf) - - if os.path.exists('/zap/wrk'): - yaml_copy_file = '/zap/wrk/zap.yaml' - try: - # Write the yaml file to the mapped directory, if there is one - copyfile(yaml_file, yaml_copy_file) - except OSError as err: - logging.warning('Unable to copy yaml file to ' + yaml_copy_file + ' ' + str(err)) + generate_af_plan(yaml_file, summary_file, target, out_of_scope_dict, debug, mins, ajax, timeout, + detailed_output, config_dict, config_msg, report_html, report_md, report_xml, + report_json, base_dir) try: if "-silent" not in zap_options: