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
dockerdna/__init__.py
ADDED
dockerdna/ai/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI-powered remediation using Anthropic Claude.
|
|
3
|
+
|
|
4
|
+
Synthesises findings from ALL of DockerDNA's scanners (Dockerfile,
|
|
5
|
+
compose, secrets, supply-chain) into one remediation pass and produces:
|
|
6
|
+
1. A prioritised fix plan with CIS control IDs
|
|
7
|
+
2. A rewritten Dockerfile with all issues corrected
|
|
8
|
+
3. A rewritten docker-compose with all misconfigs fixed
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_context(
|
|
18
|
+
dockerfile_path: Optional[str],
|
|
19
|
+
compose_path: Optional[str],
|
|
20
|
+
dockerfile_findings: list[Any],
|
|
21
|
+
compose_findings: list[Any],
|
|
22
|
+
secret_findings: list[Any],
|
|
23
|
+
supply_chain_findings: list[Any],
|
|
24
|
+
compliance_score: float,
|
|
25
|
+
) -> str:
|
|
26
|
+
parts: list[str] = []
|
|
27
|
+
|
|
28
|
+
parts.append(f"CIS Docker Benchmark compliance score: {compliance_score:.1f}%\n")
|
|
29
|
+
|
|
30
|
+
if dockerfile_findings:
|
|
31
|
+
parts.append("=== Dockerfile Findings ===")
|
|
32
|
+
for f in dockerfile_findings:
|
|
33
|
+
parts.append(f"[{f.severity}] {f.cis_id} line {f.line_number}: {f.detail}")
|
|
34
|
+
|
|
35
|
+
if compose_findings:
|
|
36
|
+
parts.append("\n=== docker-compose.yml Findings ===")
|
|
37
|
+
for f in compose_findings:
|
|
38
|
+
parts.append(f"[{f.severity}] {f.cis_id} service '{f.service}': {f.detail}")
|
|
39
|
+
|
|
40
|
+
if secret_findings:
|
|
41
|
+
parts.append("\n=== Secrets Detected ===")
|
|
42
|
+
for f in secret_findings:
|
|
43
|
+
parts.append(
|
|
44
|
+
f"[{f.severity}] {f.cis_id} {f.file} line {f.line_number}: "
|
|
45
|
+
f"{f.secret_type} ({f.detection_method})"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if supply_chain_findings:
|
|
49
|
+
parts.append("\n=== Supply Chain Risks ===")
|
|
50
|
+
for f in supply_chain_findings:
|
|
51
|
+
parts.append(
|
|
52
|
+
f"[{f.severity}] {f.image} — risk score {f.risk_score}/100: "
|
|
53
|
+
+ "; ".join(f.factors)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Append file contents (truncated)
|
|
57
|
+
if dockerfile_path and os.path.exists(dockerfile_path):
|
|
58
|
+
try:
|
|
59
|
+
content = open(dockerfile_path).read()[:3000]
|
|
60
|
+
parts.append(f"\n=== Current Dockerfile ===\n{content}")
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
if compose_path and os.path.exists(compose_path):
|
|
65
|
+
try:
|
|
66
|
+
content = open(compose_path).read()[:3000]
|
|
67
|
+
parts.append(f"\n=== Current docker-compose.yml ===\n{content}")
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
return "\n".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_ai_remediation(
|
|
75
|
+
dockerfile_path: Optional[str],
|
|
76
|
+
compose_path: Optional[str],
|
|
77
|
+
dockerfile_findings: list[Any],
|
|
78
|
+
compose_findings: list[Any],
|
|
79
|
+
secret_findings: list[Any],
|
|
80
|
+
supply_chain_findings: list[Any],
|
|
81
|
+
compliance_score: float,
|
|
82
|
+
model: str = "claude-sonnet-4-6",
|
|
83
|
+
) -> dict:
|
|
84
|
+
"""
|
|
85
|
+
Call Anthropic Claude to produce a fix plan and corrected files.
|
|
86
|
+
Requires ANTHROPIC_API_KEY environment variable.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
import anthropic
|
|
90
|
+
except ImportError:
|
|
91
|
+
return {"error": "anthropic package not installed. Run: pip install anthropic"}
|
|
92
|
+
|
|
93
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
94
|
+
if not api_key:
|
|
95
|
+
return {"error": "ANTHROPIC_API_KEY environment variable not set"}
|
|
96
|
+
|
|
97
|
+
context = _build_context(
|
|
98
|
+
dockerfile_path,
|
|
99
|
+
compose_path,
|
|
100
|
+
dockerfile_findings,
|
|
101
|
+
compose_findings,
|
|
102
|
+
secret_findings,
|
|
103
|
+
supply_chain_findings,
|
|
104
|
+
compliance_score,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
system_prompt = (
|
|
108
|
+
"You are a senior DevSecOps engineer specialising in Docker and container security. "
|
|
109
|
+
"You have been given a set of security findings from the DockerDNA scanner. "
|
|
110
|
+
"Your task is to:\n"
|
|
111
|
+
"1. Produce a prioritised fix plan (Critical first, then High, Medium, Low).\n"
|
|
112
|
+
"2. Write a corrected Dockerfile that resolves all Dockerfile findings.\n"
|
|
113
|
+
"3. Write a corrected docker-compose.yml that resolves all compose findings.\n"
|
|
114
|
+
"4. Provide specific advice on rotating any detected secrets.\n"
|
|
115
|
+
"Map every recommendation to its CIS Docker Benchmark control ID. "
|
|
116
|
+
"Be concise, specific, and actionable."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
user_prompt = (
|
|
120
|
+
f"Here are the security findings from DockerDNA:\n\n{context}\n\n"
|
|
121
|
+
"Please provide:\n"
|
|
122
|
+
"## 1. Prioritised Fix Plan\n"
|
|
123
|
+
"## 2. Corrected Dockerfile\n"
|
|
124
|
+
"## 3. Corrected docker-compose.yml (if applicable)\n"
|
|
125
|
+
"## 4. Secret Remediation Steps\n"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
129
|
+
message = client.messages.create(
|
|
130
|
+
model=model,
|
|
131
|
+
max_tokens=4096,
|
|
132
|
+
messages=[{"role": "user", "content": user_prompt}],
|
|
133
|
+
system=system_prompt,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
response_text = message.content[0].text if message.content else ""
|
|
137
|
+
return {
|
|
138
|
+
"model": model,
|
|
139
|
+
"input_tokens": message.usage.input_tokens,
|
|
140
|
+
"output_tokens": message.usage.output_tokens,
|
|
141
|
+
"remediation": response_text,
|
|
142
|
+
}
|
dockerdna/cli.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DockerDNA CLI entry point.
|
|
3
|
+
|
|
4
|
+
Usage examples:
|
|
5
|
+
dockerdna Dockerfile
|
|
6
|
+
dockerdna Dockerfile --compose docker-compose.yml
|
|
7
|
+
dockerdna --dir ./myapp --format json html sarif sbom
|
|
8
|
+
dockerdna Dockerfile --ai --threshold HIGH
|
|
9
|
+
dockerdna --dir . --format sarif --output-dir .github/security
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from dockerdna.scanner import scan
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print_summary(report: dict) -> None:
|
|
22
|
+
summary = report.get("summary", {})
|
|
23
|
+
by_sev = summary.get("by_severity", {})
|
|
24
|
+
meta = report.get("metadata", {})
|
|
25
|
+
print()
|
|
26
|
+
print(" ____ _ ____ _ _ _")
|
|
27
|
+
print(" | _ \\ ___ ___| | _____ _ __| __ )| \\ | | / \\")
|
|
28
|
+
print(" | | | |/ _ \\ / __| |/ / _ \\ '__| _ \\| \\| | / _ \\")
|
|
29
|
+
print(" | |_| | (_) | (__| < __/ | | |_) | |\\ |/ ___ \\")
|
|
30
|
+
print(" |____/ \\___/ \\___|_|\\_\\___|_| |____/|_| \\_/_/ \\_\\")
|
|
31
|
+
print()
|
|
32
|
+
print(" Layer-by-Layer Container Security DNA Analysis")
|
|
33
|
+
print(" github.com/sunilgentyala/DockerDNA\n")
|
|
34
|
+
print(f" Scanned : {meta.get('scanned_path','')}")
|
|
35
|
+
if meta.get("compose_file"):
|
|
36
|
+
print(f" Compose : {meta.get('compose_file')}")
|
|
37
|
+
print()
|
|
38
|
+
print(f" Risk Score : {summary.get('risk_score', 0)}/100")
|
|
39
|
+
print(f" CIS Compliance : {summary.get('compliance_score', 0):.1f}%")
|
|
40
|
+
print(f" Total Findings : {summary.get('total_findings', 0)}")
|
|
41
|
+
print()
|
|
42
|
+
print(f" CRITICAL {by_sev.get('CRITICAL', 0):>4}")
|
|
43
|
+
print(f" HIGH {by_sev.get('HIGH', 0):>4}")
|
|
44
|
+
print(f" MEDIUM {by_sev.get('MEDIUM', 0):>4}")
|
|
45
|
+
print(f" LOW {by_sev.get('LOW', 0):>4}")
|
|
46
|
+
print()
|
|
47
|
+
|
|
48
|
+
# Print critical / high findings inline
|
|
49
|
+
findings = report.get("findings", {})
|
|
50
|
+
all_findings = (
|
|
51
|
+
findings.get("secrets", [])
|
|
52
|
+
+ findings.get("dockerfile", [])
|
|
53
|
+
+ findings.get("compose", [])
|
|
54
|
+
+ findings.get("supply_chain", [])
|
|
55
|
+
)
|
|
56
|
+
critical_high = [
|
|
57
|
+
f for f in all_findings if f.get("severity") in ("CRITICAL", "HIGH")
|
|
58
|
+
]
|
|
59
|
+
if critical_high:
|
|
60
|
+
print(" Top findings requiring immediate attention:")
|
|
61
|
+
for f in critical_high[:10]:
|
|
62
|
+
sev = f.get("severity", "")
|
|
63
|
+
cis = f.get("cis_id", "")
|
|
64
|
+
msg = f.get("detail") or f.get("description") or str(f.get("factors", ""))
|
|
65
|
+
svc = f.get("service", "") or f.get("file", "")
|
|
66
|
+
loc = f" [{svc}]" if svc else ""
|
|
67
|
+
print(f" [{sev}] {cis}{loc}: {msg[:90]}")
|
|
68
|
+
if len(critical_high) > 10:
|
|
69
|
+
print(f" ... and {len(critical_high) - 10} more (see report)")
|
|
70
|
+
print()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> None:
|
|
74
|
+
parser = argparse.ArgumentParser(
|
|
75
|
+
prog="dockerdna",
|
|
76
|
+
description="DockerDNA — Layer-by-Layer Container Security DNA Analysis",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"dockerfile",
|
|
80
|
+
nargs="?",
|
|
81
|
+
help="Path to Dockerfile (optional if --dir is used)",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--compose",
|
|
85
|
+
"-c",
|
|
86
|
+
metavar="FILE",
|
|
87
|
+
help="Path to docker-compose.yml",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--dir",
|
|
91
|
+
"-d",
|
|
92
|
+
metavar="DIRECTORY",
|
|
93
|
+
help="Scan an entire project directory",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--file",
|
|
97
|
+
"-f",
|
|
98
|
+
metavar="FILE",
|
|
99
|
+
action="append",
|
|
100
|
+
dest="extra_files",
|
|
101
|
+
help="Additional file to scan for secrets (repeatable)",
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--format",
|
|
105
|
+
nargs="+",
|
|
106
|
+
choices=["json", "html", "sarif", "sbom"],
|
|
107
|
+
default=["json", "html"],
|
|
108
|
+
metavar="FORMAT",
|
|
109
|
+
help="Output formats: json html sarif sbom (default: json html)",
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--output",
|
|
113
|
+
"-o",
|
|
114
|
+
default="dockerdna-results",
|
|
115
|
+
metavar="DIR",
|
|
116
|
+
help="Output directory (default: ./dockerdna-results)",
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--ai",
|
|
120
|
+
action="store_true",
|
|
121
|
+
help="Enable AI-powered remediation via Anthropic Claude",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--ai-model",
|
|
125
|
+
default="claude-sonnet-4-6",
|
|
126
|
+
metavar="MODEL",
|
|
127
|
+
help="Claude model to use for AI remediation",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--threshold",
|
|
131
|
+
choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"],
|
|
132
|
+
metavar="SEVERITY",
|
|
133
|
+
help="Exit with code 1 if findings at this severity or above are found",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--no-redact",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="Do not redact secret values in reports (use with caution)",
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--verbose",
|
|
142
|
+
"-v",
|
|
143
|
+
action="store_true",
|
|
144
|
+
help="Print verbose output",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
args = parser.parse_args()
|
|
148
|
+
|
|
149
|
+
if not args.dockerfile and not args.dir:
|
|
150
|
+
parser.print_help()
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
report = scan(
|
|
154
|
+
dockerfile=args.dockerfile,
|
|
155
|
+
compose_file=args.compose,
|
|
156
|
+
extra_files=args.extra_files,
|
|
157
|
+
scan_directory=args.dir,
|
|
158
|
+
ai_remediation=args.ai,
|
|
159
|
+
ai_model=args.ai_model,
|
|
160
|
+
output_dir=args.output,
|
|
161
|
+
formats=args.format,
|
|
162
|
+
threshold=args.threshold,
|
|
163
|
+
redact_secrets=not args.no_redact,
|
|
164
|
+
verbose=args.verbose,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
_print_summary(report)
|
|
168
|
+
|
|
169
|
+
out = Path(args.output)
|
|
170
|
+
if "json" in args.format:
|
|
171
|
+
print(f" JSON : {out / 'report.json'}")
|
|
172
|
+
if "html" in args.format:
|
|
173
|
+
print(f" HTML : {out / 'report.html'}")
|
|
174
|
+
if "sarif" in args.format:
|
|
175
|
+
print(f" SARIF : {out / 'report.sarif'}")
|
|
176
|
+
if "sbom" in args.format:
|
|
177
|
+
print(f" SBOM : {out / 'sbom.cyclonedx.json'}")
|
|
178
|
+
print()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Interactive HTML report generator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as html_lib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_SEVERITY_COLORS = {
|
|
9
|
+
"CRITICAL": "#dc2626",
|
|
10
|
+
"HIGH": "#ea580c",
|
|
11
|
+
"MEDIUM": "#d97706",
|
|
12
|
+
"LOW": "#65a30d",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
_STATUS_COLORS = {
|
|
16
|
+
"PASS": "#16a34a",
|
|
17
|
+
"FAIL": "#dc2626",
|
|
18
|
+
"NOT_CHECKED": "#6b7280",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _esc(text: Any) -> str:
|
|
23
|
+
return html_lib.escape(str(text) if text is not None else "")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _badge(severity: str) -> str:
|
|
27
|
+
color = _SEVERITY_COLORS.get(severity, "#6b7280")
|
|
28
|
+
return (
|
|
29
|
+
f'<span style="background:{color};color:#fff;padding:2px 8px;'
|
|
30
|
+
f'border-radius:4px;font-size:0.75rem;font-weight:bold;">'
|
|
31
|
+
f"{_esc(severity)}</span>"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _status_badge(status: str) -> str:
|
|
36
|
+
color = _STATUS_COLORS.get(status, "#6b7280")
|
|
37
|
+
return (
|
|
38
|
+
f'<span style="background:{color};color:#fff;padding:2px 8px;'
|
|
39
|
+
f'border-radius:4px;font-size:0.75rem;font-weight:bold;">'
|
|
40
|
+
f"{_esc(status)}</span>"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _finding_rows(findings: list[Any], cols: list[tuple[str, str]]) -> str:
|
|
45
|
+
if not findings:
|
|
46
|
+
return '<tr><td colspan="100" style="text-align:center;color:#6b7280;">No findings</td></tr>'
|
|
47
|
+
rows = []
|
|
48
|
+
for f in findings:
|
|
49
|
+
cells = []
|
|
50
|
+
for attr, kind in cols:
|
|
51
|
+
val = getattr(f, attr, "")
|
|
52
|
+
if kind == "badge":
|
|
53
|
+
cells.append(f"<td>{_badge(str(val))}</td>")
|
|
54
|
+
elif kind == "code":
|
|
55
|
+
cells.append(f"<td><code>{_esc(val)}</code></td>")
|
|
56
|
+
else:
|
|
57
|
+
cells.append(f"<td>{_esc(val)}</td>")
|
|
58
|
+
rows.append(f"<tr>{''.join(cells)}</tr>")
|
|
59
|
+
return "\n".join(rows)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def generate_html(report: dict) -> str:
|
|
63
|
+
summary = report.get("summary", {})
|
|
64
|
+
risk = summary.get("risk_score", 0)
|
|
65
|
+
comp = summary.get("compliance_score", 0)
|
|
66
|
+
total = summary.get("total_findings", 0)
|
|
67
|
+
by_sev = summary.get("by_severity", {})
|
|
68
|
+
|
|
69
|
+
risk_color = (
|
|
70
|
+
"#dc2626"
|
|
71
|
+
if risk >= 70
|
|
72
|
+
else "#ea580c" if risk >= 40 else "#d97706" if risk >= 20 else "#16a34a"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
meta = report.get("metadata", {})
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ #
|
|
78
|
+
# Findings tables
|
|
79
|
+
# ------------------------------------------------------------------ #
|
|
80
|
+
|
|
81
|
+
def _table(
|
|
82
|
+
title: str, findings: list[dict], columns: list[tuple[str, str, str]]
|
|
83
|
+
) -> str:
|
|
84
|
+
headers = "".join(f"<th>{h}</th>" for h, _, _ in columns)
|
|
85
|
+
rows_html = ""
|
|
86
|
+
if not findings:
|
|
87
|
+
rows_html = f'<tr><td colspan="{len(columns)}" style="text-align:center;color:#6b7280;">No findings</td></tr>'
|
|
88
|
+
else:
|
|
89
|
+
for f in findings:
|
|
90
|
+
cells = []
|
|
91
|
+
for _, key, kind in columns:
|
|
92
|
+
val = f.get(key, "")
|
|
93
|
+
if kind == "badge":
|
|
94
|
+
cells.append(f"<td>{_badge(str(val))}</td>")
|
|
95
|
+
elif kind == "code":
|
|
96
|
+
cells.append(
|
|
97
|
+
f'<td><code style="font-size:0.8rem;">{_esc(val)}</code></td>'
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
cells.append(f"<td>{_esc(val)}</td>")
|
|
101
|
+
rows_html += f"<tr>{''.join(cells)}</tr>\n"
|
|
102
|
+
|
|
103
|
+
return f"""
|
|
104
|
+
<h3>{_esc(title)}</h3>
|
|
105
|
+
<div style="overflow-x:auto;">
|
|
106
|
+
<table>
|
|
107
|
+
<thead><tr>{headers}</tr></thead>
|
|
108
|
+
<tbody>{rows_html}</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
</div>"""
|
|
111
|
+
|
|
112
|
+
df_findings = report.get("findings", {}).get("dockerfile", [])
|
|
113
|
+
cf_findings = report.get("findings", {}).get("compose", [])
|
|
114
|
+
sec_findings = report.get("findings", {}).get("secrets", [])
|
|
115
|
+
sc_findings = report.get("findings", {}).get("supply_chain", [])
|
|
116
|
+
|
|
117
|
+
df_table = _table(
|
|
118
|
+
"Dockerfile Findings",
|
|
119
|
+
df_findings,
|
|
120
|
+
[
|
|
121
|
+
("Severity", "severity", "badge"),
|
|
122
|
+
("Line", "line", "text"),
|
|
123
|
+
("CIS ID", "cis_id", "code"),
|
|
124
|
+
("Title", "title", "text"),
|
|
125
|
+
("Detail", "detail", "text"),
|
|
126
|
+
("Stage", "stage", "text"),
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
cf_table = _table(
|
|
131
|
+
"docker-compose.yml Findings",
|
|
132
|
+
cf_findings,
|
|
133
|
+
[
|
|
134
|
+
("Severity", "severity", "badge"),
|
|
135
|
+
("Service", "service", "text"),
|
|
136
|
+
("CIS ID", "cis_id", "code"),
|
|
137
|
+
("Title", "title", "text"),
|
|
138
|
+
("Detail", "detail", "text"),
|
|
139
|
+
],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
sec_table = _table(
|
|
143
|
+
"Secrets Detected",
|
|
144
|
+
sec_findings,
|
|
145
|
+
[
|
|
146
|
+
("Severity", "severity", "badge"),
|
|
147
|
+
("File", "file", "text"),
|
|
148
|
+
("Line", "line", "text"),
|
|
149
|
+
("CIS ID", "cis_id", "code"),
|
|
150
|
+
("Type", "type", "text"),
|
|
151
|
+
("Method", "detection", "text"),
|
|
152
|
+
("Value", "matched_value", "code"),
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
sc_table = _table(
|
|
157
|
+
"Supply Chain Analysis",
|
|
158
|
+
sc_findings,
|
|
159
|
+
[
|
|
160
|
+
("Severity", "severity", "badge"),
|
|
161
|
+
("Image", "image", "code"),
|
|
162
|
+
("Stage", "stage", "text"),
|
|
163
|
+
("Risk Score", "risk_score", "text"),
|
|
164
|
+
("Factors", "factors", "text"),
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Compliance table
|
|
169
|
+
compliance = report.get("compliance", {})
|
|
170
|
+
c_summary = compliance.get("summary", {})
|
|
171
|
+
c_controls = compliance.get("controls", [])
|
|
172
|
+
c_rows = ""
|
|
173
|
+
for ctrl in c_controls:
|
|
174
|
+
c_rows += (
|
|
175
|
+
f"<tr>"
|
|
176
|
+
f"<td><code>{_esc(ctrl.get('id',''))}</code></td>"
|
|
177
|
+
f"<td>{_esc(ctrl.get('title',''))}</td>"
|
|
178
|
+
f"<td>{_status_badge(ctrl.get('status',''))}</td>"
|
|
179
|
+
f"<td>{_badge(ctrl.get('severity',''))}</td>"
|
|
180
|
+
f"<td>{_esc(ctrl.get('findings_count',''))}</td>"
|
|
181
|
+
f"</tr>\n"
|
|
182
|
+
)
|
|
183
|
+
comp_table = f"""
|
|
184
|
+
<h3>CIS Docker Benchmark v1.6 Compliance</h3>
|
|
185
|
+
<p>Controls passed: {c_summary.get('passed',0)} /
|
|
186
|
+
{c_summary.get('total_controls',0)} |
|
|
187
|
+
Score: <strong>{c_summary.get('compliance_score',0):.1f}%</strong></p>
|
|
188
|
+
<div style="overflow-x:auto;">
|
|
189
|
+
<table>
|
|
190
|
+
<thead><tr><th>ID</th><th>Title</th><th>Status</th><th>Severity</th><th>Findings</th></tr></thead>
|
|
191
|
+
<tbody>{c_rows}</tbody>
|
|
192
|
+
</table>
|
|
193
|
+
</div>"""
|
|
194
|
+
|
|
195
|
+
return f"""<!DOCTYPE html>
|
|
196
|
+
<html lang="en">
|
|
197
|
+
<head>
|
|
198
|
+
<meta charset="UTF-8">
|
|
199
|
+
<title>DockerDNA Security Report</title>
|
|
200
|
+
<style>
|
|
201
|
+
body {{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;background:#f9fafb;color:#111827;}}
|
|
202
|
+
.header {{background:#1e293b;color:#f8fafc;padding:24px 40px;}}
|
|
203
|
+
.header h1 {{margin:0;font-size:1.8rem;}}
|
|
204
|
+
.header p {{margin:4px 0 0;opacity:.7;}}
|
|
205
|
+
.container {{max-width:1200px;margin:0 auto;padding:24px 40px;}}
|
|
206
|
+
.cards {{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:32px;}}
|
|
207
|
+
.card {{background:#fff;border-radius:8px;padding:20px 28px;box-shadow:0 1px 3px rgba(0,0,0,.1);min-width:160px;}}
|
|
208
|
+
.card .value {{font-size:2.2rem;font-weight:700;}}
|
|
209
|
+
.card .label {{font-size:.85rem;color:#6b7280;margin-top:4px;}}
|
|
210
|
+
table {{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;
|
|
211
|
+
box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:32px;}}
|
|
212
|
+
th {{background:#1e293b;color:#f8fafc;padding:10px 14px;text-align:left;font-size:.85rem;}}
|
|
213
|
+
td {{padding:9px 14px;border-bottom:1px solid #e5e7eb;font-size:.9rem;vertical-align:top;}}
|
|
214
|
+
tr:hover td {{background:#f9fafb;}}
|
|
215
|
+
h3 {{margin:32px 0 12px;color:#1e293b;}}
|
|
216
|
+
code {{background:#f1f5f9;padding:2px 6px;border-radius:3px;font-size:.85rem;}}
|
|
217
|
+
</style>
|
|
218
|
+
</head>
|
|
219
|
+
<body>
|
|
220
|
+
<div class="header">
|
|
221
|
+
<h1>🧬 DockerDNA Security Report</h1>
|
|
222
|
+
<p>Generated: {_esc(report.get('timestamp',''))} | {_esc(meta.get('scanned_path',''))}</p>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="container">
|
|
225
|
+
|
|
226
|
+
<div class="cards">
|
|
227
|
+
<div class="card">
|
|
228
|
+
<div class="value" style="color:{risk_color};">{risk}</div>
|
|
229
|
+
<div class="label">Risk Score (0-100)</div>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="card">
|
|
232
|
+
<div class="value" style="color:#2563eb;">{comp:.0f}%</div>
|
|
233
|
+
<div class="label">CIS Compliance</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div class="card">
|
|
236
|
+
<div class="value" style="color:#dc2626;">{by_sev.get('CRITICAL',0)}</div>
|
|
237
|
+
<div class="label">Critical</div>
|
|
238
|
+
</div>
|
|
239
|
+
<div class="card">
|
|
240
|
+
<div class="value" style="color:#ea580c;">{by_sev.get('HIGH',0)}</div>
|
|
241
|
+
<div class="label">High</div>
|
|
242
|
+
</div>
|
|
243
|
+
<div class="card">
|
|
244
|
+
<div class="value" style="color:#d97706;">{by_sev.get('MEDIUM',0)}</div>
|
|
245
|
+
<div class="label">Medium</div>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="card">
|
|
248
|
+
<div class="value" style="color:#65a30d;">{by_sev.get('LOW',0)}</div>
|
|
249
|
+
<div class="label">Low</div>
|
|
250
|
+
</div>
|
|
251
|
+
<div class="card">
|
|
252
|
+
<div class="value">{total}</div>
|
|
253
|
+
<div class="label">Total Findings</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{df_table}
|
|
258
|
+
{cf_table}
|
|
259
|
+
{sec_table}
|
|
260
|
+
{sc_table}
|
|
261
|
+
{comp_table}
|
|
262
|
+
|
|
263
|
+
</div>
|
|
264
|
+
</body>
|
|
265
|
+
</html>"""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""JSON report assembler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_json_report(
|
|
10
|
+
dockerfile_findings: list[Any],
|
|
11
|
+
compose_findings: list[Any],
|
|
12
|
+
secret_findings: list[Any],
|
|
13
|
+
supply_chain_findings: list[Any],
|
|
14
|
+
compliance_report: Any,
|
|
15
|
+
sbom: dict,
|
|
16
|
+
metadata: dict,
|
|
17
|
+
) -> dict:
|
|
18
|
+
|
|
19
|
+
def _count(findings: list[Any]) -> dict:
|
|
20
|
+
counts: dict[str, int] = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
|
21
|
+
for f in findings:
|
|
22
|
+
sev = getattr(f, "severity", "LOW")
|
|
23
|
+
counts[sev] = counts.get(sev, 0) + 1
|
|
24
|
+
return counts
|
|
25
|
+
|
|
26
|
+
df_counts = _count(dockerfile_findings)
|
|
27
|
+
cf_counts = _count(compose_findings)
|
|
28
|
+
sf_counts = _count(secret_findings)
|
|
29
|
+
sc_counts = _count(supply_chain_findings)
|
|
30
|
+
|
|
31
|
+
total_critical = sum(
|
|
32
|
+
c["CRITICAL"] for c in [df_counts, cf_counts, sf_counts, sc_counts]
|
|
33
|
+
)
|
|
34
|
+
total_high = sum(c["HIGH"] for c in [df_counts, cf_counts, sf_counts, sc_counts])
|
|
35
|
+
|
|
36
|
+
risk_score = min(100, total_critical * 15 + total_high * 5)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"tool": "DockerDNA",
|
|
40
|
+
"version": "1.0.2",
|
|
41
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
42
|
+
"metadata": metadata,
|
|
43
|
+
"summary": {
|
|
44
|
+
"risk_score": risk_score,
|
|
45
|
+
"compliance_score": compliance_report.score if compliance_report else 0,
|
|
46
|
+
"total_findings": (
|
|
47
|
+
len(dockerfile_findings)
|
|
48
|
+
+ len(compose_findings)
|
|
49
|
+
+ len(secret_findings)
|
|
50
|
+
+ len(supply_chain_findings)
|
|
51
|
+
),
|
|
52
|
+
"by_severity": {
|
|
53
|
+
"CRITICAL": total_critical,
|
|
54
|
+
"HIGH": total_high,
|
|
55
|
+
"MEDIUM": sum(
|
|
56
|
+
c["MEDIUM"] for c in [df_counts, cf_counts, sf_counts, sc_counts]
|
|
57
|
+
),
|
|
58
|
+
"LOW": sum(
|
|
59
|
+
c["LOW"] for c in [df_counts, cf_counts, sf_counts, sc_counts]
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"findings": {
|
|
64
|
+
"dockerfile": [f.to_dict() for f in dockerfile_findings],
|
|
65
|
+
"compose": [f.to_dict() for f in compose_findings],
|
|
66
|
+
"secrets": [f.to_dict() for f in secret_findings],
|
|
67
|
+
"supply_chain": [f.to_dict() for f in supply_chain_findings],
|
|
68
|
+
},
|
|
69
|
+
"compliance": compliance_report.to_dict() if compliance_report else {},
|
|
70
|
+
"sbom": sbom,
|
|
71
|
+
}
|