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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ 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

# Print results as JSON to stdout for scripts and CI pipelines
docksec -i myapp:latest --image-only --json

# Reduce output to warnings, errors, and the result summary
docksec Dockerfile --scan-only --quiet

Expand All @@ -123,6 +126,20 @@ Every scan ends with a result summary: a severity table, the security score with
rating, a "Quick take" action block, the generated reports, and a suggested next
command. Use `--quiet` for a compact result and `--no-color` for plain output.

### Machine-readable output

`--json` prints a single JSON object to stdout (scan info, vulnerabilities, severity
counts, and any AI findings) instead of the human-readable summary, so it can be piped
straight into other tools:

```bash
docksec -i myapp:latest --image-only --json | jq '.severity_counts'
```

With `--json` alone, no report files are written; combine it with `--format` to write
files and print JSON in the same run. All human-readable messages (info, warnings,
errors) move to stderr in `--json` mode, so stdout only ever contains the JSON payload.

### Exit codes

DockSec uses CI-friendly exit codes so builds and shells can react to results:
Expand Down
54 changes: 49 additions & 5 deletions docksec/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def main() -> None:
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('--json', dest='json_stdout', action='store_true', help='Print scan results as JSON to stdout (no report files unless --format is also given)')
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 All @@ -69,7 +70,7 @@ def main() -> None:
# Every Rich console (including the AI-findings console in utils) honors
# NO_COLOR, so set it before those modules are imported.
os.environ["NO_COLOR"] = "1"
output.configure(quiet=args.quiet, no_color=no_color)
output.configure(quiet=args.quiet, no_color=no_color, json_mode=args.json_stdout)

# Set provider and model from CLI args if provided (overrides env vars)
if args.provider:
Expand Down Expand Up @@ -130,6 +131,11 @@ def main() -> None:
sys.exit(2)
# Preserve a stable order and drop duplicates.
report_formats = [f for f in valid_formats if f in requested]
elif args.json_stdout:
# --json alone means "just print JSON to stdout" - it does not also
# write the report file bundle unless the user explicitly asks via
# --format. Passing an empty list writes nothing.
report_formats = []
output_dir = args.output_dir or RESULTS_DIR

# Validate argument combinations
Expand Down Expand Up @@ -331,14 +337,20 @@ def main() -> None:
# 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:
# Run advanced scan if available and image is provided (skip for compose
# and for --json, since Docker Scout output is not part of the payload
# and would otherwise print to stdout alongside it)
if hasattr(scanner, 'advanced_scan') and args.image and not run_compose_analysis \
and not args.json_stdout:
output.section("Advanced scan (Docker Scout)")
scanner.advanced_scan()

scan_ok = True
_render_scan_summary(output, args, scanner, results, report_paths,
run_ai, run_compose_analysis)
if args.json_stdout:
_print_json_results(results, scanner, report_paths)
else:
_render_scan_summary(output, args, scanner, results, report_paths,
run_ai, run_compose_analysis)

# --fail-on gate: flag findings at or above the chosen threshold.
if args.fail_on:
Expand Down Expand Up @@ -374,6 +386,38 @@ def main() -> None:
sys.exit(1)


def _print_json_results(results, scanner, report_paths):
"""Print scan results as a single JSON object to stdout.

Mirrors the shape ReportGenerator.generate_json_report writes to disk, so
--json and the JSON report file stay consistent. stdout carries only this
payload; all human-readable output goes to stderr in --json mode (see
docksec.output.configure).
"""
import json as json_module

from docksec import output

vulnerabilities = results.get("json_data", [])
payload = {
"scan_info": {
"image": scanner.image_name,
"dockerfile": results.get("dockerfile_path", "N/A"),
"scan_time": results.get("timestamp", ""),
"analysis_score": getattr(scanner, "analysis_score", None),
"scan_mode": results.get("scan_mode", "full"),
},
"vulnerabilities": vulnerabilities,
"severity_counts": output.count_by_severity(vulnerabilities),
}
if "ai_findings" in results:
payload["ai_analysis"] = results["ai_findings"]
if report_paths:
payload["report_files"] = {fmt: path for fmt, path in report_paths.items() if path}

print(json_module.dumps(payload, indent=2))


def _render_scan_summary(output, args, scanner, results, report_paths,
run_ai, run_compose_analysis):
"""Render the consolidated result summary: severity table, score, a Quick
Expand Down
24 changes: 19 additions & 5 deletions docksec/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
box-drawing table borders only.
"""

import sys
from typing import Dict, Iterable, List, Optional

from rich.box import SQUARE
Expand All @@ -25,7 +26,7 @@
from rich.text import Text

# Module-level state configured once by the CLI.
_state = {"quiet": False, "no_color": False}
_state = {"quiet": False, "no_color": False, "json_mode": False}
_console: Optional[Console] = None

# Severity display order and colors used across the summary.
Expand All @@ -37,22 +38,35 @@
]


def configure(quiet: bool = False, no_color: bool = False) -> None:
"""Configure the output layer. Called once, early, by the CLI."""
def configure(quiet: bool = False, no_color: bool = False, json_mode: bool = False) -> None:
"""Configure the output layer. Called once, early, by the CLI.

json_mode reserves stdout for a single machine-readable JSON payload
(see --json). All human-readable output (banner, sections, info, warn,
error, the result summary) is redirected to stderr instead, so scripts
piping stdout never see anything but the JSON.
"""
_state["quiet"] = quiet
_state["no_color"] = no_color
_state["json_mode"] = json_mode
global _console
_console = Console(no_color=no_color, highlight=False)
stream = sys.stderr if json_mode else sys.stdout
_console = Console(file=stream, no_color=no_color, highlight=False)


