mirror of
https://github.com/lennyzeltser/conversation-replay
synced 2026-06-21 13:55:53 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
../../CLAUDE.md
|
||||
+34
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.5x–4x)
|
||||
- **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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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."
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+430
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user