From 93adeaf8316d9a9f7bf4f5f58007d94ae1906035 Mon Sep 17 00:00:00 2001 From: Angela Hudson Date: Sun, 5 Jul 2026 09:34:18 -0400 Subject: [PATCH] Add live browser scanner so the Pages link is a real app, not just a landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan/classify/extract logic in payload_revealer/engine was Python-only, reachable only via the CLI or the Electron desktop app, so the GitHub Pages link had nothing to actually do โ€” it just rendered index.html as a static landing page. Every other repo in this account works as a live, no-install browser app at its Pages link; this one didn't match that. Ports sweeper.py, classifier.py, payload_extractor.py, and word_counter.py to vanilla client-side JS (assets/js/scan-engine.js), validated char-for-char against the Python engine's JSON output across all five test fixtures (forensic_report.txt, bidi.txt, normal.txt, null_bytes.txt, zero_width.txt) โ€” identical total/visible/hidden counts, category/risk breakdowns, and decoded payloads (including the 312-char ZWSP-encoded beacon URL fixture). Wires it into index.html using the Electron renderer's UI as the visual reference (assets/js/app.js, assets/css/scanner.css): paste text or drop a .txt file, get findings/payloads/raw-JSON tabs and JSON/TXT export, entirely client-side. The Python CLI and Electron desktop app are unchanged and stay available for offline/scripted use. Closes #13 --- README.md | 43 ++- assets/css/scanner.css | 316 +++++++++++++++++++++ assets/js/app.js | 309 +++++++++++++++++++++ assets/js/scan-engine.js | 581 +++++++++++++++++++++++++++++++++++++++ index.html | 145 +++++++++- 5 files changed, 1379 insertions(+), 15 deletions(-) create mode 100644 assets/css/scanner.css create mode 100644 assets/js/app.js create mode 100644 assets/js/scan-engine.js diff --git a/README.md b/README.md index e36a2ae..266a984 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,35 @@

- Project page + English + Espaรฑol + Franรงais + Deutsch + Portuguรชs + ไธญๆ–‡ + ๆ—ฅๆœฌ่ชž + ํ•œ๊ตญ์–ด + Italiano + ุงู„ุนุฑุจูŠุฉ +

+ +

+ Live scanner Source code

deploy-GitHub Pages - stack-Python + stack-Python + JS

### Languages

- Python - JavaScript - CSS + Python + JavaScript + HTML + CSS

### Stack @@ -50,18 +64,14 @@ Payload Revealer catches invisible Unicode instructions hidden inside copied tex A copied answer can look normal in Slack while carrying invisible Unicode underneath โ€” hidden instructions like โ€œpick option 3โ€ or โ€œtell them X said Y.โ€ PowerShell and some editors may expose weird characters; Payload Revealer does the systematic version: enumerate every codepoint, report suspicious invisible characters, and decode embedded payloads when possible. -**If something pasted suspicious:** +**If something pasted suspicious:** paste it straight into the **[live scanner](https://dacameragirl.github.io/payload-revealer/)** โ€” it runs entirely in your browser tab, nothing is uploaded anywhere. -1. Save the pasted text to a plain `.txt` file (not rich text). -2. Scan it: +For scripting or a shareable audit trail, use the CLI instead: ```bash python -m payload_revealer .\suspicious.txt -``` - -3. For a shareable audit trail: -```bash +# For a shareable audit trail: python -m payload_revealer .\suspicious.txt --format json --output report.json ``` @@ -72,6 +82,7 @@ If the hidden instruction used zero-width Unicode, bidi tricks, Unicode tags, or

Quick Download