def get_console() -> Console:
"""Return the shared console, creating a default one if needed."""
global _console
if _console is None:
_console = Console(no_color=_state["no_color"], highlight=False)
stream = sys.stderr if _state["json_mode"] else sys.stdout
_console = Console(file=stream, no_color=_state["no_color"], highlight=False)
return _console


def is_json_mode() -> bool:
return bool(_state["json_mode"])


def _line(renderable) -> None:
"""Print a single line without hard-wrapping (long paths stay intact)."""
get_console().print(renderable, soft_wrap=True)
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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`).
- `--json` flag: print scan results as a single JSON object to stdout for scripts and CI pipelines. All human-readable output (banner, sections, info/warn/error, the result summary) moves to stderr in `--json` mode, so stdout carries only the JSON payload. `--json` alone does not write report files; combine with `--format` to also write files.

### 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
127 changes: 127 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,5 +389,132 @@ def test_no_fail_on_never_gates(self):
self.assertEqual(self._run_image_only_with(["CRITICAL"], None), 0)


class TestJsonOutput(unittest.TestCase):
"""Test cases for --json stdout output."""

def test_print_json_results_shape(self):
import json as json_module
from docksec.cli import _print_json_results

scanner = Mock()
scanner.image_name = "myapp:latest"
scanner.analysis_score = 82.5
results = {
"json_data": [{"Severity": "HIGH"}, {"Severity": "LOW"}],
"dockerfile_path": "Dockerfile",
"timestamp": "2026-01-01 00:00:00",
"scan_mode": "full",
}

printed = []
with patch('builtins.print', side_effect=lambda x: printed.append(x)):
_print_json_results(results, scanner, report_paths={})

self.assertEqual(len(printed), 1)
payload = json_module.loads(printed[0])
self.assertEqual(payload["scan_info"]["image"], "myapp:latest")
self.assertEqual(payload["scan_info"]["analysis_score"], 82.5)
self.assertEqual(len(payload["vulnerabilities"]), 2)
self.assertEqual(payload["severity_counts"]["HIGH"], 1)
self.assertNotIn("report_files", payload)

def test_print_json_results_includes_report_files_when_written(self):
import json as json_module
from docksec.cli import _print_json_results

scanner = Mock()
scanner.image_name = "myapp:latest"
scanner.analysis_score = 90
results = {"json_data": [], "scan_mode": "full"}
report_paths = {"json": "/tmp/x.json", "csv": ""}

printed = []
with patch('builtins.print', side_effect=lambda x: printed.append(x)):
_print_json_results(results, scanner, report_paths)

payload = json_module.loads(printed[0])
# Only non-empty paths are included.
self.assertEqual(payload["report_files"], {"json": "/tmp/x.json"})

def test_print_json_results_includes_ai_findings(self):
import json as json_module
from docksec.cli import _print_json_results

scanner = Mock()
scanner.image_name = "myapp:latest"
scanner.analysis_score = 90
results = {
"json_data": [],
"scan_mode": "full",
"ai_findings": {"vulnerabilities": ["hardcoded secret"]},
}

printed = []
with patch('builtins.print', side_effect=lambda x: printed.append(x)):
_print_json_results(results, scanner, report_paths={})

payload = json_module.loads(printed[0])
self.assertEqual(payload["ai_analysis"]["vulnerabilities"], ["hardcoded secret"])

def _run_image_only_json(self, extra_argv=None, findings=None):
"""Run main() image-only with --json, capturing stdout prints."""
from docksec.cli import main

argv = ['docksec', '--image-only', '-i', 'test:latest', '--json'] + (extra_argv or [])
with patch('sys.argv', argv), \
patch('docksec.docker_scanner.DockerSecurityScanner') as cls:
scanner = Mock()
cls.return_value = scanner
scanner.image_name = "test:latest"
scanner.run_image_only_scan.return_value = {
'json_data': [{"Severity": s} for s in (findings or [])],
'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 = {}
scanner.RESULTS_DIR = '/tmp'

printed = []
code = 0
with patch('builtins.print', side_effect=lambda x='': printed.append(x)):
try:
main()
except SystemExit as e:
code = e.code
return code, printed, scanner

def test_json_flag_prints_exactly_one_json_object(self):
import json as json_module

code, printed, _ = self._run_image_only_json(findings=["CRITICAL"])
self.assertEqual(code, 0)
self.assertEqual(len(printed), 1)
json_module.loads(printed[0]) # must not raise

def test_json_flag_without_format_writes_no_reports(self):
_, _, scanner = self._run_image_only_json()
scanner.generate_all_reports.assert_called_once_with({
'json_data': [],
'dockerfile_scan': {'skipped': True},
'image_scan': {'skipped': False},
'scan_mode': 'image_only',
}, formats=[])

def test_json_flag_with_format_passes_requested_formats(self):
_, _, scanner = self._run_image_only_json(extra_argv=['--format', 'json'])
_, kwargs = scanner.generate_all_reports.call_args
self.assertEqual(kwargs.get('formats'), ['json'])

def test_json_flag_respects_fail_on_exit_code(self):
code, printed, _ = self._run_image_only_json(
extra_argv=['--fail-on', 'critical'], findings=["CRITICAL"]
)
self.assertEqual(code, 1)
# stdout still carries exactly the JSON payload, nothing else.
self.assertEqual(len(printed), 1)


if __name__ == '__main__':
unittest.main()
Loading