Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Evil Twin Attack Detector (ML) — Browser Demo</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <meta name="description" | |
| content="Evil Twin attack detection demo using a lightweight ML model in the browser (TensorFlow.js). No backend required." /> | |
| <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script> | |
| <style> | |
| :root { | |
| --bg: #0b1020; | |
| --bg-2: #0f1630; | |
| --card: #121a35; | |
| --card-2: #0f1a3a; | |
| --text: #dfe7ff; | |
| --muted: #8ea0c8; | |
| --primary: #5b8cff; | |
| --primary-2: #7ea2ff; | |
| --accent: #8cffd9; | |
| --danger: #ff6b6b; | |
| --warn: #f7b267; | |
| --good: #57e39a; | |
| --shadow: 0 8px 30px rgba(0, 0, 0, .35); | |
| --radius: 14px; | |
| --radius-sm: 10px; | |
| --radius-xs: 8px; | |
| --grid-gap: 16px; | |
| --ring: 0 0 0 2px rgba(91, 140, 255, .2), 0 0 0 6px rgba(91, 140, 255, .15); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| } | |
| body { | |
| margin: 0; | |
| font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji; | |
| color: var(--text); | |
| background: | |
| radial-gradient(1200px 600px at 20% -10%, #1c2447 0%, rgba(28, 36, 71, 0) 60%), | |
| radial-gradient(900px 800px at 100% 0%, #12204a 0%, rgba(18, 32, 74, 0) 60%), | |
| linear-gradient(180deg, var(--bg) 0%, #060912 100%); | |
| background-attachment: fixed; | |
| } | |
| header { | |
| position: sticky; | |
| top: 0; | |
| z-index: 5; | |
| background: linear-gradient(180deg, rgba(10, 15, 30, 0.95), rgba(10, 15, 30, 0.6)); | |
| backdrop-filter: blur(10px); | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.06); | |
| } | |
| .nav { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 14px 20px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| font-weight: 700; | |
| letter-spacing: .3px; | |
| } | |
| .brand .logo { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 10px; | |
| background: conic-gradient(from 180deg at 50% 50%, #6ea8ff, #8cffd9, #6ea8ff); | |
| box-shadow: inset 0 0 20px rgba(255, 255, 255, .25), 0 8px 25px rgba(140, 255, 217, .15); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .brand .logo::after { | |
| content: ''; | |
| position: absolute; | |
| inset: 2px; | |
| background: radial-gradient(120px 120px at 30% 20%, rgba(255, 255, 255, .45), rgba(255, 255, 255, 0)); | |
| border-radius: 8px; | |
| } | |
| .brand .title { | |
| display: flex; | |
| flex-direction: column; | |
| line-height: 1.15; | |
| } | |
| .brand .title b { | |
| font-size: 16px; | |
| } | |
| .brand .title small { | |
| color: var(--muted); | |
| font-weight: 500; | |
| } | |
| .hdr-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .link { | |
| color: var(--primary); | |
| text-decoration: none; | |
| padding: 8px 10px; | |
| border-radius: 8px; | |
| background: rgba(91, 140, 255, .08); | |
| border: 1px solid rgba(91, 140, 255, .25); | |
| } | |
| .link:hover { | |
| background: rgba(91, 140, 255, .15); | |
| } | |
| .btn { | |
| background: linear-gradient(180deg, var(--primary) 0%, var(--primary-2) 100%); | |
| color: #071022; | |
| border: none; | |
| padding: 10px 14px; | |
| border-radius: 10px; | |
| font-weight: 700; | |
| cursor: pointer; | |
| box-shadow: 0 8px 20px rgba(91, 140, 255, .35); | |
| } | |
| .btn.secondary { | |
| background: linear-gradient(180deg, #1a244a, #121a3a); | |
| color: var(--text); | |
| border: 1px solid rgba(255, 255, 255, .08); | |
| box-shadow: none; | |
| } | |
| .btn.warn { | |
| background: linear-gradient(180deg, #ffb86c, #ff9a52); | |
| color: #2c1200; | |
| } | |
| .btn:disabled { | |
| opacity: .6; | |
| cursor: not-allowed; | |
| } | |
| main { | |
| max-width: 1200px; | |
| margin: 18px auto 60px; | |
| padding: 0 20px; | |
| display: grid; | |
| grid-template-columns: 1.1fr 1.6fr; | |
| gap: 22px; | |
| } | |
| @media (max-width: 1000px) { | |
| main { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .card { | |
| background: linear-gradient(180deg, rgba(20, 30, 60, .9), rgba(16, 22, 45, .85)); | |
| border: 1px solid rgba(255, 255, 255, .06); | |
| border-radius: var(--radius); | |
| box-shadow: var(--shadow); | |
| overflow: clip; | |
| } | |
| .card h2 { | |
| margin: 0; | |
| padding: 16px 18px; | |
| font-size: 16px; | |
| border-bottom: 1px solid rgba(255, 255, 255, .06); | |
| background: linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, 0)); | |
| } | |
| .card .body { | |
| padding: 16px; | |
| } | |
| .grid { | |
| display: grid; | |
| gap: var(--grid-gap); | |
| grid-template-columns: repeat(12, 1fr); | |
| } | |
| .col-12 { | |
| grid-column: span 12; | |
| } | |
| .col-6 { | |
| grid-column: span 6; | |
| } | |
| .col-4 { | |
| grid-column: span 4; | |
| } | |
| .col-8 { | |
| grid-column: span 8; | |
| } | |
| @media (max-width: 800px) { | |
| .col-6, | |
| .col-4, | |
| .col-8 { | |
| grid-column: span 12; | |
| } | |
| } | |
| .field { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .field label { | |
| font-size: 12px; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: .7px; | |
| } | |
| input, | |
| select { | |
| appearance: none; | |
| outline: none; | |
| background: #0d1530; | |
| border: 1px solid rgba(255, 255, 255, .08); | |
| color: var(--text); | |
| padding: 10px 12px; | |
| border-radius: var(--radius-sm); | |
| transition: border .2s ease, box-shadow .2s ease, background .2s ease; | |
| } | |
| input:focus, | |
| select:focus { | |
| border-color: rgba(91, 140, 255, .7); | |
| box-shadow: var(--ring); | |
| background: #0f1a3f; | |
| } | |
| .actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .muted { | |
| color: var(--muted); | |
| font-size: 13px; | |
| } | |
| .badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| font-size: 12px; | |
| font-weight: 700; | |
| border: 1px solid rgba(255, 255, 255, .1); | |
| background: rgba(255, 255, 255, .04); | |
| } | |
| .badge.good { | |
| background: rgba(87, 227, 154, .12); | |
| color: var(--good); | |
| border-color: rgba(87, 227, 154, .35); | |
| } | |
| .badge.warn { | |
| background: rgba(247, 178, 103, .12); | |
| color: var(--warn); | |
| border-color: rgba(247, 178, 103, .35); | |
| } | |
| .badge.danger { | |
| background: rgba(255, 107, 107, .14); | |
| color: var(--danger); | |
| border-color: rgba(255, 107, 107, .35); | |
| } | |
| .sep { | |
| height: 1px; | |
| background: rgba(255, 255, 255, .06); | |
| margin: 12px 0; | |
| } | |
| .kpi { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 10px 12px; | |
| border: 1px solid rgba(255, 255, 255, .08); | |
| border-radius: var(--radius-sm); | |
| background: rgba(255, 255, 255, .03); | |
| } | |
| .kpi .label { | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| .kpi .value { | |
| font-weight: 800; | |
| font-size: 18px; | |
| } | |
| .progress { | |
| height: 10px; | |
| width: 100%; | |
| background: rgba(255, 255, 255, .08); | |
| border-radius: 999px; | |
| overflow: hidden; | |
| border: 1px solid rgba(255, 255, 255, .06); | |
| } | |
| .progress>div { | |
| height: 100%; | |
| background: linear-gradient(90deg, #8cffd9, #5b8cff); | |
| width: 0%; | |
| transition: width .2s ease; | |
| } | |
| .list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| max-height: 300px; | |
| overflow: auto; | |
| } | |
| .list .row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| padding: 10px 12px; | |
| border-radius: 10px; | |
| border: 1px solid rgba(255, 255, 255, .06); | |
| background: rgba(255, 255, 255, .03); | |
| } | |
| .mini { | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| .score { | |
| font-weight: 800; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .pill { | |
| padding: 4px 8px; | |
| border-radius: 999px; | |
| font-size: 12px; | |
| font-weight: 800; | |
| border: 1px solid rgba(255, 255, 255, .08); | |
| } | |
| .pill.good { | |
| background: rgba(87, 227, 154, .12); | |
| color: var(--good); | |
| border-color: rgba(87, 227, 154, .25); | |
| } | |
| .pill.bad { | |
| background: rgba(255, 107, 107, .12); | |
| color: var(--danger); | |
| border-color: rgba(255, 107, 107, .25); | |
| } | |
| .chart-wrap { | |
| height: 220px; | |
| border-radius: var(--radius-sm); | |
| background: #0e1430; | |
| border: 1px solid rgba(255, 255, 255, .06); | |
| padding: 10px; | |
| } | |
| .note { | |
| font-size: 12px; | |
| color: var(--muted); | |
| padding: 10px 12px; | |
| border-radius: var(--radius-sm); | |
| background: rgba(255, 255, 255, .03); | |
| border: 1px solid rgba(255, 255, 255, .06); | |
| } | |
| .toast { | |
| position: fixed; | |
| right: 20px; | |
| bottom: 20px; | |
| z-index: 50; | |
| padding: 12px 14px; | |
| background: #121b3a; | |
| border: 1px solid rgba(255, 255, 255, .1); | |
| border-left: 4px solid var(--primary); | |
| border-radius: 10px; | |
| color: var(--text); | |
| box-shadow: var(--shadow); | |
| display: none; | |
| max-width: 420px; | |
| } | |
| .toast.show { | |
| display: block; | |
| animation: slideIn .25s ease; | |
| } | |
| @keyframes slideIn { | |
| from { | |
| transform: translateY(10px); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateY(0); | |
| opacity: 1; | |
| } | |
| } | |
| .grid-2 { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 12px; | |
| } | |
| @media (max-width: 700px) { | |
| .grid-2 { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .small { | |
| font-size: 12px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="nav"> | |
| <div class="brand"> | |
| <div class="logo" aria-hidden="true"></div> | |
| <div class="title"> | |
| <b>Evil Twin Attack Detector</b> | |
| <small>Client-side ML demo (TensorFlow.js)</small> | |
| </div> | |
| </div> | |
| <div class="hdr-actions"> | |
| <a class="link" href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noreferrer">Built | |
| with anycoder</a> | |
| <button class="btn secondary" id="btnRetrain">Retrain</button> | |
| </div> | |
| </div> | |
| </header> | |
| <main> | |
| <section class="card"> | |
| <h2>1) Add Wi‑Fi sample & predict</h2> | |
| <div class="body grid"> | |
| <div class="col-12"> | |
| <div class="grid-2"> | |
| <div class="field"> | |
| <label for="ssid">SSID</label> | |
| <input id="ssid" placeholder="e.g., CoffeeShop_WiFi" value="CoffeeShop_WiFi" /> | |
| </div> | |
| <div class="field"> | |
| <label for="bssid">BSSID (MAC)</label> | |
| <input id="bssid" placeholder="02:00:00:AA:BB:CC" value="02:00:00:AA:BB:CC" /> | |
| </div> | |
| <div class="field"> | |
| <label for="rssi">Signal (RSSI dBm)</label> | |
| <input id="rssi" type="number" step="1" value="-55" /> | |
| </div> | |
| <div class="field"> | |
| <label for="channel">Channel</label> | |
| <input id="channel" type="number" step="1" value="6" /> | |
| </div> | |
| <div class="field"> | |
| <label for="frequency">Frequency (MHz)</label> | |
| <input id="frequency" type="number" step="1" value="2437" /> | |
| </div> | |
| <div class="field"> | |
| <label for="encryption">Encryption</label> | |
| <select id="encryption"> | |
| <option value="WPA2-PSK">WPA2-PSK</option> | |
| <option value="WPA3-PSK">WPA3-PSK</option> | |
| <option value="WEP">WEP</option> | |
| <option value="Open">Open</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label for="hidden">Hidden</label> | |
| <select id="hidden"> | |
| <option value="false">No</option> | |
| <option value="true">Yes</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label for="enc-mismatch">Encryption mismatch</label> | |
| <select id="enc-mismatch"> | |
| <option value="false">No</option> | |
| <option value="true">Yes</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label for="expected-bssids">Expected BSSIDs (comma)</label> | |
| <input id="expected-bssids" placeholder="02:00:00:AA:BB:CC, 06:00:00:11:22:33" value="02:00:00:AA:BB:CC, 06:00:00:11:22:33" /> | |
| </div> | |
| <div class="field"> | |
| <label for="known-ch">Known channels (comma)</label> | |
| <input id="known-ch" placeholder="6, 11, 1" value="6, 11, 1" /> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-12"> | |
| <div class="actions"> | |
| <button class="btn" id="btnPredict">Predict</button> | |
| <button class="btn secondary" id="btnRandom">Randomize sample</button> | |
| <span class="muted">Feature vector is automatically derived from these inputs.</span> | |
| </div> | |
| </div> | |
| <div class="col-12 sep"></div> | |
| <div class="col-12"> | |
| <div id="predCard" class="kpi" style="display:none"> | |
| <div style="display:flex; flex-direction:column; gap:6px;"> | |
| <div class="label">Prediction</div> | |
| <div id="predLabel" class="value">—</div> | |
| <div class="mini" id="predExplain">—</div> | |
| </div> | |
| <div style="display:flex; flex-direction:column; gap:6px; align-items:flex-end;"> | |
| <div class="label">Evil Twin probability</div> | |
| <div class="score" id="predProb">—</div> | |
| <div class="progress" style="width:220px;"> | |
| <div id="predBar" style="width:0%"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-12"> | |
| <div class="note"> | |
| Tip: Toggle “Encryption mismatch” to simulate when an AP claims a different security type than expected. The | |
| model uses signal strength, channel, and known channels/BSSIDs to inform its inference. | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section class="card"> | |
| <h2>2) Real-time threat monitor</h2> | |
| <div class="body"> | |
| <div class="grid"> | |
| <div class="col-12"> | |
| <div class="actions"> | |
| <button class="btn" id="btnMonitor">Start monitoring</button> | |
| <button class="btn warn" id="btnClearDetections">Clear detections</button> | |
| <span class="muted">Simulated environment: random APs appear; malicious ones are generated by a hidden adversary policy.</span> | |
| </div> | |
| </div> | |
| <div class="col-6"> | |
| <div class="kpi"> | |
| <div> | |
| <div class="label">Total scanned</div> | |
| <div class="value" id="kpiTotal">0</div> | |
| </div> | |
| <div> | |
| <div class="label">Detections (evil twin)</div> | |
| <div class="value" id="kpiDetections" style="color:var(--danger)">0</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-6"> | |
| <div class="kpi"> | |
| <div> | |
| <div class="label">Detection rate</div> | |
| <div class="value" id="kpiRate">0%</div> | |
| </div> | |
| <div> | |
| <div class="label">Last risk</div> | |
| <div class="value" id="kpiLast">—</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-12 chart-wrap"> | |
| <canvas id="monitorChart"></canvas> | |
| </div> | |
| <div class="col-12 list" id="detList"></div> | |
| <div class="col-12"> | |
| <div class="note"> | |
| Detection logic uses the trained model score with a calibrated threshold of 0.50. In production, tune the | |
| threshold with a validation set to balance false positives/negatives. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section class="card"> | |
| <h2>3) Train the model (on synthetic data)</h2> | |
| <div class="body grid"> | |
| <div class="col-12 actions"> | |
| <button class="btn" id="btnTrain">Generate data & Train</button> | |
| <span class="muted">The dataset is generated in-browser and based on plausible Evil Twin behaviors.</span> | |
| </div> | |
| <div class="col-12 kpi" id="trainKPI" style="display:none"> | |
| <div style="display:flex; gap:16px; flex-wrap:wrap;"> | |
| <div> | |
| <div class="label">Training samples</div> | |
| <div class="value" id="kpiTrainN">0</div> | |
| </div> | |
| <div> | |
| <div class="label">Validation samples</div> | |
| <div class="value" id="kpiValN">0</div> | |
| </div> | |
| <div> | |
| <div class="label">Epochs</div> | |
| <div class="value" id="kpiEpochs">0/10</div> | |
| </div> | |
| <div> | |
| <div class="label">Final loss</div> | |
| <div class="value" id="kpiLoss">—</div> | |
| </div> | |
| <div> | |
| <div class="label">Val accuracy</div> | |
| <div class="value" id="kpiAcc">—</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-12"> | |
| <div class="chart-wrap" style="height: 260px;"> | |
| <canvas id="lossChart"></canvas> | |
| </div> | |
| </div> | |
| <div class="col-12"> | |
| <div class="chart-wrap" style="height: 260px;"> | |
| <canvas id="cmChart"></canvas> | |
| </div> | |
| </div> | |
| <div class="col-12"> | |
| <div class="note"> | |
| This demo runs fully in your browser using TensorFlow.js. No network scans are performed; it simulates Wi‑Fi | |
| features for training and prediction. For a production deployment, feed real measurements (e.g., BSSID, | |
| signal, channel) from your platform’s Wi‑Fi stack, and monitor over time for anomalies. | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <div class="toast" id="toast"></div> | |
| <script> | |
| // --- Utilities --- | |
| const $ = (sel) => document.querySelector(sel); | |
| const fmtPct = (x) => (x*100).toFixed(1) + '%'; | |
| const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); | |
| const randn = (() => { | |
| // Box-Muller | |
| let spare = null; | |
| return () => { | |
| if (spare !== null) { const n = spare; spare = null; return n; } | |
| let u = 0, v = 0, s = 0; | |
| do { u = Math.random()*2-1; v = Math.random()*2-1; s = u*u+v*v; } while (s === 0 || s >= 1); | |
| const mul = Math.sqrt(-2.0 * Math.log(s) / s); | |
| spare = v * mul; | |
| return u * mul; | |
| }; | |
| })(); | |
| const logistic = (x) => 1 / (1 + Math.exp(-x)); | |
| const showToast = (msg, color) => { | |
| const t = $('#toast'); | |
| t.textContent = msg; | |
| t.style.borderLeftColor = color || 'var(--primary)'; | |
| t.classList.add('show'); | |
| setTimeout(() => t.classList.remove('show'), 2500); | |
| }; | |
| // --- Model (simple logistic regression with 3 features) --- | |
| // Features: [signalNorm, chDeltaNorm, mismatchScore] | |
| const normalizeSignal = (rssi) => (clamp((rssi + 100) / 50, 0, 1)); | |
| const normalizeChDelta = (delta) => (clamp(Math.abs(delta) / 5, 0, 1)); | |
| function mismatchScore({encryption, encMismatch, hidden}){ | |
| let s = 0; | |
| if (encMismatch) s += 0.35; | |
| if (hidden) s += 0.15; | |
| if (encryption === 'WEP') s += 0.20; | |
| if (encryption === 'Open') s += 0.20; | |
| if (encryption === 'WPA3-PSK') s -= 0.05; // slightly less suspicious in isolation | |
| return clamp(s, 0, 1); | |
| } | |
| function extractFeatures({rssi, channel, knownChannels, encryption, encMismatch, hidden}){ | |
| // nearest known channel | |
| let nearest = Infinity; | |
| for (const kc of knownChannels) nearest = Math.min(nearest, Math.abs(kc - channel)); | |
| const chDelta = isFinite(nearest) ? nearest : 10; | |
| const signalNorm = normalizeSignal(rssi); | |
| const chDeltaNorm = normalizeChDelta(chDelta); | |
| const mismatch = mismatchScore({encryption, encMismatch, hidden}); | |
| return [signalNorm, chDeltaNorm, mismatch]; | |
| } | |
| // Hidden adversary policy for monitor | |
| function adversaryIsEvilTwinLabel(features){ | |
| const [sNorm, chDeltaNorm, mismatch] = features; | |
| const y = -1.4 + 2.0*mismatch + 1.3*(1 - sNorm) + 1.0*chDeltaNorm; | |
| const p = 1/(1+Math.exp(-y)); | |
| return Math.random() < p; | |
| } | |
| // Weights learned by gradient descent on synthetic data | |
| let W = tf.tensor1d([0,0,0]); // [w1, w2, w3] | |
| let b = tf.scalar(0); | |
| let trained = false; | |
| let training = false; | |
| function predictProba(featuresArr){ | |
| if (!trained) return 0.0; | |
| const t = tf.tensor2d([featuresArr]); | |
| const z = tf.sum(tf.mul(W, t.reshape([3])), 0).add(b); | |
| return logistic(z.dataSync()[0]); | |
| } | |
| function predictLabel(featuresArr){ | |
| const p = predictProba(featuresArr); | |
| return p >= 0.5 ? 1 : 0; | |
| } | |
| // --- Data generation (synthetic, but plausible) --- | |
| function genSamples(n, knownChannels, advPolicy){ | |
| const X = []; | |
| const y = []; | |
| for (let i=0; i<n; i++){ | |
| const isEvil = Math.random() < 0.45; // 45% malicious in training | |
| let rssi, channel, encryption, encMismatch=false, hidden=false; | |
| if (isEvil){ | |
| rssi = clamp(-30 + randn()*6, -100, -25); | |
| channel = Math.random() < 0.55 ? (knownChannels[0] ?? 6) : (1 + Math.floor(Math.random()*14)); | |
| encryption = Math.random() < 0.5 ? 'WEP' : (Math.random() < 0.5 ? 'Open' : 'WPA2-PSK'); | |
| encMismatch = Math.random() < 0.55; | |
| hidden = Math.random() < 0.35; | |
| } else { | |
| rssi = clamp(-65 + randn()*8, -100, -25); | |
| channel = (Math.random() < 0.75) ? (knownChannels[Math.floor(Math.random()*knownChannels.length)] ?? 6) : (1 + Math.floor(Math.random()*14)); | |
| encryption = Math.random() < 0.65 ? 'WPA2-PSK' : (Math.random() < 0.5 ? 'WPA3-PSK' : 'Open'); | |
| encMismatch = Math.random() < 0.08; | |
| hidden = Math.random() < 0.12; | |
| } | |
| const features = extractFeatures({rssi, channel, knownChannels, encryption, encMismatch, hidden}); | |
| const label = advPolicy ? (adversaryIsEvilTwinLabel(features) ? 1 : 0) : isEvil ? 1 : 0; | |
| X.push(features); | |
| y.push(label); | |
| } | |
| return {X, y}; | |
| } | |
| // --- Training loop --- | |
| async function trainModel({epochs=10, lr=0.35, trainN=6000, valN=2000, knownChannels=[1,6,11]}){ | |
| if (trained || training) return; | |
| training = true; | |
| showToast('Generating synthetic dataset...', 'var(--primary)'); | |
| $('#trainKPI').style.display = 'flex'; | |
| const train = genSamples(trainN, knownChannels, true); | |
| const val = genSamples(valN, knownChannels, true); | |
| $('#kpiTrainN').textContent = trainN.toLocaleString(); | |
| $('#kpiValN').textContent = valN.toLocaleString(); | |
| $('#kpiEpochs').textContent = `0/${epochs}`; | |
| $('#kpiLoss').textContent = '—'; | |
| $('#kpiAcc').textContent = '—'; | |
| const Xtr = tf.tensor2d(train.X); | |
| const ytr = tf.tensor2d(train.y.map(v => [v]), [trainN, 1]); | |
| const Xval = tf.tensor2d(val.X); | |
| const yval = tf.tensor2d(val.y.map(v => [v]), [valN, 1]); | |
| // Initialize weights with small noise | |
| W.dispose(); b.dispose(); | |
| W = tf.tensor1d([ (Math.random()-0.5)*0.01, (Math.random()-0.5)*0.01, (Math.random()-0.5)*0.01 ]); | |
| b = tf.scalar((Math.random()-0.5)*0.01); | |
| const lossSeries = []; | |
| const accSeries = []; | |
| for (let e=1; e<=epochs; e++){ | |
| // Forward + backward | |
| const lossT = tf.tidy(() => { | |
| const z = Xtr.matMul(W.reshape([3,1])).add(b); | |
| const preds = z.sigmoid(); | |
| const loss = tf.losses.logLoss(ytr, preds).mean(); | |
| return loss; | |
| }); | |
| const lossVal = (await lossT.data())[0]; | |
| lossT.dispose(); | |
| // Accuracy | |
| const acc = await accuracyOn(Xval, yval); | |
| lossSeries.push(lossVal); | |
| accSeries.push(acc); | |
| $('#kpiEpochs').textContent = `${e}/${epochs}`; | |
| $('#kpiLoss').textContent = lossVal.toFixed(3); | |
| $('#kpiAcc').textContent = fmtPct(acc); | |
| // Update weights | |
| const opt = tf.train.sgd(lr); | |
| await opt.minimize(() => { | |
| const z = Xtr.matMul(W.reshape([3,1])).add(b); | |
| const preds = z.sigmoid(); | |
| const loss = tf.losses.logLoss(ytr, preds).mean(); | |
| return loss; | |
| }); | |
| // Update charts | |
| updateLossChart(lossSeries, accSeries); | |
| await tf.nextFrame(); | |
| } | |
| // Final validation metrics + confusion matrix | |
| const predsVal = tf.tidy(() => { | |
| const z = Xval.matMul(W.reshape([3,1])).add(b); | |
| return z.sigmoid(); | |
| }); | |
| const yhat = (await predsVal.data()).map(v => v >= 0.5 ? 1 : 0); | |
| predsVal.dispose(); | |
| const cm = confusionMatrix(val.y, yhat, 2); | |
| renderConfusionMatrix(cm); | |
| Xtr.dispose(); ytr.dispose(); Xval.dispose(); yval.dispose(); | |
| trained = true; | |
| training = false; | |
| showToast('Model trained. Try predicting samples or start the monitor.', 'var(--good)'); | |
| } | |
| async function accuracyOn(X, y){ | |
| const preds = tf.tidy(() => { | |
| const z = X.matMul(W.reshape([3,1])).add(b); | |
| return z.sigmoid(); | |
| }); | |
| const arr = await preds.data(); | |
| preds.dispose(); | |
| const n = y.shape[0]; | |
| let correct = 0; | |
| for (let i=0; i<n; i++){ | |
| const yi = y.get(i,0); | |
| const pi = arr[i] >= 0.5 ? 1 : 0; | |
| if (yi === pi) correct++; | |
| } | |
| return correct / n; | |
| } | |
| function confusionMatrix(yTrue, yPred, classes=2){ | |
| const cm = Array.from({length: classes}, () => Array(classes).fill(0)); | |
| for (let i=0; i<yTrue.length; i++){ | |
| cm[yTrue[i]][yPred[i]] += 1; | |
| } | |
| return cm; | |
| } | |
| // --- Charts --- | |
| let lossChart, cmChart, monitorChart; | |
| function initCharts(){ | |
| const lossCtx = $('#lossChart').getContext('2d'); | |
| lossChart = new Chart(lossCtx, { | |
| type: 'line', | |
| data: { | |
| labels: [], | |
| datasets: [ | |
| { label: 'Loss', data: [], borderColor: '#ff9a52', backgroundColor: 'rgba(255,154,82,.2)', tension: .2, yAxisID: 'y' }, | |
| { label: 'Val Acc', data: [], borderColor: '#8cffd9', backgroundColor: 'rgba(140,255,217,.2)', tension: .2, yAxisID: 'y1' } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| y: { type: 'linear', position: 'left', min: 0, grid:{ color:'rgba(255,255,255,.06)' }, ticks:{ color:'#9ab' } }, | |
| y1: { type: 'linear', position: 'right', min: 0, max: 1, grid:{ drawOnChartArea:false }, ticks:{ color:'#9ab' } }, | |
| x: { grid:{ color:'rgba(255,255,255,.06)' }, ticks:{ color:'#9ab' } } | |
| }, | |
| plugins: { | |
| legend: { labels: { color:'#dfe7ff' } } | |
| } | |
| } | |
| }); | |
| const cmCtx = $('#cmChart').getContext('2d'); | |
| cmChart = new Chart(cmCtx, { | |
| type: 'bar', | |
| data: { | |
| labels: ['True Neg', 'False Pos', 'False Neg', 'True Pos'], | |
| datasets: [{ label:'Validation set', data: [0,0,0,0], backgroundColor: ['#57e39a','#f7b267','#f7b267','#57e39a'] }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| y: { beginAtZero: true, grid:{ color:'rgba(255,255,255,.06)' }, ticks:{ color:'#9ab' } }, | |
| x: { grid:{ color:'rgba(255,255,255,.06)' }, ticks:{ color:'#9ab' } } | |
| }, | |
| plugins: { legend: { labels: { color:'#dfe7ff' } } } | |
| } | |
| }); | |
| const monCtx = $('#monitorChart').getContext('2d'); | |
| monitorChart = new Chart(monCtx, { | |
| type: 'line', | |
| data: { | |
| labels: [], | |
| datasets: [ | |
| { label:'Risk score', data: [], borderColor:'#5b8cff', backgroundColor:'rgba(91,140,255,.2)', tension:.25 }, | |
| { label:'Threshold', data: [], borderColor:'#ff6b6b', borderDash:[6,4], pointRadius:0 } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| y: { min: 0, max: 1, grid:{ color:'rgba(255,255,255,.06)' }, ticks:{ color:'#9ab' } }, | |
| x: { grid:{ color:'rgba(255,255,255,.06)' }, ticks:{ color:'#9ab', autoSkip: true } } | |
| }, | |
| plugins: { legend: { labels:{ color:'#dfe7ff' } } } | |
| } | |
| }); | |
| } | |
| function updateLossChart(lossArr, accArr){ | |
| if (!lossChart) return; | |
| lossChart.data.labels = lossArr.map((_,i)=>`Epoch ${i+1}`); | |
| lossChart.data.datasets[0].data = lossArr; | |
| lossChart.data.datasets[1].data = accArr; | |
| lossChart.update(); | |
| } | |
| function renderConfusionMatrix(cm){ | |
| if (!cmChart) return; | |
| const tn = cm[0][0], fp = cm[0][1], fn = cm[1][0], tp = cm[1][1]; | |
| cmChart.data.datasets[0].data = [tn, fp, fn, tp]; | |
| cmChart.update(); | |
| } | |
| function pushMonitorPoint(score){ | |
| if (!monitorChart) return; | |
| const labels = monitorChart.data.labels; | |
| labels.push(labels.length+1); | |
| monitorChart.data.datasets[0].data.push(score); | |
| monitorChart.data.datasets[1].push(0.5); | |
| if (labels.length > 50){ | |
| labels.shift(); | |
| monitorChart.data.datasets.forEach(ds => ds.data.shift()); | |
| } | |
| monitorChart.update(); | |
| } | |
| // --- UI: sample parsing & prediction --- | |
| function parseListNumberInput(el){ | |
| return el.value | |
| .split(',') | |
| .map(s => Number(s.trim())) | |
| .filter(v => Number.isFinite(v)); | |
| } | |
| function getSampleFromUI(){ | |
| const ssid = $('#ssid').value.trim() || 'Unknown'; | |
| const bssid = $('#bssid').value.trim() || '00:00:00:00:00:00'; | |
| const rssi = Number($('#rssi').value); | |
| const channel = Number($('#channel').value); | |
| const frequency = Number($('#frequency').value) || (2412 + (channel-1)*5); | |
| const encryption = $('#encryption').value; | |
| const hidden = $('#hidden').value === 'true'; | |
| const encMismatch = $('#enc-mismatch').value === 'true'; | |
| const expectedBSSIDs = $('#expected-bssids').value.split(',').map(s => s.trim()).filter(Boolean); | |
| const knownChannels = parseListNumberInput($('#known-ch')) || [1,6,11]; | |
| return { | |
| ssid, bssid, rssi, channel, frequency, | |
| encryption, hidden, encMismatch, expectedBSSIDs, knownChannels | |
| }; | |
| } | |
| function featuresFromUI(){ | |
| const s = getSampleFromUI(); | |
| return extractFeatures({ | |
| rssi: s.rssi, | |
| channel: s.channel, | |
| knownChannels: s.knownChannels, | |
| encryption: s.encryption, | |
| encMismatch: s.encMismatch, | |
| hidden: s.hidden | |
| }); | |
| } | |
| function explain(features, sample){ | |
| const [sNorm, chDeltaNorm, mismatch] = features.map(v => Number(v.toFixed(2))); | |
| const nearest = sample.knownChannels.reduce((best, kc) => Math.min(best, Math.abs(kc - sample.channel)), 99); | |
| const exp = []; | |
| if (mismatch > 0.5) exp.push(`Security mismatch or weak encryption (${mismatch.toFixed(2)})`); | |
| if (sNorm < 0.5) exp.push(`Unusually strong signal (${sNorm.toFixed(2)})`); | |
| if (chDeltaNorm > 0.4) exp.push(`Channel far from known (Δ=${nearest})`); | |
| if (sample.hidden) exp.push(`Hidden SSID`); | |
| return exp.length ? exp.join('; ') : 'No strong anomalies detected'; | |
| } | |
| function showPrediction(features){ | |
| const sample = getSampleFromUI(); | |
| const prob = predictProba(features); | |
| const label |