+The [live scanner](https://dacameragirl.github.io/payload-revealer/) needs nothing installed. Grab the desktop app instead if you want it offline, or working outside a browser: **[Download the Windows installer](https://github.com/DaCameraGirl/payload-revealer/releases/latest)** โ€” installs the Payload Revealer desktop app (Electron GUI). No Python required. Drag a file onto the drop zone or click Open File. @@ -94,6 +105,7 @@ beacon://c2-exfil.lan/reg?agent=demo-01

Features

+- **Live Browser Scanner** โ€” Paste or drop a file at the [Pages link](https://dacameragirl.github.io/payload-revealer/) and scan instantly, no install, nothing leaves your tab - **Character Count Scanner** โ€” Total character count even if content is invisible - **Invisible Payload Detector** โ€” Flags zero-width spaces, Unicode control characters, bidirectional overrides, invisible whitespace, Unicode tags, and other hidden text artifacts - **Visualizer Panel** โ€” Shows hidden content in decoded, readable format @@ -193,6 +205,13 @@ The output goes to `dist/payload_revealer_engine.exe`. Electron auto-detects it ``` payload_revealer/ +โ”œโ”€โ”€ index.html # Pages entry point โ€” live browser scanner +โ”œโ”€โ”€ assets/ # Browser scan engine (JS port of the Python engine) +โ”‚ โ”œโ”€โ”€ js/ +โ”‚ โ”‚ โ”œโ”€โ”€ scan-engine.js # Classifier + payload extractor, runs client-side +โ”‚ โ”‚ โ””โ”€โ”€ app.js # UI wiring: paste/drop input, results rendering, export +โ”‚ โ””โ”€โ”€ css/ +โ”‚ โ””โ”€โ”€ scanner.css โ”œโ”€โ”€ payload_revealer/ # Python engine โ”‚ โ”œโ”€โ”€ engine/ โ”‚ โ”‚ โ”œโ”€โ”€ sweeper.py # Core character scanner diff --git a/assets/css/scanner.css b/assets/css/scanner.css new file mode 100644 index 0000000..77404a9 --- /dev/null +++ b/assets/css/scanner.css @@ -0,0 +1,316 @@ +/* ====================== Payload Revealer - Live Scanner (browser) ====================== + Adapted from electron/renderer/style.css for use as an embedded section of index.html + instead of a full standalone app window (drops body/header/-webkit-app-region rules). +*/ + +.scanner { + --sc-bg-secondary: #161b22; + --sc-bg-tertiary: #21262d; + --sc-bg-card: #1c2128; + --sc-border: #30363d; + --sc-text-primary: #e6edf3; + --sc-text-secondary: #8b949e; + --sc-text-muted: #6e7681; + --sc-accent: #58a6ff; + --sc-critical: #ff7b72; + --sc-critical-bg: #49020233; + --sc-high: #ffa657; + --sc-high-bg: #3d2200; + --sc-medium: #d2a8ff; + --sc-medium-bg: #2b1a3d; + --sc-low: #7ee787; + --sc-low-bg: #033a16; + --sc-warning: #d29922; + --sc-font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 13px; + line-height: 1.5; +} + +.scanner-tabs { + display: flex; + gap: 10px; + margin: 20px 0 16px; +} + +.scanner-tab-btn { + flex: 1; + padding: 10px 16px; + border: 1px solid var(--sc-border); + border-radius: 8px; + background: var(--sc-bg-tertiary); + color: var(--sc-text-secondary); + font-size: 0.95rem; + cursor: pointer; +} + +.scanner-tab-btn.active { + color: var(--sc-accent); + border-color: var(--sc-accent); + background: rgba(88, 166, 255, 0.08); +} + +.scanner-input-panel.hidden, +.scanner-tab-btn.hidden { + display: none; +} + +.paste-panel textarea { + width: 100%; + min-height: 160px; + resize: vertical; + background: var(--sc-bg-card); + border: 1px solid var(--sc-border); + border-radius: 8px; + padding: 14px; + color: var(--sc-text-primary); + font-family: var(--sc-font-mono); + font-size: 0.85rem; + line-height: 1.5; +} + +.paste-panel textarea:focus { + outline: none; + border-color: var(--sc-accent); +} + +.scanner-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + border: 1px solid var(--sc-border); + border-radius: 8px; + font-size: 0.9rem; + font-family: inherit; + cursor: pointer; + background: var(--sc-bg-tertiary); + color: var(--sc-text-primary); +} + +.btn:hover { border-color: var(--sc-text-muted); } + +.btn-primary { + background: #1f6feb; + border-color: #1f6feb; + color: #fff; +} + +.btn-primary:hover { background: #388bfd; border-color: #388bfd; } + +.btn-outline { background: transparent; } +.btn-outline:hover { border-color: var(--sc-accent); } + +.scanner-hint { color: var(--sc-text-muted); font-size: 0.85rem; } + +/* ===== Drop Zone ===== */ +.drop-zone { + border: 2px dashed var(--sc-border); + border-radius: 12px; + padding: 48px 20px; + text-align: center; + transition: all 0.2s ease; + cursor: pointer; +} + +.drop-zone:hover, +.drop-zone.drag-over { + border-color: var(--sc-accent); + background: rgba(88, 166, 255, 0.04); +} + +.drop-zone-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.drop-icon { font-size: 40px; opacity: 0.5; margin-bottom: 4px; } +.drop-zone p { color: var(--sc-text-secondary); font-size: 0.95rem; margin: 0; } +.drop-hint { font-size: 0.8rem !important; color: var(--sc-text-muted) !important; } + +/* ===== Status Bar ===== */ +.status-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + margin: 16px 0 0; + padding: 12px 16px; + background: var(--sc-bg-secondary); + border: 1px solid var(--sc-border); + border-radius: 8px; +} + +.status-file { font-family: var(--sc-font-mono); font-size: 0.8rem; color: var(--sc-text-secondary); word-break: break-all; } +.status-badges { display: flex; gap: 8px; flex-wrap: wrap; } + +.badge { + padding: 3px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + background: var(--sc-bg-tertiary); + color: var(--sc-text-secondary); +} + +.badge-danger { background: var(--sc-critical-bg); color: var(--sc-critical); } +.badge-critical { background: var(--sc-critical-bg); color: var(--sc-critical); border: 1px solid var(--sc-critical); } + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--sc-border); + border-top-color: var(--sc-accent); + border-radius: 50%; + animation: sc-spin 0.6s linear infinite; +} + +@keyframes sc-spin { to { transform: rotate(360deg); } } + +/* ===== Stats Grid ===== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + margin: 16px 0; +} + +.stat-card { + background: var(--sc-bg-card); + border: 1px solid var(--sc-border); + border-radius: 8px; + padding: 14px 16px; +} + +.stat-card-warn { border-color: var(--sc-warning); } + +.stat-label { + font-size: 0.7rem; + color: var(--sc-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.stat-value { + font-size: 1.4rem; + font-weight: 700; + font-family: var(--sc-font-mono); + color: var(--sc-text-primary); +} + +.stat-card-warn .stat-value { color: var(--sc-warning); } + +/* ===== Risk Summary ===== */ +.risk-summary { display: flex; gap: 12px; margin: 0 0 16px; flex-wrap: wrap; } + +.risk-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + background: var(--sc-bg-tertiary); + color: var(--sc-text-secondary); +} + +.risk-chip.critical { background: var(--sc-critical-bg); color: var(--sc-critical); } +.risk-chip.high { background: var(--sc-high-bg); color: var(--sc-high); } +.risk-chip.medium { background: var(--sc-medium-bg); color: var(--sc-medium); } +.risk-chip.low { background: var(--sc-low-bg); color: var(--sc-low); } + +.chip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; } + +/* ===== Results Panel ===== */ +.results-panel { + margin: 0 0 16px; + border: 1px solid var(--sc-border); + border-radius: 8px; + overflow: hidden; +} + +.tabs { display: flex; background: var(--sc-bg-secondary); border-bottom: 1px solid var(--sc-border); } + +.tab { + padding: 10px 20px; + border: none; + background: none; + color: var(--sc-text-muted); + font-size: 0.85rem; + font-family: inherit; + cursor: pointer; + border-bottom: 2px solid transparent; +} + +.tab:hover { color: var(--sc-text-primary); background: rgba(177, 186, 196, 0.06); } +.tab.active { color: var(--sc-accent); border-bottom-color: var(--sc-accent); } + +.tab-content { max-height: 420px; overflow: auto; } +.tab-content.hidden { display: none; } +.table-scroll { overflow: auto; max-height: 420px; } + +#findingsTable { width: 100%; border-collapse: collapse; font-size: 0.78rem; font-family: var(--sc-font-mono); } +#findingsTable thead { position: sticky; top: 0; z-index: 1; } + +#findingsTable th { + background: var(--sc-bg-tertiary); + color: var(--sc-text-muted); + padding: 10px 12px; + text-align: left; + font-weight: 600; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--sc-border); +} + +#findingsTable td { padding: 8px 12px; border-bottom: 1px solid var(--sc-border); color: var(--sc-text-secondary); } +#findingsTable tr:hover td { background: rgba(177, 186, 196, 0.04); } +#findingsTable .risk-critical td { color: var(--sc-critical); } +#findingsTable .risk-high td { color: var(--sc-high); } +#findingsTable .risk-medium td { color: var(--sc-medium); } +#findingsTable .risk-low td { color: var(--sc-text-secondary); } + +.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--sc-text-muted); } +.empty-icon { font-size: 32px; margin-bottom: 12px; } +.empty-state p { font-size: 0.9rem; } + +#payloadContainer { padding: 16px; display: flex; flex-direction: column; gap: 16px; } + +.payload-card { background: var(--sc-bg-card); border: 1px solid var(--sc-border); border-radius: 8px; overflow: hidden; } +.payload-card-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; padding: 12px 16px; background: var(--sc-bg-secondary); border-bottom: 1px solid var(--sc-border); } +.payload-card-type { font-weight: 600; color: var(--sc-accent); font-size: 0.85rem; } +.payload-card-desc { font-size: 0.75rem; color: var(--sc-text-muted); } +.payload-card-body { padding: 16px; font-family: var(--sc-font-mono); font-size: 0.8rem; color: var(--sc-text-primary); white-space: pre-wrap; word-break: break-all; line-height: 1.6; } + +.raw-output { padding: 16px; font-family: var(--sc-font-mono); font-size: 0.72rem; line-height: 1.6; color: var(--sc-text-secondary); white-space: pre-wrap; word-break: break-all; max-height: 420px; overflow: auto; margin: 0; } + +.export-bar { display: flex; gap: 10px; margin: 0 0 8px; flex-wrap: wrap; } + +.toast { + position: fixed; + bottom: 20px; + right: 20px; + padding: 12px 20px; + border-radius: 8px; + font-size: 0.85rem; + z-index: 1000; + max-width: 400px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); +} + +.toast-error { background: var(--sc-critical-bg); border: 1px solid var(--sc-critical); color: var(--sc-critical); } +.toast-success { background: var(--sc-low-bg); border: 1px solid var(--sc-low); color: var(--sc-low); } + +.scanner .hidden { display: none !important; } diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..d96cd42 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,309 @@ +/* ====================== Payload Revealer - Browser App Wiring ====================== + Drives the live in-page scanner using PayloadRevealerEngine (scan-engine.js). + Adapted from electron/renderer/renderer.js: same results UI, but fed by text + pasted or dropped directly in the browser instead of Electron IPC to Python. +*/ +(function () { + "use strict"; + + const Engine = window.PayloadRevealerEngine; + + const DOM = { + pasteTab: document.getElementById("scPasteTab"), + fileTab: document.getElementById("scFileTab"), + pastePanel: document.getElementById("scPastePanel"), + filePanel: document.getElementById("scFilePanel"), + textarea: document.getElementById("scTextarea"), + btnScanText: document.getElementById("scBtnScanText"), + fileInput: document.getElementById("scFileInput"), + dropZone: document.getElementById("dropZone"), + statusBar: document.getElementById("statusBar"), + statusFile: document.querySelector(".status-file"), + statusSpinner: document.querySelector(".status-spinner"), + badgeTotal: document.getElementById("badgeTotal"), + badgeHidden: document.getElementById("badgeHidden"), + badgeCritical: document.getElementById("badgeCritical"), + statsGrid: document.getElementById("statsGrid"), + statTotalChars: document.getElementById("statTotalChars"), + statVisibleChars: document.getElementById("statVisibleChars"), + statHiddenChars: document.getElementById("statHiddenChars"), + statVisibleWords: document.getElementById("statVisibleWords"), + statActualWords: document.getElementById("statActualWords"), + statWordDelta: document.getElementById("statWordDelta"), + riskSummary: document.getElementById("riskSummary"), + riskCritical: document.getElementById("riskCritical"), + riskHigh: document.getElementById("riskHigh"), + riskMedium: document.getElementById("riskMedium"), + riskLow: document.getElementById("riskLow"), + resultsPanel: document.getElementById("resultsPanel"), + tabFindings: document.getElementById("tabFindings"), + tabPayloads: document.getElementById("tabPayloads"), + tabRaw: document.getElementById("tabRaw"), + findingsBody: document.getElementById("findingsBody"), + findingsTable: document.getElementById("findingsTable"), + noFindings: document.getElementById("noFindings"), + payloadContainer: document.getElementById("payloadContainer"), + noPayloads: document.getElementById("noPayloads"), + rawOutput: document.getElementById("rawOutput"), + exportBar: document.getElementById("exportBar"), + errorToast: document.getElementById("errorToast"), + }; + + let lastReport = null; + + /* ===== Input mode tabs (paste vs. file) ===== */ + DOM.pasteTab.addEventListener("click", () => setInputMode("paste")); + DOM.fileTab.addEventListener("click", () => setInputMode("file")); + + function setInputMode(mode) { + DOM.pasteTab.classList.toggle("active", mode === "paste"); + DOM.fileTab.classList.toggle("active", mode === "file"); + DOM.pastePanel.classList.toggle("hidden", mode !== "paste"); + DOM.filePanel.classList.toggle("hidden", mode !== "file"); + } + + /* ===== Paste + Scan ===== */ + DOM.btnScanText.addEventListener("click", () => { + const text = DOM.textarea.value; + if (!text) { + showToast("Paste some text first.", "error"); + return; + } + runScan(() => Engine.scanPastedText(text, "(pasted text)")); + }); + + /* ===== File picker + drop zone ===== */ + DOM.dropZone.addEventListener("click", () => DOM.fileInput.click()); + DOM.fileInput.addEventListener("change", () => { + const file = DOM.fileInput.files[0]; + if (file) scanFile(file); + }); + + ["dragover", "dragenter"].forEach((evt) => + DOM.dropZone.addEventListener(evt, (e) => { + e.preventDefault(); + DOM.dropZone.classList.add("drag-over"); + }) + ); + ["dragleave", "dragend"].forEach((evt) => + DOM.dropZone.addEventListener(evt, () => DOM.dropZone.classList.remove("drag-over")) + ); + DOM.dropZone.addEventListener("drop", (e) => { + e.preventDefault(); + DOM.dropZone.classList.remove("drag-over"); + const file = e.dataTransfer.files[0]; + if (file) scanFile(file); + }); + + function scanFile(file) { + const reader = new FileReader(); + reader.onload = () => { + const bytes = new Uint8Array(reader.result); + runScan(() => Engine.scanFileBytes(bytes, file.name)); + }; + reader.onerror = () => showToast("Could not read that file.", "error"); + reader.readAsArrayBuffer(file); + } + + /* ===== Run a scan and render ===== */ + function runScan(getReport) { + DOM.statusBar.classList.remove("hidden"); + showSpinner(true); + // Defer one tick so the spinner actually paints before the (synchronous) scan runs. + setTimeout(() => { + try { + lastReport = getReport(); + renderResults(lastReport); + } catch (err) { + showToast("Scan failed: " + err.message, "error"); + } finally { + showSpinner(false); + } + }, 10); + } + + /* ===== Tab switching (results: findings / payloads / raw) ===== */ + document.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + tab.classList.add("active"); + const target = tab.dataset.tab; + DOM.tabFindings.classList.toggle("hidden", target !== "findings"); + DOM.tabPayloads.classList.toggle("hidden", target !== "payloads"); + DOM.tabRaw.classList.toggle("hidden", target !== "raw"); + }); + }); + + /* ===== Export ===== */ + document.getElementById("btnExportJson").addEventListener("click", () => { + if (!lastReport) return; + downloadFile( + JSON.stringify(lastReport, null, 2), + "payload-revealer-report.json", + "application/json" + ); + }); + + document.getElementById("btnExportTxt").addEventListener("click", () => { + if (!lastReport) return; + downloadFile(summaryText(lastReport), "payload-revealer-report.txt", "text/plain"); + }); + + function downloadFile(content, filename, mime) { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + + function summaryText(data) { + const { file, stats } = data; + const lines = [ + `File: ${file.path}`, + `Size: ${file.size.toLocaleString()} bytes`, + `Encoding: ${file.encoding}${file.has_bom ? " (BOM)" : ""}`, + `Total characters: ${stats.total_chars.toLocaleString()}`, + ` Visible: ${stats.visible_chars.toLocaleString()}`, + ` Hidden: ${stats.hidden_chars.toLocaleString()}`, + `Naive word count: ${stats.visible_word_count.toLocaleString()}`, + `Unicode-aware word count: ${stats.actual_word_count.toLocaleString()}`, + "", + "Category breakdown:", + ]; + const cats = Object.entries(stats.category_counts).sort((a, b) => b[1] - a[1]); + for (const [cat, count] of cats) lines.push(` ${cat}: ${count.toLocaleString()}`); + lines.push("", "Risk breakdown:"); + for (const risk of ["critical", "high", "medium", "low", "none"]) { + const count = stats.risk_counts[risk]; + if (count) lines.push(` ${risk}: ${count.toLocaleString()}`); + } + return lines.join("\n"); + } + + /* ===== Render Results ===== */ + function renderResults(data) { + const stats = data.stats; + const file = data.file; + const findings = data.findings || []; + const payloads = data.extracted_payloads || []; + const riskCounts = stats.risk_counts || {}; + + DOM.badgeTotal.textContent = stats.total_chars + " chars total"; + DOM.statusFile.textContent = file.path; + + if (stats.hidden_chars > 0) { + DOM.badgeHidden.classList.remove("hidden"); + DOM.badgeHidden.textContent = stats.hidden_chars + " hidden"; + } else { + DOM.badgeHidden.classList.add("hidden"); + } + + if ((riskCounts.critical || 0) > 0) { + DOM.badgeCritical.classList.remove("hidden"); + DOM.badgeCritical.textContent = riskCounts.critical + " critical"; + } else { + DOM.badgeCritical.classList.add("hidden"); + } + + DOM.statsGrid.classList.remove("hidden"); + DOM.statTotalChars.textContent = stats.total_chars.toLocaleString(); + DOM.statVisibleChars.textContent = stats.visible_chars.toLocaleString(); + DOM.statHiddenChars.textContent = stats.hidden_chars.toLocaleString(); + DOM.statVisibleWords.textContent = stats.visible_word_count.toLocaleString(); + DOM.statActualWords.textContent = stats.actual_word_count.toLocaleString(); + DOM.statWordDelta.textContent = + (stats.word_count_delta >= 0 ? "+" : "") + stats.word_count_delta.toLocaleString(); + + const hiddenCard = DOM.statHiddenChars.closest(".stat-card"); + hiddenCard.classList.toggle("stat-card-warn", stats.hidden_chars > 0); + + DOM.riskSummary.classList.remove("hidden"); + DOM.riskCritical.textContent = riskCounts.critical || 0; + DOM.riskHigh.textContent = riskCounts.high || 0; + DOM.riskMedium.textContent = riskCounts.medium || 0; + DOM.riskLow.textContent = riskCounts.low || 0; + + DOM.resultsPanel.classList.remove("hidden"); + DOM.findingsBody.innerHTML = ""; + + const suspicious = findings.filter((f) => !f.visible || f.risk !== "none"); + + if (suspicious.length === 0) { + DOM.noFindings.classList.remove("hidden"); + DOM.findingsTable.classList.add("hidden"); + } else { + DOM.noFindings.classList.add("hidden"); + DOM.findingsTable.classList.remove("hidden"); + + const frag = document.createDocumentFragment(); + for (const f of suspicious) { + const row = document.createElement("tr"); + row.className = "risk-" + f.risk; + row.innerHTML = ` + ${f.index} + ${f.codepoint} + ${escapeHtml(f.name)} + ${f.category} + ${escapeHtml(f.block)} + ${f.risk.toUpperCase()} + ${f.tags.map((t) => "" + escapeHtml(t) + "").join(" ")} + `; + frag.appendChild(row); + } + DOM.findingsBody.appendChild(frag); + } + + DOM.payloadContainer.innerHTML = ""; + if (payloads.length === 0) { + DOM.noPayloads.classList.remove("hidden"); + } else { + DOM.noPayloads.classList.add("hidden"); + for (const p of payloads) { + const card = document.createElement("div"); + card.className = "payload-card"; + card.innerHTML = ` +
+ ${escapeHtml(p.type)} + ${escapeHtml(p.description || "")} +
+
${escapeHtml(p.decoded)}
+ `; + DOM.payloadContainer.appendChild(card); + } + } + + DOM.rawOutput.textContent = JSON.stringify({ file, stats, findings, extracted_payloads: payloads }, null, 2); + + document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + document.querySelector('.tab[data-tab="findings"]').classList.add("active"); + DOM.tabFindings.classList.remove("hidden"); + DOM.tabPayloads.classList.add("hidden"); + DOM.tabRaw.classList.add("hidden"); + + DOM.exportBar.classList.remove("hidden"); + + DOM.statusFile.textContent = file.path + " | " + file.encoding + (file.has_bom ? " (BOM)" : ""); + } + + function showSpinner(show) { + DOM.statusSpinner.classList.toggle("hidden", !show); + } + + function escapeHtml(text) { + if (!text) return ""; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + function showToast(message, type) { + const toast = DOM.errorToast; + toast.classList.remove("hidden", "toast-error", "toast-success"); + toast.classList.add("toast-" + type); + toast.textContent = message; + setTimeout(() => toast.classList.add("hidden"), 4000); + } +})(); diff --git a/assets/js/scan-engine.js b/assets/js/scan-engine.js new file mode 100644 index 0000000..864b6d1 --- /dev/null +++ b/assets/js/scan-engine.js @@ -0,0 +1,581 @@ +/* ====================== Payload Revealer - Browser Scan Engine ====================== + Client-side port of payload_revealer/engine/{sweeper,classifier,payload_extractor,word_counter}.py + Runs entirely in the browser tab โ€” no upload, no server, no install. +*/ +(function (global) { + "use strict"; + + const HIDDEN_CATEGORIES = new Set(["Cc", "Cf", "Cs", "Co", "Cn", "Zl", "Zp"]); + + const INVISIBLE_WHITESPACE = new Set([ + 0x00a0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, + 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, + 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, + 0xfeff, 0x180e, + ]); + + const ZERO_WIDTH_CHARS = new Set([ + 0x200b, 0x200c, 0x200d, 0xfeff, 0x2060, + 0x2061, 0x2062, 0x2063, 0x2064, 0x180e, 0x00ad, + ]); + + const BIDI_OVERRIDES = new Set([ + 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, + 0x2067, 0x2068, 0x2069, + ]); + + const UNICODE_TAG_LO = 0xe0001, UNICODE_TAG_HI = 0xe007f; + const VARIATION_SELECTOR_LO = 0xfe00, VARIATION_SELECTOR_HI = 0xfe0f; + const VARIATION_SELECTOR_SUPP_LO = 0xe0100, VARIATION_SELECTOR_SUPP_HI = 0xe01ef; + const C0_LO = 0x0000, C0_HI = 0x001f; + const C1_LO = 0x0080, C1_HI = 0x009f; + const PUA_BMP_LO = 0xe000, PUA_BMP_HI = 0xf8ff; + const PUA_AST_LO = 0xf0000, PUA_AST_HI = 0xffffd; + const PUA_B_LO = 0x100000, PUA_B_HI = 0x10fffd; + + const NONCHARACTERS = new Set([ + ...Array.from({ length: 16 }, (_, n) => 0xfdd0 + n), + 0xfffe, 0xffff, 0x1fffe, 0x1ffff, 0x2fffe, 0x2ffff, + 0x3fffe, 0x3ffff, 0x4fffe, 0x4ffff, 0x5fffe, 0x5ffff, + 0x6fffe, 0x6ffff, 0x7fffe, 0x7ffff, 0x8fffe, 0x8ffff, + 0x9fffe, 0x9ffff, 0xafffe, 0xaffff, 0xbfffe, 0xbffff, + 0xcfffe, 0xcffff, 0xdfffe, 0xdffff, 0xefffe, 0xeffff, + 0xffffe, 0xfffff, 0x10fffe, 0x10ffff, + ]); + + const INTERLINEAR_ANNOTATION = new Set([0xfff9, 0xfffa, 0xfffb]); + + const BLOCKS = [ + [0x0000, 0x007f, "Basic Latin"], + [0x0080, 0x00ff, "Latin-1 Supplement"], + [0x0100, 0x017f, "Latin Extended-A"], + [0x0180, 0x024f, "Latin Extended-B"], + [0x0250, 0x02af, "IPA Extensions"], + [0x02b0, 0x02ff, "Spacing Modifier Letters"], + [0x0300, 0x036f, "Combining Diacritical Marks"], + [0x0370, 0x03ff, "Greek and Coptic"], + [0x0400, 0x04ff, "Cyrillic"], + [0x0500, 0x052f, "Cyrillic Supplement"], + [0x0530, 0x058f, "Armenian"], + [0x0590, 0x05ff, "Hebrew"], + [0x0600, 0x06ff, "Arabic"], + [0x0700, 0x074f, "Syriac"], + [0x0780, 0x07bf, "Thaana"], + [0x0900, 0x097f, "Devanagari"], + [0x0e00, 0x0e7f, "Thai"], + [0x0e80, 0x0eff, "Lao"], + [0x1000, 0x109f, "Myanmar"], + [0x1100, 0x11ff, "Hangul Jamo"], + [0x2000, 0x206f, "General Punctuation"], + [0x2070, 0x209f, "Superscripts and Subscripts"], + [0x20a0, 0x20cf, "Currency Symbols"], + [0x20d0, 0x20ff, "Combining Diacritical Marks for Symbols"], + [0x2100, 0x214f, "Letterlike Symbols"], + [0x2150, 0x218f, "Number Forms"], + [0x2190, 0x21ff, "Arrows"], + [0x2200, 0x22ff, "Mathematical Operators"], + [0x2300, 0x23ff, "Miscellaneous Technical"], + [0x2400, 0x243f, "Control Pictures"], + [0x2460, 0x24ff, "Enclosed Alphanumerics"], + [0x2500, 0x257f, "Box Drawing"], + [0x2580, 0x259f, "Block Elements"], + [0x25a0, 0x25ff, "Geometric Shapes"], + [0x2600, 0x26ff, "Miscellaneous Symbols"], + [0x2700, 0x27bf, "Dingbats"], + [0x27c0, 0x27ef, "Miscellaneous Mathematical Symbols-A"], + [0x27f0, 0x27ff, "Supplemental Arrows-A"], + [0x2800, 0x28ff, "Braille Patterns"], + [0x2900, 0x297f, "Supplemental Arrows-B"], + [0x2980, 0x29ff, "Miscellaneous Mathematical Symbols-B"], + [0x2a00, 0x2aff, "Supplemental Mathematical Operators"], + [0x2b00, 0x2bff, "Miscellaneous Symbols and Arrows"], + [0x3000, 0x303f, "CJK Symbols and Punctuation"], + [0x3040, 0x309f, "Hiragana"], + [0x30a0, 0x30ff, "Katakana"], + [0x4e00, 0x9fff, "CJK Unified Ideographs"], + [0xe000, 0xf8ff, "Private Use Area"], + [0xe0001, 0xe007f, "Tags"], + [0xe0100, 0xe01ef, "Variation Selectors Supplement"], + [0xf0000, 0xffffd, "Supplementary Private Use Area-A"], + [0x100000, 0x10fffd, "Supplementary Private Use Area-B"], + ]; + + function codepointBlock(cp) { + for (const [lo, hi, name] of BLOCKS) { + if (cp >= lo && cp <= hi) return name; + } + return "Unknown"; + } + + // General Category detection via Unicode property escapes (native ICU data, same + // categories unicodedata.category() returns in the Python engine). + const GENERAL_CATEGORIES = [ + "Cc", "Cf", "Cs", "Co", "Cn", "Zl", "Zp", "Zs", + "Mn", "Mc", "Me", "Nd", "Nl", "No", + "Lu", "Ll", "Lt", "Lm", "Lo", + "Pc", "Pd", "Ps", "Pe", "Pi", "Pf", "Po", + "Sm", "Sc", "Sk", "So", + ]; + const categoryRegex = new Map( + GENERAL_CATEGORIES.map((code) => [code, new RegExp(`^\\p{${code}}$`, "u")]) + ); + + function unicodeCategory(ch) { + for (const code of GENERAL_CATEGORIES) { + if (categoryRegex.get(code).test(ch)) return code; + } + return "Cn"; + } + + // Best-effort character names. Full parity with Python's unicodedata.name() + // would require embedding the entire Unicode Character Database; instead this + // names ASCII printable/control characters and every codepoint the risk logic + // below actually cares about, and falls back to "UNKNOWN U+XXXX" otherwise โ€” + // the same fallback the Python engine uses for unnamed codepoints. + const C0_NAMES = [ + "NULL", "START OF HEADING", "START OF TEXT", "END OF TEXT", "END OF TRANSMISSION", + "ENQUIRY", "ACKNOWLEDGE", "BELL", "BACKSPACE", "CHARACTER TABULATION", "LINE FEED", + "LINE TABULATION", "FORM FEED", "CARRIAGE RETURN", "SHIFT OUT", "SHIFT IN", + "DATA LINK ESCAPE", "DEVICE CONTROL ONE", "DEVICE CONTROL TWO", "DEVICE CONTROL THREE", + "DEVICE CONTROL FOUR", "NEGATIVE ACKNOWLEDGE", "SYNCHRONOUS IDLE", + "END OF TRANSMISSION BLOCK", "CANCEL", "END OF MEDIUM", "SUBSTITUTE", "ESCAPE", + "INFORMATION SEPARATOR FOUR", "INFORMATION SEPARATOR THREE", "INFORMATION SEPARATOR TWO", + "INFORMATION SEPARATOR ONE", + ]; + + const SPECIAL_NAMES = { + 0x0020: "SPACE", 0x007f: "DELETE", + 0x00a0: "NO-BREAK SPACE", 0x00ad: "SOFT HYPHEN", + 0x1680: "OGHAM SPACE MARK", + 0x180e: "MONGOLIAN VOWEL SEPARATOR", + 0x2000: "EN QUAD", 0x2001: "EM QUAD", 0x2002: "EN SPACE", 0x2003: "EM SPACE", + 0x2004: "THREE-PER-EM SPACE", 0x2005: "FOUR-PER-EM SPACE", 0x2006: "SIX-PER-EM SPACE", + 0x2007: "FIGURE SPACE", 0x2008: "PUNCTUATION SPACE", 0x2009: "THIN SPACE", + 0x200a: "HAIR SPACE", + 0x200b: "ZERO WIDTH SPACE", 0x200c: "ZERO WIDTH NON-JOINER", 0x200d: "ZERO WIDTH JOINER", + 0x2028: "LINE SEPARATOR", 0x2029: "PARAGRAPH SEPARATOR", + 0x202a: "LEFT-TO-RIGHT EMBEDDING", 0x202b: "RIGHT-TO-LEFT EMBEDDING", + 0x202c: "POP DIRECTIONAL FORMATTING", 0x202d: "LEFT-TO-RIGHT OVERRIDE", + 0x202e: "RIGHT-TO-LEFT OVERRIDE", 0x202f: "NARROW NO-BREAK SPACE", + 0x205f: "MEDIUM MATHEMATICAL SPACE", + 0x2060: "WORD JOINER", 0x2061: "FUNCTION APPLICATION", 0x2062: "INVISIBLE TIMES", + 0x2063: "INVISIBLE SEPARATOR", 0x2064: "INVISIBLE PLUS", + 0x2066: "LEFT-TO-RIGHT ISOLATE", 0x2067: "RIGHT-TO-LEFT ISOLATE", + 0x2068: "FIRST STRONG ISOLATE", 0x2069: "POP DIRECTIONAL ISOLATE", + 0x3000: "IDEOGRAPHIC SPACE", + 0xfeff: "ZERO WIDTH NO-BREAK SPACE", + 0xfff9: "INTERLINEAR ANNOTATION ANCHOR", 0xfffa: "INTERLINEAR ANNOTATION SEPARATOR", + 0xfffb: "INTERLINEAR ANNOTATION TERMINATOR", + 0xfffe: "", 0xffff: "", + }; + + function codepointName(cp) { + if (SPECIAL_NAMES[cp]) return SPECIAL_NAMES[cp]; + if (cp >= C0_LO && cp <= C0_HI) return C0_NAMES[cp]; + if (cp >= UNICODE_TAG_LO && cp <= UNICODE_TAG_HI) return `TAG CHARACTER U+${hex(cp)}`; + if (cp >= VARIATION_SELECTOR_LO && cp <= VARIATION_SELECTOR_HI) + return `VARIATION SELECTOR-${cp - VARIATION_SELECTOR_LO + 1}`; + if (NONCHARACTERS.has(cp)) return ``; + return `UNKNOWN U+${hex(cp)}`; + } + + function hex(cp) { + return cp.toString(16).toUpperCase().padStart(4, "0"); + } + + function classifyChar(ch, index) { + const cp = ch.codePointAt(0); + const category = unicodeCategory(ch); + const name = codepointName(cp); + const block = codepointBlock(cp); + + const tags = []; + let risk = "none"; + let visible; + + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + visible = true; + } else if (INVISIBLE_WHITESPACE.has(cp)) { + visible = false; + tags.push("invisible-whitespace"); + } else if (ZERO_WIDTH_CHARS.has(cp)) { + visible = false; + tags.push("zero-width"); + } else if (HIDDEN_CATEGORIES.has(category)) { + visible = false; + } else { + visible = true; + } + + if (ZERO_WIDTH_CHARS.has(cp)) { + if (!tags.includes("zero-width")) tags.push("zero-width"); + risk = "critical"; + } + if (BIDI_OVERRIDES.has(cp)) { + tags.push("bidi-override"); + risk = "critical"; + } + if (cp >= UNICODE_TAG_LO && cp <= UNICODE_TAG_HI) { + tags.push("unicode-tag"); + risk = "critical"; + } + if (INTERLINEAR_ANNOTATION.has(cp)) { + tags.push("interlinear-annotation"); + if (risk !== "critical") risk = "high"; + } + if (cp >= C0_LO && cp <= C0_HI && cp !== 0x0009 && cp !== 0x000a && cp !== 0x000d) { + tags.push("c0-control"); + if (risk !== "critical") risk = "high"; + } + if (cp >= C1_LO && cp <= C1_HI) { + tags.push("c1-control"); + if (risk !== "critical") risk = "high"; + } + if ( + (cp >= VARIATION_SELECTOR_LO && cp <= VARIATION_SELECTOR_HI) || + (cp >= VARIATION_SELECTOR_SUPP_LO && cp <= VARIATION_SELECTOR_SUPP_HI) + ) { + tags.push("variation-selector"); + if (risk !== "critical" && risk !== "high") risk = "medium"; + } + if ( + (cp >= PUA_BMP_LO && cp <= PUA_BMP_HI) || + (cp >= PUA_AST_LO && cp <= PUA_AST_HI) || + (cp >= PUA_B_LO && cp <= PUA_B_HI) + ) { + tags.push("private-use-area"); + if (risk !== "critical" && risk !== "high") risk = "medium"; + } + if (NONCHARACTERS.has(cp)) { + tags.push("noncharacter"); + if (risk !== "critical" && risk !== "high") risk = "high"; + } + if (cp === 0x0000) { + tags.push("null-byte"); + risk = "critical"; + } + + return { index, char: ch, codepoint: cp, name, category, block, visible, risk, tags }; + } + + function unicodeWordCount(text) { + let count = 0; + let inWord = false; + for (const ch of text) { + const cat = unicodeCategory(ch); + if (cat.startsWith("L") || cat.startsWith("N")) { + if (!inWord) { + count += 1; + inWord = true; + } + } else if (cat === "Mn" || cat === "Mc") { + continue; + } else { + inWord = false; + } + } + return count; + } + + function naiveWordCount(text) { + const trimmed = text.trim(); + return trimmed === "" ? 0 : trimmed.split(/\s+/).length; + } + + function scanText(text) { + const findings = []; + let i = 0; + for (const ch of text) { + findings.push(classifyChar(ch, i)); + i += 1; + } + + const visibleCount = findings.reduce((n, f) => n + (f.visible ? 1 : 0), 0); + const hiddenCount = findings.length - visibleCount; + + const categoryCounts = {}; + const riskCounts = {}; + for (const f of findings) { + categoryCounts[f.category] = (categoryCounts[f.category] || 0) + 1; + riskCounts[f.risk] = (riskCounts[f.risk] || 0) + 1; + } + + return { + totalChars: findings.length, + visibleChars: visibleCount, + hiddenChars: hiddenCount, + visibleWordCount: naiveWordCount(text), + actualWordCount: unicodeWordCount(text), + categoryCounts, + riskCounts, + findings, + }; + } + + // ====================== Payload extraction (payload_extractor.py port) ====================== + + const ZWSP_BINARY_MAP = { + 0x200b: "1", 0x200c: "1", 0x200d: "0", 0xfeff: "0", 0x2060: "1", 0x2062: "0", + }; + + function extractZwspBinary(findings) { + const bits = []; + for (const f of findings) { + const b = ZWSP_BINARY_MAP[f.codepoint]; + if (b !== undefined) bits.push(b); + } + if (bits.length < 8) return null; + + const result = []; + let acc = ""; + for (const bit of bits) { + acc += bit; + if (acc.length === 8) { + const val = parseInt(acc, 2); + if (val >= 32 && val < 127) result.push(String.fromCharCode(val)); + else if (val === 10) result.push("\\n"); + else if (val === 13) result.push("\\r"); + else result.push(`[${val.toString(16).padStart(2, "0")}]`); + acc = ""; + } + } + return result.length ? result.join("") : null; + } + + function extractBidiReversal(findings) { + let mode = null; + let buf = []; + const segments = []; + + for (const f of findings) { + if (f.codepoint === 0x202e) { + mode = "rlo"; + buf = []; + } else if (f.codepoint === 0x202d) { + mode = "lro"; + buf = []; + } else if (f.codepoint === 0x202c) { + if (mode && buf.length) { + segments.push(`[${mode}] ${buf.slice().reverse().join("")}`); + } + mode = null; + buf = []; + } else if (mode && f.visible && f.category.startsWith("L")) { + buf.push(f.char); + } + } + return segments.length ? segments.join("\n") : null; + } + + const TAG_LETTER_MAP = {}; + for (let cp = 0xe0021; cp <= 0xe007a; cp++) { + TAG_LETTER_MAP[cp] = String.fromCharCode(cp - 0xe0000 + "a".charCodeAt(0)); + } + for (let cp = 0xe0030; cp <= 0xe0039; cp++) { + TAG_LETTER_MAP[cp] = String.fromCharCode(cp - 0xe0000 + "0".charCodeAt(0)); + } + TAG_LETTER_MAP[0xe0020] = " "; + + function extractTagBlock(findings) { + const segments = []; + let inTag = false; + let buf = []; + for (const f of findings) { + if (f.codepoint === 0xe0001) { + inTag = true; + buf = []; + } else if (f.codepoint === 0xe007f) { + if (inTag && buf.length) segments.push(buf.join("")); + inTag = false; + buf = []; + } else if (inTag && TAG_LETTER_MAP[f.codepoint] !== undefined) { + buf.push(TAG_LETTER_MAP[f.codepoint]); + } + } + return segments.length ? segments.join(" | ") : null; + } + + function extractNullByteChunks(findings) { + const chunks = []; + let buf = []; + for (const f of findings) { + if (f.codepoint === 0x0000) { + if (buf.length) { + chunks.push(buf.join("")); + buf = []; + } + } else { + buf.push(f.char); + } + } + if (buf.length) chunks.push(buf.join("")); + return chunks.filter((c) => c.length > 0 && c !== " " && c.length < 200); + } + + function extractControlSequences(findings) { + const seqs = []; + let buf = []; + for (const f of findings) { + if (f.category === "Cc" && f.codepoint !== 0x000a && f.codepoint !== 0x000d && f.codepoint !== 0x0009) { + buf.push(`U+${hex(f.codepoint)}`); + } else { + if (buf.length >= 2) seqs.push(buf.join(" ")); + buf = []; + } + } + if (buf.length >= 2) seqs.push(buf.join(" ")); + return seqs.length ? seqs.join("\n") : null; + } + + function extractAll(findings) { + const payloads = []; + + const zwsp = extractZwspBinary(findings); + if (zwsp) payloads.push({ type: "zwsp_binary_stream", decoded: zwsp, description: "Zero-width space binary encoding" }); + + const bidi = extractBidiReversal(findings); + if (bidi) payloads.push({ type: "bidi_override_reversal", decoded: bidi, description: "Bidi-override reversed text" }); + + const tags = extractTagBlock(findings); + if (tags) payloads.push({ type: "unicode_tag_block", decoded: tags, description: "Unicode tag block metadata" }); + + for (const chunk of extractNullByteChunks(findings)) { + payloads.push({ type: "null_byte_delimited", decoded: chunk, description: "Null-byte-delimited segment" }); + } + + const controlSeq = extractControlSequences(findings); + if (controlSeq) payloads.push({ type: "control_sequence", decoded: controlSeq, description: "Control character sequence" }); + + return payloads; + } + + // ====================== Encoding detection for uploaded files ====================== + + const BOM_TABLE = [ + { bytes: [0xff, 0xfe, 0x00, 0x00], encoding: "utf-32-le" }, + { bytes: [0x00, 0x00, 0xfe, 0xff], encoding: "utf-32-be" }, + { bytes: [0xef, 0xbb, 0xbf], encoding: "utf-8" }, + { bytes: [0xff, 0xfe], encoding: "utf-16-le" }, + { bytes: [0xfe, 0xff], encoding: "utf-16-be" }, + ]; + + function detectBom(bytes) { + for (const { bytes: bom, encoding } of BOM_TABLE) { + if (bytes.length >= bom.length && bom.every((b, i) => bytes[i] === b)) { + return { hasBom: true, bomEncoding: encoding }; + } + } + return { hasBom: false, bomEncoding: null }; + } + + const TEXT_DECODER_LABELS = { + "utf-8": "utf-8", + "utf-16-le": "utf-16le", + "utf-16-be": "utf-16be", + "utf-32-le": "utf-8", // browsers have no native UTF-32 decoder; fall back below + "utf-32-be": "utf-8", + }; + + function decodeBytes(bytes) { + const { hasBom, bomEncoding } = detectBom(bytes); + let encoding = "utf-8"; + let text; + + if (hasBom && bomEncoding !== "utf-32-le" && bomEncoding !== "utf-32-be") { + encoding = bomEncoding; + text = new TextDecoder(TEXT_DECODER_LABELS[bomEncoding]).decode(bytes); + } else if (hasBom) { + // No native UTF-32 TextDecoder in browsers; decode as latin-1 fallback like the + // Python engine does when its chosen codec can't decode the bytes. + encoding = "latin-1 (fallback)"; + text = decodeLatin1(bytes); + } else { + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + encoding = "utf-8"; + } catch (e) { + try { + text = new TextDecoder("utf-16le", { fatal: true }).decode(bytes); + encoding = "utf-16"; + } catch (e2) { + text = decodeLatin1(bytes); + encoding = "latin-1"; + } + } + } + + return { text, encoding, hasBom, bomEncoding }; + } + + function decodeLatin1(bytes) { + let s = ""; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return s; + } + + // ====================== Public report builders (match ipc_bridge.py's JSON shape) ====================== + + function buildReport(text, file) { + const scan = scanText(text); + const findings = scan.findings.map((f) => ({ + index: f.index, + char: f.char, + codepoint: `U+${hex(f.codepoint)}`, + codepoint_int: f.codepoint, + name: f.name, + category: f.category, + block: f.block, + visible: f.visible, + risk: f.risk, + tags: f.tags, + })); + const extractedPayloads = extractAll(scan.findings); + + return { + file, + stats: { + total_chars: scan.totalChars, + visible_chars: scan.visibleChars, + hidden_chars: scan.hiddenChars, + hidden_pct: scan.totalChars ? Math.round((scan.hiddenChars / scan.totalChars) * 10000) / 100 : 0, + visible_word_count: scan.visibleWordCount, + actual_word_count: scan.actualWordCount, + word_count_delta: scan.actualWordCount - scan.visibleWordCount, + category_counts: scan.categoryCounts, + risk_counts: scan.riskCounts, + }, + findings, + extracted_payloads: extractedPayloads, + }; + } + + function scanPastedText(text, label) { + return buildReport(text, { + path: label || "(pasted text)", + size: new TextEncoder().encode(text).length, + encoding: "utf-8 (pasted)", + has_bom: false, + bom_encoding: null, + }); + } + + function scanFileBytes(bytes, fileName) { + const { text, encoding, hasBom, bomEncoding } = decodeBytes(bytes); + return buildReport(text, { + path: fileName, + size: bytes.length, + encoding, + has_bom: hasBom, + bom_encoding: bomEncoding, + }); + } + + global.PayloadRevealerEngine = { + scanText, + classifyChar, + extractAll, + scanPastedText, + scanFileBytes, + decodeBytes, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/index.html b/index.html index f71c909..2b714de 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,7 @@ Payload Revealer โ€” Catch invisible Unicode instructions hidden in copied text + -
+
-

Catches invisible Unicode instructions hidden inside copied text โ€” zero-width characters, bidi overrides, Unicode tags, and other copy/paste artifacts that can smuggle answers or prompt instructions into Slack, docs, chats, or evaluation tasks.

+

Catches invisible Unicode instructions hidden inside copied text โ€” zero-width characters, bidi overrides, Unicode tags, and other copy/paste artifacts that can smuggle answers or prompt instructions into Slack, docs, chats, or evaluation tasks. Runs live in this page, no install needed.

+ Try the live scanner Source code Download
@@ -139,11 +141,145 @@

Built by Angela Hudson ยท DaCameraGirl

+ + Live Scanner +
+

Runs entirely in this tab — nothing you paste or drop ever leaves your browser. Paste suspicious text or drop a .txt file to scan it.

+ +
+ + +
+ +
+ +
+ + Nothing is uploaded — scanning happens locally in this tab. +
+
+ + + + + + + + + + + + + + +
+ The Slack Scenario

A copied answer can look normal in Slack while carrying invisible Unicode underneath โ€” hidden instructions like “pick option 3” or “tell them X said Y.” PowerShell and some editors may expose weird characters; Payload Revealer does the systematic version: enumerate every codepoint, report suspicious invisible characters, and decode embedded payloads when possible.

-

If something pasted suspicious:

+

If something pasted suspicious: paste it into the live scanner above — or, for scripting and batch/audit-trail work, use the CLI:

python -m payload_revealer .\suspicious.txt
 
 # For a shareable audit trail:
@@ -154,6 +290,7 @@
   
   Quick Download
   
+

The live scanner above needs nothing installed. Grab the desktop app instead if you want it offline, or working outside a browser:

Download the Windows installer

Installs the Payload Revealer desktop app. No Python required. Drag a file onto the drop zone or click Open File.

@@ -272,5 +409,7 @@

CLI Options

+ +