Last active
March 15, 2026 08:52
-
-
Save secdev02/bc50cbec83e73ef0be5312971bb5cb44 to your computer and use it in GitHub Desktop.
Lateral Movement Simulator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Lateral Movement Modeler</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Mono&display=swap'); | |
| :root { | |
| –bg: #f8f9fa; | |
| –surface: #ffffff; | |
| –surface2: #f1f3f4; | |
| –border: #dadce0; | |
| –border-focus: #1a73e8; | |
| ``` | |
| --text-primary: #202124; | |
| --text-secondary: #5f6368; | |
| --text-hint: #9aa0a6; | |
| --blue: #1a73e8; | |
| --red: #d93025; | |
| --green: #188038; | |
| --yellow: #f29900; | |
| --orange: #e8710a; | |
| /* protocol colours — vivid but readable on white */ | |
| --rdp: #8430ce; | |
| --smb: #e8710a; | |
| --wmi: #0097a7; | |
| --https: #188038; | |
| --ssh: #c5960c; | |
| --shadow-sm: 0 1px 3px rgba(60,64,67,.15), 0 1px 2px rgba(60,64,67,.1); | |
| --shadow-md: 0 2px 6px 2px rgba(60,64,67,.15); | |
| --radius: 8px; | |
| --radius-sm: 4px; | |
| ``` | |
| } | |
| - { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| background: var(–bg); | |
| color: var(–text-primary); | |
| font-family: ‘Google Sans’, ‘Segoe UI’, sans-serif; | |
| font-size: 13px; | |
| display: flex; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* ── Sidebar ── */ | |
| #sidebar { | |
| width: 272px; | |
| min-width: 272px; | |
| background: var(–surface); | |
| border-right: 1px solid var(–border); | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| } | |
| #sidebar::-webkit-scrollbar { width: 6px; } | |
| #sidebar::-webkit-scrollbar-track { background: transparent; } | |
| #sidebar::-webkit-scrollbar-thumb { background: var(–border); border-radius: 3px; } | |
| .sidebar-header { | |
| padding: 20px 16px 16px; | |
| border-bottom: 1px solid var(–border); | |
| } | |
| .sidebar-header h1 { | |
| font-size: 15px; | |
| font-weight: 700; | |
| color: var(–text-primary); | |
| letter-spacing: 0; | |
| line-height: 1.3; | |
| } | |
| .sidebar-header h1 span { | |
| color: var(–blue); | |
| } | |
| .sidebar-header p { | |
| font-size: 11px; | |
| color: var(–text-hint); | |
| margin-top: 3px; | |
| } | |
| .section { | |
| padding: 16px; | |
| border-bottom: 1px solid var(–border); | |
| } | |
| .section-title { | |
| font-size: 10px; | |
| font-weight: 700; | |
| color: var(–text-hint); | |
| letter-spacing: 0.8px; | |
| text-transform: uppercase; | |
| margin-bottom: 12px; | |
| } | |
| label { | |
| display: block; | |
| font-size: 11px; | |
| font-weight: 500; | |
| color: var(–text-secondary); | |
| margin-bottom: 5px; | |
| } | |
| input[type=“number”] { | |
| width: 100%; | |
| background: var(–surface); | |
| border: 1px solid var(–border); | |
| border-radius: var(–radius-sm); | |
| color: var(–text-primary); | |
| font-family: inherit; | |
| font-size: 13px; | |
| padding: 7px 10px; | |
| margin-bottom: 12px; | |
| outline: none; | |
| transition: border-color 0.15s, box-shadow 0.15s; | |
| } | |
| input[type=“number”]:focus { | |
| border-color: var(–border-focus); | |
| box-shadow: 0 0 0 2px rgba(26,115,232,.2); | |
| } | |
| input[type=“range”] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 4px; | |
| background: var(–border); | |
| border-radius: 2px; | |
| cursor: pointer; | |
| accent-color: var(–blue); | |
| border: none; | |
| padding: 0; | |
| margin-bottom: 0; | |
| outline: none; | |
| } | |
| .protocol-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 6px; | |
| } | |
| .proto-btn { | |
| background: var(–surface2); | |
| border: 1.5px solid var(–border); | |
| border-radius: var(–radius-sm); | |
| color: var(–text-secondary); | |
| font-family: inherit; | |
| font-size: 12px; | |
| font-weight: 500; | |
| padding: 7px 6px; | |
| cursor: pointer; | |
| text-align: center; | |
| transition: all 0.15s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 5px; | |
| } | |
| .proto-btn:hover { background: #e8f0fe; border-color: #a8c7fa; color: var(–blue); } | |
| .proto-btn.active { color: #fff; font-weight: 600; } | |
| .proto-btn[data-proto=“RDP”].active { background: var(–rdp); border-color: var(–rdp); } | |
| .proto-btn[data-proto=“SMB”].active { background: var(–smb); border-color: var(–smb); } | |
| .proto-btn[data-proto=“WMI”].active { background: var(–wmi); border-color: var(–wmi); } | |
| .proto-btn[data-proto=“HTTPS”].active{ background: var(–https); border-color: var(–https); } | |
| .proto-btn[data-proto=“SSH”].active { background: var(–ssh); border-color: var(–ssh); } | |
| .proto-dot { | |
| display: inline-block; | |
| width: 7px; height: 7px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| .proto-btn.active .proto-dot { background: rgba(255,255,255,0.6) !important; } | |
| .speed-row { display: flex; align-items: center; gap: 10px; } | |
| .speed-row span { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(–blue); | |
| min-width: 36px; | |
| text-align: right; | |
| } | |
| /* ── Buttons ── */ | |
| .btn { | |
| width: 100%; | |
| border: none; | |
| border-radius: var(–radius-sm); | |
| font-family: inherit; | |
| font-size: 12px; | |
| font-weight: 600; | |
| letter-spacing: 0.2px; | |
| padding: 8px 12px; | |
| cursor: pointer; | |
| transition: background 0.15s, box-shadow 0.15s; | |
| margin-bottom: 6px; | |
| display: block; | |
| } | |
| .btn-primary { background: var(–blue); color: #fff; } | |
| .btn-primary:hover { background: #1765cc; box-shadow: var(–shadow-sm); } | |
| .btn-danger { background: var(–red); color: #fff; } | |
| .btn-danger:hover { background: #b31412; box-shadow: var(–shadow-sm); } | |
| .btn-warn { background: var(–yellow); color: #fff; } | |
| .btn-warn:hover { background: #d08000; box-shadow: var(–shadow-sm); } | |
| .btn-green { background: var(–green); color: #fff; } | |
| .btn-green:hover { background: #146c2e; box-shadow: var(–shadow-sm); } | |
| .btn-muted { background: var(–surface2); color: var(–text-secondary); border: 1px solid var(–border); } | |
| .btn-muted:hover { background: #e8eaed; } | |
| .btn:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; } | |
| /* ── Stats ── */ | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 8px; | |
| } | |
| .stat-box { | |
| background: var(–surface2); | |
| border-radius: var(–radius-sm); | |
| padding: 10px 8px; | |
| text-align: center; | |
| } | |
| .stat-val { | |
| font-size: 22px; | |
| font-weight: 700; | |
| color: var(–text-primary); | |
| line-height: 1; | |
| } | |
| .stat-label { | |
| font-size: 9px; | |
| font-weight: 600; | |
| color: var(–text-hint); | |
| letter-spacing: 0.5px; | |
| text-transform: uppercase; | |
| margin-top: 4px; | |
| } | |
| .stat-val.danger { color: var(–red); } | |
| .stat-val.canary { color: var(–yellow); } | |
| .stat-val.accent { color: var(–blue); } | |
| /* ── Log ── */ | |
| #log { | |
| overflow-y: auto; | |
| padding: 8px 12px; | |
| font-family: ‘Google Sans Mono’, ‘Roboto Mono’, monospace; | |
| font-size: 10px; | |
| line-height: 1.8; | |
| color: var(–text-secondary); | |
| min-height: 100px; | |
| max-height: 200px; | |
| background: var(–surface2); | |
| border-radius: var(–radius-sm); | |
| } | |
| #log::-webkit-scrollbar { width: 4px; } | |
| #log::-webkit-scrollbar-thumb { background: var(–border); border-radius: 2px; } | |
| .log-entry { } | |
| .log-entry.alert { color: var(–yellow); font-weight: 600; } | |
| .log-entry.compromised{ color: var(–red); } | |
| .log-entry.info { color: var(–blue); } | |
| .log-entry.move { color: var(–text-secondary); } | |
| /* ── Main area ── */ | |
| #main { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| #topbar { | |
| height: 44px; | |
| background: var(–surface); | |
| border-bottom: 1px solid var(–border); | |
| display: flex; | |
| align-items: center; | |
| padding: 0 20px; | |
| gap: 18px; | |
| } | |
| .topbar-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 11px; | |
| font-weight: 500; | |
| color: var(–text-secondary); | |
| } | |
| .legend-dot { | |
| width: 10px; height: 10px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| .topbar-sep { | |
| width: 1px; | |
| height: 20px; | |
| background: var(–border); | |
| } | |
| #canvas-wrap { | |
| flex: 1; | |
| position: relative; | |
| overflow: hidden; | |
| background: var(–bg); | |
| } | |
| svg { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .node circle { | |
| stroke-width: 2; | |
| cursor: pointer; | |
| transition: filter 0.15s; | |
| } | |
| .node circle:hover { filter: brightness(0.88); } | |
| .node text { | |
| font-family: ‘Google Sans’, sans-serif; | |
| font-size: 10px; | |
| font-weight: 600; | |
| fill: var(–text-primary); | |
| text-anchor: middle; | |
| dominant-baseline: middle; | |
| pointer-events: none; | |
| user-select: none; | |
| } | |
| .link { | |
| stroke-opacity: 0.5; | |
| stroke-width: 1.5; | |
| fill: none; | |
| } | |
| .link-packet { pointer-events: none; } | |
| .attacker-ring { | |
| fill: none; | |
| stroke: var(–red); | |
| stroke-width: 1.5; | |
| opacity: 0.6; | |
| animation: pulse-ring 1.6s ease-out infinite; | |
| } | |
| @keyframes pulse-ring { | |
| 0% { r: 16px; opacity: 0.7; } | |
| 100% { r: 28px; opacity: 0; } | |
| } | |
| @keyframes blink { 50% { opacity: 0.35; } } | |
| #status-bar { | |
| height: 32px; | |
| background: var(–surface); | |
| border-top: 1px solid var(–border); | |
| display: flex; | |
| align-items: center; | |
| padding: 0 20px; | |
| gap: 16px; | |
| } | |
| #status-phase { | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: var(–blue); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| #status-step { | |
| font-size: 11px; | |
| color: var(–text-secondary); | |
| } | |
| /* ── Replay ── */ | |
| .replay-controls { display: flex; gap: 6px; } | |
| .replay-controls .btn { width: auto; padding: 6px 12px; margin-bottom: 0; font-size: 11px; } | |
| /* ── Saves ── */ | |
| .saves-list { max-height: 120px; overflow-y: auto; margin-top: 8px; } | |
| .saves-list::-webkit-scrollbar { width: 4px; } | |
| .saves-list::-webkit-scrollbar-thumb { background: var(–border); border-radius: 2px; } | |
| .save-entry { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 7px 10px; | |
| background: var(–surface2); | |
| border-radius: var(–radius-sm); | |
| margin-bottom: 5px; | |
| font-size: 11px; | |
| cursor: pointer; | |
| border: 1px solid transparent; | |
| transition: border-color 0.15s, background 0.15s; | |
| } | |
| .save-entry:hover { background: #e8f0fe; border-color: #a8c7fa; } | |
| .save-entry .save-del { | |
| color: var(–text-hint); | |
| cursor: pointer; | |
| font-size: 14px; | |
| line-height: 1; | |
| padding: 0 3px; | |
| transition: color 0.1s; | |
| } | |
| .save-entry .save-del:hover { color: var(–red); } | |
| /* ── Tags ── */ | |
| .tag { | |
| display: inline-flex; | |
| align-items: center; | |
| font-size: 10px; | |
| font-weight: 600; | |
| padding: 2px 8px; | |
| border-radius: 12px; | |
| letter-spacing: 0.2px; | |
| } | |
| .tag-replay { background: #e6f4ea; color: var(–green); } | |
| .tag-live { background: #fce8e6; color: var(–red); animation: blink 1s step-end infinite; } | |
| /* ── Tooltip ── */ | |
| .tooltip { | |
| position: absolute; | |
| background: var(–surface); | |
| border: 1px solid var(–border); | |
| border-radius: var(–radius); | |
| padding: 10px 12px; | |
| font-size: 12px; | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.12s; | |
| z-index: 100; | |
| max-width: 180px; | |
| line-height: 1.6; | |
| box-shadow: var(–shadow-md); | |
| color: var(–text-primary); | |
| } | |
| .tooltip.visible { opacity: 1; } | |
| .tooltip strong { | |
| display: block; | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: var(–blue); | |
| margin-bottom: 4px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.3px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="sidebar"> | |
| <div class="sidebar-header"> | |
| <h1>Lateral Movement <span style="color:#1a73e8">Modeler</span></h1> | |
| <p>Adversary simulation & canary detection</p> | |
| </div> | |
| <!-- Setup --> | |
| <div class="section" id="setup-section"> | |
| <div class="section-title">Network Config</div> | |
| <label>Host Nodes</label> | |
| <input type="number" id="num-nodes" value="12" min="3" max="40"> | |
| <label>Thinkst Canaries</label> | |
| <input type="number" id="num-canaries" value="2" min="0" max="10"> | |
| </div> | |
| <!-- Protocol --> | |
| <div class="section"> | |
| <div class="section-title">Movement Protocol</div> | |
| <div class="protocol-grid"> | |
| <button class="proto-btn active" data-proto="RDP"> | |
| <span class="proto-dot" style="background:var(--rdp)"></span>RDP | |
| </button> | |
| <button class="proto-btn active" data-proto="SMB"> | |
| <span class="proto-dot" style="background:var(--smb)"></span>SMB | |
| </button> | |
| <button class="proto-btn active" data-proto="WMI"> | |
| <span class="proto-dot" style="background:var(--wmi)"></span>WMI | |
| </button> | |
| <button class="proto-btn active" data-proto="HTTPS"> | |
| <span class="proto-dot" style="background:var(--https)"></span>HTTPS | |
| </button> | |
| <button class="proto-btn active" data-proto="SSH" style="grid-column: span 2"> | |
| <span class="proto-dot" style="background:var(--ssh)"></span>SSH | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Speed --> | |
| <div class="section"> | |
| <div class="section-title">Attack Speed</div> | |
| <label>Step Delay (ms)</label> | |
| <div class="speed-row"> | |
| <input type="range" id="speed" min="300" max="3000" value="1000" step="100" style="flex:1"> | |
| <span id="speed-val">1000</span> | |
| </div> | |
| </div> | |
| <!-- Controls --> | |
| <div class="section"> | |
| <div class="section-title">Simulation</div> | |
| <button class="btn btn-primary" id="btn-build">Build Network</button> | |
| <button class="btn btn-danger" id="btn-start" disabled>Start Attack</button> | |
| <button class="btn btn-muted" id="btn-reset" disabled>Reset</button> | |
| </div> | |
| <!-- Stats --> | |
| <div class="section"> | |
| <div class="section-title">Telemetry</div> | |
| <div class="stats-grid"> | |
| <div class="stat-box"> | |
| <div class="stat-val" id="stat-comp">0</div> | |
| <div class="stat-label">Compromised</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-val canary" id="stat-canary">0</div> | |
| <div class="stat-label">Canary Hits</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-val accent" id="stat-moves">0</div> | |
| <div class="stat-label">Moves</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-val" id="stat-remain">0</div> | |
| <div class="stat-label">Safe</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Save / Load --> | |
| <div class="section"> | |
| <div class="section-title">Sessions</div> | |
| <button class="btn btn-green" id="btn-save" disabled>Save Session</button> | |
| <div class="saves-list" id="saves-list"></div> | |
| </div> | |
| <!-- Replay --> | |
| <div class="section" id="replay-section" style="display:none"> | |
| <div class="section-title">Replay Controls</div> | |
| <div class="replay-controls"> | |
| <button class="btn btn-warn" id="btn-replay-play">▶ Play</button> | |
| <button class="btn btn-muted" id="btn-replay-step">Step</button> | |
| <button class="btn btn-muted" id="btn-replay-stop">■ Stop</button> | |
| </div> | |
| </div> | |
| <!-- Log --> | |
| <div class="section" style="border-bottom:none; flex:1; display:flex; flex-direction:column; padding-bottom:0"> | |
| <div class="section-title">Event Log</div> | |
| <div id="log"></div> | |
| </div> | |
| </div> | |
| <div id="main"> | |
| <div id="topbar"> | |
| <div class="topbar-item"> | |
| <div class="legend-dot" style="background:var(--danger)"></div> ATTACKER | |
| </div> | |
| <div class="topbar-item"> | |
| <div class="legend-dot" style="background:#1e8a5a"></div> HOST | |
| </div> | |
| <div class="topbar-item"> | |
| <div class="legend-dot" style="background:var(--canary)"></div> CANARY | |
| </div> | |
| <div class="topbar-item"> | |
| <div class="legend-dot" style="background:var(--compromised)"></div> COMPROMISED | |
| </div> | |
| <div style="margin-left:auto; display:flex; gap:12px; align-items:center"> | |
| <span style="font-size:9px; color:#9aa0a6">Protocols:</span> | |
| <span class="topbar-item"><div class="legend-dot" style="background:var(--rdp)"></div>RDP</span> | |
| <span class="topbar-item"><div class="legend-dot" style="background:var(--smb)"></div>SMB</span> | |
| <span class="topbar-item"><div class="legend-dot" style="background:var(--wmi)"></div>WMI</span> | |
| <span class="topbar-item"><div class="legend-dot" style="background:var(--https)"></div>HTTPS</span> | |
| <span class="topbar-item"><div class="legend-dot" style="background:var(--ssh)"></div>SSH</span> | |
| <div id="mode-tag"></div> | |
| </div> | |
| </div> | |
| <div id="canvas-wrap"> | |
| <svg id="svg"></svg> | |
| <div class="tooltip" id="tooltip"></div> | |
| </div> | |
| <div id="status-bar"> | |
| <span id="status-phase">IDLE</span> | |
| <span id="status-step"></span> | |
| <span id="status-proto" style="margin-left:auto; font-size:11px; color:#9aa0a6"></span> | |
| </div> | |
| </div> | |
| <script> | |
| // ─── State ─────────────────────────────────────────────────────────────────── | |
| const PROTO_COLORS = { RDP:'#e040fb', SMB:'#ff6d00', WMI:'#00e5ff', HTTPS:'#76ff03', SSH:'#ffd740' }; | |
| let nodes = [], links = [], simulation = null; | |
| let attackerNode = null; | |
| let sessionEvents = []; // recorded events | |
| let replayEvents = []; | |
| let replayIdx = 0; | |
| let replayTimer = null; | |
| let isRunning = false; | |
| let isReplay = false; | |
| let attackTimer = null; | |
| let compromisedSet = new Set(); | |
| let canaryHitSet = new Set(); | |
| let moveCount = 0; | |
| let enabledProtos = new Set(['RDP','SMB','WMI','HTTPS','SSH']); | |
| let saves = JSON.parse(localStorage.getItem('lm_saves') || '[]'); | |
| // ─── DOM refs ───────────────────────────────────────────────────────────────── | |
| const svg = d3.select('#svg'); | |
| const logEl = document.getElementById('log'); | |
| const tooltip = document.getElementById('tooltip'); | |
| const modeTag = document.getElementById('mode-tag'); | |
| // ─── Helpers ────────────────────────────────────────────────────────────────── | |
| function getSpeed() { return parseInt(document.getElementById('speed').value); } | |
| function activeProtos() { | |
| return [...enabledProtos]; | |
| } | |
| function randProto() { | |
| const p = activeProtos(); | |
| return p[Math.floor(Math.random() * p.length)]; | |
| } | |
| function addLog(msg, cls) { | |
| const ts = new Date().toLocaleTimeString('en-GB',{hour12:false}); | |
| const d = document.createElement('div'); | |
| d.className = 'log-entry ' + (cls||'move'); | |
| d.textContent = '[' + ts + '] ' + msg; | |
| logEl.appendChild(d); | |
| logEl.scrollTop = logEl.scrollHeight; | |
| } | |
| function updateStats() { | |
| document.getElementById('stat-comp').textContent = compromisedSet.size; | |
| document.getElementById('stat-canary').textContent = canaryHitSet.size; | |
| document.getElementById('stat-moves').textContent = moveCount; | |
| const safe = nodes.filter(n => !n.isAttacker && !compromisedSet.has(n.id)).length; | |
| document.getElementById('stat-remain').textContent = safe; | |
| const el = document.getElementById('stat-comp'); | |
| el.className = 'stat-val' + (compromisedSet.size > 0 ? ' danger' : ''); | |
| } | |
| // ─── Protocol buttons ───────────────────────────────────────────────────────── | |
| document.querySelectorAll('.proto-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const p = btn.dataset.proto; | |
| if (enabledProtos.has(p)) { | |
| if (enabledProtos.size === 1) return; // keep at least one | |
| enabledProtos.delete(p); | |
| btn.classList.remove('active'); | |
| } else { | |
| enabledProtos.add(p); | |
| btn.classList.add('active'); | |
| } | |
| }); | |
| }); | |
| document.getElementById('speed').addEventListener('input', function() { | |
| document.getElementById('speed-val').textContent = this.value; | |
| }); | |
| // ─── Build Network ──────────────────────────────────────────────────────────── | |
| document.getElementById('btn-build').addEventListener('click', buildNetwork); | |
| function buildNetwork() { | |
| stopAll(); | |
| isReplay = false; | |
| document.getElementById('replay-section').style.display = 'none'; | |
| modeTag.innerHTML = ''; | |
| const numNodes = Math.max(3, Math.min(40, parseInt(document.getElementById('num-nodes').value) || 12)); | |
| const numCanaries = Math.max(0, Math.min(numNodes-2, parseInt(document.getElementById('num-canaries').value) || 2)); | |
| compromisedSet.clear(); | |
| canaryHitSet.clear(); | |
| moveCount = 0; | |
| sessionEvents = []; | |
| logEl.innerHTML = ''; | |
| updateStats(); | |
| // Build nodes | |
| nodes = []; | |
| // Attacker | |
| nodes.push({ id: 'attacker', label: 'ATK', isAttacker: true, isCanary: false, compromised: false }); | |
| // Canaries | |
| for (let i = 0; i < numCanaries; i++) { | |
| nodes.push({ id: 'canary-' + i, label: 'C' + (i+1), isAttacker: false, isCanary: true, compromised: false }); | |
| } | |
| // Hosts | |
| for (let i = 0; i < numNodes; i++) { | |
| nodes.push({ id: 'host-' + i, label: 'H' + (i+1), isAttacker: false, isCanary: false, compromised: false }); | |
| } | |
| // Build links (sparse random graph, each non-attacker connected to ~3 others) | |
| links = []; | |
| const nonAttacker = nodes.filter(n => !n.isAttacker); | |
| // Connect attacker to 2-3 random hosts | |
| const atkTargets = d3.shuffle([...nonAttacker]).slice(0, 3); | |
| atkTargets.forEach(t => links.push({ source: 'attacker', target: t.id })); | |
| // Random edges between other nodes | |
| for (let i = 0; i < nonAttacker.length; i++) { | |
| const degree = 2 + Math.floor(Math.random() * 3); | |
| const others = nonAttacker.filter((n,j) => j !== i); | |
| d3.shuffle(others).slice(0, degree).forEach(t => { | |
| const exists = links.some(l => | |
| (l.source === nonAttacker[i].id && l.target === t.id) || | |
| (l.source === t.id && l.target === nonAttacker[i].id) | |
| ); | |
| if (!exists) links.push({ source: nonAttacker[i].id, target: t.id }); | |
| }); | |
| } | |
| attackerNode = nodes[0]; | |
| attackerNode.current = true; | |
| drawGraph(); | |
| updateStats(); | |
| addLog('Network built. ' + numNodes + ' hosts, ' + numCanaries + ' canaries.', 'info'); | |
| addLog('Attacker at entry point. Awaiting execution.', 'info'); | |
| document.getElementById('btn-start').disabled = false; | |
| document.getElementById('btn-reset').disabled = false; | |
| document.getElementById('btn-save').disabled = true; | |
| document.getElementById('status-phase').textContent = 'READY'; | |
| document.getElementById('status-step').textContent = ''; | |
| } | |
| // ─── Draw Graph ─────────────────────────────────────────────────────────────── | |
| let svgG, linkSel, nodeSel; | |
| function drawGraph() { | |
| svg.selectAll('*').remove(); | |
| const W = document.getElementById('canvas-wrap').offsetWidth; | |
| const H = document.getElementById('canvas-wrap').offsetHeight; | |
| // Grid bg | |
| const defs = svg.append('defs'); | |
| const pattern = defs.append('pattern') | |
| .attr('id','grid').attr('width',40).attr('height',40) | |
| .attr('patternUnits','userSpaceOnUse'); | |
| pattern.append('path').attr('d','M 40 0 L 0 0 0 40') | |
| .attr('fill','none').attr('stroke','#e8eaed').attr('stroke-width','0.8'); | |
| svg.append('rect').attr('width','100%').attr('height','100%').attr('fill','url(#grid)'); | |
| // Arrow markers | |
| Object.entries(PROTO_COLORS).forEach(([proto, color]) => { | |
| defs.append('marker') | |
| .attr('id', 'arrow-' + proto) | |
| .attr('viewBox','0 -4 8 8') | |
| .attr('refX', 20).attr('refY', 0) | |
| .attr('markerWidth', 5).attr('markerHeight', 5) | |
| .attr('orient','auto') | |
| .append('path') | |
| .attr('d','M0,-4L8,0L0,4') | |
| .attr('fill', color); | |
| }); | |
| defs.append('marker') | |
| .attr('id','arrow-default') | |
| .attr('viewBox','0 -4 8 8') | |
| .attr('refX',20).attr('refY',0) | |
| .attr('markerWidth',5).attr('markerHeight',5) | |
| .attr('orient','auto') | |
| .append('path').attr('d','M0,-4L8,0L0,4').attr('fill','#c0c4c9'); | |
| svgG = svg.append('g'); | |
| // Zoom | |
| const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', e => svgG.attr('transform', e.transform)); | |
| svg.call(zoom); | |
| // Links layer | |
| const linkGroup = svgG.append('g').attr('class','links'); | |
| const nodeGroup = svgG.append('g').attr('class','nodes'); | |
| const packetGroup = svgG.append('g').attr('class','packets'); | |
| linkSel = linkGroup.selectAll('.link') | |
| .data(links).enter().append('line') | |
| .attr('class','link') | |
| .attr('stroke','#dadce0') | |
| .attr('marker-end','url(#arrow-default)'); | |
| nodeSel = nodeGroup.selectAll('.node') | |
| .data(nodes).enter().append('g') | |
| .attr('class','node') | |
| .call(d3.drag() | |
| .on('start', dragstarted) | |
| .on('drag', dragged) | |
| .on('end', dragended)) | |
| .on('mouseover', showTooltip) | |
| .on('mousemove', moveTooltipFn) | |
| .on('mouseout', hideTooltip); | |
| // Attacker ring animation | |
| nodeSel.filter(d => d.isAttacker).append('circle') | |
| .attr('class','attacker-ring') | |
| .attr('r', 18); | |
| nodeSel.append('circle') | |
| .attr('r', d => d.isAttacker ? 16 : (d.isCanary ? 14 : 13)) | |
| .attr('fill', d => { | |
| if (d.isAttacker) return '#fce8e6'; | |
| if (d.isCanary) return '#fef7e0'; | |
| return '#e8f0fe'; | |
| }) | |
| .attr('stroke', d => { | |
| if (d.isAttacker) return '#d93025'; | |
| if (d.isCanary) return '#f29900'; | |
| return '#1a73e8'; | |
| }); | |
| nodeSel.append('text') | |
| .text(d => { | |
| if (d.isAttacker) return '☠'; | |
| if (d.isCanary) return '⚠'; | |
| return d.label; | |
| }) | |
| .attr('font-size', d => d.isAttacker ? '13px' : '10px') | |
| .attr('fill', d => { | |
| if (d.isAttacker) return '#d93025'; | |
| if (d.isCanary) return '#b06000'; | |
| return '#1a73e8'; | |
| }); | |
| // Force simulation | |
| simulation = d3.forceSimulation(nodes) | |
| .force('link', d3.forceLink(links).id(d => d.id).distance(90).strength(0.5)) | |
| .force('charge', d3.forceManyBody().strength(-260)) | |
| .force('center', d3.forceCenter(W/2, H/2)) | |
| .force('collision', d3.forceCollide(28)) | |
| .on('tick', ticked); | |
| } | |
| function ticked() { | |
| if (!linkSel) return; | |
| linkSel | |
| .attr('x1', d => d.source.x).attr('y1', d => d.source.y) | |
| .attr('x2', d => d.target.x).attr('y2', d => d.target.y); | |
| nodeSel.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')'); | |
| } | |
| function dragstarted(event, d) { | |
| if (!event.active) simulation.alphaTarget(0.3).restart(); | |
| d.fx = d.x; d.fy = d.y; | |
| } | |
| function dragged(event, d) { d.fx = event.x; d.fy = event.y; } | |
| function dragended(event, d) { | |
| if (!event.active) simulation.alphaTarget(0); | |
| d.fx = null; d.fy = null; | |
| } | |
| // ─── Tooltip ───────────────────────────────────────────────────────────────── | |
| function showTooltip(event, d) { | |
| let html = '<strong>' + (d.isAttacker ? 'ATTACKER' : d.isCanary ? 'CANARY: ' + d.label : 'HOST: ' + d.label) + '</strong>'; | |
| if (d.isAttacker) { | |
| html += 'Entry point<br>Moves: ' + moveCount; | |
| } else if (d.isCanary) { | |
| html += 'Thinkst Canary<br>Status: ' + (canaryHitSet.has(d.id) ? '⚠ TRIGGERED' : 'Active'); | |
| } else { | |
| html += 'Status: ' + (compromisedSet.has(d.id) ? '✗ COMPROMISED' : '✓ Secure'); | |
| if (d.lastProto) html += '<br>Last: ' + d.lastProto; | |
| } | |
| tooltip.innerHTML = html; | |
| tooltip.classList.add('visible'); | |
| } | |
| function moveTooltipFn(event) { | |
| const wrap = document.getElementById('canvas-wrap').getBoundingClientRect(); | |
| let x = event.clientX - wrap.left + 12; | |
| let y = event.clientY - wrap.top - 10; | |
| if (x + 170 > wrap.width) x -= 180; | |
| tooltip.style.left = x + 'px'; | |
| tooltip.style.top = y + 'px'; | |
| } | |
| function hideTooltip() { tooltip.classList.remove('visible'); } | |
| // ─── Node visual update ─────────────────────────────────────────────────────── | |
| function refreshNodeVisuals() { | |
| if (!nodeSel) return; | |
| nodeSel.select('circle:not(.attacker-ring)') | |
| .attr('fill', d => { | |
| if (d.isAttacker) return '#fce8e6'; | |
| if (d.isCanary) return canaryHitSet.has(d.id) ? '#f9ab00' : '#fef7e0'; | |
| return compromisedSet.has(d.id) ? '#fce8e6' : '#e8f0fe'; | |
| }) | |
| .attr('stroke', d => { | |
| if (d.isAttacker) return '#d93025'; | |
| if (d.isCanary) return canaryHitSet.has(d.id) ? '#e8710a' : '#f29900'; | |
| if (compromisedSet.has(d.id)) return '#d93025'; | |
| return d.current ? '#1a73e8' : '#9aa0a6'; | |
| }) | |
| .attr('stroke-width', d => d.current ? 3 : 2); | |
| } | |
| function refreshLinkVisuals() { | |
| if (!linkSel) return; | |
| linkSel | |
| .attr('stroke', d => { | |
| const tgt = typeof d.target === 'object' ? d.target.id : d.target; | |
| if (d.proto && compromisedSet.has(tgt)) return PROTO_COLORS[d.proto] || '#dadce0'; | |
| return '#dadce0'; | |
| }) | |
| .attr('stroke-opacity', d => d.proto ? 0.8 : 0.6) | |
| .attr('marker-end', d => d.proto ? 'url(#arrow-' + d.proto + ')' : 'url(#arrow-default)'); | |
| } | |
| // ─── Packet animation ───────────────────────────────────────────────────────── | |
| function animatePacket(srcNode, tgtNode, proto, cb) { | |
| const color = PROTO_COLORS[proto] || '#fff'; | |
| const packetGroup = svgG.select('.packets'); | |
| const pkt = packetGroup.append('circle') | |
| .attr('class','link-packet') | |
| .attr('r', 4) | |
| .attr('fill', color) | |
| .attr('opacity', 0.9) | |
| .attr('cx', srcNode.x).attr('cy', srcNode.y) | |
| .style('filter', 'drop-shadow(0 0 4px ' + color + ')'); | |
| pkt.transition().duration(getSpeed() * 0.7) | |
| .attr('cx', tgtNode.x).attr('cy', tgtNode.y) | |
| .on('end', function() { | |
| pkt.remove(); | |
| if (cb) cb(); | |
| }); | |
| } | |
| // ─── Attack logic ───────────────────────────────────────────────────────────── | |
| document.getElementById('btn-start').addEventListener('click', startAttack); | |
| function startAttack() { | |
| if (isRunning) return; | |
| isRunning = true; | |
| isReplay = false; | |
| modeTag.innerHTML = '<span class="tag tag-live">● LIVE</span>'; | |
| document.getElementById('btn-start').disabled = true; | |
| document.getElementById('btn-save').disabled = true; | |
| document.getElementById('status-phase').textContent = 'ATTACKING'; | |
| // Mark attacker as current | |
| nodes.forEach(n => n.current = false); | |
| attackerNode.current = true; | |
| refreshNodeVisuals(); | |
| addLog('Attack sequence initiated.', 'info'); | |
| recordEvent({ type: 'start', attackerId: attackerNode.id }); | |
| scheduleNextMove(); | |
| } | |
| function scheduleNextMove() { | |
| if (!isRunning) return; | |
| attackTimer = setTimeout(doMove, getSpeed()); | |
| } | |
| function doMove() { | |
| if (!isRunning) return; | |
| // Find current attacker position | |
| const current = nodes.find(n => n.current && !n.isCanary); | |
| if (!current) { endAttack(); return; } | |
| // Find reachable nodes via links | |
| const reachable = getNeighbors(current).filter(n => !compromisedSet.has(n.id) && !n.isAttacker); | |
| if (reachable.length === 0) { | |
| // Try to jump from any compromised node | |
| const allPositions = [current, ...nodes.filter(n => compromisedSet.has(n.id))]; | |
| let found = null; | |
| for (const pos of allPositions) { | |
| const r = getNeighbors(pos).filter(n => !compromisedSet.has(n.id) && !n.isAttacker); | |
| if (r.length > 0) { found = { from: pos, targets: r }; break; } | |
| } | |
| if (!found) { endAttack(); return; } | |
| // Move attacker perspective | |
| nodes.forEach(n => n.current = false); | |
| found.from.current = true; | |
| refreshNodeVisuals(); | |
| doMoveFrom(found.from, found.targets); | |
| return; | |
| } | |
| doMoveFrom(current, reachable); | |
| } | |
| function doMoveFrom(fromNode, targets) { | |
| const target = targets[Math.floor(Math.random() * targets.length)]; | |
| const proto = randProto(); | |
| moveCount++; | |
| document.getElementById('status-step').textContent = | |
| 'Step ' + moveCount + ': ' + fromNode.label + ' → ' + target.label + ' via ' + proto; | |
| document.getElementById('status-proto').textContent = proto; | |
| // Mark link | |
| const link = links.find(l => { | |
| const s = typeof l.source === 'object' ? l.source.id : l.source; | |
| const t = typeof l.target === 'object' ? l.target.id : l.target; | |
| return (s === fromNode.id && t === target.id) || (s === target.id && t === fromNode.id); | |
| }); | |
| if (link) link.proto = proto; | |
| animatePacket(fromNode, target, proto, () => { | |
| // Check canary | |
| if (target.isCanary) { | |
| canaryHitSet.add(target.id); | |
| target.lastProto = proto; | |
| addLog('⚠ CANARY TRIGGERED: ' + target.label + ' via ' + proto + ' — ATTACK HALTED', 'alert'); | |
| addLog('Attacker detected. Simulation stopped after ' + moveCount + ' move(s).', 'alert'); | |
| recordEvent({ type: 'canary', fromId: fromNode.id, targetId: target.id, proto: proto, step: moveCount }); | |
| flashCanary(target); | |
| refreshNodeVisuals(); | |
| refreshLinkVisuals(); | |
| updateStats(); | |
| endAttackCanary(target, proto); | |
| return; | |
| } else { | |
| compromisedSet.add(target.id); | |
| target.lastProto = proto; | |
| nodes.forEach(n => n.current = false); | |
| target.current = true; | |
| addLog('Host ' + target.label + ' compromised via ' + proto, 'compromised'); | |
| recordEvent({ type: 'compromise', fromId: fromNode.id, targetId: target.id, proto: proto, step: moveCount }); | |
| } | |
| refreshNodeVisuals(); | |
| refreshLinkVisuals(); | |
| updateStats(); | |
| // Check win condition | |
| const remaining = nodes.filter(n => !n.isAttacker && !n.isCanary && !compromisedSet.has(n.id)); | |
| if (remaining.length === 0) { | |
| endAttack(); | |
| return; | |
| } | |
| scheduleNextMove(); | |
| }); | |
| } | |
| function flashCanary(node) { | |
| const canaryRing = svgG.select('.nodes').selectAll('.node') | |
| .filter(d => d.id === node.id) | |
| .append('circle') | |
| .attr('r', 12) | |
| .attr('fill', 'none') | |
| .attr('stroke', '#ffcc00') | |
| .attr('stroke-width', 2) | |
| .attr('opacity', 1); | |
| canaryRing.transition().duration(600) | |
| .attr('r', 35) | |
| .attr('opacity', 0) | |
| .on('end', function() { d3.select(this).remove(); }); | |
| } | |
| function getNeighbors(node) { | |
| return links.flatMap(l => { | |
| const s = typeof l.source === 'object' ? l.source : nodes.find(n => n.id === l.source); | |
| const t = typeof l.target === 'object' ? l.target : nodes.find(n => n.id === l.target); | |
| if (!s || !t) return []; | |
| if (s.id === node.id) return [t]; | |
| if (t.id === node.id) return [s]; | |
| return []; | |
| }); | |
| } | |
| function endAttack() { | |
| isRunning = false; | |
| const allHosts = nodes.filter(n => !n.isAttacker && !n.isCanary).length; | |
| const pct = Math.round((compromisedSet.size / allHosts) * 100); | |
| addLog( | |
| 'Attack complete. ' + compromisedSet.size + '/' + allHosts + ' hosts (' + pct + '%) compromised. ' + | |
| canaryHitSet.size + ' canary(ies) triggered.', 'info' | |
| ); | |
| document.getElementById('status-phase').textContent = 'COMPLETE'; | |
| document.getElementById('btn-save').disabled = false; | |
| recordEvent({ type: 'end' }); | |
| } | |
| function endAttackCanary(canaryNode, proto) { | |
| isRunning = false; | |
| document.getElementById('status-phase').textContent = '\u26a0 Canary Detected'; | |
| document.getElementById('btn-save').disabled = false; | |
| recordEvent({ type: 'end', reason: 'canary', canaryId: canaryNode.id }); | |
| modeTag.innerHTML = '<span class="tag" style="background:#fef7e0;color:#b06000;animation:blink 0.6s step-end infinite">\u26a0 Busted</span>'; | |
| // Flash canvas border | |
| const wrap = document.getElementById('canvas-wrap'); | |
| wrap.style.transition = 'box-shadow 0.1s'; | |
| wrap.style.boxShadow = 'inset 0 0 40px rgba(242,153,0,0.2)'; | |
| setTimeout(function() { wrap.style.boxShadow = ''; }, 2000); | |
| // Overlay banner | |
| const banner = document.createElement('div'); | |
| banner.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border:2px solid #f29900;border-radius:8px;color:#b06000;font-family:Google Sans,sans-serif;font-size:15px;font-weight:700;padding:24px 36px;text-align:center;z-index:200;pointer-events:none;box-shadow:0 4px 24px rgba(242,153,0,0.2);white-space:nowrap'; | |
| banner.innerHTML = '\u26a0\ufe0f Canary Triggered \u26a0\ufe0f<br><span style="font-size:12px;color:#5f6368;font-weight:500;font-family:monospace">' + canaryNode.label + ' · ' + proto + ' · Attack Halted</span>'; | |
| wrap.appendChild(banner); | |
| setTimeout(function() { | |
| banner.style.transition = 'opacity 0.6s'; | |
| banner.style.opacity = '0'; | |
| setTimeout(function() { banner.remove(); }, 700); | |
| }, 2800); | |
| } | |
| function stopAll() { | |
| isRunning = false; | |
| if (attackTimer) { clearTimeout(attackTimer); attackTimer = null; } | |
| if (replayTimer) { clearTimeout(replayTimer); replayTimer = null; } | |
| } | |
| // ─── Reset ──────────────────────────────────────────────────────────────────── | |
| document.getElementById('btn-reset').addEventListener('click', () => { | |
| stopAll(); | |
| buildNetwork(); | |
| }); | |
| // ─── Record / Replay ───────────────────────────────────────────────────────── | |
| function recordEvent(ev) { | |
| sessionEvents.push(Object.assign({ ts: Date.now() }, ev)); | |
| } | |
| // ─── Save ───────────────────────────────────────────────────────────────────── | |
| document.getElementById('btn-save').addEventListener('click', saveSession); | |
| function saveSession() { | |
| if (sessionEvents.length === 0) return; | |
| const snapshot = { | |
| id: Date.now(), | |
| date: new Date().toLocaleString(), | |
| nodes: nodes.map(n => ({ | |
| id: n.id, label: n.label, isAttacker: n.isAttacker, | |
| isCanary: n.isCanary, x: n.x, y: n.y | |
| })), | |
| links: links.map(l => ({ | |
| source: typeof l.source === 'object' ? l.source.id : l.source, | |
| target: typeof l.target === 'object' ? l.target.id : l.target, | |
| proto: l.proto || null | |
| })), | |
| events: sessionEvents, | |
| stats: { | |
| compromised: compromisedSet.size, | |
| canaryHits: canaryHitSet.size, | |
| moves: moveCount | |
| } | |
| }; | |
| saves.unshift(snapshot); | |
| if (saves.length > 10) saves = saves.slice(0, 10); | |
| localStorage.setItem('lm_saves', JSON.stringify(saves)); | |
| renderSaves(); | |
| addLog('Session saved: ' + snapshot.date, 'info'); | |
| } | |
| function renderSaves() { | |
| const el = document.getElementById('saves-list'); | |
| el.innerHTML = ''; | |
| saves.forEach((s, i) => { | |
| const d = document.createElement('div'); | |
| d.className = 'save-entry'; | |
| d.innerHTML = | |
| '<span>' + s.date + '<br><span style="color:#3a5a6a;font-size:8px">' + | |
| s.stats.compromised + ' comp · ' + s.stats.canaryHits + ' canary · ' + s.stats.moves + ' moves</span></span>' + | |
| '<span class="save-del" data-idx="' + i + '" title="Delete">✕</span>'; | |
| d.querySelector('.save-del').addEventListener('click', e => { | |
| e.stopPropagation(); | |
| saves.splice(parseInt(e.target.dataset.idx), 1); | |
| localStorage.setItem('lm_saves', JSON.stringify(saves)); | |
| renderSaves(); | |
| }); | |
| d.addEventListener('click', () => loadReplay(s)); | |
| el.appendChild(d); | |
| }); | |
| } | |
| // ─── Replay ─────────────────────────────────────────────────────────────────── | |
| function loadReplay(snapshot) { | |
| stopAll(); | |
| isReplay = true; | |
| sessionEvents = []; | |
| replayEvents = snapshot.events; | |
| replayIdx = 0; | |
| logEl.innerHTML = ''; | |
| compromisedSet.clear(); | |
| canaryHitSet.clear(); | |
| moveCount = 0; | |
| updateStats(); | |
| // Reconstruct nodes/links | |
| nodes = snapshot.nodes.map(n => Object.assign({}, n, { current: n.isAttacker, compromised: false })); | |
| links = snapshot.links.map(l => ({ source: l.source, target: l.target, proto: null })); | |
| attackerNode = nodes.find(n => n.isAttacker); | |
| drawGraph(); | |
| // Set fixed positions | |
| nodes.forEach(n => { n.fx = n.x; n.fy = n.y; }); | |
| simulation.alpha(0).stop(); | |
| ticked(); | |
| document.getElementById('replay-section').style.display = 'block'; | |
| document.getElementById('btn-start').disabled = true; | |
| document.getElementById('btn-save').disabled = true; | |
| document.getElementById('status-phase').textContent = 'REPLAY'; | |
| modeTag.innerHTML = '<span class="tag tag-replay">▶ REPLAY</span>'; | |
| addLog('Replay loaded: ' + snapshot.date + ' (' + replayEvents.length + ' events)', 'info'); | |
| } | |
| document.getElementById('btn-replay-play').addEventListener('click', function() { | |
| if (!isReplay) return; | |
| isRunning = true; | |
| playNextReplayEvent(); | |
| this.disabled = true; | |
| }); | |
| document.getElementById('btn-replay-step').addEventListener('click', () => { | |
| if (!isReplay) return; | |
| isRunning = false; | |
| if (replayTimer) clearTimeout(replayTimer); | |
| applyReplayEvent(); | |
| }); | |
| document.getElementById('btn-replay-stop').addEventListener('click', () => { | |
| isRunning = false; | |
| if (replayTimer) clearTimeout(replayTimer); | |
| document.getElementById('btn-replay-play').disabled = false; | |
| document.getElementById('status-phase').textContent = 'REPLAY PAUSED'; | |
| }); | |
| function playNextReplayEvent() { | |
| if (!isRunning || replayIdx >= replayEvents.length) { | |
| isRunning = false; | |
| document.getElementById('btn-replay-play').disabled = false; | |
| document.getElementById('status-phase').textContent = 'REPLAY DONE'; | |
| return; | |
| } | |
| applyReplayEvent(); | |
| replayTimer = setTimeout(playNextReplayEvent, getSpeed()); | |
| } | |
| function applyReplayEvent() { | |
| if (replayIdx >= replayEvents.length) return; | |
| const ev = replayEvents[replayIdx++]; | |
| if (ev.type === 'start') { | |
| addLog('Replay: attack start', 'info'); | |
| document.getElementById('status-phase').textContent = 'REPLAY'; | |
| } else if (ev.type === 'compromise' || ev.type === 'canary') { | |
| const fromNode = nodes.find(n => n.id === ev.fromId); | |
| const tgtNode = nodes.find(n => n.id === ev.targetId); | |
| if (!tgtNode) return; | |
| moveCount = ev.step; | |
| const link = links.find(l => { | |
| const s = typeof l.source === 'object' ? l.source.id : l.source; | |
| const t = typeof l.target === 'object' ? l.target.id : l.target; | |
| return (s === ev.fromId && t === ev.targetId) || (s === ev.targetId && t === ev.fromId); | |
| }); | |
| if (link) link.proto = ev.proto; | |
| if (ev.type === 'canary') { | |
| canaryHitSet.add(tgtNode.id); | |
| tgtNode.lastProto = ev.proto; | |
| addLog('⚠ CANARY TRIGGERED: ' + tgtNode.label + ' via ' + ev.proto, 'alert'); | |
| flashCanary(tgtNode); | |
| } else { | |
| compromisedSet.add(tgtNode.id); | |
| tgtNode.lastProto = ev.proto; | |
| nodes.forEach(n => n.current = false); | |
| tgtNode.current = true; | |
| addLog('Host ' + tgtNode.label + ' compromised via ' + ev.proto, 'compromised'); | |
| } | |
| if (fromNode) animatePacket(fromNode, tgtNode, ev.proto, () => {}); | |
| refreshNodeVisuals(); | |
| refreshLinkVisuals(); | |
| updateStats(); | |
| document.getElementById('status-step').textContent = | |
| 'Step ' + ev.step + ': ' + (fromNode ? fromNode.label : '?') + ' → ' + tgtNode.label + ' via ' + ev.proto; | |
| } else if (ev.type === 'end') { | |
| addLog('Replay: attack sequence end.', 'info'); | |
| document.getElementById('status-phase').textContent = 'REPLAY DONE'; | |
| isRunning = false; | |
| document.getElementById('btn-replay-play').disabled = false; | |
| } | |
| } | |
| // ─── Init ───────────────────────────────────────────────────────────────────── | |
| renderSaves(); | |
| document.getElementById('status-phase').textContent = 'IDLE'; | |
| addLog('System ready. Configure network and build.', 'info'); | |
| // Build on load for quick start | |
| setTimeout(buildNetwork, 300); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment