dockerdna 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dockerdna/__init__.py +4 -0
- dockerdna/ai/__init__.py +0 -0
- dockerdna/ai/remediation.py +142 -0
- dockerdna/cli.py +182 -0
- dockerdna/reports/__init__.py +0 -0
- dockerdna/reports/html.py +265 -0
- dockerdna/reports/json_report.py +71 -0
- dockerdna/reports/sarif.py +139 -0
- dockerdna/reports/sbom.py +176 -0
- dockerdna/scanner.py +234 -0
- dockerdna/scanners/__init__.py +0 -0
- dockerdna/scanners/compliance.py +149 -0
- dockerdna/scanners/compose.py +276 -0
- dockerdna/scanners/dockerfile.py +343 -0
- dockerdna/scanners/secrets.py +209 -0
- dockerdna/scanners/supply_chain.py +189 -0
- dockerdna/utils/__init__.py +0 -0
- dockerdna/utils/patterns.py +422 -0
- dockerdna-1.0.2.dist-info/METADATA +291 -0
- dockerdna-1.0.2.dist-info/RECORD +24 -0
- dockerdna-1.0.2.dist-info/WHEEL +5 -0
- dockerdna-1.0.2.dist-info/entry_points.txt +2 -0
- dockerdna-1.0.2.dist-info/licenses/LICENSE +21 -0
- dockerdna-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SARIF 2.1.0 output for GitHub Advanced Security integration.
|
|
3
|
+
|
|
4
|
+
DockerDNA ships with full SARIF support out of the box, enabling
|
|
5
|
+
GitHub Security tab annotations on pull requests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_TOOL = {
|
|
15
|
+
"driver": {
|
|
16
|
+
"name": "DockerDNA",
|
|
17
|
+
"version": "1.0.2",
|
|
18
|
+
"informationUri": "https://github.com/sunilgentyala/DockerDNA",
|
|
19
|
+
"rules": [],
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_SEVERITY_MAP = {
|
|
24
|
+
"CRITICAL": "error",
|
|
25
|
+
"HIGH": "error",
|
|
26
|
+
"MEDIUM": "warning",
|
|
27
|
+
"LOW": "note",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generate_sarif(
|
|
32
|
+
dockerfile_findings: list[Any],
|
|
33
|
+
compose_findings: list[Any],
|
|
34
|
+
secret_findings: list[Any],
|
|
35
|
+
supply_chain_findings: list[Any],
|
|
36
|
+
base_path: str = "",
|
|
37
|
+
) -> dict:
|
|
38
|
+
rules: dict[str, dict] = {}
|
|
39
|
+
results: list[dict] = []
|
|
40
|
+
|
|
41
|
+
def _add_rule(rule_id: str, name: str, description: str, severity: str):
|
|
42
|
+
if rule_id not in rules:
|
|
43
|
+
rules[rule_id] = {
|
|
44
|
+
"id": rule_id,
|
|
45
|
+
"name": name,
|
|
46
|
+
"shortDescription": {"text": name},
|
|
47
|
+
"fullDescription": {"text": description},
|
|
48
|
+
"defaultConfiguration": {
|
|
49
|
+
"level": _SEVERITY_MAP.get(severity, "warning")
|
|
50
|
+
},
|
|
51
|
+
"helpUri": f"https://github.com/sunilgentyala/DockerDNA/wiki/{rule_id}",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def _add_result(
|
|
55
|
+
rule_id: str, message: str, filepath: str, line: int, severity: str
|
|
56
|
+
):
|
|
57
|
+
uri = Path(filepath).as_posix() if filepath else "unknown"
|
|
58
|
+
if base_path:
|
|
59
|
+
try:
|
|
60
|
+
uri = Path(filepath).relative_to(base_path).as_posix()
|
|
61
|
+
except ValueError:
|
|
62
|
+
pass
|
|
63
|
+
results.append(
|
|
64
|
+
{
|
|
65
|
+
"ruleId": rule_id,
|
|
66
|
+
"level": _SEVERITY_MAP.get(severity, "warning"),
|
|
67
|
+
"message": {"text": message},
|
|
68
|
+
"locations": [
|
|
69
|
+
{
|
|
70
|
+
"physicalLocation": {
|
|
71
|
+
"artifactLocation": {"uri": uri, "uriBaseId": "%SRCROOT%"},
|
|
72
|
+
"region": {"startLine": max(1, line)},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Dockerfile findings
|
|
80
|
+
for f in dockerfile_findings:
|
|
81
|
+
_add_rule(f.cis_id, f.title, f.remediation, f.severity)
|
|
82
|
+
_add_result(f.cis_id, f.detail, "Dockerfile", f.line_number, f.severity)
|
|
83
|
+
|
|
84
|
+
# Compose findings
|
|
85
|
+
for f in compose_findings:
|
|
86
|
+
_add_rule(f.cis_id, f.title, f.remediation, f.severity)
|
|
87
|
+
_add_result(
|
|
88
|
+
f.cis_id,
|
|
89
|
+
f"{f.service}: {f.detail}",
|
|
90
|
+
"docker-compose.yml",
|
|
91
|
+
f.line_hint or 1,
|
|
92
|
+
f.severity,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Secret findings
|
|
96
|
+
for f in secret_findings:
|
|
97
|
+
_add_rule(f.cis_id, f"Secret: {f.secret_type}", f.remediation, f.severity)
|
|
98
|
+
_add_result(
|
|
99
|
+
f.cis_id,
|
|
100
|
+
f"{f.secret_type} detected (method: {f.detection_method})",
|
|
101
|
+
f.file,
|
|
102
|
+
f.line_number,
|
|
103
|
+
f.severity,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Supply chain findings
|
|
107
|
+
for f in supply_chain_findings:
|
|
108
|
+
if f.risk_score >= 15:
|
|
109
|
+
rule_id = f"{f.cis_id}-SUPPLY-CHAIN"
|
|
110
|
+
_add_rule(
|
|
111
|
+
rule_id, f"Supply chain risk: {f.image}", f.remediation, f.severity
|
|
112
|
+
)
|
|
113
|
+
_add_result(
|
|
114
|
+
rule_id,
|
|
115
|
+
f"Risk score {f.risk_score}/100 — {'; '.join(f.factors)}",
|
|
116
|
+
"Dockerfile",
|
|
117
|
+
1,
|
|
118
|
+
f.severity,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
tool = dict(_TOOL)
|
|
122
|
+
tool["driver"]["rules"] = list(rules.values())
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
126
|
+
"version": "2.1.0",
|
|
127
|
+
"runs": [
|
|
128
|
+
{
|
|
129
|
+
"tool": tool,
|
|
130
|
+
"results": results,
|
|
131
|
+
"invocations": [
|
|
132
|
+
{
|
|
133
|
+
"executionSuccessful": True,
|
|
134
|
+
"endTimeUtc": datetime.now(timezone.utc).isoformat(),
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
}
|
|
138
|
+
],
|
|
139
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CycloneDX SBOM (Software Bill of Materials) generator.
|
|
3
|
+
|
|
4
|
+
Parses Dockerfile package install instructions to produce a
|
|
5
|
+
CycloneDX 1.5 JSON SBOM documenting what is installed in each layer,
|
|
6
|
+
enabling supply-chain transparency and NTIA minimum-elements compliance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Component:
|
|
20
|
+
name: str
|
|
21
|
+
version: str
|
|
22
|
+
pkg_type: str # "deb" | "rpm" | "apk" | "pip" | "npm" | "gem" | "unknown"
|
|
23
|
+
source_layer: int
|
|
24
|
+
stage: str
|
|
25
|
+
purl: str = ""
|
|
26
|
+
|
|
27
|
+
def to_cyclonedx(self) -> dict:
|
|
28
|
+
comp: dict = {
|
|
29
|
+
"type": "library",
|
|
30
|
+
"name": self.name,
|
|
31
|
+
"version": self.version or "unknown",
|
|
32
|
+
"properties": [
|
|
33
|
+
{"name": "dockerdna:layer", "value": str(self.source_layer)},
|
|
34
|
+
{"name": "dockerdna:stage", "value": self.stage},
|
|
35
|
+
{"name": "dockerdna:pkg_type", "value": self.pkg_type},
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
if self.purl:
|
|
39
|
+
comp["purl"] = self.purl
|
|
40
|
+
return comp
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Package manager install patterns
|
|
44
|
+
_INSTALLERS: list[tuple[str, str, re.Pattern]] = [
|
|
45
|
+
(
|
|
46
|
+
"deb",
|
|
47
|
+
"apt",
|
|
48
|
+
re.compile(
|
|
49
|
+
r"apt(?:-get)?\s+install\s+(?:-[^\s]+\s+)*(.+?)(?:\s*&&|\s*$|\s*\\)",
|
|
50
|
+
re.DOTALL,
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
"apk",
|
|
55
|
+
"apk",
|
|
56
|
+
re.compile(r"apk\s+add\s+(?:--[^\s]+\s+)*(.+?)(?:\s*&&|\s*$|\s*\\)", re.DOTALL),
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
"rpm",
|
|
60
|
+
"yum",
|
|
61
|
+
re.compile(
|
|
62
|
+
r"yum\s+install\s+(?:-[^\s]+\s+)*(.+?)(?:\s*&&|\s*$|\s*\\)", re.DOTALL
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
"rpm",
|
|
67
|
+
"dnf",
|
|
68
|
+
re.compile(
|
|
69
|
+
r"dnf\s+install\s+(?:-[^\s]+\s+)*(.+?)(?:\s*&&|\s*$|\s*\\)", re.DOTALL
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
(
|
|
73
|
+
"pip",
|
|
74
|
+
"pip",
|
|
75
|
+
re.compile(
|
|
76
|
+
r"pip(?:3)?\s+install\s+(?:--[^\s]+\s+)*(.+?)(?:\s*&&|\s*$|\s*\\)",
|
|
77
|
+
re.DOTALL,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"npm",
|
|
82
|
+
"npm",
|
|
83
|
+
re.compile(
|
|
84
|
+
r"npm\s+install\s+(?:-[^\s]+\s+)*(.+?)(?:\s*&&|\s*$|\s*\\)", re.DOTALL
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
(
|
|
88
|
+
"gem",
|
|
89
|
+
"gem",
|
|
90
|
+
re.compile(
|
|
91
|
+
r"gem\s+install\s+(?:--[^\s]+\s+)*(.+?)(?:\s*&&|\s*$|\s*\\)", re.DOTALL
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
_PKG_TOKEN = re.compile(r"[A-Za-z0-9_\-\.]+(?:=[^\s]+)?")
|
|
97
|
+
_VERSION_SEP = re.compile(r"[=><~!]+")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_pkg(token: str, pkg_type: str) -> tuple[str, str]:
|
|
101
|
+
"""Split 'pkg=1.2.3' into (name, version)."""
|
|
102
|
+
parts = _VERSION_SEP.split(token, maxsplit=1)
|
|
103
|
+
name = parts[0].strip()
|
|
104
|
+
version = parts[1].strip() if len(parts) > 1 else ""
|
|
105
|
+
return name, version
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _make_purl(pkg_type: str, name: str, version: str) -> str:
|
|
109
|
+
if not name:
|
|
110
|
+
return ""
|
|
111
|
+
v = f"@{version}" if version else ""
|
|
112
|
+
return f"pkg:{pkg_type}/{name}{v}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def extract_components(layers: list[Any]) -> list[Component]:
|
|
116
|
+
components: list[Component] = []
|
|
117
|
+
for layer in layers:
|
|
118
|
+
if layer.instruction != "RUN":
|
|
119
|
+
continue
|
|
120
|
+
args = layer.arguments
|
|
121
|
+
for pkg_type, _, pattern in _INSTALLERS:
|
|
122
|
+
for match in pattern.finditer(args):
|
|
123
|
+
raw_pkgs = match.group(1)
|
|
124
|
+
for token in _PKG_TOKEN.findall(raw_pkgs):
|
|
125
|
+
if token.startswith("-"):
|
|
126
|
+
continue
|
|
127
|
+
name, version = _parse_pkg(token, pkg_type)
|
|
128
|
+
if len(name) < 2:
|
|
129
|
+
continue
|
|
130
|
+
components.append(
|
|
131
|
+
Component(
|
|
132
|
+
name=name,
|
|
133
|
+
version=version,
|
|
134
|
+
pkg_type=pkg_type,
|
|
135
|
+
source_layer=layer.index,
|
|
136
|
+
stage=layer.stage,
|
|
137
|
+
purl=_make_purl(pkg_type, name, version),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return components
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def generate_cyclonedx(
|
|
144
|
+
components: list[Component],
|
|
145
|
+
image_name: str = "unknown",
|
|
146
|
+
dockerfile_path: str = "Dockerfile",
|
|
147
|
+
) -> dict:
|
|
148
|
+
"""Return a CycloneDX 1.5 BOM as a Python dict (serialise to JSON)."""
|
|
149
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
150
|
+
bom_ref = str(uuid.uuid4())
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"bomFormat": "CycloneDX",
|
|
154
|
+
"specVersion": "1.5",
|
|
155
|
+
"serialNumber": f"urn:uuid:{bom_ref}",
|
|
156
|
+
"version": 1,
|
|
157
|
+
"metadata": {
|
|
158
|
+
"timestamp": now,
|
|
159
|
+
"tools": [
|
|
160
|
+
{
|
|
161
|
+
"vendor": "DockerDNA",
|
|
162
|
+
"name": "DockerDNA SBOM Generator",
|
|
163
|
+
"version": "1.0.2",
|
|
164
|
+
}
|
|
165
|
+
],
|
|
166
|
+
"component": {
|
|
167
|
+
"type": "container",
|
|
168
|
+
"name": image_name,
|
|
169
|
+
"version": "unknown",
|
|
170
|
+
"properties": [
|
|
171
|
+
{"name": "dockerdna:dockerfile", "value": dockerfile_path}
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
"components": [c.to_cyclonedx() for c in components],
|
|
176
|
+
}
|
dockerdna/scanner.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DockerDNA main orchestrator.
|
|
3
|
+
|
|
4
|
+
Runs all sub-scanners and assembles a unified report.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from dockerdna.reports.html import generate_html
|
|
15
|
+
from dockerdna.reports.json_report import generate_json_report
|
|
16
|
+
from dockerdna.reports.sarif import generate_sarif
|
|
17
|
+
from dockerdna.reports.sbom import extract_components, generate_cyclonedx
|
|
18
|
+
from dockerdna.scanners.compliance import ComplianceMapper
|
|
19
|
+
from dockerdna.scanners.compose import ComposeScanner
|
|
20
|
+
from dockerdna.scanners.dockerfile import DockerfileScanner
|
|
21
|
+
from dockerdna.scanners.secrets import SecretsScanner
|
|
22
|
+
from dockerdna.scanners.supply_chain import SupplyChainScanner
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def scan(
|
|
26
|
+
dockerfile: Optional[str] = None,
|
|
27
|
+
compose_file: Optional[str] = None,
|
|
28
|
+
extra_files: Optional[list[str]] = None,
|
|
29
|
+
scan_directory: Optional[str] = None,
|
|
30
|
+
ai_remediation: bool = False,
|
|
31
|
+
ai_model: str = "claude-sonnet-4-6",
|
|
32
|
+
output_dir: str = "dockerdna-results",
|
|
33
|
+
formats: list[str] | None = None,
|
|
34
|
+
threshold: Optional[str] = None, # CRITICAL | HIGH | MEDIUM | LOW
|
|
35
|
+
redact_secrets: bool = True,
|
|
36
|
+
verbose: bool = False,
|
|
37
|
+
) -> dict:
|
|
38
|
+
"""
|
|
39
|
+
Run all DockerDNA scanners and write reports.
|
|
40
|
+
|
|
41
|
+
Returns the full report dict. Raises SystemExit with code 1 if
|
|
42
|
+
``threshold`` is set and findings at that severity or above are found.
|
|
43
|
+
"""
|
|
44
|
+
if formats is None:
|
|
45
|
+
formats = ["json", "html"]
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ #
|
|
48
|
+
# Auto-discover files
|
|
49
|
+
# ------------------------------------------------------------------ #
|
|
50
|
+
search_roots: list[Path] = []
|
|
51
|
+
if scan_directory:
|
|
52
|
+
search_roots.append(Path(scan_directory))
|
|
53
|
+
if dockerfile:
|
|
54
|
+
search_roots.append(Path(dockerfile).parent)
|
|
55
|
+
|
|
56
|
+
if dockerfile is None and scan_directory:
|
|
57
|
+
for candidate in ["Dockerfile", "dockerfile"]:
|
|
58
|
+
p = Path(scan_directory) / candidate
|
|
59
|
+
if p.exists():
|
|
60
|
+
dockerfile = str(p)
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
if compose_file is None and scan_directory:
|
|
64
|
+
for candidate in [
|
|
65
|
+
"docker-compose.yml",
|
|
66
|
+
"docker-compose.yaml",
|
|
67
|
+
"compose.yml",
|
|
68
|
+
"compose.yaml",
|
|
69
|
+
]:
|
|
70
|
+
p = Path(scan_directory) / candidate
|
|
71
|
+
if p.exists():
|
|
72
|
+
compose_file = str(p)
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------ #
|
|
76
|
+
# Run scanners
|
|
77
|
+
# ------------------------------------------------------------------ #
|
|
78
|
+
secrets_scanner = SecretsScanner(redact=redact_secrets)
|
|
79
|
+
compose_scanner = ComposeScanner()
|
|
80
|
+
dockerfile_scanner = DockerfileScanner(redact=redact_secrets)
|
|
81
|
+
supply_chain_scanner = SupplyChainScanner()
|
|
82
|
+
compliance_mapper = ComplianceMapper()
|
|
83
|
+
|
|
84
|
+
# Secrets
|
|
85
|
+
secret_findings = []
|
|
86
|
+
if dockerfile:
|
|
87
|
+
secret_findings.extend(secrets_scanner.scan_file(dockerfile))
|
|
88
|
+
if compose_file:
|
|
89
|
+
secret_findings.extend(secrets_scanner.scan_file(compose_file))
|
|
90
|
+
if scan_directory:
|
|
91
|
+
secret_findings.extend(secrets_scanner.scan_directory(scan_directory))
|
|
92
|
+
if extra_files:
|
|
93
|
+
for ef in extra_files:
|
|
94
|
+
secret_findings.extend(secrets_scanner.scan_file(ef))
|
|
95
|
+
|
|
96
|
+
# Deduplicate secrets by (file, line)
|
|
97
|
+
seen_secrets: set[tuple[str, int]] = set()
|
|
98
|
+
deduped_secrets = []
|
|
99
|
+
for sf in secret_findings:
|
|
100
|
+
key = (sf.file, sf.line_number)
|
|
101
|
+
if key not in seen_secrets:
|
|
102
|
+
seen_secrets.add(key)
|
|
103
|
+
deduped_secrets.append(sf)
|
|
104
|
+
secret_findings = deduped_secrets
|
|
105
|
+
|
|
106
|
+
# Compose
|
|
107
|
+
compose_findings = []
|
|
108
|
+
if compose_file:
|
|
109
|
+
compose_findings = compose_scanner.scan(compose_file)
|
|
110
|
+
|
|
111
|
+
# Dockerfile + layers
|
|
112
|
+
layers: list = []
|
|
113
|
+
dockerfile_findings = []
|
|
114
|
+
if dockerfile:
|
|
115
|
+
layers, dockerfile_findings = dockerfile_scanner.scan(dockerfile)
|
|
116
|
+
|
|
117
|
+
# Supply chain
|
|
118
|
+
supply_chain_findings = supply_chain_scanner.analyze_base_images(layers)
|
|
119
|
+
|
|
120
|
+
# Compliance
|
|
121
|
+
compliance_report = compliance_mapper.generate(
|
|
122
|
+
dockerfile_findings,
|
|
123
|
+
compose_findings,
|
|
124
|
+
secret_findings,
|
|
125
|
+
supply_chain_findings,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# SBOM
|
|
129
|
+
components = extract_components(layers)
|
|
130
|
+
image_name = Path(dockerfile).stem if dockerfile else "unknown"
|
|
131
|
+
sbom = generate_cyclonedx(components, image_name, dockerfile or "Dockerfile")
|
|
132
|
+
|
|
133
|
+
# Metadata
|
|
134
|
+
metadata = {
|
|
135
|
+
"scanned_path": dockerfile or scan_directory or ".",
|
|
136
|
+
"compose_file": compose_file or "",
|
|
137
|
+
"dockerfile": dockerfile or "",
|
|
138
|
+
"files_scanned": (
|
|
139
|
+
([dockerfile] if dockerfile else [])
|
|
140
|
+
+ ([compose_file] if compose_file else [])
|
|
141
|
+
+ (extra_files or [])
|
|
142
|
+
),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------ #
|
|
146
|
+
# Assemble report
|
|
147
|
+
# ------------------------------------------------------------------ #
|
|
148
|
+
report = generate_json_report(
|
|
149
|
+
dockerfile_findings,
|
|
150
|
+
compose_findings,
|
|
151
|
+
secret_findings,
|
|
152
|
+
supply_chain_findings,
|
|
153
|
+
compliance_report,
|
|
154
|
+
sbom,
|
|
155
|
+
metadata,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------ #
|
|
159
|
+
# AI remediation (optional)
|
|
160
|
+
# ------------------------------------------------------------------ #
|
|
161
|
+
if ai_remediation:
|
|
162
|
+
from dockerdna.ai.remediation import get_ai_remediation
|
|
163
|
+
|
|
164
|
+
ai_result = get_ai_remediation(
|
|
165
|
+
dockerfile,
|
|
166
|
+
compose_file,
|
|
167
|
+
dockerfile_findings,
|
|
168
|
+
compose_findings,
|
|
169
|
+
secret_findings,
|
|
170
|
+
supply_chain_findings,
|
|
171
|
+
compliance_report.score,
|
|
172
|
+
model=ai_model,
|
|
173
|
+
)
|
|
174
|
+
report["ai_remediation"] = ai_result
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------ #
|
|
177
|
+
# Write outputs
|
|
178
|
+
# ------------------------------------------------------------------ #
|
|
179
|
+
out = Path(output_dir)
|
|
180
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
|
|
182
|
+
if "json" in formats:
|
|
183
|
+
json_path = out / "report.json"
|
|
184
|
+
json_path.write_text(
|
|
185
|
+
json.dumps(report, indent=2, default=str), encoding="utf-8"
|
|
186
|
+
)
|
|
187
|
+
if verbose:
|
|
188
|
+
print(f"[DockerDNA] JSON report: {json_path}")
|
|
189
|
+
|
|
190
|
+
if "html" in formats:
|
|
191
|
+
html_path = out / "report.html"
|
|
192
|
+
html_path.write_text(generate_html(report), encoding="utf-8")
|
|
193
|
+
if verbose:
|
|
194
|
+
print(f"[DockerDNA] HTML report: {html_path}")
|
|
195
|
+
|
|
196
|
+
if "sarif" in formats:
|
|
197
|
+
sarif_data = generate_sarif(
|
|
198
|
+
dockerfile_findings,
|
|
199
|
+
compose_findings,
|
|
200
|
+
secret_findings,
|
|
201
|
+
supply_chain_findings,
|
|
202
|
+
base_path=str(Path(dockerfile).parent) if dockerfile else "",
|
|
203
|
+
)
|
|
204
|
+
sarif_path = out / "report.sarif"
|
|
205
|
+
sarif_path.write_text(json.dumps(sarif_data, indent=2), encoding="utf-8")
|
|
206
|
+
if verbose:
|
|
207
|
+
print(f"[DockerDNA] SARIF report: {sarif_path}")
|
|
208
|
+
|
|
209
|
+
if "sbom" in formats:
|
|
210
|
+
sbom_path = out / "sbom.cyclonedx.json"
|
|
211
|
+
sbom_path.write_text(json.dumps(sbom, indent=2), encoding="utf-8")
|
|
212
|
+
if verbose:
|
|
213
|
+
print(f"[DockerDNA] SBOM: {sbom_path}")
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------ #
|
|
216
|
+
# Threshold gate (CI/CD)
|
|
217
|
+
# ------------------------------------------------------------------ #
|
|
218
|
+
if threshold:
|
|
219
|
+
order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
220
|
+
threshold_level = order.get(threshold.upper(), 3)
|
|
221
|
+
by_sev = report["summary"]["by_severity"]
|
|
222
|
+
should_fail = any(
|
|
223
|
+
count > 0
|
|
224
|
+
for sev, count in by_sev.items()
|
|
225
|
+
if order.get(sev, 99) <= threshold_level
|
|
226
|
+
)
|
|
227
|
+
if should_fail:
|
|
228
|
+
print(
|
|
229
|
+
f"[DockerDNA] FAIL: findings at or above {threshold} severity detected.",
|
|
230
|
+
file=sys.stderr,
|
|
231
|
+
)
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
|
|
234
|
+
return report
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CIS Docker Benchmark v1.6 compliance report generator.
|
|
3
|
+
|
|
4
|
+
Aggregates findings from all scanners and maps them to specific CIS Docker
|
|
5
|
+
Benchmark control IDs, producing a structured pass/fail/not-checked scorecard.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from dockerdna.utils.patterns import CIS_RULES, CISRule
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ControlResult:
|
|
18
|
+
rule: CISRule
|
|
19
|
+
status: str # PASS | FAIL | NOT_CHECKED
|
|
20
|
+
findings: list[dict] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"id": self.rule.id,
|
|
25
|
+
"level": self.rule.level,
|
|
26
|
+
"title": self.rule.title,
|
|
27
|
+
"severity": self.rule.severity,
|
|
28
|
+
"status": self.status,
|
|
29
|
+
"findings_count": len(self.findings),
|
|
30
|
+
"remediation": self.rule.remediation if self.status == "FAIL" else "",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ComplianceReport:
|
|
36
|
+
total: int
|
|
37
|
+
passed: int
|
|
38
|
+
failed: int
|
|
39
|
+
not_checked: int
|
|
40
|
+
score: float # 0–100 compliance score
|
|
41
|
+
controls: list[ControlResult]
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"summary": {
|
|
46
|
+
"total_controls": self.total,
|
|
47
|
+
"passed": self.passed,
|
|
48
|
+
"failed": self.failed,
|
|
49
|
+
"not_checked": self.not_checked,
|
|
50
|
+
"compliance_score": round(self.score, 1),
|
|
51
|
+
},
|
|
52
|
+
"controls": [c.to_dict() for c in self.controls],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ComplianceMapper:
|
|
57
|
+
"""Map raw findings to CIS Docker Benchmark controls."""
|
|
58
|
+
|
|
59
|
+
def generate(
|
|
60
|
+
self,
|
|
61
|
+
dockerfile_findings: list[Any],
|
|
62
|
+
compose_findings: list[Any],
|
|
63
|
+
secret_findings: list[Any],
|
|
64
|
+
supply_chain_findings: list[Any],
|
|
65
|
+
) -> ComplianceReport:
|
|
66
|
+
|
|
67
|
+
# Build a map: check_key -> list of findings
|
|
68
|
+
hit_map: dict[str, list[dict]] = {}
|
|
69
|
+
|
|
70
|
+
for f in dockerfile_findings:
|
|
71
|
+
hit_map.setdefault(f.check_key, []).append(f.to_dict())
|
|
72
|
+
|
|
73
|
+
for f in compose_findings:
|
|
74
|
+
hit_map.setdefault(f.check_key, []).append(f.to_dict())
|
|
75
|
+
|
|
76
|
+
for f in secret_findings:
|
|
77
|
+
# map secret type to a check_key
|
|
78
|
+
key = (
|
|
79
|
+
"hardcoded_secret" if f.detection_method == "pattern" else "env_secret"
|
|
80
|
+
)
|
|
81
|
+
hit_map.setdefault(key, []).append(f.to_dict())
|
|
82
|
+
|
|
83
|
+
for f in supply_chain_findings:
|
|
84
|
+
if f.risk_score >= 15:
|
|
85
|
+
hit_map.setdefault("no_content_trust", []).append(f.to_dict())
|
|
86
|
+
|
|
87
|
+
results: list[ControlResult] = []
|
|
88
|
+
passed = failed = not_checked = 0
|
|
89
|
+
|
|
90
|
+
for rule in CIS_RULES:
|
|
91
|
+
findings_for_rule = hit_map.get(rule.check_key, [])
|
|
92
|
+
if findings_for_rule:
|
|
93
|
+
status = "FAIL"
|
|
94
|
+
failed += 1
|
|
95
|
+
else:
|
|
96
|
+
# We only mark PASS for checks we actually ran
|
|
97
|
+
if rule.check_key in self._checked_keys():
|
|
98
|
+
status = "PASS"
|
|
99
|
+
passed += 1
|
|
100
|
+
else:
|
|
101
|
+
status = "NOT_CHECKED"
|
|
102
|
+
not_checked += 1
|
|
103
|
+
|
|
104
|
+
results.append(
|
|
105
|
+
ControlResult(
|
|
106
|
+
rule=rule,
|
|
107
|
+
status=status,
|
|
108
|
+
findings=findings_for_rule,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
total = len(CIS_RULES)
|
|
113
|
+
checkable = passed + failed
|
|
114
|
+
score = (passed / checkable * 100) if checkable else 0.0
|
|
115
|
+
|
|
116
|
+
return ComplianceReport(
|
|
117
|
+
total=total,
|
|
118
|
+
passed=passed,
|
|
119
|
+
failed=failed,
|
|
120
|
+
not_checked=not_checked,
|
|
121
|
+
score=score,
|
|
122
|
+
controls=results,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _checked_keys() -> set[str]:
|
|
127
|
+
"""The set of check_keys that DockerDNA actually evaluates."""
|
|
128
|
+
return {
|
|
129
|
+
"no_user",
|
|
130
|
+
"latest_tag",
|
|
131
|
+
"unnecessary_packages",
|
|
132
|
+
"no_healthcheck",
|
|
133
|
+
"update_without_install",
|
|
134
|
+
"env_secret",
|
|
135
|
+
"hardcoded_secret",
|
|
136
|
+
"no_content_trust",
|
|
137
|
+
"privileged",
|
|
138
|
+
"excess_capabilities",
|
|
139
|
+
"sensitive_mount",
|
|
140
|
+
"docker_socket",
|
|
141
|
+
"host_network",
|
|
142
|
+
"no_memory_limit",
|
|
143
|
+
"no_cpu_limit",
|
|
144
|
+
"no_readonly_fs",
|
|
145
|
+
"no_new_privileges",
|
|
146
|
+
"privileged_ports",
|
|
147
|
+
"no_apparmor",
|
|
148
|
+
"ssh_in_container",
|
|
149
|
+
}
|