Bericht zusätzlich als PDF exportieren
Adds a "Als PDF exportieren" button to the scan report page next to the existing JSON export, fulfilling Task #3. Implementation: - artifacts/skillguard/src/pages/scan-report.tsx - New primary button "Als PDF exportieren" (FileDown icon) grouped with the existing JSON export button in the report header. - handleExportPdf opens a new window, writes a self-contained print- friendly HTML document, and triggers window.print() (browser "Als PDF speichern"). No new dependencies added. - buildReportHtml(data) generates the document containing: title + verdict, metadata (date, source, file count, KI flag), optional AI warning, Risiko-Score with summary text, Achsen-Zusammenfassung table (severity + axis counts incl. total), all findings (severity/axis/ rule/detection, location, description, snippet, remediation), and the checked files table. - All labels are German via VERDICT/SEVERITY/AXIS/SOURCE/KIND label maps; no emojis used; print-friendly inline CSS with page-break-inside avoid on findings. - User-provided content is escaped via escapeHtml to prevent HTML injection in the generated document. Verification: - pnpm --filter @workspace/skillguard typecheck passes. - Created test scan (id 8) via API, confirmed button renders and German label mapping is correct for verdict/severity/axis. Deviations: none. Chose the browser print-to-PDF approach (zero new deps) over a client-side PDF library, which keeps the bundle lean and produces selectable, print-friendly output. Replit-Task-Id: e3f37193-89fd-42de-8cec-9383c8406b25
This commit is contained in:
parent
9f7b67972f
commit
87d71c1dca
2 changed files with 213 additions and 5 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -11,7 +11,8 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { VerdictBadge, SeverityBadge, AxisBadge } from "@/components/ui-helpers";
|
import { VerdictBadge, SeverityBadge, AxisBadge } from "@/components/ui-helpers";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield } from "lucide-react";
|
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown } from "lucide-react";
|
||||||
|
import type { ScanDetail } from "@workspace/api-client-react";
|
||||||
|
|
||||||
export default function ScanReport() {
|
export default function ScanReport() {
|
||||||
const [, params] = useRoute("/berichte/:id");
|
const [, params] = useRoute("/berichte/:id");
|
||||||
|
|
@ -78,6 +79,19 @@ export default function ScanReport() {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportPdf = () => {
|
||||||
|
const html = buildReportHtml(data);
|
||||||
|
const printWindow = window.open("", "_blank");
|
||||||
|
if (!printWindow) return;
|
||||||
|
printWindow.document.open();
|
||||||
|
printWindow.document.write(html);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.print();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-12">
|
<div className="space-y-6 pb-12">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
|
@ -100,10 +114,16 @@ export default function ScanReport() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleExport} variant="outline" className="gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Download className="w-4 h-4" />
|
<Button onClick={handleExportPdf} variant="default" className="gap-2">
|
||||||
Bericht exportieren (JSON)
|
<FileDown className="w-4 h-4" />
|
||||||
</Button>
|
Als PDF exportieren
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport} variant="outline" className="gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Bericht exportieren (JSON)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.aiError && (
|
{data.aiError && (
|
||||||
|
|
@ -337,3 +357,191 @@ export default function ScanReport() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VERDICT_LABELS: Record<string, string> = {
|
||||||
|
pass: "Freigabe",
|
||||||
|
review: "Manuelle Prüfung",
|
||||||
|
block: "Blockieren",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEVERITY_LABELS: Record<string, string> = {
|
||||||
|
critical: "Kritisch",
|
||||||
|
high: "Hoch",
|
||||||
|
medium: "Mittel",
|
||||||
|
low: "Niedrig",
|
||||||
|
info: "Info",
|
||||||
|
};
|
||||||
|
|
||||||
|
const AXIS_LABELS: Record<string, string> = {
|
||||||
|
security: "IT-Sicherheit",
|
||||||
|
privacy: "Datenschutz",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
upload: "Upload",
|
||||||
|
url: "URL",
|
||||||
|
paste: "Einfügung",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<string, string> = {
|
||||||
|
instruction: "Anweisung",
|
||||||
|
script: "Skript",
|
||||||
|
resource: "Ressource",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(value: unknown): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskSummary(score: number): string {
|
||||||
|
if (score < 30) return "Geringes Risiko. Keine bedenklichen Muster gefunden.";
|
||||||
|
if (score < 70) return "Mittleres Risiko. Einige Auffälligkeiten erfordern Prüfung.";
|
||||||
|
return "Hohes Risiko. Kritische Sicherheitsprobleme erkannt.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReportHtml(data: ScanDetail): string {
|
||||||
|
const title = data.name || `Scan #${data.id}`;
|
||||||
|
const verdict = VERDICT_LABELS[data.verdict] ?? data.verdict;
|
||||||
|
const source = SOURCE_LABELS[data.source] ?? data.source;
|
||||||
|
const counts = data.findingCounts;
|
||||||
|
|
||||||
|
const findingsHtml = data.findings.length === 0
|
||||||
|
? `<p class="empty">Keine Auffälligkeiten gefunden. Der analysierte Skill entspricht den Sicherheits- und Datenschutzrichtlinien.</p>`
|
||||||
|
: data.findings.map((f, i) => {
|
||||||
|
const location = (f.file || f.line)
|
||||||
|
? `<div class="meta-line">Fundstelle: ${escapeHtml(f.file || "unbekannt")}${f.line ? `:${escapeHtml(f.line)}` : ""}</div>`
|
||||||
|
: "";
|
||||||
|
const snippet = f.snippet
|
||||||
|
? `<pre class="snippet">${escapeHtml(f.snippet)}</pre>`
|
||||||
|
: "";
|
||||||
|
const remediation = f.remediation
|
||||||
|
? `<div class="remediation"><strong>Empfehlung:</strong> ${escapeHtml(f.remediation)}</div>`
|
||||||
|
: "";
|
||||||
|
return `
|
||||||
|
<div class="finding sev-${escapeHtml(f.severity)}">
|
||||||
|
<div class="finding-head">
|
||||||
|
<span class="finding-num">${i + 1}.</span>
|
||||||
|
<span class="finding-title">${escapeHtml(f.title)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="badges">
|
||||||
|
<span class="tag">Schweregrad: ${escapeHtml(SEVERITY_LABELS[f.severity] ?? f.severity)}</span>
|
||||||
|
<span class="tag">Bereich: ${escapeHtml(AXIS_LABELS[f.axis] ?? f.axis)}</span>
|
||||||
|
<span class="tag">Regel: ${escapeHtml(f.ruleId)}</span>
|
||||||
|
<span class="tag">Erkennung: ${f.detectedBy === "ai" ? "KI" : "Statisch"}</span>
|
||||||
|
</div>
|
||||||
|
${location}
|
||||||
|
<p class="finding-desc">${escapeHtml(f.description)}</p>
|
||||||
|
${snippet}
|
||||||
|
${remediation}
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const aiWarning = data.aiError
|
||||||
|
? `<div class="warning">KI-Analyse nicht durchgeführt: ${escapeHtml(data.aiError)}. Die statische Analyse wurde dennoch abgeschlossen.</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>SkillGuard Bericht - ${escapeHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: Arial, Helvetica, sans-serif; color: #1e293b; margin: 32px; font-size: 12px; line-height: 1.5; }
|
||||||
|
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 15px; margin: 24px 0 8px; border-bottom: 1px solid #cbd5e1; padding-bottom: 4px; }
|
||||||
|
.subtitle { color: #475569; margin: 0 0 16px; font-size: 12px; }
|
||||||
|
.verdict { display: inline-block; padding: 2px 10px; border: 1px solid #475569; border-radius: 4px; font-weight: bold; }
|
||||||
|
.summary-grid { display: flex; gap: 16px; margin: 12px 0; flex-wrap: wrap; }
|
||||||
|
.score-box { border: 1px solid #cbd5e1; border-radius: 6px; padding: 12px 18px; text-align: center; min-width: 120px; }
|
||||||
|
.score-num { font-size: 32px; font-weight: bold; }
|
||||||
|
.score-label { color: #64748b; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
||||||
|
th, td { border: 1px solid #cbd5e1; padding: 6px 8px; text-align: left; }
|
||||||
|
th { background: #f1f5f9; }
|
||||||
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.warning { border: 1px solid #f59e0b; background: #fffbeb; padding: 8px 12px; border-radius: 4px; margin: 12px 0; }
|
||||||
|
.finding { border: 1px solid #cbd5e1; border-left-width: 4px; border-radius: 4px; padding: 10px 12px; margin: 10px 0; page-break-inside: avoid; }
|
||||||
|
.finding.sev-critical { border-left-color: #e11d48; }
|
||||||
|
.finding.sev-high { border-left-color: #f97316; }
|
||||||
|
.finding.sev-medium { border-left-color: #f59e0b; }
|
||||||
|
.finding.sev-low { border-left-color: #3b82f6; }
|
||||||
|
.finding.sev-info { border-left-color: #94a3b8; }
|
||||||
|
.finding-head { font-size: 14px; font-weight: bold; margin-bottom: 6px; }
|
||||||
|
.finding-num { color: #64748b; margin-right: 4px; }
|
||||||
|
.badges { margin: 4px 0 8px; }
|
||||||
|
.tag { display: inline-block; background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 3px; padding: 1px 6px; margin: 0 4px 4px 0; font-size: 11px; }
|
||||||
|
.meta-line { color: #475569; font-family: "Courier New", monospace; font-size: 11px; margin-bottom: 6px; }
|
||||||
|
.finding-desc { margin: 6px 0; }
|
||||||
|
.snippet { background: #0f172a; color: #f1f5f9; padding: 8px 10px; border-radius: 4px; font-family: "Courier New", monospace; font-size: 11px; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.remediation { background: #ecfdf5; border: 1px solid #a7f3d0; border-radius: 4px; padding: 8px 10px; margin-top: 8px; }
|
||||||
|
.empty { color: #475569; }
|
||||||
|
.footer { margin-top: 24px; color: #94a3b8; font-size: 10px; border-top: 1px solid #e2e8f0; padding-top: 8px; }
|
||||||
|
@media print { body { margin: 0; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>SkillGuard Sicherheitsbericht</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
${escapeHtml(title)} | <span class="verdict">${escapeHtml(verdict)}</span><br />
|
||||||
|
Erstellt am ${escapeHtml(formatDate(data.createdAt))} | Quelle: ${escapeHtml(source)} | ${escapeHtml(data.fileCount)} ${data.fileCount === 1 ? "Datei" : "Dateien"}${data.aiUsed ? " | KI-Analyse aktiv" : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${aiWarning}
|
||||||
|
|
||||||
|
<h2>Risiko-Score</h2>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="score-box">
|
||||||
|
<div class="score-num">${escapeHtml(data.riskScore)}</div>
|
||||||
|
<div class="score-label">/ 100</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:200px; align-self:center;">${riskSummary(data.riskScore)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Achsen-Zusammenfassung</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Kennzahl</th><th>Anzahl</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Kritisch</td><td class="num">${escapeHtml(counts.critical)}</td></tr>
|
||||||
|
<tr><td>Hoch</td><td class="num">${escapeHtml(counts.high)}</td></tr>
|
||||||
|
<tr><td>Mittel</td><td class="num">${escapeHtml(counts.medium)}</td></tr>
|
||||||
|
<tr><td>Niedrig</td><td class="num">${escapeHtml(counts.low)}</td></tr>
|
||||||
|
<tr><td>Info</td><td class="num">${escapeHtml(counts.info)}</td></tr>
|
||||||
|
<tr><td>IT-Sicherheit</td><td class="num">${escapeHtml(counts.security)}</td></tr>
|
||||||
|
<tr><td>Datenschutz</td><td class="num">${escapeHtml(counts.privacy)}</td></tr>
|
||||||
|
<tr><td><strong>Gesamt</strong></td><td class="num"><strong>${escapeHtml(counts.total)}</strong></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Auffälligkeiten (${data.findings.length})</h2>
|
||||||
|
${findingsHtml}
|
||||||
|
|
||||||
|
<h2>Geprüfte Dateien (${data.files.length})</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Pfad</th><th>Typ</th><th>Sprache</th><th>Größe</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${data.files.length === 0
|
||||||
|
? `<tr><td colspan="4">Keine Dateien verfügbar.</td></tr>`
|
||||||
|
: data.files.map(file => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(file.path)}</td>
|
||||||
|
<td>${escapeHtml(KIND_LABELS[file.kind] ?? file.kind)}</td>
|
||||||
|
<td>${escapeHtml(file.language || "-")}</td>
|
||||||
|
<td class="num">${escapeHtml(file.size)} B</td>
|
||||||
|
</tr>`).join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="footer">SkillGuard - Erstellt am ${escapeHtml(formatDate(new Date()))}</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue