kekkai-cli 1.0.5__py3-none-any.whl → 1.1.0__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.
- kekkai/cli.py +693 -14
- kekkai/compliance/__init__.py +68 -0
- kekkai/compliance/hipaa.py +235 -0
- kekkai/compliance/mappings.py +136 -0
- kekkai/compliance/owasp.py +517 -0
- kekkai/compliance/owasp_agentic.py +267 -0
- kekkai/compliance/pci_dss.py +205 -0
- kekkai/compliance/soc2.py +209 -0
- kekkai/dojo.py +91 -14
- kekkai/fix/__init__.py +47 -0
- kekkai/fix/audit.py +278 -0
- kekkai/fix/differ.py +427 -0
- kekkai/fix/engine.py +500 -0
- kekkai/fix/prompts.py +251 -0
- kekkai/output.py +10 -12
- kekkai/report/__init__.py +41 -0
- kekkai/report/compliance_matrix.py +98 -0
- kekkai/report/generator.py +365 -0
- kekkai/report/html.py +69 -0
- kekkai/report/pdf.py +63 -0
- kekkai/scanners/container.py +33 -3
- kekkai/scanners/gitleaks.py +3 -1
- kekkai/scanners/semgrep.py +1 -1
- kekkai/scanners/trivy.py +1 -1
- kekkai/threatflow/model_adapter.py +143 -1
- kekkai_cli-1.1.0.dist-info/METADATA +359 -0
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.0.dist-info}/RECORD +33 -16
- portal/enterprise/__init__.py +15 -2
- portal/enterprise/licensing.py +88 -22
- portal/web.py +9 -0
- kekkai_cli-1.0.5.dist-info/METADATA +0 -135
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.0.dist-info}/WHEEL +0 -0
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.0.dist-info}/entry_points.txt +0 -0
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Report generation orchestration.
|
|
2
|
+
|
|
3
|
+
Handles report generation workflow including compliance mapping,
|
|
4
|
+
format selection, and output management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
|
|
20
|
+
from kekkai.compliance.mappings import ComplianceMappingResult
|
|
21
|
+
from kekkai.scanners.base import Finding
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReportFormat(str, Enum):
|
|
25
|
+
"""Available report formats."""
|
|
26
|
+
|
|
27
|
+
HTML = "html"
|
|
28
|
+
PDF = "pdf"
|
|
29
|
+
COMPLIANCE = "compliance"
|
|
30
|
+
JSON = "json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ReportConfig:
|
|
35
|
+
"""Configuration for report generation."""
|
|
36
|
+
|
|
37
|
+
formats: list[ReportFormat] = field(default_factory=lambda: [ReportFormat.HTML])
|
|
38
|
+
frameworks: list[str] = field(default_factory=lambda: ["PCI-DSS", "SOC2", "OWASP", "HIPAA"])
|
|
39
|
+
min_severity: str = "info"
|
|
40
|
+
include_executive_summary: bool = True
|
|
41
|
+
include_remediation_timeline: bool = True
|
|
42
|
+
title: str = "Security Scan Report"
|
|
43
|
+
organization: str = ""
|
|
44
|
+
project_name: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ReportResult:
|
|
49
|
+
"""Result of report generation."""
|
|
50
|
+
|
|
51
|
+
success: bool
|
|
52
|
+
output_files: list[Path] = field(default_factory=list)
|
|
53
|
+
errors: list[str] = field(default_factory=list)
|
|
54
|
+
warnings: list[str] = field(default_factory=list)
|
|
55
|
+
generation_time_ms: int = 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ReportMetadata:
|
|
60
|
+
"""Metadata included in generated reports."""
|
|
61
|
+
|
|
62
|
+
generated_at: str
|
|
63
|
+
generator_version: str
|
|
64
|
+
findings_count: int
|
|
65
|
+
frameworks_mapped: list[str]
|
|
66
|
+
content_hash: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def generate_report(
|
|
70
|
+
findings: Sequence[Finding],
|
|
71
|
+
output_dir: Path,
|
|
72
|
+
config: ReportConfig | None = None,
|
|
73
|
+
) -> ReportResult:
|
|
74
|
+
"""Generate reports in specified formats.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
findings: Security findings to include
|
|
78
|
+
output_dir: Directory for output files
|
|
79
|
+
config: Report configuration
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
ReportResult with output files and status
|
|
83
|
+
"""
|
|
84
|
+
generator = ReportGenerator(config or ReportConfig())
|
|
85
|
+
return generator.generate(findings, output_dir)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ReportGenerator:
|
|
89
|
+
"""Orchestrates report generation across formats."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, config: ReportConfig) -> None:
|
|
92
|
+
self.config = config
|
|
93
|
+
|
|
94
|
+
def generate(
|
|
95
|
+
self,
|
|
96
|
+
findings: Sequence[Finding],
|
|
97
|
+
output_dir: Path,
|
|
98
|
+
) -> ReportResult:
|
|
99
|
+
"""Generate reports for all configured formats."""
|
|
100
|
+
import time
|
|
101
|
+
|
|
102
|
+
start_time = time.monotonic()
|
|
103
|
+
result = ReportResult(success=True)
|
|
104
|
+
|
|
105
|
+
# Ensure output directory exists
|
|
106
|
+
output_dir = output_dir.expanduser().resolve()
|
|
107
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
# Filter findings by severity
|
|
110
|
+
filtered = self._filter_by_severity(list(findings))
|
|
111
|
+
|
|
112
|
+
# Map to compliance frameworks
|
|
113
|
+
from kekkai.compliance import map_findings_to_all_frameworks
|
|
114
|
+
|
|
115
|
+
compliance_result = map_findings_to_all_frameworks(filtered)
|
|
116
|
+
|
|
117
|
+
# Build report data
|
|
118
|
+
report_data = self._build_report_data(filtered, compliance_result)
|
|
119
|
+
|
|
120
|
+
# Generate each format
|
|
121
|
+
for fmt in self.config.formats:
|
|
122
|
+
try:
|
|
123
|
+
output_path = self._generate_format(fmt, report_data, output_dir)
|
|
124
|
+
if output_path:
|
|
125
|
+
result.output_files.append(output_path)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
result.errors.append(f"Failed to generate {fmt.value}: {e}")
|
|
128
|
+
result.success = False
|
|
129
|
+
|
|
130
|
+
result.generation_time_ms = int((time.monotonic() - start_time) * 1000)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def _filter_by_severity(self, findings: list[Finding]) -> list[Finding]:
|
|
134
|
+
"""Filter findings by minimum severity."""
|
|
135
|
+
from kekkai.scanners.base import Severity
|
|
136
|
+
|
|
137
|
+
severity_order = [
|
|
138
|
+
Severity.CRITICAL,
|
|
139
|
+
Severity.HIGH,
|
|
140
|
+
Severity.MEDIUM,
|
|
141
|
+
Severity.LOW,
|
|
142
|
+
Severity.INFO,
|
|
143
|
+
Severity.UNKNOWN,
|
|
144
|
+
]
|
|
145
|
+
min_sev = Severity.from_string(self.config.min_severity)
|
|
146
|
+
try:
|
|
147
|
+
min_index = severity_order.index(min_sev)
|
|
148
|
+
except ValueError:
|
|
149
|
+
min_index = len(severity_order) - 1
|
|
150
|
+
|
|
151
|
+
return [f for f in findings if severity_order.index(f.severity) <= min_index]
|
|
152
|
+
|
|
153
|
+
def _build_report_data(
|
|
154
|
+
self,
|
|
155
|
+
findings: list[Finding],
|
|
156
|
+
compliance_result: ComplianceMappingResult,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Build unified report data structure."""
|
|
159
|
+
from kekkai.output import VERSION
|
|
160
|
+
|
|
161
|
+
# Calculate content hash for integrity
|
|
162
|
+
content = json.dumps(
|
|
163
|
+
[f.dedupe_hash() for f in findings],
|
|
164
|
+
sort_keys=True,
|
|
165
|
+
)
|
|
166
|
+
content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
167
|
+
|
|
168
|
+
metadata = ReportMetadata(
|
|
169
|
+
generated_at=datetime.now(UTC).isoformat(),
|
|
170
|
+
generator_version=VERSION,
|
|
171
|
+
findings_count=len(findings),
|
|
172
|
+
frameworks_mapped=list(compliance_result.framework_summary.keys()),
|
|
173
|
+
content_hash=content_hash,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Severity counts
|
|
177
|
+
severity_counts = self._count_by_severity(findings)
|
|
178
|
+
|
|
179
|
+
# Executive summary
|
|
180
|
+
executive_summary = self._build_executive_summary(findings, compliance_result)
|
|
181
|
+
|
|
182
|
+
# Remediation timeline
|
|
183
|
+
remediation_timeline = self._build_remediation_timeline(findings)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"metadata": metadata,
|
|
187
|
+
"config": self.config,
|
|
188
|
+
"findings": findings,
|
|
189
|
+
"compliance": compliance_result,
|
|
190
|
+
"severity_counts": severity_counts,
|
|
191
|
+
"executive_summary": executive_summary,
|
|
192
|
+
"remediation_timeline": remediation_timeline,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def _count_by_severity(self, findings: list[Finding]) -> dict[str, int]:
|
|
196
|
+
"""Count findings by severity."""
|
|
197
|
+
counts: dict[str, int] = {
|
|
198
|
+
"critical": 0,
|
|
199
|
+
"high": 0,
|
|
200
|
+
"medium": 0,
|
|
201
|
+
"low": 0,
|
|
202
|
+
"info": 0,
|
|
203
|
+
}
|
|
204
|
+
for f in findings:
|
|
205
|
+
key = f.severity.value
|
|
206
|
+
if key in counts:
|
|
207
|
+
counts[key] += 1
|
|
208
|
+
return counts
|
|
209
|
+
|
|
210
|
+
def _build_executive_summary(
|
|
211
|
+
self,
|
|
212
|
+
findings: list[Finding],
|
|
213
|
+
compliance_result: ComplianceMappingResult,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
"""Build executive summary section."""
|
|
216
|
+
severity_counts = self._count_by_severity(findings)
|
|
217
|
+
|
|
218
|
+
# Risk score (simple weighted calculation)
|
|
219
|
+
risk_score = (
|
|
220
|
+
severity_counts["critical"] * 10
|
|
221
|
+
+ severity_counts["high"] * 5
|
|
222
|
+
+ severity_counts["medium"] * 2
|
|
223
|
+
+ severity_counts["low"] * 1
|
|
224
|
+
)
|
|
225
|
+
max_possible = len(findings) * 10 if findings else 1
|
|
226
|
+
risk_percentage = min(100, int((risk_score / max_possible) * 100))
|
|
227
|
+
|
|
228
|
+
# Risk level
|
|
229
|
+
if risk_percentage >= 70:
|
|
230
|
+
risk_level = "Critical"
|
|
231
|
+
elif risk_percentage >= 50:
|
|
232
|
+
risk_level = "High"
|
|
233
|
+
elif risk_percentage >= 30:
|
|
234
|
+
risk_level = "Medium"
|
|
235
|
+
elif risk_percentage > 0:
|
|
236
|
+
risk_level = "Low"
|
|
237
|
+
else:
|
|
238
|
+
risk_level = "None"
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
"total_findings": len(findings),
|
|
242
|
+
"severity_counts": severity_counts,
|
|
243
|
+
"risk_score": risk_score,
|
|
244
|
+
"risk_percentage": risk_percentage,
|
|
245
|
+
"risk_level": risk_level,
|
|
246
|
+
"frameworks_impacted": compliance_result.framework_summary,
|
|
247
|
+
"top_issues": self._get_top_issues(findings),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def _get_top_issues(self, findings: list[Finding], limit: int = 5) -> list[dict[str, Any]]:
|
|
251
|
+
"""Get top issues by severity."""
|
|
252
|
+
sorted_findings = sorted(
|
|
253
|
+
findings,
|
|
254
|
+
key=lambda f: (
|
|
255
|
+
["critical", "high", "medium", "low", "info", "unknown"].index(f.severity.value),
|
|
256
|
+
f.title,
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
return [
|
|
260
|
+
{
|
|
261
|
+
"title": f.title,
|
|
262
|
+
"severity": f.severity.value,
|
|
263
|
+
"file": f.file_path,
|
|
264
|
+
"rule_id": f.rule_id,
|
|
265
|
+
}
|
|
266
|
+
for f in sorted_findings[:limit]
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
def _build_remediation_timeline(self, findings: list[Finding]) -> dict[str, Any]:
|
|
270
|
+
"""Build remediation timeline recommendations."""
|
|
271
|
+
severity_counts = self._count_by_severity(findings)
|
|
272
|
+
|
|
273
|
+
# SLA recommendations based on industry standards
|
|
274
|
+
return {
|
|
275
|
+
"immediate": {
|
|
276
|
+
"description": "Address within 24 hours",
|
|
277
|
+
"count": severity_counts["critical"],
|
|
278
|
+
"severity": "critical",
|
|
279
|
+
},
|
|
280
|
+
"urgent": {
|
|
281
|
+
"description": "Address within 7 days",
|
|
282
|
+
"count": severity_counts["high"],
|
|
283
|
+
"severity": "high",
|
|
284
|
+
},
|
|
285
|
+
"standard": {
|
|
286
|
+
"description": "Address within 30 days",
|
|
287
|
+
"count": severity_counts["medium"],
|
|
288
|
+
"severity": "medium",
|
|
289
|
+
},
|
|
290
|
+
"planned": {
|
|
291
|
+
"description": "Address within 90 days",
|
|
292
|
+
"count": severity_counts["low"],
|
|
293
|
+
"severity": "low",
|
|
294
|
+
},
|
|
295
|
+
"informational": {
|
|
296
|
+
"description": "Review and document",
|
|
297
|
+
"count": severity_counts["info"],
|
|
298
|
+
"severity": "info",
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
def _generate_format(
|
|
303
|
+
self,
|
|
304
|
+
fmt: ReportFormat,
|
|
305
|
+
report_data: dict[str, Any],
|
|
306
|
+
output_dir: Path,
|
|
307
|
+
) -> Path | None:
|
|
308
|
+
"""Generate a specific report format."""
|
|
309
|
+
if fmt == ReportFormat.HTML:
|
|
310
|
+
from .html import HTMLReportGenerator
|
|
311
|
+
|
|
312
|
+
html_gen = HTMLReportGenerator()
|
|
313
|
+
return html_gen.generate(report_data, output_dir)
|
|
314
|
+
|
|
315
|
+
if fmt == ReportFormat.PDF:
|
|
316
|
+
from .pdf import PDFReportGenerator
|
|
317
|
+
|
|
318
|
+
pdf_gen = PDFReportGenerator()
|
|
319
|
+
return pdf_gen.generate(report_data, output_dir)
|
|
320
|
+
|
|
321
|
+
if fmt == ReportFormat.COMPLIANCE:
|
|
322
|
+
from .compliance_matrix import generate_compliance_matrix
|
|
323
|
+
|
|
324
|
+
return generate_compliance_matrix(report_data, output_dir)
|
|
325
|
+
|
|
326
|
+
if fmt == ReportFormat.JSON:
|
|
327
|
+
return self._generate_json(report_data, output_dir)
|
|
328
|
+
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
def _generate_json(self, report_data: dict[str, Any], output_dir: Path) -> Path:
|
|
332
|
+
"""Generate JSON report."""
|
|
333
|
+
output_path = output_dir / "report.json"
|
|
334
|
+
|
|
335
|
+
# Convert dataclasses to dicts for JSON serialization
|
|
336
|
+
json_data = {
|
|
337
|
+
"metadata": {
|
|
338
|
+
"generated_at": report_data["metadata"].generated_at,
|
|
339
|
+
"generator_version": report_data["metadata"].generator_version,
|
|
340
|
+
"findings_count": report_data["metadata"].findings_count,
|
|
341
|
+
"frameworks_mapped": report_data["metadata"].frameworks_mapped,
|
|
342
|
+
"content_hash": report_data["metadata"].content_hash,
|
|
343
|
+
},
|
|
344
|
+
"executive_summary": report_data["executive_summary"],
|
|
345
|
+
"remediation_timeline": report_data["remediation_timeline"],
|
|
346
|
+
"severity_counts": report_data["severity_counts"],
|
|
347
|
+
"compliance_summary": report_data["compliance"].framework_summary,
|
|
348
|
+
"findings": [
|
|
349
|
+
{
|
|
350
|
+
"title": f.title,
|
|
351
|
+
"severity": f.severity.value,
|
|
352
|
+
"scanner": f.scanner,
|
|
353
|
+
"file_path": f.file_path,
|
|
354
|
+
"line": f.line,
|
|
355
|
+
"rule_id": f.rule_id,
|
|
356
|
+
"cwe": f.cwe,
|
|
357
|
+
"cve": f.cve,
|
|
358
|
+
"description": f.description,
|
|
359
|
+
}
|
|
360
|
+
for f in report_data["findings"]
|
|
361
|
+
],
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
output_path.write_text(json.dumps(json_data, indent=2))
|
|
365
|
+
return output_path
|
kekkai/report/html.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""HTML report generator.
|
|
2
|
+
|
|
3
|
+
Uses Jinja2 for templating with autoescaping enabled for XSS prevention.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HTMLReportGenerator:
|
|
15
|
+
"""Generates HTML security reports."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.env = Environment(
|
|
19
|
+
loader=PackageLoader("kekkai.report", "templates"),
|
|
20
|
+
autoescape=select_autoescape(["html", "xml"]),
|
|
21
|
+
trim_blocks=True,
|
|
22
|
+
lstrip_blocks=True,
|
|
23
|
+
)
|
|
24
|
+
# Add custom filters
|
|
25
|
+
self.env.filters["severity_class"] = self._severity_class
|
|
26
|
+
self.env.filters["severity_badge"] = self._severity_badge
|
|
27
|
+
|
|
28
|
+
def generate(self, report_data: dict[str, Any], output_dir: Path) -> Path:
|
|
29
|
+
"""Generate HTML report file."""
|
|
30
|
+
template = self.env.get_template("report.html")
|
|
31
|
+
|
|
32
|
+
html_content = template.render(
|
|
33
|
+
metadata=report_data["metadata"],
|
|
34
|
+
config=report_data["config"],
|
|
35
|
+
findings=report_data["findings"],
|
|
36
|
+
compliance=report_data["compliance"],
|
|
37
|
+
severity_counts=report_data["severity_counts"],
|
|
38
|
+
executive_summary=report_data["executive_summary"],
|
|
39
|
+
remediation_timeline=report_data["remediation_timeline"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
output_path = output_dir / "report.html"
|
|
43
|
+
output_path.write_text(html_content, encoding="utf-8")
|
|
44
|
+
return output_path
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _severity_class(severity: str) -> str:
|
|
48
|
+
"""Return CSS class for severity level."""
|
|
49
|
+
classes = {
|
|
50
|
+
"critical": "severity-critical",
|
|
51
|
+
"high": "severity-high",
|
|
52
|
+
"medium": "severity-medium",
|
|
53
|
+
"low": "severity-low",
|
|
54
|
+
"info": "severity-info",
|
|
55
|
+
}
|
|
56
|
+
return classes.get(severity.lower(), "severity-unknown")
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _severity_badge(severity: str) -> str:
|
|
60
|
+
"""Return badge HTML for severity level."""
|
|
61
|
+
colors = {
|
|
62
|
+
"critical": "#dc3545",
|
|
63
|
+
"high": "#fd7e14",
|
|
64
|
+
"medium": "#ffc107",
|
|
65
|
+
"low": "#17a2b8",
|
|
66
|
+
"info": "#6c757d",
|
|
67
|
+
}
|
|
68
|
+
color = colors.get(severity.lower(), "#6c757d")
|
|
69
|
+
return f'<span class="badge" style="background-color: {color};">{severity.upper()}</span>'
|
kekkai/report/pdf.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""PDF report generator.
|
|
2
|
+
|
|
3
|
+
Uses weasyprint for HTML-to-PDF conversion if available.
|
|
4
|
+
Falls back to HTML-only with warning if weasyprint is not installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .html import HTMLReportGenerator
|
|
13
|
+
|
|
14
|
+
# Check weasyprint availability
|
|
15
|
+
_WEASYPRINT_AVAILABLE = False
|
|
16
|
+
try:
|
|
17
|
+
from weasyprint import HTML as WeasyprintHTML # type: ignore[import-not-found]
|
|
18
|
+
|
|
19
|
+
_WEASYPRINT_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
WeasyprintHTML = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PDFReportGenerator:
|
|
25
|
+
"""Generates PDF security reports.
|
|
26
|
+
|
|
27
|
+
Requires weasyprint to be installed. If not available,
|
|
28
|
+
falls back to HTML generation with a warning.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.html_generator = HTMLReportGenerator()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_available(self) -> bool:
|
|
36
|
+
"""Check if PDF generation is available."""
|
|
37
|
+
return _WEASYPRINT_AVAILABLE
|
|
38
|
+
|
|
39
|
+
def generate(self, report_data: dict[str, Any], output_dir: Path) -> Path:
|
|
40
|
+
"""Generate PDF report file.
|
|
41
|
+
|
|
42
|
+
If weasyprint is not available, generates HTML instead.
|
|
43
|
+
"""
|
|
44
|
+
# First generate HTML
|
|
45
|
+
html_path = self.html_generator.generate(report_data, output_dir)
|
|
46
|
+
|
|
47
|
+
if not _WEASYPRINT_AVAILABLE:
|
|
48
|
+
# Return HTML path with warning - caller should handle
|
|
49
|
+
return html_path
|
|
50
|
+
|
|
51
|
+
# Convert HTML to PDF
|
|
52
|
+
pdf_path = output_dir / "report.pdf"
|
|
53
|
+
|
|
54
|
+
html_content = html_path.read_text(encoding="utf-8")
|
|
55
|
+
html_doc = WeasyprintHTML(string=html_content, base_url=str(output_dir))
|
|
56
|
+
html_doc.write_pdf(pdf_path)
|
|
57
|
+
|
|
58
|
+
return pdf_path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_pdf_available() -> bool:
|
|
62
|
+
"""Check if PDF generation is available."""
|
|
63
|
+
return _WEASYPRINT_AVAILABLE
|
kekkai/scanners/container.py
CHANGED
|
@@ -36,6 +36,19 @@ def docker_command() -> str:
|
|
|
36
36
|
return docker
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def _resolve_image_ref(image: str, digest: str | None) -> str:
|
|
40
|
+
"""Resolve full image reference with tag or digest.
|
|
41
|
+
|
|
42
|
+
Ensures images have explicit :latest tag when no digest or tag is provided
|
|
43
|
+
for reliable cross-platform pulling.
|
|
44
|
+
"""
|
|
45
|
+
if digest:
|
|
46
|
+
return f"{image}@{digest}"
|
|
47
|
+
if ":" not in image:
|
|
48
|
+
return f"{image}:latest"
|
|
49
|
+
return image
|
|
50
|
+
|
|
51
|
+
|
|
39
52
|
def run_container(
|
|
40
53
|
config: ContainerConfig,
|
|
41
54
|
repo_path: Path,
|
|
@@ -61,7 +74,7 @@ def run_container(
|
|
|
61
74
|
user: User to run as (default: 1000:1000, None for container default)
|
|
62
75
|
"""
|
|
63
76
|
docker = docker_command()
|
|
64
|
-
image_ref =
|
|
77
|
+
image_ref = _resolve_image_ref(config.image, config.image_digest)
|
|
65
78
|
|
|
66
79
|
args = [
|
|
67
80
|
docker,
|
|
@@ -73,7 +86,20 @@ def run_container(
|
|
|
73
86
|
args.extend(["--user", user])
|
|
74
87
|
|
|
75
88
|
if config.read_only:
|
|
76
|
-
args.extend(["--read-only"
|
|
89
|
+
args.extend(["--read-only"])
|
|
90
|
+
# Determine uid/gid for tmpfs ownership (match container user)
|
|
91
|
+
tmpfs_opts = "rw"
|
|
92
|
+
if user:
|
|
93
|
+
uid_gid = user.split(":")[0]
|
|
94
|
+
tmpfs_opts = f"rw,uid={uid_gid},gid={uid_gid}"
|
|
95
|
+
# Core temp directory (2GB for Trivy DB ~500MB + scanner temp files)
|
|
96
|
+
args.extend(["--tmpfs", f"/tmp:{tmpfs_opts},noexec,nosuid,size=2g"]) # nosec B108 # noqa: S108
|
|
97
|
+
# Scanner cache directories (Trivy DB, Semgrep cache, etc.)
|
|
98
|
+
args.extend(["--tmpfs", f"/root:{tmpfs_opts},size=1g"])
|
|
99
|
+
# Generic home for tools that need writable home
|
|
100
|
+
args.extend(["--tmpfs", f"/home:{tmpfs_opts},size=256m"])
|
|
101
|
+
# Set HOME env to writable location for tools that use $HOME/.cache
|
|
102
|
+
args.extend(["-e", "HOME=/tmp"])
|
|
77
103
|
|
|
78
104
|
if config.network_disabled:
|
|
79
105
|
args.extend(["--network", "none"])
|
|
@@ -132,8 +158,12 @@ def run_container(
|
|
|
132
158
|
|
|
133
159
|
|
|
134
160
|
def pull_image(image: str, digest: str | None = None) -> bool:
|
|
161
|
+
"""Pull a Docker image.
|
|
162
|
+
|
|
163
|
+
Uses _resolve_image_ref to ensure proper tag handling.
|
|
164
|
+
"""
|
|
135
165
|
docker = docker_command()
|
|
136
|
-
ref =
|
|
166
|
+
ref = _resolve_image_ref(image, digest)
|
|
137
167
|
proc = subprocess.run( # noqa: S603 # nosec B603
|
|
138
168
|
[docker, "pull", ref],
|
|
139
169
|
capture_output=True,
|
kekkai/scanners/gitleaks.py
CHANGED
|
@@ -16,7 +16,7 @@ from .base import Finding, ScanContext, ScanResult, Severity
|
|
|
16
16
|
from .container import ContainerConfig, run_container
|
|
17
17
|
|
|
18
18
|
GITLEAKS_IMAGE = "zricethezav/gitleaks"
|
|
19
|
-
GITLEAKS_DIGEST =
|
|
19
|
+
GITLEAKS_DIGEST: str | None = None # Allow Docker to pull architecture-appropriate image
|
|
20
20
|
SCAN_TYPE = "Gitleaks Scan"
|
|
21
21
|
|
|
22
22
|
|
|
@@ -85,6 +85,7 @@ class GitleaksScanner:
|
|
|
85
85
|
"detect",
|
|
86
86
|
"--source",
|
|
87
87
|
"/repo",
|
|
88
|
+
"--no-git", # Scan all files, not just git-tracked
|
|
88
89
|
"--report-format",
|
|
89
90
|
"json",
|
|
90
91
|
"--report-path",
|
|
@@ -125,6 +126,7 @@ class GitleaksScanner:
|
|
|
125
126
|
"detect",
|
|
126
127
|
"--source",
|
|
127
128
|
str(ctx.repo_path),
|
|
129
|
+
"--no-git", # Scan all files, not just git-tracked
|
|
128
130
|
"--report-format",
|
|
129
131
|
"json",
|
|
130
132
|
"--report-path",
|
kekkai/scanners/semgrep.py
CHANGED
|
@@ -16,7 +16,7 @@ from .base import Finding, ScanContext, ScanResult, Severity
|
|
|
16
16
|
from .container import ContainerConfig, run_container
|
|
17
17
|
|
|
18
18
|
SEMGREP_IMAGE = "returntocorp/semgrep"
|
|
19
|
-
SEMGREP_DIGEST =
|
|
19
|
+
SEMGREP_DIGEST: str | None = None # Allow Docker to pull architecture-appropriate image
|
|
20
20
|
SCAN_TYPE = "Semgrep JSON Report"
|
|
21
21
|
|
|
22
22
|
|
kekkai/scanners/trivy.py
CHANGED
|
@@ -15,7 +15,7 @@ from .base import Finding, ScanContext, ScanResult, Severity
|
|
|
15
15
|
from .container import ContainerConfig, run_container
|
|
16
16
|
|
|
17
17
|
TRIVY_IMAGE = "aquasec/trivy"
|
|
18
|
-
TRIVY_DIGEST =
|
|
18
|
+
TRIVY_DIGEST: str | None = None # Allow Docker to pull architecture-appropriate image
|
|
19
19
|
SCAN_TYPE = "Trivy Scan"
|
|
20
20
|
|
|
21
21
|
|