diff --git a/docksec/cli.py b/docksec/cli.py index 21d48cf..1c5cc5f 100644 --- a/docksec/cli.py +++ b/docksec/cli.py @@ -767,6 +767,9 @@ def _print_json_results(results, scanner, report_paths): if results.get("suppressed_count"): payload["scan_info"]["suppressed_count"] = results["suppressed_count"] payload["scan_info"]["ignore_file"] = results.get("ignore_file") + if results.get("failed_services"): + payload["scan_info"]["failed_services"] = results["failed_services"] + payload["scan_info"]["total_services"] = results.get("total_services") if "ai_findings" in results: payload["ai_analysis"] = results["ai_findings"] if report_paths: @@ -792,6 +795,23 @@ def _render_scan_summary(output, args, scanner, results, report_paths, output.next_command(_suggest_next_command(args, results, run_ai, run_compose_analysis)) +def _failed_service_names(failed_services): + """Return the distinct service names in ``failed_services``, in order. + + A service that fails both its Dockerfile and its image scan is recorded + once per scan, so de-duplicate before counting. + """ + names = [] + for entry in failed_services or []: + name = entry.get("service") if isinstance(entry, dict) else entry + if not name: + continue + name = str(name) + if name not in names: + names.append(name) + return names + + def _quick_take_lines(results, counts, run_ai): """Build a few high-signal lines summarizing what matters most.""" lines = [] @@ -825,6 +845,17 @@ def _quick_take_lines(results, counts, run_ai): if suppressed: lines.append(f"{suppressed} triaged finding(s) suppressed via ignore file") + # A compose service whose scan failed still leaves the run with a score, so + # say so here: otherwise the summary reads as if every service was covered. + failed_names = _failed_service_names(results.get("failed_services")) + if failed_names: + names = ", ".join(failed_names) + total = results.get("total_services") + if isinstance(total, int) and total > 0: + lines.append(f"{len(failed_names)} of {total} services could not be scanned: {names}") + else: + lines.append(f"{len(failed_names)} service(s) could not be scanned: {names}") + if not run_ai and not results.get("ai_findings"): if results.get("scan_mode") == "image_only": lines.append("Add a Dockerfile scan for AI-powered explanations and fixes: docksec -i ") diff --git a/docksec/compose_scanner.py b/docksec/compose_scanner.py index b8c6691..d842f8f 100644 --- a/docksec/compose_scanner.py +++ b/docksec/compose_scanner.py @@ -384,7 +384,8 @@ def run_full_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: 'image_name': "N/A", 'dockerfile_path': self.compose_path, 'scan_mode': 'compose', - 'failed_services': [] + 'failed_services': [], + 'total_services': 0 } compose_findings = self.scanner.scan() @@ -516,5 +517,6 @@ def run_full_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: 'image_name': "Multiple Services", 'dockerfile_path': self.compose_path, 'scan_mode': 'compose', - 'failed_services': failed_services + 'failed_services': failed_services, + 'total_services': len(services) } diff --git a/tests/test_cli.py b/tests/test_cli.py index b78b2a7..0ed37ef 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -415,6 +415,68 @@ def test_quick_take_reports_suppressed_findings(self): lines = _quick_take_lines(results, counts, run_ai=True) self.assertTrue(any("4 triaged finding(s) suppressed" in line for line in lines)) + def test_quick_take_reports_failed_compose_services(self): + from docksec.cli import _quick_take_lines + + results = { + "dockerfile_scan": {"skipped": True}, + "scan_mode": "compose", + "total_services": 3, + "failed_services": [ + {"service": "web", "reason": "Image scan failed"}, + {"service": "db", "reason": "Image scan failed"}, + ], + } + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + lines = _quick_take_lines(results, counts, run_ai=True) + self.assertTrue( + any("2 of 3 services could not be scanned: web, db" in line for line in lines) + ) + + def test_quick_take_counts_a_doubly_failed_service_once(self): + from docksec.cli import _quick_take_lines + + results = { + "dockerfile_scan": {"skipped": True}, + "scan_mode": "compose", + "total_services": 2, + "failed_services": [ + {"service": "web", "reason": "Dockerfile scan failed"}, + {"service": "web", "reason": "Image scan failed"}, + ], + } + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + lines = _quick_take_lines(results, counts, run_ai=True) + self.assertTrue( + any("1 of 2 services could not be scanned: web" in line for line in lines) + ) + + def test_quick_take_reports_failures_without_a_service_total(self): + from docksec.cli import _quick_take_lines + + results = { + "dockerfile_scan": {"skipped": True}, + "failed_services": [{"service": "web", "reason": "boom"}], + } + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + lines = _quick_take_lines(results, counts, run_ai=True) + self.assertTrue( + any("1 service(s) could not be scanned: web" in line for line in lines) + ) + + def test_quick_take_stays_silent_when_every_service_scanned(self): + from docksec.cli import _quick_take_lines + + results = { + "dockerfile_scan": {"skipped": True}, + "scan_mode": "compose", + "total_services": 3, + "failed_services": [], + } + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + lines = _quick_take_lines(results, counts, run_ai=True) + self.assertFalse(any("could not be scanned" in line for line in lines)) + def test_suggest_next_command_recommends_image_scan(self): from docksec.cli import _suggest_next_command diff --git a/tests/test_compose_scanner.py b/tests/test_compose_scanner.py index 54f065c..2ac4792 100644 --- a/tests/test_compose_scanner.py +++ b/tests/test_compose_scanner.py @@ -173,4 +173,47 @@ def test_compose_orchestrator_offline(valid_compose_file, mocker): assert results['scan_mode'] == 'compose' assert results['dockerfile_scan']['success'] is True assert results['image_scan']['success'] is True - assert results["failed_services"] == [] \ No newline at end of file + assert results["failed_services"] == [] + +def test_compose_orchestrator_reports_total_services(valid_compose_file, mocker): + mock_scanner = mocker.patch('docksec.compose_scanner.DockerSecurityScanner') + mock_instance = mock_scanner.return_value + mock_instance.run_image_only_scan.return_value = { + 'image_scan': {'success': True, 'output': 'Mock output'}, + 'json_data': [] + } + + orchestrator = ComposeOrchestrator(valid_compose_file, scan_only=True) + results = orchestrator.run_full_scan() + + # The quick take renders "N of M services could not be scanned", so the + # denominator has to travel with failed_services. + assert results['total_services'] == 2 + assert results['failed_services'] == [] + + +def test_compose_orchestrator_records_failed_services(valid_compose_file, mocker): + mock_scanner = mocker.patch('docksec.compose_scanner.DockerSecurityScanner') + mock_instance = mock_scanner.return_value + mock_instance.run_image_only_scan.return_value = { + 'image_scan': {'success': False, 'output': 'image not found locally'}, + 'json_data': [] + } + + orchestrator = ComposeOrchestrator(valid_compose_file, scan_only=True) + results = orchestrator.run_full_scan() + + assert results['total_services'] == 2 + failed = {entry['service'] for entry in results['failed_services']} + assert failed == {'web', 'db'} + + +def test_compose_orchestrator_unparseable_file_reports_zero_services(tmp_path): + bad = tmp_path / "docker-compose.yml" + bad.write_text("services: [this is not a mapping") + + orchestrator = ComposeOrchestrator(str(bad), scan_only=True) + results = orchestrator.run_full_scan() + + assert results['failed_services'] == [] + assert results['total_services'] == 0