Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docksec/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 = []
Expand Down Expand Up @@ -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 <Dockerfile> -i <image>")
Expand Down
6 changes: 4 additions & 2 deletions docksec/compose_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
62 changes: 62 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 44 additions & 1 deletion tests/test_compose_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] == []
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