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
29 changes: 10 additions & 19 deletions docksec/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,12 @@ def parse_hadolint_json(raw: str, target: str) -> List[Dict]:


def parse_trivy_config_json(
raw: str, target: str, target_basename: Optional[str] = None
raw: str, target: str
) -> List[Dict]:
"""Convert ``trivy config -f json`` output into structured findings.

``trivy config`` scans a directory, so its output can include other config
files that happen to sit beside the Dockerfile. ``target_basename`` limits
the results to the file actually being scanned.
Trivy is invoked with the specific Dockerfile path, so its output is expected
to contain findings for that Dockerfile only.
"""
if not raw or not raw.strip():
return []
Expand All @@ -179,10 +178,6 @@ def parse_trivy_config_json(

findings = []
for result in (payload or {}).get("Results") or []:
if target_basename:
result_target = str(result.get("Target") or "")
if os.path.basename(result_target) != target_basename:
continue
for misconfig in result.get("Misconfigurations") or []:
rule_id = normalize_rule_id(
misconfig.get("ID") or misconfig.get("AVDID") or ""
Expand Down Expand Up @@ -266,24 +261,20 @@ def run_hadolint(dockerfile_path: str) -> Tuple[bool, List[Dict], Optional[str]]


def run_trivy_config(dockerfile_path: str, offline: bool = False) -> Tuple[bool, List[Dict], Optional[str]]:
"""Run ``trivy config`` against the Dockerfile's directory.
"""Run ``trivy config`` against the specified Dockerfile.

``trivy config`` only accepts a directory - given a file it prints its usage
text and exits 0, which parses as "no findings" rather than an error. The
directory is scanned and the results are then filtered back down to the
target file. ``--file-patterns`` narrows the scan so an unusually named
Dockerfile (``Dockerfile.prod``) is still analyzed as one.
Trivy accepts a Dockerfile path directly, so scanning the parent directory
is unnecessary and can cause findings from nested Dockerfiles with the same
basename to be attributed to the requested Dockerfile.
"""
directory = os.path.dirname(os.path.abspath(dockerfile_path)) or "."
basename = os.path.basename(dockerfile_path)
dockerfile_path = os.path.abspath(dockerfile_path)

cmd = [
"trivy", "config", "-f", "json", "--quiet", "--skip-version-check",
"--file-patterns", f"dockerfile:{basename}",
]
if offline:
cmd.append("--skip-check-update")
cmd.append(directory)
cmd.append(dockerfile_path)

try:
result = subprocess.run(
Expand All @@ -309,7 +300,7 @@ def run_trivy_config(dockerfile_path: str, offline: bool = False) -> Tuple[bool,
if not stdout.startswith("{"):
return False, [], "Trivy config scan returned unexpected output (not JSON)"

return True, parse_trivy_config_json(stdout, dockerfile_path, basename), None
return True, parse_trivy_config_json(stdout, dockerfile_path), None


def scan_dockerfile_findings(
Expand Down
49 changes: 39 additions & 10 deletions tests/test_findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@

import json
import unittest
from unittest.mock import patch

from docksec.findings import (
HADOLINT_SEVERITY,
HADOLINT_TRIVY_COLLISIONS,
deduplicate,
parse_hadolint_json,
parse_trivy_config_json,
run_trivy_config,
summarize,
)

Expand Down Expand Up @@ -87,22 +89,49 @@ def test_missing_line_is_none_not_zero(self):
raw = _trivy_result([_misconfig("DS026", "LOW")])
self.assertIsNone(parse_trivy_config_json(raw, "Dockerfile")[0]["Line"])

def test_results_for_other_files_are_filtered_out(self):
"""trivy config scans a directory, so it can report on files beside the
Dockerfile."""
payload = json.dumps({"Results": [
{"Target": "Dockerfile", "Misconfigurations": [_misconfig("DS002")]},
{"Target": "other.yaml", "Misconfigurations": [_misconfig("DS999")]},
]})
findings = parse_trivy_config_json(payload, "Dockerfile", target_basename="Dockerfile")
self.assertEqual([f["VulnerabilityID"] for f in findings], ["DS002"])

def test_malformed_input_is_survivable(self):
for raw in ("", "not json", "{}", '{"Results": null}'):
with self.subTest(raw=raw):
self.assertEqual(parse_trivy_config_json(raw, "Dockerfile"), [])


class TestTrivyConfigExecution(unittest.TestCase):
def test_scans_exact_dockerfile_path(self):
"""Trivy must receive the Dockerfile path, not its parent directory."""

raw = _trivy_result([_misconfig("DS002", "HIGH", 7)], target="/repo/Dockerfile")

with patch("docksec.findings.subprocess.run") as mock_run:
mock_run.return_value.stdout = raw
mock_run.return_value.stderr = ""
mock_run.return_value.returncode = 0

ok, findings, error = run_trivy_config("/repo/Dockerfile")

self.assertTrue(ok)
self.assertIsNone(error)
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["VulnerabilityID"], "DS002")

command = mock_run.call_args.args[0]

self.assertEqual(command, ["trivy", "config", "-f", "json", "--quiet", "--skip-version-check", "/repo/Dockerfile"])

def test_offline_mode_adds_skip_check_update(self):

with patch("docksec.findings.subprocess.run") as mock_run:
mock_run.return_value.stdout = "{}"
mock_run.return_value.stderr = ""
mock_run.return_value.returncode = 0

run_trivy_config("/repo/Dockerfile", offline=True)

command = mock_run.call_args.args[0]

self.assertIn("--skip-check-update", command)
self.assertEqual(command[-1], "/repo/Dockerfile")


class TestDeduplication(unittest.TestCase):
"""Both scanners run because neither is a superset; where they overlap the
Trivy finding wins, since its IDs are stable and its severities are real."""
Expand Down
Loading