Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 26 additions & 7 deletions docksec/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}')
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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:
Expand All @@ -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
)
Expand All @@ -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:
Expand Down
22 changes: 12 additions & 10 deletions docksec/docker_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 17 additions & 14 deletions docksec/report_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <severity>` 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.
Expand Down
40 changes: 40 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/test_docker_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
34 changes: 34 additions & 0 deletions tests/test_report_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,37 @@ def test_html_special_characters_are_escaped(tmp_path):
content = f.read()
assert "<script>" not in content
assert "&lt;script&gt;" in content


# ---------- FORMAT SELECTION TESTS ----------


def test_generate_all_reports_default_writes_all_formats(
tmp_path, sample_vulnerabilities, sample_scan_info
):
rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path))
results = make_results(sample_vulnerabilities, sample_scan_info)
paths = rg.generate_all_reports(results)
assert set(paths) == {"json", "csv", "pdf", "html"}
for path in paths.values():
assert os.path.exists(path)


def test_generate_all_reports_writes_only_requested_formats(
tmp_path, sample_vulnerabilities, sample_scan_info
):
rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path))
results = make_results(sample_vulnerabilities, sample_scan_info)
paths = rg.generate_all_reports(results, formats=["json", "html"])
assert set(paths) == {"json", "html"}
# The unrequested formats must not be written to disk.
written = {p.rsplit(".", 1)[-1] for p in os.listdir(tmp_path)}
assert "csv" not in written
assert "pdf" not in written


def test_generate_all_reports_empty_formats_writes_nothing(tmp_path, sample_scan_info):
rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path))
results = make_results([], sample_scan_info)
paths = rg.generate_all_reports(results, formats=[])
assert paths == {}
Loading