|
| 1 | +"""Shareable SVG artifacts: an embeddable grade badge and a social share card. |
| 2 | +
|
| 3 | +Small businesses and agencies share a result, not a JSON file. The badge is |
| 4 | +meant for a website footer ("scanned by SentinelDeck — Grade A"); the card is a |
| 5 | +1200x630 image sized for link previews on X, LinkedIn, and Slack. |
| 6 | +""" |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +from html import escape |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +from sentineldeck.models import ScanReport |
| 13 | + |
| 14 | +GRADE_COLORS = { |
| 15 | + "A": "#22c55e", |
| 16 | + "B": "#84cc16", |
| 17 | + "C": "#f59e0b", |
| 18 | + "D": "#f97316", |
| 19 | + "F": "#ef4444", |
| 20 | +} |
| 21 | +DEFAULT_GRADE_COLOR = "#64748b" |
| 22 | +SEVERITY_ORDER = ("critical", "high", "medium", "low", "info") |
| 23 | + |
| 24 | + |
| 25 | +def grade_color(grade: str) -> str: |
| 26 | + return GRADE_COLORS.get(grade.upper(), DEFAULT_GRADE_COLOR) |
| 27 | + |
| 28 | + |
| 29 | +def _severity_counts(report: ScanReport) -> dict[str, int]: |
| 30 | + counts = {severity: 0 for severity in SEVERITY_ORDER} |
| 31 | + for finding in report.findings: |
| 32 | + severity = finding.severity.lower() |
| 33 | + counts[severity if severity in counts else "info"] += 1 |
| 34 | + return counts |
| 35 | + |
| 36 | + |
| 37 | +def _text_width(text: str, char_px: float) -> float: |
| 38 | + # Rough average glyph width; good enough to keep a shields-style badge tidy. |
| 39 | + return len(text) * char_px |
| 40 | + |
| 41 | + |
| 42 | +def render_badge_svg(report: ScanReport) -> str: |
| 43 | + """A compact, shields-style badge: ``SentinelDeck | Grade A``.""" |
| 44 | + label = "SentinelDeck" |
| 45 | + value = f"Grade {escape(report.grade)}" |
| 46 | + color = grade_color(report.grade) |
| 47 | + |
| 48 | + label_w = int(_text_width(label, 6.6)) + 16 |
| 49 | + value_w = int(_text_width(value, 6.6)) + 16 |
| 50 | + total_w = label_w + value_w |
| 51 | + label_mid = label_w / 2 |
| 52 | + value_mid = label_w + value_w / 2 |
| 53 | + |
| 54 | + return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" role="img" aria-label="SentinelDeck grade {escape(report.grade)}"> |
| 55 | + <linearGradient id="s" x2="0" y2="100%"> |
| 56 | + <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> |
| 57 | + <stop offset="1" stop-opacity=".1"/> |
| 58 | + </linearGradient> |
| 59 | + <rect rx="3" width="{total_w}" height="20" fill="#1f2937"/> |
| 60 | + <rect rx="3" x="{label_w}" width="{value_w}" height="20" fill="{color}"/> |
| 61 | + <rect rx="3" width="{total_w}" height="20" fill="url(#s)"/> |
| 62 | + <g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11"> |
| 63 | + <text x="{label_mid:.0f}" y="14">{escape(label)}</text> |
| 64 | + <text x="{value_mid:.0f}" y="14" font-weight="bold">{value}</text> |
| 65 | + </g> |
| 66 | +</svg> |
| 67 | +""" |
| 68 | + |
| 69 | + |
| 70 | +def render_card_svg(report: ScanReport) -> str: |
| 71 | + """A 1200x630 social share card summarising the scan.""" |
| 72 | + counts = _severity_counts(report) |
| 73 | + color = grade_color(report.grade) |
| 74 | + target = escape(report.target) |
| 75 | + high_total = counts["critical"] + counts["high"] |
| 76 | + |
| 77 | + stats = [ |
| 78 | + ("Risk score", f"{report.risk_score}/100"), |
| 79 | + ("Findings", str(len(report.findings))), |
| 80 | + ("High / Critical", str(high_total)), |
| 81 | + ("Medium", str(counts["medium"])), |
| 82 | + ] |
| 83 | + stat_cells = "".join( |
| 84 | + f""" |
| 85 | + <g transform="translate({80 + i * 270},430)"> |
| 86 | + <rect width="240" height="120" rx="18" fill="#101c2f" stroke="#1e3a5f"/> |
| 87 | + <text x="24" y="46" fill="#9fb3c8" font-size="20" font-family="Inter,sans-serif">{escape(label)}</text> |
| 88 | + <text x="24" y="92" fill="#edf5ff" font-size="40" font-weight="700" font-family="Inter,sans-serif">{escape(value)}</text> |
| 89 | + </g>""" |
| 90 | + for i, (label, value) in enumerate(stats) |
| 91 | + ) |
| 92 | + |
| 93 | + return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="SentinelDeck report for {target}"> |
| 94 | + <defs> |
| 95 | + <radialGradient id="bg" cx="20%" cy="0%" r="90%"> |
| 96 | + <stop offset="0" stop-color="#12345c"/> |
| 97 | + <stop offset="0.45" stop-color="#08111f"/> |
| 98 | + </radialGradient> |
| 99 | + </defs> |
| 100 | + <rect width="1200" height="630" fill="url(#bg)"/> |
| 101 | + <text x="80" y="110" fill="#38bdf8" font-size="22" letter-spacing="4" font-family="Inter,sans-serif" font-weight="700">SENTINELDECK SECURITY REPORT</text> |
| 102 | + <text x="80" y="200" fill="#edf5ff" font-size="72" font-weight="800" font-family="Inter,sans-serif">{target}</text> |
| 103 | + <text x="80" y="250" fill="#9fb3c8" font-size="24" font-family="Inter,sans-serif">Passive attack-surface posture</text> |
| 104 | + <g transform="translate(940,90)"> |
| 105 | + <circle cx="90" cy="90" r="90" fill="#101c2f" stroke="{color}" stroke-width="8"/> |
| 106 | + <text x="90" y="80" fill="{color}" font-size="96" font-weight="800" text-anchor="middle" font-family="Inter,sans-serif">{escape(report.grade)}</text> |
| 107 | + <text x="90" y="135" fill="#9fb3c8" font-size="22" text-anchor="middle" font-family="Inter,sans-serif">Grade</text> |
| 108 | + </g> |
| 109 | + {stat_cells} |
| 110 | + <text x="80" y="600" fill="#64748b" font-size="20" font-family="Inter,sans-serif">Generated by SentinelDeck — passive attack-surface visibility for small businesses.</text> |
| 111 | +</svg> |
| 112 | +""" |
| 113 | + |
| 114 | + |
| 115 | +def write_badge_svg(report: ScanReport, output: str | Path) -> Path: |
| 116 | + path = Path(output) |
| 117 | + path.parent.mkdir(parents=True, exist_ok=True) |
| 118 | + path.write_text(render_badge_svg(report), encoding="utf-8") |
| 119 | + return path |
| 120 | + |
| 121 | + |
| 122 | +def write_card_svg(report: ScanReport, output: str | Path) -> Path: |
| 123 | + path = Path(output) |
| 124 | + path.parent.mkdir(parents=True, exist_ok=True) |
| 125 | + path.write_text(render_card_svg(report), encoding="utf-8") |
| 126 | + return path |
0 commit comments