Skip to content

Commit 1ac03e5

Browse files
committed
Add shareable score cards and embeddable grade badges
Agencies and small businesses share a result, not a JSON file. This adds two SVG outputs rendered straight from a saved report: a 1200x630 score card sized for link previews on social and chat, and a compact grade badge for a website footer. Both are plain SVG with no new dependencies, and the report command now takes --svg and --badge alongside --html.
1 parent 67cbb06 commit 1ac03e5

6 files changed

Lines changed: 246 additions & 4 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ sentineldeck scan example.com --output reports/example.json
4646
sentineldeck report reports/example.json --html reports/example.html
4747
```
4848

49+
From a saved report you can also render a shareable SVG score card (sized for
50+
link previews) and a small embeddable grade badge for a site footer:
51+
52+
```bash
53+
sentineldeck report reports/example.json --svg reports/example-card.svg --badge reports/example-badge.svg
54+
```
55+
4956
The passive checks (DNS, HTTP, TLS, email) run concurrently, so a scan
5057
finishes close to the speed of the slowest single check. Use `--timeout` to
5158
bound how long the HTTP and TLS probes wait:

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,6 @@ src = ["src", "tests"]
4343
select = ["E", "F", "I", "UP", "B"]
4444

4545
[tool.ruff.lint.per-file-ignores]
46-
# The HTML report is a large inline template with long CSS declarations.
46+
# Report renderers are large inline templates with long markup/CSS lines.
4747
"src/sentineldeck/reporters/html_report.py" = ["E501"]
48+
"src/sentineldeck/reporters/badge.py" = ["E501"]

src/sentineldeck/cli.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66

77
from sentineldeck import __version__
8+
from sentineldeck.reporters.badge import write_badge_svg, write_card_svg
89
from sentineldeck.reporters.html_report import read_json_report, write_html_report
910
from sentineldeck.reporters.json_report import write_json_report
1011
from sentineldeck.scanner import scan_domain
@@ -31,7 +32,9 @@ def build_parser() -> argparse.ArgumentParser:
3132

3233
report = subparsers.add_parser("report", help="Render a saved JSON scan report.")
3334
report.add_argument("source", help="Path to a SentinelDeck JSON report.")
34-
report.add_argument("--html", required=True, help="Write an HTML report to this path.")
35+
report.add_argument("--html", help="Write a client-ready HTML report to this path.")
36+
report.add_argument("--svg", help="Write a shareable SVG score card to this path.")
37+
report.add_argument("--badge", help="Write an embeddable SVG grade badge to this path.")
3538
return parser
3639

3740

@@ -55,9 +58,16 @@ def main(argv: list[str] | None = None) -> int:
5558
return 0
5659

5760
if args.command == "report":
61+
if not (args.html or args.svg or args.badge):
62+
print("error: choose at least one of --html, --svg, or --badge", file=sys.stderr)
63+
return 2
5864
report = read_json_report(args.source)
59-
path = write_html_report(report, args.html)
60-
print(f"HTML report written: {path}")
65+
if args.html:
66+
print(f"HTML report written: {write_html_report(report, args.html)}")
67+
if args.svg:
68+
print(f"Share card written: {write_card_svg(report, args.svg)}")
69+
if args.badge:
70+
print(f"Badge written: {write_badge_svg(report, args.badge)}")
6171
return 0
6272

6373
parser.print_help()
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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

tests/test_badge.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from sentineldeck.models import Finding, ScanReport
2+
from sentineldeck.reporters.badge import (
3+
grade_color,
4+
render_badge_svg,
5+
render_card_svg,
6+
write_badge_svg,
7+
write_card_svg,
8+
)
9+
10+
11+
def sample_report(grade="C", score=42) -> ScanReport:
12+
return ScanReport(
13+
target="example.com",
14+
generated_at="2026-06-24T21:00:00+00:00",
15+
risk_score=score,
16+
grade=grade,
17+
checks={},
18+
findings=[
19+
Finding("a", "High issue", "high", "d", "r"),
20+
Finding("b", "Medium issue", "medium", "d", "r"),
21+
],
22+
)
23+
24+
25+
def test_grade_color_maps_known_grades():
26+
assert grade_color("A") == "#22c55e"
27+
assert grade_color("F") == "#ef4444"
28+
assert grade_color("?") == "#64748b"
29+
30+
31+
def test_render_badge_includes_grade_and_color():
32+
svg = render_badge_svg(sample_report(grade="A"))
33+
34+
assert svg.startswith("<svg")
35+
assert "Grade A" in svg
36+
assert "#22c55e" in svg
37+
38+
39+
def test_render_card_includes_summary_fields():
40+
svg = render_card_svg(sample_report(grade="C", score=42))
41+
42+
assert "example.com" in svg
43+
assert "42/100" in svg
44+
assert ">C<" in svg
45+
assert "High / Critical" in svg
46+
47+
48+
def test_render_card_escapes_target():
49+
report = sample_report()
50+
report.target = "<script>alert(1)</script>"
51+
52+
svg = render_card_svg(report)
53+
54+
assert "<script>alert(1)</script>" not in svg
55+
assert "&lt;script&gt;" in svg
56+
57+
58+
def test_write_helpers_create_files(tmp_path):
59+
badge = write_badge_svg(sample_report(), tmp_path / "out" / "badge.svg")
60+
card = write_card_svg(sample_report(), tmp_path / "out" / "card.svg")
61+
62+
assert badge.exists() and card.exists()
63+
assert badge.read_text(encoding="utf-8").startswith("<svg")

tests/test_cli_report.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,38 @@ def test_cli_report_writes_html_from_json_report(tmp_path):
2222
assert exit_code == 0
2323
assert output.exists()
2424
assert "Test finding" in output.read_text(encoding="utf-8")
25+
26+
27+
def _write_sample_report(tmp_path):
28+
report = ScanReport(
29+
target="example.com",
30+
generated_at="2026-06-24T21:00:00+00:00",
31+
risk_score=12,
32+
grade="A",
33+
checks={},
34+
findings=[Finding("x", "Test finding", "low", "Description", "Fix it")],
35+
)
36+
source = tmp_path / "scan.json"
37+
source.write_text(json.dumps(report.to_dict()), encoding="utf-8")
38+
return source
39+
40+
41+
def test_cli_report_writes_svg_card_and_badge(tmp_path):
42+
source = _write_sample_report(tmp_path)
43+
card = tmp_path / "card.svg"
44+
badge = tmp_path / "badge.svg"
45+
46+
exit_code = main(["report", str(source), "--svg", str(card), "--badge", str(badge)])
47+
48+
assert exit_code == 0
49+
assert card.read_text(encoding="utf-8").startswith("<svg")
50+
assert badge.read_text(encoding="utf-8").startswith("<svg")
51+
52+
53+
def test_cli_report_requires_an_output(tmp_path, capsys):
54+
source = _write_sample_report(tmp_path)
55+
56+
exit_code = main(["report", str(source)])
57+
58+
assert exit_code == 2
59+
assert "at least one" in capsys.readouterr().err

0 commit comments

Comments
 (0)