From ad97615758dda59a1dc228123ace40adae9a7cac Mon Sep 17 00:00:00 2001 From: Advait Patel Date: Thu, 2 Jul 2026 01:43:41 -0500 Subject: [PATCH] Phase 2c: add --format and --output-dir flags for report selection --- README.md | 3 +++ docksec/cli.py | 33 ++++++++++++++++++++++------ docksec/docker_scanner.py | 22 ++++++++++--------- docksec/report_generator.py | 31 ++++++++++++++------------ docs/CHANGELOG.md | 2 ++ tests/test_cli.py | 40 ++++++++++++++++++++++++++++++++++ tests/test_docker_scanner.py | 3 ++- tests/test_report_generator.py | 34 +++++++++++++++++++++++++++++ 8 files changed, 136 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 61ee88d..127ec83 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,9 @@ docksec -i myapp:latest --image-only --severity CRITICAL,HIGH,MEDIUM # Fail the build (exit 1) if any finding is HIGH or above docksec -i myapp:latest --image-only --fail-on high +# Write only the report formats you want, to a directory of your choice +docksec Dockerfile --scan-only --format json,html --output-dir ./reports + # Reduce output to warnings, errors, and the result summary docksec Dockerfile --scan-only --quiet diff --git a/docksec/cli.py b/docksec/cli.py index d692cf9..ca3fd8a 100644 --- a/docksec/cli.py +++ b/docksec/cli.py @@ -54,6 +54,8 @@ def main() -> None: parser.add_argument('--skip-ai-scoring', action='store_true', help='Skip AI-based security scoring (use local scoring only)') parser.add_argument('--severity', help='Comma-separated severity levels to scan for (default: CRITICAL,HIGH; or set DOCKSEC_DEFAULT_SEVERITY)') parser.add_argument('--fail-on', dest='fail_on', metavar='SEVERITY', help='Exit with code 1 if any finding is at or above this severity (CRITICAL, HIGH, MEDIUM, or LOW)') + parser.add_argument('--format', dest='format', help='Comma-separated report formats to write: json, csv, pdf, html (default: all)') + parser.add_argument('--output-dir', dest='output_dir', metavar='DIR', help='Directory to write reports to (default: ~/.docksec/results or DOCKSEC_RESULTS_DIR)') parser.add_argument('--quiet', action='store_true', help='Reduce output to warnings, errors, and the result summary') parser.add_argument('--no-color', action='store_true', help='Disable colored output (also honors the NO_COLOR env var)') parser.add_argument('--version', action='version', version=f'DockSec {get_version()}') @@ -113,6 +115,23 @@ def main() -> None: severity = ','.join(severity_list) output.info(f"Widened scan severity to {severity} to satisfy --fail-on {args.fail_on}") + # Resolve report formats and output directory. + from docksec.config import RESULTS_DIR + valid_formats = ["json", "csv", "pdf", "html"] + report_formats = None # None = write all formats (default) + if args.format: + requested = [f.strip().lower() for f in args.format.split(',') if f.strip()] + invalid_formats = [f for f in requested if f not in valid_formats] + if not requested or invalid_formats: + output.error( + f"Invalid --format value '{args.format}'. " + f"Valid formats: {', '.join(valid_formats)}" + ) + sys.exit(2) + # Preserve a stable order and drop duplicates. + report_formats = [f for f in valid_formats if f in requested] + output_dir = args.output_dir or RESULTS_DIR + # Validate argument combinations if args.image_only and args.ai_only: output.error("--image-only and --ai-only cannot be used together (AI analysis requires a Dockerfile)") @@ -192,12 +211,11 @@ def main() -> None: run_compose_analysis = False mode_desc = "Full Analysis (AI + Scanner)" - from docksec.config import RESULTS_DIR from docksec.config_manager import get_config from docksec.enums import LLMProvider output.banner(get_version(), mode_desc) - output.kv("Reports", RESULTS_DIR) + output.kv("Reports", output_dir) if run_scan: output.kv("Severity", severity) if run_ai: @@ -218,7 +236,7 @@ def main() -> None: analyze_security, AnalyzesResponse ) - from docksec.config import docker_agent_prompt, truncate_dockerfile, RESULTS_DIR + from docksec.config import docker_agent_prompt, truncate_dockerfile from pathlib import Path # Set up the same components as main.py @@ -252,7 +270,7 @@ def main() -> None: truncated_content = truncate_dockerfile(filecontent, max_lines=150, max_chars=4000) if run_compose_analysis else truncate_dockerfile(filecontent, max_lines=50, max_chars=2000) response = analyser_chain.invoke({"filecontent": truncated_content}) - ai_findings = analyze_security(response, compact=True, report_path=RESULTS_DIR) + ai_findings = analyze_security(response, compact=True, report_path=output_dir) except ImportError as e: output.error(f"Required modules not found - {e}") @@ -280,7 +298,7 @@ def main() -> None: results = orchestrator.run_full_scan(severity) # We need a scanner instance just for scoring and reporting - scanner = DockerSecurityScanner(None, None, scan_only=not run_ai, skip_ai_scoring=args.skip_ai_scoring) + scanner = DockerSecurityScanner(None, None, results_dir=output_dir, scan_only=not run_ai, skip_ai_scoring=args.skip_ai_scoring) scanner.image_name = "Multiple Services" scanner.dockerfile_path = args.compose else: @@ -289,6 +307,7 @@ def main() -> None: scanner = DockerSecurityScanner( dockerfile_path, args.image, + results_dir=output_dir, scan_only=not run_ai, skip_ai_scoring=args.skip_ai_scoring ) @@ -309,8 +328,8 @@ def main() -> None: if ai_findings: results["ai_findings"] = ai_findings - # Generate all reports - report_paths = scanner.generate_all_reports(results) + # Generate reports (all formats by default, or the requested subset) + report_paths = scanner.generate_all_reports(results, formats=report_formats) # Run advanced scan if available and image is provided (skip for compose) if hasattr(scanner, 'advanced_scan') and args.image and not run_compose_analysis: diff --git a/docksec/docker_scanner.py b/docksec/docker_scanner.py index 01d058c..972fe34 100644 --- a/docksec/docker_scanner.py +++ b/docksec/docker_scanner.py @@ -777,29 +777,31 @@ def run_full_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: return results - def generate_all_reports(self, results: Dict) -> Dict: + def generate_all_reports(self, results: Dict, formats=None) -> Dict: """ - Generate all report formats (JSON, CSV, PDF, HTML) from scan results. - + Generate report formats (JSON, CSV, PDF, HTML) from scan results. + Args: results: The scan results to save - + formats: Optional iterable of formats to write ('json', 'csv', 'pdf', + 'html'). When None, all four formats are written. + Returns: Dictionary with paths to the generated reports """ from docksec.report_generator import ReportGenerator - + # Calculate security score if not already set if self.analysis_score is None: self.analysis_score = self.get_security_score(results) - + # Initialize report generator generator = ReportGenerator(self.image_name or "docksec_report", self.RESULTS_DIR) generator.set_analysis_score(self.analysis_score) - - # Generate all reports using the dedicated generator - report_paths = generator.generate_all_reports(results) - + + # Generate the requested reports using the dedicated generator + report_paths = generator.generate_all_reports(results, formats=formats) + return report_paths def _calculate_local_score(self, results: Dict) -> float: diff --git a/docksec/report_generator.py b/docksec/report_generator.py index 3b30770..2ea1a8d 100644 --- a/docksec/report_generator.py +++ b/docksec/report_generator.py @@ -785,28 +785,31 @@ def _count_by_severity(self, vulnerabilities: List[Dict]) -> Dict[str, int]: severity_counts["UNKNOWN"] += 1 return severity_counts - def generate_all_reports(self, results: Dict) -> Dict[str, str]: + def generate_all_reports(self, results: Dict, formats=None) -> Dict[str, str]: """ - Generate all report formats. + Generate report formats. - Writing four files is effectively instant, so this runs silently and - returns the written paths; the CLI renders a single report summary from - the return value (see docksec.output.report_results). + Writing files is effectively instant, so this runs silently and returns + the written paths; the CLI renders a single report summary from the + return value (see docksec.output.report_results). Args: results: Scan results dictionary + formats: Iterable of formats to write ('json', 'csv', 'pdf', 'html'). + When None, all four formats are written. Returns: - Dictionary mapping format to file path + Dictionary mapping the requested format(s) to their file path """ - logger.info("Generating all report formats") - - report_paths = { - "json": self.generate_json_report(results), - "csv": self.generate_csv_report(results), - "pdf": self.generate_pdf_report(results), - "html": self.generate_html_report(results), + writers = { + "json": self.generate_json_report, + "csv": self.generate_csv_report, + "pdf": self.generate_pdf_report, + "html": self.generate_html_report, } + selected = list(writers) if formats is None else [f for f in writers if f in formats] - logger.info(f"All reports generated: {report_paths}") + logger.info(f"Generating report formats: {', '.join(selected) or 'none'}") + report_paths = {fmt: writers[fmt](results) for fmt in selected} + logger.info(f"Reports generated: {report_paths}") return report_paths diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6fcc4a6..9548937 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--severity` flag to choose which severity levels the image vulnerability scan reports (default `CRITICAL,HIGH`; also settable via `DOCKSEC_DEFAULT_SEVERITY`). Invalid values are rejected with a clear error. - `--fail-on ` flag: exit with code 1 when any finding is at or above the chosen severity (`CRITICAL`, `HIGH`, `MEDIUM`, or `LOW`). The scan severity is auto-widened when needed so the gate can observe those findings. - CI-friendly exit codes: `0` clean, `1` findings at or above `--fail-on`, `2` usage/argument error, `3` tool or runtime error (scan failed, image not found, missing tools). +- `--format` flag to choose which report formats are written (`json`, `csv`, `pdf`, `html`; default: all). Invalid values are rejected with a clear error. +- `--output-dir` flag to write reports to a specific directory for the run (default: `~/.docksec/results` or `DOCKSEC_RESULTS_DIR`). ### Changed - **Cleaner terminal output**: internal logs now write to `stderr` instead of `stdout` and stay quiet in CLI mode, so raw location-tagged log lines no longer interleave with the tool's user-facing messages. Set `DOCKSEC_LOG_LEVEL` to restore verbose logging. diff --git a/tests/test_cli.py b/tests/test_cli.py index 547b968..d251c1c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -196,6 +196,46 @@ def test_severity_flag_threads_to_scanner(self, mock_scanner_class): # 'critical' is normalized to 'CRITICAL' and passed to the image scan. scanner.run_image_only_scan.assert_called_once_with('CRITICAL') + @patch('sys.argv', ['docksec', '--image-only', '-i', 'test:latest', '--format', 'json,xml']) + def test_invalid_format_exits_2(self): + """An unknown --format value is a usage error and exits 2.""" + from docksec.cli import main + + with patch('builtins.print'): + with self.assertRaises(SystemExit) as ctx: + main() + self.assertEqual(ctx.exception.code, 2) + + @patch('sys.argv', ['docksec', '--image-only', '-i', 'test:latest', + '--format', 'html,json', '--output-dir', '/tmp/docksec_test_out']) + @patch('docksec.docker_scanner.DockerSecurityScanner') + def test_format_and_output_dir_thread_to_reports(self, mock_scanner_class): + """--format (normalized/ordered) and --output-dir reach the report call + and the scanner construction.""" + from docksec.cli import main + + scanner = Mock() + mock_scanner_class.return_value = scanner + scanner.run_image_only_scan.return_value = { + 'json_data': [], + 'dockerfile_scan': {'skipped': True}, + 'image_scan': {'skipped': False}, + 'scan_mode': 'image_only', + } + scanner.get_security_score.return_value = 90.0 + scanner.generate_all_reports.return_value = {'json': 'x'} + scanner.RESULTS_DIR = '/tmp/docksec_test_out' + + main() + + # --output-dir is passed to the scanner as results_dir. + _, kwargs = mock_scanner_class.call_args + self.assertEqual(kwargs.get('results_dir'), '/tmp/docksec_test_out') + + # --format is normalized to canonical order json,html and passed through. + _, gen_kwargs = scanner.generate_all_reports.call_args + self.assertEqual(gen_kwargs.get('formats'), ['json', 'html']) + @patch('sys.argv', ['docksec', '--image-only', '-i', 'docksec_missing_img_xyz:latest', '--quiet', '--no-color']) def test_quiet_and_no_color_flags_are_accepted(self): """--quiet and --no-color must parse. Using a missing image makes the run diff --git a/tests/test_docker_scanner.py b/tests/test_docker_scanner.py index 222d32f..54be10c 100644 --- a/tests/test_docker_scanner.py +++ b/tests/test_docker_scanner.py @@ -479,7 +479,8 @@ def test_generate_all_reports(self, mock_report_gen_class): self.assertEqual(report_paths['json'], "report.json") self.assertEqual(report_paths['html'], "report.html") - mock_gen.generate_all_reports.assert_called_once_with(results) + # Default run delegates with formats=None (all formats). + mock_gen.generate_all_reports.assert_called_once_with(results, formats=None) def test_calculate_local_score(self): """Test the local scoring logic.""" diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py index fe0c2e8..9455130 100644 --- a/tests/test_report_generator.py +++ b/tests/test_report_generator.py @@ -254,3 +254,37 @@ def test_html_special_characters_are_escaped(tmp_path): content = f.read() assert "