Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ beacon://c2-exfil.lan/reg?agent=demo-01


- **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
- **Hidden Formatting Detector** — Paste rich text (Ctrl+V from the source) to catch white-on-white, zero-size-font, and `display:none` instructions hidden in docs, webpages, or Slack messages — no invisible Unicode required
- **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
Expand Down Expand Up @@ -175,15 +176,24 @@ python -m payload_revealer ./suspicious_docs/ --recursive
| Invisible whitespace | NBSP, en-space, ideographic space | Low |
| Noncharacters | `U+FFFE`, `U+FFFF`, etc. | High |

<p align="center"><img src="docs/readme-divider.svg" width="720" alt="" /></p>
<p align="center"><img src="https://capsule-render.vercel.app/api?type=waving&color=0:070b14,100:12102a&height=50&section=header&text=Hidden%20Formatting&fontSize=22&fontColor=e6edf3&animation=twinkling" width="720" alt="Hidden Formatting" /></p>

Invisible Unicode isn't the only trick. A shared doc, webpage, or Slack message can hide an instruction in plain sight with **white-on-white text, `display:none`, `opacity:0`, or a zero-size font** — no exotic characters involved, just formatting. That text looks completely normal once it's copied, so a plain-text paste alone can't catch it: the styling that hid it is already gone by the time it lands in a scanner.

The **live scanner's "Paste rich text" mode** catches this instead. Copy directly from the source (Ctrl+C in the Google Doc, webpage, or Slack message, not a re-typed version) and paste with Ctrl+V into that tab — browsers preserve the source's styling in the clipboard's HTML data, and the scanner reads it directly for text whose color matches its background, is set to invisible, or has been shrunk to nothing.

This is a **live-scanner-only** feature for now — the Python CLI and desktop app work from saved `.txt` files, which never carry formatting to begin with.

<p align="center"><img src="docs/readme-divider.svg" width="720" alt="" /></p>
<p align="center"><img src="https://capsule-render.vercel.app/api?type=waving&color=0:070b14,100:12102a&height=50&section=header&text=What%20It%20Does%20Not%20Detect&fontSize=22&fontColor=e6edf3&animation=twinkling" width="720" alt="What It Does Not Detect" /></p>


This tool is scoped to **text and Unicode forensics**. It is not a universal steganography detector.
This tool is scoped to **text, Unicode, and hidden-formatting forensics**. It is not a universal steganography detector.

| Scenario | Why it is out of scope |
|----------|------------------------|
| **CSS-transparent text** | Letters hidden with `color: transparent` or similar styling are normal Unicode when pasted into a plain text file. Payload Revealer does not parse HTML or CSS. |
| **CSS-transparent text pasted as plain text** | The live scanner's "Paste rich text" mode catches this if you copy from the original source. But a `.txt` file, the CLI, or the desktop app only ever see plain text, which has no formatting left to inspect. |
| **Image, PDF, or media steganography** | No parsers for LSB image stego, PDF object tricks, audio watermarks, or other binary media payloads. |

<p align="center"><img src="docs/readme-divider.svg" width="720" alt="" /></p>
Expand All @@ -209,7 +219,8 @@ payload_revealer/
├── 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
│ │ ├── hidden-format-scanner.js # Detects CSS-hidden text (white-on-white, etc.)
│ │ └── app.js # UI wiring: paste/drop/rich-paste input, results rendering, export
│ └── css/
│ └── scanner.css
├── payload_revealer/ # Python engine
Expand Down
39 changes: 39 additions & 0 deletions assets/css/scanner.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,45 @@

.scanner-hint { color: var(--sc-text-muted); font-size: 0.85rem; }

.rich-paste-box {
min-height: 160px;
max-height: 320px;
overflow: auto;
background: var(--sc-bg-card);
border: 1px solid var(--sc-border);
border-radius: 8px;
padding: 14px;
color: var(--sc-text-primary);
font-size: 0.9rem;
line-height: 1.5;
}

.rich-paste-box:focus { outline: none; border-color: var(--sc-accent); }

.rich-paste-box:empty::before {
content: attr(data-placeholder);
color: var(--sc-text-muted);
}

.hiddenformat-card {
background: var(--sc-bg-card);
border: 1px solid var(--sc-critical);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
}

