Skip to content

Commit e216acf

Browse files
author
Your Name
committed
docs(demo): add terminal gif and reproducible generator script
1 parent 5ce9f40 commit e216acf

3 files changed

Lines changed: 163 additions & 7 deletions

File tree

README.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,12 @@ go build ./cmd/secrethawk
2020

2121
## Demo
2222

23-
```text
24-
Problem: A leaked key is detected in source.
25-
SecretHawk flow:
26-
scan -> validate -> rotate/revoke -> patch -> report
27-
Outcome:
28-
- active secret can be blocked in CI
29-
- remediation artifacts are generated for follow-up
23+
![SecretHawk terminal demo](assets/demo-terminal.gif)
24+
25+
Regenerate demo GIF:
26+
27+
```bash
28+
python scripts/generate_demo_gif.py
3029
```
3130

3231
## Why SecretHawk

assets/demo-terminal.gif

1.26 MB
Loading

scripts/generate_demo_gif.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#!/usr/bin/env python3
2+
"""Generate a terminal-style demo GIF for README."""
3+
4+
from __future__ import annotations
5+
6+
from dataclasses import dataclass
7+
from pathlib import Path
8+
from typing import List
9+
10+
from PIL import Image, ImageDraw, ImageFont
11+
12+
13+
WIDTH = 960
14+
HEIGHT = 540
15+
PADDING = 24
16+
LINE_HEIGHT = 28
17+
FPS_MS = 90
18+
19+
BG = (11, 16, 32)
20+
PANEL = (17, 24, 39)
21+
PANEL_BORDER = (54, 74, 109)
22+
TEXT = (216, 231, 255)
23+
MUTED = (125, 148, 186)
24+
GREEN = (140, 224, 161)
25+
YELLOW = (246, 209, 126)
26+
RED = (255, 138, 128)
27+
28+
PROMPT = "PS C:\\repo\\SecretHawk> "
29+
30+
31+
@dataclass
32+
class Event:
33+
kind: str # command | output | info
34+
text: str
35+
hold: int = 2
36+
37+
38+
EVENTS = [
39+
Event("info", "SecretHawk Demo (simulated run)", hold=6),
40+
Event("command", "./secrethawk.exe scan . --validate --fail-on high --fail-on-active", hold=3),
41+
Event("output", "[scan] files=42 rules=5 findings=1 active=1", hold=3),
42+
Event("output", "[gate] blocked: active high-severity secret found", hold=4),
43+
Event("command", "./secrethawk.exe remediate --auto --dry-run", hold=3),
44+
Event("output", "[remediate] connector=aws action=rotate (simulated)", hold=3),
45+
Event("output", "[remediate] patch plan: 1 file -> env reference", hold=3),
46+
Event("output", "[remediate] report: .secrethawk/reports/inc-2026-03-02.md", hold=4),
47+
Event("command", "./secrethawk.exe report --input findings.json", hold=3),
48+
Event("output", "report generated: .secrethawk/reports/2026-03-02-090500.md", hold=3),
49+
Event("info", "Done. CI can now fail only on verified active findings.", hold=8),
50+
]
51+
52+
53+
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
54+
candidates = [
55+
r"C:\Windows\Fonts\consola.ttf",
56+
r"C:\Windows\Fonts\CascadiaMono.ttf",
57+
r"C:\Windows\Fonts\lucon.ttf",
58+
]
59+
for path in candidates:
60+
p = Path(path)
61+
if p.exists():
62+
return ImageFont.truetype(str(p), size=size)
63+
return ImageFont.load_default()
64+
65+
66+
def line_color(text: str, kind: str) -> tuple[int, int, int]:
67+
if kind == "command":
68+
return GREEN
69+
if "blocked" in text:
70+
return RED
71+
if "Done." in text:
72+
return GREEN
73+
if kind == "info":
74+
return YELLOW
75+
return TEXT
76+
77+
78+
def render_frame(
79+
lines: List[tuple[str, str]],
80+
font: ImageFont.ImageFont,
81+
cursor: bool,
82+
partial: str = "",
83+
) -> Image.Image:
84+
img = Image.new("RGB", (WIDTH, HEIGHT), BG)
85+
draw = ImageDraw.Draw(img)
86+
87+
panel_x0, panel_y0 = 16, 16
88+
panel_x1, panel_y1 = WIDTH - 16, HEIGHT - 16
89+
draw.rounded_rectangle((panel_x0, panel_y0, panel_x1, panel_y1), radius=16, fill=PANEL, outline=PANEL_BORDER, width=2)
90+
91+
# Window dots
92+
draw.ellipse((36, 32, 48, 44), fill=(255, 95, 86))
93+
draw.ellipse((56, 32, 68, 44), fill=(255, 189, 46))
94+
draw.ellipse((76, 32, 88, 44), fill=(39, 201, 63))
95+
draw.text((104, 28), "secrethawk-demo", font=font, fill=MUTED)
96+
97+
y = 64
98+
max_lines = (HEIGHT - 100) // LINE_HEIGHT
99+
visible = lines[-max_lines:]
100+
for text, kind in visible:
101+
draw.text((PADDING + 16, y), text, font=font, fill=line_color(text, kind))
102+
y += LINE_HEIGHT
103+
104+
if partial:
105+
cursor_char = "_" if cursor else " "
106+
draw.text((PADDING + 16, y), partial + cursor_char, font=font, fill=GREEN)
107+
108+
return img
109+
110+
111+
def build_frames() -> list[Image.Image]:
112+
font = load_font(20)
113+
frames: list[Image.Image] = []
114+
lines: list[tuple[str, str]] = []
115+
116+
for event in EVENTS:
117+
if event.kind == "command":
118+
full = PROMPT + event.text
119+
step = 4
120+
for i in range(1, len(full) + 1, step):
121+
partial = full[:i]
122+
frames.append(render_frame(lines, font, cursor=True, partial=partial))
123+
lines.append((full, "command"))
124+
for _ in range(event.hold):
125+
frames.append(render_frame(lines, font, cursor=False))
126+
else:
127+
lines.append((event.text, event.kind))
128+
for _ in range(event.hold):
129+
frames.append(render_frame(lines, font, cursor=False))
130+
131+
# A short tail pause
132+
for _ in range(12):
133+
frames.append(render_frame(lines, font, cursor=False))
134+
return frames
135+
136+
137+
def main() -> None:
138+
repo_root = Path(__file__).resolve().parent.parent
139+
out_dir = repo_root / "assets"
140+
out_dir.mkdir(parents=True, exist_ok=True)
141+
out_path = out_dir / "demo-terminal.gif"
142+
143+
frames = build_frames()
144+
frames[0].save(
145+
out_path,
146+
save_all=True,
147+
append_images=frames[1:],
148+
optimize=True,
149+
duration=FPS_MS,
150+
loop=0,
151+
disposal=2,
152+
)
153+
print(f"generated: {out_path}")
154+
155+
156+
if __name__ == "__main__":
157+
main()

0 commit comments

Comments
 (0)