Initial commit

This commit is contained in:
lennyzeltser
2026-01-14 17:55:56 -05:00
commit c437f04e0b
19 changed files with 7765 additions and 0 deletions
@@ -0,0 +1 @@
../../CLAUDE.md
+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+10
View File
@@ -0,0 +1,10 @@
# Agents
Before working on this project, review [README.md](./README.md) for:
- Project purpose: CLI tool generating animated conversation demos from YAML
- YAML schema for scenarios, participants, and steps
- Repository structure and key source files
- Development commands and architecture
- Security considerations (input validation, output safety)
See the **AI Agent Quick Reference** section for file locations and common tasks.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Zeltser Security Corp.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+388
View File
@@ -0,0 +1,388 @@
# Conversation Replay
Create animated conversation demos from YAML for security awareness, IR training, and communication skills education.
**"VHS for conversations"** — Define conversations declaratively, generate self-contained HTML demos that play back like videos.
---
- [Why This Matters](#why-this-matters)
- [Quick Start](#quick-start)
- [YAML Schema](#yaml-schema)
- [Output Features](#output-features)
- [Embedding in Websites](#embedding-in-websites)
- [Use Cases](#use-cases)
- [Development](#development)
- [AI Agent Quick Reference](#ai-agent-quick-reference)
- [Security](#security)
- [Author](#author)
---
## Why This Matters
Security training often involves showing how attacks unfold through conversation — phishing emails, social engineering calls, BEC attempts. Static screenshots lose the temporal element. Video production is time-consuming and hard to update.
Conversation Replay lets you:
- **Define conversations in YAML** — Easy to write, review, and version control
- **Generate self-contained HTML** — No external dependencies, works offline
- **Embed anywhere** — Drop into articles, training materials, or presentations
- **Update easily** — Change the YAML, regenerate the HTML
## Quick Start
**Want to see it in action first?** Open [examples/london-scam.html](examples/london-scam.html) or [examples/ir-report.html](examples/ir-report.html) directly in your browser — no build step required.
**To build your own demos:**
```bash
# Prerequisite: Bun (https://bun.sh) or Node.js 18+
# Install dependencies
bun install
# Build a demo from YAML
bun run src/cli.ts build examples/london-scam.yaml -o demo.html
```
Output:
```
Loading examples/london-scam.yaml...
Building demo.html...
Done! Generated demo.html
Title: London Scam - Social Engineering Demo
Scenarios: 1
- London Scam: 17 steps (Matt (compromised account), Rakesh)
```
```bash
# Open in browser
open demo.html
```
The generated HTML is completely self-contained — CSS, JavaScript, and content are all inlined. No external dependencies, works offline.
## YAML Schema
### Basic Structure
```yaml
meta:
title: "Demo Title"
description: "Shown in header"
theme: chat # chat | email | slack | terminal | generic
autoAdvance: true # Auto-advance between scenarios
scenarios:
- id: scenario-1
title: "Scenario Name" # Shown in tab (if multiple scenarios)
participants:
- id: attacker
label: "Scammer"
role: left # Messages appear on left
- id: victim
label: "Target"
role: right # Messages appear on right
steps:
- type: message
from: attacker
content: "Hey, how are you?"
```
### Step Types
| Type | Purpose | Renders As |
|------|---------|------------|
| `message` | Conversation turn | Chat bubble aligned left or right based on participant role |
| `annotation` | Educational note | Highlighted callout with vertical accent bar |
| `transition` | Scene break | Centered text card indicating time/scene change |
### Message Options
```yaml
# Basic message
- type: message
from: attacker
content: "Transfer the funds immediately."
# Message with code block
- type: message
from: analyst
content: "Here's what I found in the logs:"
codeBlock: |
error: unauthorized access attempt
source: 192.168.1.105
timestamp: 2024-01-15T14:23:00Z
# Message with footnote
- type: message
from: ai
content: "I can help with that."
footnote: "The AI retrieves context from the MCP server"
```
### Meta Options
**Required:**
- `title` — Demo title shown in header
**Display options:**
```yaml
meta:
description: "Shown below title"
theme: chat # chat | email | slack | terminal | generic
articleUrl: "/related-article" # "View Article" link in header
annotationLabel: "Security Note" # Label for annotations (default: "Behind the Scenes")
timerStyle: circle # circle | bar (progress indicator style)
cornerStyle: rounded # rounded | straight (bubble corners)
```
**Behavior options:**
```yaml
meta:
autoAdvance: true # Auto-play next scenario when current ends
hideHeaderInIframe: true # Hide header when embedded (default: true)
```
**Custom colors:**
```yaml
meta:
colors:
accent: "#1a45bc" # Buttons, links
pageBg: "#f6f7f9" # Page background (standalone mode)
canvasBg: "#ffffff" # Chat container background
leftBg: "#e0f2fe" # Left participant bubble background
leftBorder: "#7dd3fc" # Left participant bubble border
rightBg: "#f0fdf4" # Right participant bubble background
rightBorder: "#86efac" # Right participant bubble border
tabInactiveColor: "#666666" # Inactive tab text
```
**Timing configuration:**
```yaml
meta:
speed:
minDelay: 3000 # Minimum pause between steps (ms)
maxDelay: 8000 # Maximum pause between steps (ms)
msPerWord: 200 # Reading time per word
annotationMultiplier: 1.15 # Extra time multiplier for annotations
upNextDelay: 2500 # How long to show "Up Next" before transitioning
```
### Multi-Scenario Demos
When you define multiple scenarios, they appear as tabs:
```yaml
scenarios:
- id: create
title: "Creating Reports"
# ...steps...
- id: review
title: "Reviewing Reports"
# ...steps...
- id: tips
title: "Writing Tips"
# ...steps...
```
With `autoAdvance: true`, the demo automatically transitions between scenarios.
## Output Features
Generated HTML files include:
- **Zero external dependencies** — Everything inlined
- **Dark mode support** — Respects `prefers-color-scheme` and syncs with parent page
- **Responsive design** — Fills screen standalone, fixed height when embedded
- **Accessibility** — Respects `prefers-reduced-motion`, includes ARIA labels, keyboard navigation
- **Playback controls** — Play/pause, restart, speed selector (0.5x4x)
- **Tab navigation** — Arrow keys navigate between scenario tabs
- **Progress indicator** — Timer and step counter
## Embedding in Websites
### Basic Iframe
```html
<iframe
src="/demos/security-awareness.html"
style="width:100%; height:650px; border:none; border-radius:8px;"
loading="lazy"
></iframe>
```
### Dark Mode Sync
The demo automatically respects `prefers-color-scheme`. If your site has a manual dark mode toggle, sync it to the iframe:
```javascript
// When your page toggles dark mode
const iframe = document.querySelector('iframe');
iframe.contentWindow.postMessage({
type: 'theme-change',
theme: 'dark' // or 'light'
}, '*');
// On page load, sync initial theme
iframe.addEventListener('load', function() {
const theme = document.documentElement.getAttribute('data-theme') || 'light';
iframe.contentWindow.postMessage({ type: 'theme-change', theme }, '*');
});
```
### Behavior Differences
| Context | Behavior |
|---------|----------|
| **Standalone** | Chat fills available screen height, header visible |
| **Embedded (iframe)** | Fixed height, transparent background, header hidden |
## Use Cases
- **Social engineering awareness** — Show how scams unfold over time ([example](examples/london-scam.html))
- **IR training** — Demonstrate incident communication patterns ([example](examples/ir-report.html))
- **Communication skills** — Good vs. bad professional dialogue
- **Tool demos** — Showcase AI/chatbot interactions
- **Phishing education** — Step-by-step attack anatomy
## Development
### Repository Structure
```
conversation-replay/
├── src/
│ ├── cli.ts # CLI entry point (build, validate commands)
│ ├── parser.ts # YAML parsing and validation
│ ├── generator.ts # HTML generation with embedded CSS/JS
│ └── types.ts # TypeScript type definitions
├── examples/
│ ├── README.md # Examples documentation
│ ├── london-scam.yaml # Single-scenario demo (source)
│ ├── london-scam.html # Pre-built HTML demo
│ ├── ir-report.yaml # Multi-scenario demo (source)
│ └── ir-report.html # Pre-built HTML demo
├── package.json
└── tsconfig.json
```
See [examples/README.md](examples/README.md) for detailed descriptions of each demo.
### Commands
```bash
# Build a demo
bun run src/cli.ts build examples/london-scam.yaml -o demo.html
# Validate scenario without building (catches errors before generation)
bun run src/cli.ts validate examples/london-scam.yaml
# Build with different theme
bun run src/cli.ts build examples/london-scam.yaml -o demo.html --theme email
# Build without header
bun run src/cli.ts build examples/london-scam.yaml -o demo.html --no-header
```
### CLI Reference
```
conversation-replay build <scenario.yaml> -o <output.html> [options]
conversation-replay validate <scenario.yaml>
Options:
-o, --output <path> Output HTML file (required for build)
--theme <theme> Override theme: chat, email, slack, terminal, generic
--no-header Exclude the demo header
-h, --help Show help
```
### Adding New Themes
1. Add theme name to `VALID_THEMES` in `src/types.ts`
2. Add CSS variables in `generateCss()` in `src/generator.ts`
3. Theme is automatically available via `--theme` CLI option
---
## AI Agent Quick Reference
### Key Files
| File | Purpose |
|------|---------|
| `src/types.ts` | TypeScript interfaces defining YAML schema |
| `src/parser.ts` | YAML parsing with validation (color sanitization, type checking) |
| `src/generator.ts` | HTML generation with embedded CSS/JS (~1700 lines) |
| `src/cli.ts` | CLI interface |
### Architecture
```
YAML Input → Parser (validation) → Generator (HTML/CSS/JS) → Self-contained HTML
```
The generator produces a single HTML file with:
- All CSS in a `<style>` tag
- All JavaScript in a `<script>` tag
- Scenario data embedded as JavaScript objects
### Common Tasks
**Add a new meta option:**
1. Add to interface in `src/types.ts`
2. Add validation in `src/parser.ts`
3. Use in `src/generator.ts`
**Modify CSS:**
- Edit the template strings in `generateCss()` in `src/generator.ts`
**Modify JavaScript behavior:**
- Edit the template string in `generateJs()` in `src/generator.ts`
### Validation
Run `bun run src/cli.ts validate <file.yaml>` to check a scenario without building. The parser validates:
- Required fields (title, scenarios, participants, steps)
- Type constraints (theme, timerStyle, cornerStyle must be valid enum values)
- Color values (blocks CSS injection via semicolons, braces, url())
- Speed config (numeric values, minDelay ≤ maxDelay)
- Participant references (step.from must match a participant.id)
- Unique IDs (no duplicate scenario or participant IDs)
---
## Security
### Input Validation
The parser validates all input to prevent:
- **CSS injection** — Color values checked against allowlist of valid formats
- **XSS via links** — Markdown links block `javascript:`, `data:`, `vbscript:` URLs
- **Invalid configurations** — Type checking for all enum values
### Output Security
Generated HTML:
- Uses `textContent` for all dynamic content (no `innerHTML` with user data)
- Self-contained with no external resource loading
- No cookies or local storage (except theme preference)
### Threat Model
This tool generates static HTML from trusted YAML input. Consider:
- YAML files should be treated as code (review before building)
- Generated HTML is safe to host publicly
- No server-side execution — pure static output
---
## Author
**[Lenny Zeltser](https://zeltser.com)**: Builder of security products and programs. Teacher of those who run them.
+31
View File
@@ -0,0 +1,31 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "conversation-replay",
"dependencies": {
"yaml": "^2.8.2",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
}
}
+1438
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
meta:
title: Example Conversation
description: A test scenario to evaluate the UI.
theme: chat
scenarios:
- id: scenario1
title: Basic Chat
participants:
- id: user
label: User
role: left
- id: ai
label: Assistant
role: right
steps:
- type: message
from: user
content: "Hello, can you help me with something?"
- type: message
from: ai
content: "Of course! What do you need help with?"
- type: annotation
content: "The assistant is ready to help."
- type: message
from: user
content: "I want to improve my project's design."
- type: transition
content: "Processing request..."
- type: message
from: ai
content: "I can definitely help with that. Let's look at the code."
+47
View File
@@ -0,0 +1,47 @@
# Examples
Example YAML source files and their pre-built HTML demos.
## Files
| File | Description |
|------|-------------|
| `london-scam.yaml` | Single-scenario social engineering demo (source) |
| `london-scam.html` | Pre-built HTML demo |
| `ir-report.yaml` | Multi-scenario IR report writing demo (source) |
| `ir-report.html` | Pre-built HTML demo |
## Viewing the Demos
Open any `.html` file directly in your browser — no server required.
## Example Descriptions
### London Scam
**Source:** [Facebook Fraud: A Transcript](https://rake.sh/2009/01/20/facebook-fraud-a-transcript/) by Rakesh Pai
A social engineering awareness demo showing how attackers use compromised social media accounts to scam victims with the "stranded traveler" pretext.
**Features demonstrated:** Single scenario with linear flow, `message` steps with left/right participant roles, `annotation` steps for educational callouts, `transition` steps for scene breaks, custom `annotationLabel`.
### IR Report Writing
**Source:** [AI Can Help You Create Good Incident Response Reports](https://zeltser.com/good-ir-reports-with-ai) by Lenny Zeltser
Three-part demo showing how AI can assist with incident response documentation:
1. **IR Report Creation** — Gathering incident details for a BEC attack
2. **IR Report Review** — Improving clarity and eliminating jargon
3. **Writing Coach** — Executive summary structure and tone guidance
**Features demonstrated:** Multiple scenarios with tab navigation, `autoAdvance` for automatic scenario progression, `codeBlock` for report text, `footnote` for supplementary information.
## Rebuilding the HTML Files
```bash
bun run src/cli.ts build examples/london-scam.yaml -o examples/london-scam.html
bun run src/cli.ts build examples/ir-report.yaml -o examples/ir-report.html
```
See the [main README](../README.md) for the complete YAML schema reference.
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
# Multi-scenario demo: AI-Assisted IR Report Writing
# Demonstrates the tab system with three scenarios
#
# Based on: https://zeltser.com/good-ir-reports-with-ai
meta:
title: "AI-Assisted IR Report Writing"
description: "Using AI tools to create, review, and improve incident response documentation"
theme: chat
autoAdvance: true
scenarios:
- id: create
title: "IR Report Creation"
participants:
- id: analyst
label: "Security Analyst"
role: left
- id: ai
label: "AI Assistant"
role: right
steps:
- type: message
from: analyst
content: "I need to create an IR report for a BEC incident. The CFO received a fraudulent wire transfer request that appeared to be from the CEO."
- type: message
from: ai
content: "I can help you draft that IR report. Let me ask a few questions first to ensure we capture all the relevant details."
- type: annotation
content: "The AI uses a structured approach to gather incident details systematically."
- type: message
from: ai
content: "When was the incident discovered, and what was the initial indicator that something was wrong?"
- type: message
from: analyst
content: "Discovered yesterday at 2pm when the CFO called the CEO to confirm the transfer. The CEO had no knowledge of the request."
- type: message
from: ai
content: "Good catch by the CFO. What was the requested transfer amount and where was it supposed to go?"
- type: message
from: analyst
content: "$47,500 to an account at First National Bank, routing number ending in 4521."
- type: message
from: ai
content: "Based on what you've shared, here's a draft executive summary:"
codeBlock: |
EXECUTIVE SUMMARY
On [date], a Business Email Compromise (BEC) attempt
targeted our organization. An attacker impersonating
the CEO requested a $47,500 wire transfer from the CFO.
The attack was detected before funds were transferred
when the CFO verbally confirmed the request with the CEO.
Immediate actions: Email headers analyzed, account
security reviewed, finance team alerted to new protocols.
- type: transition
content: "The analyst continues building the full report..."
- id: review
title: "IR Report Review"
participants:
- id: analyst
label: "Security Analyst"
role: left
- id: ai
label: "AI Assistant"
role: right
steps:
- type: message
from: analyst
content: "Can you review this section of my IR report? I want to make sure it's clear for executives."
codeBlock: |
The threat actor utilized sophisticated social engineering
techniques leveraging compromised email infrastructure to
perpetrate a financial fraud attempt via BEC methodology.
- type: annotation
content: "The AI identifies jargon and passive voice that could confuse non-technical readers."
- type: message
from: ai
content: "This section could be clearer for executives. Here are specific suggestions:"
- type: message
from: ai
content: "1. 'sophisticated social engineering techniques' - What specifically made it sophisticated? Was it personalized?"
footnote: "Executives appreciate specifics over vague adjectives"
- type: message
from: ai
content: "2. 'leveraging compromised email infrastructure' - Whose email was compromised? The CEO's? An external vendor's?"
- type: message
from: ai
content: "3. 'perpetrate a financial fraud attempt via BEC methodology' - This is passive and jargon-heavy. Try: 'The attacker posed as our CEO to request a fraudulent wire transfer.'"
- type: message
from: analyst
content: "Good points. Let me revise that paragraph."
- type: message
from: analyst
content: "Revised version:"
codeBlock: |
An attacker gained access to the CEO's email account
and sent a convincing wire transfer request to the CFO.
The email matched the CEO's writing style and referenced
an actual pending acquisition, showing the attacker had
researched our company.
- type: message
from: ai
content: "Much better. This version is specific, active voice, and tells executives exactly what happened without requiring security background knowledge."
- id: coach
title: "Writing Coach"
participants:
- id: analyst
label: "Security Analyst"
role: left
- id: ai
label: "Writing Coach"
role: right
steps:
- type: message
from: analyst
content: "I struggle with executive summaries. They either end up too technical or too vague. Any tips?"
- type: message
from: ai
content: "Executive summaries for IR reports should answer four questions in plain language:"
- type: message
from: ai
content: "1. WHAT happened? (One sentence describing the incident)"
- type: message
from: ai
content: "2. WHAT's the impact? (Business terms: financial, operational, reputational)"
- type: message
from: ai
content: "3. WHAT did we do? (Immediate response actions)"
- type: message
from: ai
content: "4. WHAT's next? (Recommended follow-up actions)"
- type: annotation
content: "This framework helps analysts structure their thinking and ensures coverage of key points."
- type: message
from: analyst
content: "That's helpful. What about tone? My manager says my reports sound too alarmist."
- type: message
from: ai
content: "Good question. Compare these two approaches:"
codeBlock: |
ALARMIST:
"A catastrophic security breach nearly resulted in
massive financial losses. Urgent action is required
immediately to prevent future attacks."
MEASURED:
"A BEC attempt was detected and blocked before any
funds transferred. We recommend three process
improvements to reduce future risk."
- type: message
from: ai
content: "The measured version states facts, acknowledges what went right, and focuses on actionable improvements rather than fear."
- type: message
from: analyst
content: "I see the difference. Facts over fear, and focus on what we're doing about it."
- type: message
from: ai
content: "Exactly. Executives want to know the situation is under control and what resources you need. Alarmist language undermines confidence in the security team."
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
# London Scam - Social Engineering Demo
#
# Based on real transcript from:
# https://rake.sh/2009/01/20/facebook-fraud-a-transcript/
#
# This demonstrates how attackers use compromised social media
# accounts to social engineer victims.
meta:
title: "London Scam - Social Engineering Demo"
description: "How attackers use compromised accounts to social engineer victims via chat"
theme: chat
articleUrl: "/bots-chatting-on-social-networks"
annotationLabel: "Security Note"
scenarios:
- id: main
title: "London Scam"
participants:
- id: attacker
label: "Matt (compromised account)"
role: left
- id: victim
label: "Rakesh"
role: right
steps:
- type: message
from: attacker
content: "hi. whats up?"
- type: message
from: victim
content: "Hi Matt. Everything OK?"
- type: annotation
content: "The attacker establishes rapport using the victim's friend's account. Notice how the response seems normal at first."
- type: message
from: attacker
content: "well, im really stuck here in london. i had to visit a resort here in london and i got robbed at the hotel im staying"
- type: annotation
content: "The scam begins. The attacker creates urgency with a crisis story. The 'London emergency' is one of the most common social engineering pretexts."
- type: message
from: victim
content: "Oh no! Are you OK? Did you call the police?"
- type: message
from: attacker
content: "yes i did, but they said it might take a while to get my stuff back. the problem is i need to pay my hotel bill and get a flight home"
- type: annotation
content: "The attacker builds on the crisis, establishing a clear financial need. They're setting up the ask."
- type: message
from: attacker
content: "i was wondering if you could help me out with some money? ill pay you back as soon as i get home"
- type: transition
content: "This is where the scam becomes explicit. The attacker asks for money."
- type: message
from: victim
content: "How much do you need? And how would I send it?"
- type: message
from: attacker
content: "about $1500 would cover the hotel and flight. you can send it through western union - its the fastest way"
- type: annotation
content: "Western Union is the preferred method because transfers are difficult to trace or reverse. This is a major red flag."
- type: message
from: victim
content: "That's a lot of money. Can't you call the embassy or your bank?"
- type: message
from: attacker
content: "i tried but they said it would take days. please, i really need your help. i promise ill pay you back"
- type: annotation
content: "The attacker increases pressure and appeals to friendship. Real friends would understand delays - scammers push urgency."
- type: transition
content: "Red flags in this conversation: urgency, money request, Western Union, resistance to alternatives"
+35
View File
@@ -0,0 +1,35 @@
{
"name": "conversation-replay",
"version": "0.1.0",
"description": "Create animated conversation demos from YAML for security awareness, IR training, and communication skills education",
"module": "src/cli.ts",
"type": "module",
"bin": {
"conversation-replay": "./src/cli.ts"
},
"scripts": {
"build": "bun run src/cli.ts build",
"validate": "bun run src/cli.ts validate",
"example": "bun run src/cli.ts build examples/london-scam.yaml -o examples/london-scam.html"
},
"keywords": [
"conversation",
"demo",
"animation",
"security-awareness",
"training",
"chat",
"replay"
],
"author": "Lenny Zeltser",
"license": "MIT",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"yaml": "^2.8.2"
}
}
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bun
/**
* Conversation Replay - CLI
*
* Usage:
* conversation-replay build <scenario.yaml> -o <output.html>
* conversation-replay validate <scenario.yaml>
*/
import { parseArgs } from 'util';
import { loadDemo, ParseError } from './parser';
import { buildDemo } from './generator';
import type { Theme } from './types';
const HELP = `
conversation-replay - Create animated conversation demos from YAML
Usage:
conversation-replay build <scenario.yaml> -o <output.html> [options]
conversation-replay validate <scenario.yaml>
conversation-replay --help
Commands:
build Generate HTML from a scenario file
validate Check a scenario file for errors
Options:
-o, --output <path> Output HTML file path (required for build)
--theme <theme> Override theme (chat, email, slack, terminal, generic)
--no-header Exclude the demo header
-h, --help Show this help message
Examples:
conversation-replay build demo.yaml -o demo.html
conversation-replay build demo.yaml -o demo.html --theme email
conversation-replay validate demo.yaml
`;
const VALID_THEMES = ['chat', 'email', 'slack', 'terminal', 'generic'];
async function main() {
const { values, positionals } = parseArgs({
args: Bun.argv.slice(2),
options: {
output: { type: 'string', short: 'o' },
theme: { type: 'string' },
'no-header': { type: 'boolean' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
});
if (values.help || positionals.length === 0) {
console.log(HELP);
process.exit(0);
}
const command = positionals[0];
const inputFile = positionals[1];
if (!inputFile) {
console.error('Error: No input file specified\n');
console.log(HELP);
process.exit(1);
}
// Validate theme if provided
if (values.theme && !VALID_THEMES.includes(values.theme)) {
console.error(`Error: Invalid theme "${values.theme}"`);
console.error(`Valid themes: ${VALID_THEMES.join(', ')}`);
process.exit(1);
}
try {
switch (command) {
case 'build':
await handleBuild(inputFile, values);
break;
case 'validate':
await handleValidate(inputFile);
break;
default:
console.error(`Error: Unknown command "${command}"\n`);
console.log(HELP);
process.exit(1);
}
} catch (error) {
if (error instanceof ParseError) {
console.error(`Parse Error: ${error.message}`);
if (error.path) {
console.error(` File: ${error.path}`);
}
process.exit(1);
}
throw error;
}
}
async function handleBuild(
inputFile: string,
options: { output?: string; theme?: string; 'no-header'?: boolean }
) {
if (!options.output) {
console.error('Error: Output file required (-o <path>)\n');
console.log('Usage: conversation-replay build <input.yaml> -o <output.html>');
process.exit(1);
}
console.log(`Loading ${inputFile}...`);
const demo = await loadDemo(inputFile);
console.log(`Building ${options.output}...`);
await buildDemo(demo, options.output, {
theme: options.theme as Theme | undefined,
includeHeader: !options['no-header'],
});
console.log(`Done! Generated ${options.output}`);
console.log(` Title: ${demo.meta.title}`);
console.log(` Scenarios: ${demo.scenarios.length}`);
for (const scenario of demo.scenarios) {
const stepCount = scenario.steps.length;
const participants = scenario.participants.map(p => p.label).join(', ');
console.log(` - ${scenario.title}: ${stepCount} steps (${participants})`);
}
}
async function handleValidate(inputFile: string) {
console.log(`Validating ${inputFile}...`);
const demo = await loadDemo(inputFile);
console.log('Valid!');
console.log(` Title: ${demo.meta.title}`);
console.log(` Theme: ${demo.meta.theme ?? 'chat (default)'}`);
console.log(` Scenarios: ${demo.scenarios.length}`);
for (const scenario of demo.scenarios) {
console.log(`\n Scenario: ${scenario.title} (${scenario.id})`);
console.log(` Participants:`);
for (const p of scenario.participants) {
console.log(` - ${p.label} (${p.id}, ${p.role})`);
}
// Count step types
const counts = { message: 0, annotation: 0, transition: 0 };
for (const step of scenario.steps) {
if (step.type === 'message') counts.message++;
else if (step.type === 'annotation') counts.annotation++;
else if (step.type === 'transition') counts.transition++;
}
console.log(` Steps: ${scenario.steps.length}`);
console.log(` - Messages: ${counts.message}`);
if (counts.annotation > 0) console.log(` - Annotations: ${counts.annotation}`);
if (counts.transition > 0) console.log(` - Transitions: ${counts.transition}`);
}
}
main().catch(error => {
console.error('Unexpected error:', error);
process.exit(1);
});
+1749
View File
File diff suppressed because it is too large Load Diff
+430
View File
@@ -0,0 +1,430 @@
/**
* Conversation Replay - YAML Parser
*
* Parses and validates scenario YAML files.
*/
import { parse } from 'yaml';
import type { Demo, DemoMeta, Scenario, Step, Participant, Theme, ColorConfig, TimerStyle, CornerStyle, SpeedConfig } from './types';
const VALID_THEMES: Theme[] = ['chat', 'email', 'slack', 'terminal', 'generic'];
const VALID_TIMER_STYLES: TimerStyle[] = ['bar', 'circle'];
const VALID_CORNER_STYLES: CornerStyle[] = ['rounded', 'straight'];
export class ParseError extends Error {
constructor(message: string, public path?: string) {
super(message);
this.name = 'ParseError';
}
}
/**
* Parse YAML content into a Demo object
*/
export function parseDemo(yamlContent: string, filePath?: string): Demo {
let raw: unknown;
try {
raw = parse(yamlContent);
} catch (e) {
throw new ParseError(`Invalid YAML syntax: ${(e as Error).message}`, filePath);
}
if (!raw || typeof raw !== 'object') {
throw new ParseError('Demo must be an object', filePath);
}
const data = raw as Record<string, unknown>;
if (!Array.isArray(data.scenarios)) {
throw new ParseError('Demo must have a "scenarios" array', filePath);
}
const meta = validateDemoMeta(data.meta, filePath);
const scenarios = validateScenarios(data.scenarios as unknown[], filePath);
return { meta, scenarios };
}
function validateDemoMeta(raw: unknown, filePath?: string): DemoMeta {
if (!raw || typeof raw !== 'object') {
throw new ParseError('meta section is required', filePath);
}
const meta = raw as Record<string, unknown>;
if (typeof meta.title !== 'string' || !meta.title.trim()) {
throw new ParseError('meta.title is required and must be a non-empty string', filePath);
}
const result: DemoMeta = {
title: meta.title.trim(),
};
if (meta.description !== undefined) {
if (typeof meta.description !== 'string') {
throw new ParseError('meta.description must be a string', filePath);
}
result.description = meta.description.trim();
}
if (meta.theme !== undefined) {
if (!VALID_THEMES.includes(meta.theme as Theme)) {
throw new ParseError(
`meta.theme must be one of: ${VALID_THEMES.join(', ')}`,
filePath
);
}
result.theme = meta.theme as Theme;
}
if (meta.articleUrl !== undefined) {
if (typeof meta.articleUrl !== 'string') {
throw new ParseError('meta.articleUrl must be a string', filePath);
}
result.articleUrl = meta.articleUrl;
}
if (meta.hideHeaderInIframe !== undefined) {
result.hideHeaderInIframe = Boolean(meta.hideHeaderInIframe);
}
if (meta.autoAdvance !== undefined) {
result.autoAdvance = Boolean(meta.autoAdvance);
}
if (meta.annotationLabel !== undefined) {
if (typeof meta.annotationLabel !== 'string') {
throw new ParseError('meta.annotationLabel must be a string', filePath);
}
result.annotationLabel = meta.annotationLabel.trim();
}
if (meta.colors !== undefined) {
result.colors = validateColors(meta.colors, filePath);
}
if (meta.timerStyle !== undefined) {
if (!VALID_TIMER_STYLES.includes(meta.timerStyle as TimerStyle)) {
throw new ParseError(
`meta.timerStyle must be one of: ${VALID_TIMER_STYLES.join(', ')}`,
filePath
);
}
result.timerStyle = meta.timerStyle as TimerStyle;
}
if (meta.cornerStyle !== undefined) {
if (!VALID_CORNER_STYLES.includes(meta.cornerStyle as CornerStyle)) {
throw new ParseError(
`meta.cornerStyle must be one of: ${VALID_CORNER_STYLES.join(', ')}`,
filePath
);
}
result.cornerStyle = meta.cornerStyle as CornerStyle;
}
if (meta.speed !== undefined) {
result.speed = validateSpeedConfig(meta.speed, filePath);
}
return result;
}
function validateSpeedConfig(raw: unknown, filePath?: string): SpeedConfig {
if (!raw || typeof raw !== 'object') {
throw new ParseError('meta.speed must be an object', filePath);
}
const speed = raw as Record<string, unknown>;
const result: SpeedConfig = {};
const numericFields: (keyof SpeedConfig)[] = [
'minDelay', 'maxDelay', 'msPerWord', 'annotationMultiplier', 'upNextDelay'
];
for (const field of numericFields) {
if (speed[field] !== undefined) {
const value = speed[field];
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new ParseError(
`meta.speed.${field} must be a positive number`,
filePath
);
}
result[field] = value;
}
}
// Validate logical constraints
if (result.minDelay !== undefined && result.maxDelay !== undefined) {
if (result.minDelay > result.maxDelay) {
throw new ParseError(
'meta.speed.minDelay cannot be greater than meta.speed.maxDelay',
filePath
);
}
}
return result;
}
/**
* Validate a CSS color value to prevent CSS injection attacks.
* Allows: hex colors, rgb/rgba/hsl/hsla, named colors, CSS variables
* Blocks: semicolons, braces, and other CSS injection attempts
*/
function isValidCssColor(value: string): boolean {
// Block obvious injection attempts
if (/[;{}]/.test(value)) return false;
if (/url\s*\(/i.test(value)) return false;
if (/expression\s*\(/i.test(value)) return false;
if (/javascript:/i.test(value)) return false;
// Allow common color formats
const validPatterns = [
/^#[0-9a-f]{3,8}$/i, // hex colors
/^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/i, // rgb()
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/i, // rgba()
/^hsl\(\s*\d+\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*\)$/i, // hsl()
/^hsla\(\s*\d+\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*,\s*[\d.]+\s*\)$/i, // hsla()
/^var\(--[a-z0-9-]+\)$/i, // CSS variables
/^[a-z]+$/i, // named colors (red, blue, etc.)
/^transparent$/i,
/^inherit$/i,
/^currentColor$/i,
];
return validPatterns.some(pattern => pattern.test(value.trim()));
}
function validateColors(raw: unknown, filePath?: string): ColorConfig {
if (!raw || typeof raw !== 'object') {
throw new ParseError('meta.colors must be an object', filePath);
}
const colors = raw as Record<string, unknown>;
const result: ColorConfig = {};
const colorFields: (keyof ColorConfig)[] = [
'accent', 'pageBg', 'canvasBg', 'leftBg', 'leftBorder',
'rightBg', 'rightBorder', 'tabInactiveColor'
];
for (const field of colorFields) {
if (colors[field] !== undefined) {
if (typeof colors[field] !== 'string') {
throw new ParseError(`meta.colors.${field} must be a string`, filePath);
}
const colorValue = colors[field] as string;
if (!isValidCssColor(colorValue)) {
throw new ParseError(
`meta.colors.${field} contains invalid CSS color value: "${colorValue}"`,
filePath
);
}
result[field] = colorValue;
}
}
return result;
}
function validateScenarios(raw: unknown[], filePath?: string): Scenario[] {
if (raw.length === 0) {
throw new ParseError('at least one scenario is required', filePath);
}
const ids = new Set<string>();
return raw.map((s, i) => {
if (!s || typeof s !== 'object') {
throw new ParseError(`scenarios[${i}] must be an object`, filePath);
}
const scenario = s as Record<string, unknown>;
const prefix = `scenarios[${i}]`;
// Validate id
if (typeof scenario.id !== 'string' || !scenario.id.trim()) {
throw new ParseError(`${prefix}.id is required`, filePath);
}
const id = scenario.id.trim();
if (ids.has(id)) {
throw new ParseError(`duplicate scenario id: ${id}`, filePath);
}
ids.add(id);
// Validate title
if (typeof scenario.title !== 'string' || !scenario.title.trim()) {
throw new ParseError(`${prefix}.title is required`, filePath);
}
// Validate participants
const participants = validateParticipants(scenario.participants, prefix, filePath);
// Validate steps
const steps = validateSteps(scenario.steps, participants, prefix, filePath);
return {
id,
title: scenario.title.trim(),
participants,
steps,
};
});
}
function validateParticipants(
raw: unknown,
prefix: string,
filePath?: string
): Participant[] {
if (!Array.isArray(raw)) {
throw new ParseError(`${prefix}.participants must be an array`, filePath);
}
if (raw.length === 0) {
throw new ParseError(`${prefix}: at least one participant is required`, filePath);
}
const ids = new Set<string>();
return raw.map((p, i) => {
if (!p || typeof p !== 'object') {
throw new ParseError(`${prefix}.participants[${i}] must be an object`, filePath);
}
const participant = p as Record<string, unknown>;
if (typeof participant.id !== 'string' || !participant.id.trim()) {
throw new ParseError(`${prefix}.participants[${i}].id is required`, filePath);
}
const id = participant.id.trim();
if (ids.has(id)) {
throw new ParseError(`${prefix}: duplicate participant id: ${id}`, filePath);
}
ids.add(id);
if (typeof participant.label !== 'string' || !participant.label.trim()) {
throw new ParseError(`${prefix}.participants[${i}].label is required`, filePath);
}
const role = participant.role ?? 'left';
if (role !== 'left' && role !== 'right') {
throw new ParseError(
`${prefix}.participants[${i}].role must be 'left' or 'right'`,
filePath
);
}
return {
id,
label: participant.label.trim(),
role: role as 'left' | 'right',
};
});
}
function validateSteps(
raw: unknown,
participants: Participant[],
prefix: string,
filePath?: string
): Step[] {
if (!Array.isArray(raw)) {
throw new ParseError(`${prefix}.steps must be an array`, filePath);
}
if (raw.length === 0) {
throw new ParseError(`${prefix}: at least one step is required`, filePath);
}
const participantIds = new Set(participants.map(p => p.id));
return raw.map((s, i) => {
if (!s || typeof s !== 'object') {
throw new ParseError(`${prefix}.steps[${i}] must be an object`, filePath);
}
const step = s as Record<string, unknown>;
const type = step.type ?? 'message';
const stepPrefix = `${prefix}.steps[${i}]`;
if (type === 'annotation') {
if (typeof step.content !== 'string' || !step.content.trim()) {
throw new ParseError(`${stepPrefix}.content is required for annotation`, filePath);
}
return { type: 'annotation' as const, content: step.content.trim() };
}
if (type === 'transition') {
if (typeof step.content !== 'string' || !step.content.trim()) {
throw new ParseError(`${stepPrefix}.content is required for transition`, filePath);
}
return { type: 'transition' as const, content: step.content.trim() };
}
if (type === 'message') {
if (typeof step.from !== 'string' || !step.from.trim()) {
throw new ParseError(`${stepPrefix}.from is required for message`, filePath);
}
const from = step.from.trim();
if (!participantIds.has(from)) {
throw new ParseError(
`${stepPrefix}.from "${from}" is not a valid participant id`,
filePath
);
}
if (typeof step.content !== 'string' || !step.content.trim()) {
throw new ParseError(`${stepPrefix}.content is required for message`, filePath);
}
const result: Step = {
type: 'message',
from,
content: step.content.trim(),
};
if (step.codeBlock !== undefined) {
if (typeof step.codeBlock !== 'string') {
throw new ParseError(`${stepPrefix}.codeBlock must be a string`, filePath);
}
(result as any).codeBlock = step.codeBlock;
}
if (step.footnote !== undefined) {
if (typeof step.footnote !== 'string') {
throw new ParseError(`${stepPrefix}.footnote must be a string`, filePath);
}
(result as any).footnote = step.footnote.trim();
}
return result;
}
throw new ParseError(
`${stepPrefix}.type must be 'message', 'annotation', or 'transition'`,
filePath
);
});
}
/**
* Load and parse a demo from a file path
*/
export async function loadDemo(filePath: string): Promise<Demo> {
const file = Bun.file(filePath);
if (!(await file.exists())) {
throw new ParseError(`File not found: ${filePath}`, filePath);
}
const content = await file.text();
return parseDemo(content, filePath);
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Conversation Replay - Type Definitions
*
* Defines the schema for YAML scenario files.
*/
export type Theme = 'chat' | 'email' | 'slack' | 'terminal' | 'generic';
export type ParticipantRole = 'left' | 'right';
export type StepType = 'message' | 'annotation' | 'transition';
export type TimerStyle = 'bar' | 'circle';
export type CornerStyle = 'rounded' | 'straight';
/**
* Speed/timing configuration for frame progression
*/
export interface SpeedConfig {
/** Minimum delay between frames in ms (default: 3000) */
minDelay?: number;
/** Maximum delay between frames in ms (default: 8000) */
maxDelay?: number;
/** Milliseconds per word for reading time calculation (default: 200) */
msPerWord?: number;
/** Multiplier for annotation display time (default: 1.15) */
annotationMultiplier?: number;
/** Delay to show "Up Next" before transitioning in ms (default: 2500) */
upNextDelay?: number;
}
export interface Participant {
id: string;
label: string;
role: ParticipantRole;
}
export interface MessageStep {
type: 'message';
from: string; // participant id
content: string;
codeBlock?: string;
footnote?: string;
}
export interface AnnotationStep {
type: 'annotation';
content: string;
}
export interface TransitionStep {
type: 'transition';
content: string;
}
export type Step = MessageStep | AnnotationStep | TransitionStep;
/**
* A single scenario (one tab in a multi-scenario demo)
*/
export interface Scenario {
/** Unique identifier for this scenario (used in tabs) */
id: string;
/** Display title shown in tab */
title: string;
/** Participants in this scenario */
participants: Participant[];
/** Steps in this scenario */
steps: Step[];
}
/**
* Custom color configuration
*/
export interface ColorConfig {
/** Primary accent color (used for buttons, links) */
accent?: string;
/** Page background color (when not embedded) */
pageBg?: string;
/** Chat canvas/container background color */
canvasBg?: string;
/** Left participant message background */
leftBg?: string;
/** Left participant message border */
leftBorder?: string;
/** Right participant message background */
rightBg?: string;
/** Right participant message border */
rightBorder?: string;
/** Inactive tab text color */
tabInactiveColor?: string;
}
/**
* Overall demo metadata
*/
export interface DemoMeta {
title: string;
description?: string;
theme?: Theme;
/** Link back to related article */
articleUrl?: string;
/** Whether to auto-hide header when embedded in iframe */
hideHeaderInIframe?: boolean;
/** Whether to auto-advance to next scenario when one completes */
autoAdvance?: boolean;
/** Label for annotation steps (default: "Behind the Scenes") */
annotationLabel?: string;
/** Custom colors */
colors?: ColorConfig;
/** Timer display style: 'bar' (horizontal line) or 'circle' (circular countdown). Default: 'circle' */
timerStyle?: TimerStyle;
/** Corner style for chat container and bubbles. Default: 'rounded' */
cornerStyle?: CornerStyle;
/** Speed/timing configuration for frame progression */
speed?: SpeedConfig;
}
/**
* A complete demo with one or more scenarios
*/
export interface Demo {
meta: DemoMeta;
scenarios: Scenario[];
}
export interface BuildOptions {
outputPath: string;
theme?: Theme;
/** Include demo header with title/description */
includeHeader?: boolean;
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}