.hiddenformat-text {
font-family: var(--sc-font-mono);
font-size: 0.85rem;
color: var(--sc-text-primary);
margin-bottom: 8px;
word-break: break-word;
}

.hiddenformat-reasons { display: flex; gap: 6px; flex-wrap: wrap; }
.hiddenformat-reasons code { background: var(--sc-critical-bg); color: var(--sc-critical); padding: 2px 8px; border-radius: 10px; font-size: 0.72rem; }

/* ===== Drop Zone ===== */
.drop-zone {
border: 2px dashed var(--sc-border);
Expand Down
87 changes: 78 additions & 9 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@
"use strict";

const Engine = window.PayloadRevealerEngine;
const HiddenFormatScanner = window.HiddenFormatScanner;

const DOM = {
pasteTab: document.getElementById("scPasteTab"),
fileTab: document.getElementById("scFileTab"),
richTab: document.getElementById("scRichTab"),
pastePanel: document.getElementById("scPastePanel"),
filePanel: document.getElementById("scFilePanel"),
richPanel: document.getElementById("scRichPanel"),
textarea: document.getElementById("scTextarea"),
btnScanText: document.getElementById("scBtnScanText"),
fileInput: document.getElementById("scFileInput"),
dropZone: document.getElementById("dropZone"),
richInput: document.getElementById("scRichInput"),
btnClearRich: document.getElementById("scBtnClearRich"),
statusBar: document.getElementById("statusBar"),
statusFile: document.querySelector(".status-file"),
statusSpinner: document.querySelector(".status-spinner"),
Expand All @@ -38,30 +43,68 @@
resultsPanel: document.getElementById("resultsPanel"),
tabFindings: document.getElementById("tabFindings"),
tabPayloads: document.getElementById("tabPayloads"),
tabHiddenFormat: document.getElementById("tabHiddenFormat"),
tabHiddenFormatBtn: document.getElementById("tabHiddenFormatBtn"),
tabRaw: document.getElementById("tabRaw"),
findingsBody: document.getElementById("findingsBody"),
findingsTable: document.getElementById("findingsTable"),
noFindings: document.getElementById("noFindings"),
payloadContainer: document.getElementById("payloadContainer"),
noPayloads: document.getElementById("noPayloads"),
hiddenFormatContainer: document.getElementById("hiddenFormatContainer"),
noHiddenFormat: document.getElementById("noHiddenFormat"),
rawOutput: document.getElementById("rawOutput"),
exportBar: document.getElementById("exportBar"),
errorToast: document.getElementById("errorToast"),
};

let lastReport = null;

/* ===== Input mode tabs (paste vs. file) ===== */
/* ===== Input mode tabs (paste text vs. file vs. rich text) ===== */
DOM.pasteTab.addEventListener("click", () => setInputMode("paste"));
DOM.fileTab.addEventListener("click", () => setInputMode("file"));
DOM.richTab.addEventListener("click", () => setInputMode("rich"));

function setInputMode(mode) {
DOM.pasteTab.classList.toggle("active", mode === "paste");
DOM.fileTab.classList.toggle("active", mode === "file");
DOM.richTab.classList.toggle("active", mode === "rich");
DOM.pastePanel.classList.toggle("hidden", mode !== "paste");
DOM.filePanel.classList.toggle("hidden", mode !== "file");
DOM.richPanel.classList.toggle("hidden", mode !== "rich");
}

/* ===== Paste rich text (catches CSS-hidden formatting) ===== */
DOM.richInput.addEventListener("paste", (e) => {
e.preventDefault();
const html = e.clipboardData.getData("text/html");
const plain = e.clipboardData.getData("text/plain");

if (html) {
DOM.richInput.innerHTML = html;
} else {
DOM.richInput.textContent = plain;
}

runScan(() => {
const hf = html
? HiddenFormatScanner.scanHtml(html)
: { findings: [], visibleText: plain, hiddenText: "" };
const report = Engine.scanPastedText(hf.visibleText, "(pasted rich text)");
report.hidden_format_findings = hf.findings;
return report;
});
});

DOM.btnClearRich.addEventListener("click", () => {
DOM.richInput.innerHTML = "";
DOM.statusBar.classList.add("hidden");
DOM.statsGrid.classList.add("hidden");
DOM.riskSummary.classList.add("hidden");
DOM.resultsPanel.classList.add("hidden");
DOM.exportBar.classList.add("hidden");
});

/* ===== Paste + Scan ===== */
DOM.btnScanText.addEventListener("click", () => {
const text = DOM.textarea.value;
Expand Down Expand Up @@ -130,6 +173,7 @@
const target = tab.dataset.tab;
DOM.tabFindings.classList.toggle("hidden", target !== "findings");
DOM.tabPayloads.classList.toggle("hidden", target !== "payloads");
DOM.tabHiddenFormat.classList.toggle("hidden", target !== "hiddenFormat");
DOM.tabRaw.classList.toggle("hidden", target !== "raw");
});
});
Expand Down Expand Up @@ -189,21 +233,23 @@
const file = data.file;
const findings = data.findings || [];
const payloads = data.extracted_payloads || [];
const hiddenFormatFindings = data.hidden_format_findings || [];
const riskCounts = stats.risk_counts || {};
const totalCritical = (riskCounts.critical || 0) + hiddenFormatFindings.length;

DOM.badgeTotal.textContent = stats.total_chars + " chars total";
DOM.statusFile.textContent = file.path;

if (stats.hidden_chars > 0) {
if (stats.hidden_chars > 0 || hiddenFormatFindings.length > 0) {
DOM.badgeHidden.classList.remove("hidden");
DOM.badgeHidden.textContent = stats.hidden_chars + " hidden";
DOM.badgeHidden.textContent = stats.hidden_chars + hiddenFormatFindings.length + " hidden";
} else {
DOM.badgeHidden.classList.add("hidden");
}

if ((riskCounts.critical || 0) > 0) {
if (totalCritical > 0) {
DOM.badgeCritical.classList.remove("hidden");
DOM.badgeCritical.textContent = riskCounts.critical + " critical";
DOM.badgeCritical.textContent = totalCritical + " critical";
} else {
DOM.badgeCritical.classList.add("hidden");
}
Expand All @@ -221,7 +267,7 @@
hiddenCard.classList.toggle("stat-card-warn", stats.hidden_chars > 0);

DOM.riskSummary.classList.remove("hidden");
DOM.riskCritical.textContent = riskCounts.critical || 0;
DOM.riskCritical.textContent = totalCritical;
DOM.riskHigh.textContent = riskCounts.high || 0;
DOM.riskMedium.textContent = riskCounts.medium || 0;
DOM.riskLow.textContent = riskCounts.low || 0;
Expand Down Expand Up @@ -275,12 +321,35 @@
}
}

DOM.rawOutput.textContent = JSON.stringify({ file, stats, findings, extracted_payloads: payloads }, null, 2);
DOM.hiddenFormatContainer.innerHTML = "";
DOM.tabHiddenFormatBtn.classList.toggle("hidden", hiddenFormatFindings.length === 0 && !("hidden_format_findings" in data));
if (hiddenFormatFindings.length === 0) {
DOM.noHiddenFormat.classList.remove("hidden");
} else {
DOM.noHiddenFormat.classList.add("hidden");
for (const f of hiddenFormatFindings) {
const card = document.createElement("div");
card.className = "hiddenformat-card";
card.innerHTML = `
<div class="hiddenformat-text">${escapeHtml(f.text)}</div>
<div class="hiddenformat-reasons">${f.reasons.map((r) => "<code>" + escapeHtml(r) + "</code>").join(" ")}</div>
`;
DOM.hiddenFormatContainer.appendChild(card);
}
}

DOM.rawOutput.textContent = JSON.stringify(
{ file, stats, findings, extracted_payloads: payloads, hidden_format_findings: hiddenFormatFindings },
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");
const defaultTab = hiddenFormatFindings.length > 0 ? "hiddenFormat" : "findings";
document.querySelector(`.tab[data-tab="${defaultTab}"]`).classList.add("active");
DOM.tabFindings.classList.toggle("hidden", defaultTab !== "findings");
DOM.tabPayloads.classList.add("hidden");
DOM.tabHiddenFormat.classList.toggle("hidden", defaultTab !== "hiddenFormat");
DOM.tabRaw.classList.add("hidden");

DOM.exportBar.classList.remove("hidden");
Expand Down
Loading
Loading