diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4faa4cbe..57455b41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,9 +11,10 @@
- LLM input guard (`fuckmark-guard-v1`). `protect()` / `inspect()` / `Guard` / `@Guard().wrap` sanitize strings, lists, and dicts (OpenAI-style `messages` included) before a model call. Default action is strip; `on_findings="refuse"` raises `HiddenTextRefused` without calling the inner function; `report` scans only. Unicode tag characters are decoded to ASCII as `tag_payload` on the receipt (`extract_tag_payload`). CLI: `fuckmark guard` (`--json`, `--refuse`, `--report`, `--receipt`, `--select`). HTTP: `POST /api/guard`. JS: `editors/vscode/guard.js`. This does not detect semantic prompt injection. Default categories are the shared `SECURITY_SCAN_CATEGORIES` also used by `fuckmark lint`. Reference: `docs/guard.md`.
- Hidden-scan `format` classification uses the same explicit Cf ranges as `editors/vscode/scan.js` (including U+13430-U+1343F and U+0890-U+0891) instead of only `unicodedata.category`, so Python 3.11 (Unicode 14) agrees with the JS port and with Python 3.12+ (Unicode 15).
- Hidden-scan findings now carry **context**, **severity**, and a one-line **why/remedy**. A bidi override inside an identifier is `critical` (Trojan Source); a ZWJ inside an emoji cluster is `info`. Lint JSON, `--scan` reports, and the VS Code hover/Problems panel all show those fields. Diagnostic rank: `critical` is Error, `high` is Warning.
-- Frozen classification spec `fuckmark-hidden-scan-v1` under `specs/` (`protocol.md`, `vectors.json`, `freeze.json`). Conformance vectors are codepoint arrays only; `tests/test_hidden_scan_spec.py` replays them on the Python engine and on `editors/vscode/scan.js`. Freeze record **v2** adds language-aware comment/string roles: a bidi override in a comment or string is `critical` (the other two Trojan Source encodings), `#` is a comment only for `python`, and `//` after `:` is not a comment.
+- Frozen classification spec `fuckmark-hidden-scan-v1` under `specs/` (`protocol.md`, `vectors.json`, `freeze.json`). Conformance vectors are codepoint arrays only; `tests/test_hidden_scan_spec.py` replays them on the Python engine and on `editors/vscode/scan.js`. Freeze record **v3** adds language-aware comment/string roles: a bidi override in a comment or string is `critical` even next to emoji, `#` is a comment only for `python`, and `//` after `:` is not a comment.
- Language-aware Trojan Source analysis and autofix. `scan_hidden_characters(..., language=)` (and lint, from the file suffix) classifies source as `comment` / `string` / code. `autofix_trojan_source` / `autofixTrojanSource` strip only `bidi_control`.
- Zero-install hidden-Unicode scan page: `docs/scan.html` (packaged as `fuckmark/webui/scan.html`, engine copy `scan.js`). `fuckmark web` serves it at `/scan.html`. After deploy: `mark.q1z.org/scan.html`. Scanning runs in the browser; a local Python `/api/scan` confirm is optional when the CLI server is up.
+- Chromium browser extension under `editors/browser`: popup scanner, on-page reveal (badges plus CSS highlights), context-menu scan, per-tab toolbar badge, and optional paste-safe stripping that keeps real emoji ZWJ sequences (both neighbors emoji) and stops page paste handlers from seeing the raw clipboard. Zero remote code; `scan.js` matches the VS Code engine copy. Load unpacked from `chrome://extensions`.
- `fuckmark normalize` (`fuckmark-normalize-v1`): NFC, optional identifier lookalike fold (UTS #39-inspired subset, not a full confusable map), then strip of the security category set, plus a JSON receipt (`input_sha256` / `output_sha256` / `steps` / `report_hash`). `--clean` remains the blunt strip with no receipt. HTTP: `POST /api/normalize`. Python: `normalize_text`, `skeleton_fold`. Reference: `docs/normalize.md`.
- VS Code / Cursor: `fuckmark.cleanOnSave` (default off) strips hidden Unicode on save.
- Frozen confirmation files, hashes, mix mechanisms, and the published wheel are unchanged. Package version stays 0.4.1.
diff --git a/README.md b/README.md
index 69d417bc..87bd2e24 100644
--- a/README.md
+++ b/README.md
@@ -179,6 +179,10 @@ Binary, non-UTF-8, oversized, and vendored/VCS paths are skipped; `--exclude GLO
The [`editors/vscode`](editors/vscode) extension reveals hidden Unicode inline as you read code: a red box and a visible `‹U+202E›` badge on every hidden character, hover text with why it matters, diagnostics in the Problems panel (`critical` as Error, including Trojan Source in comments and strings), a status-bar count, one-click clean, and optional clean-on-save (`fuckmark.cleanOnSave`). It is zero-dependency plain JavaScript with no build step, and its scanner is a faithful port of `fuckmark-hidden-scan-v1` — pinned to the Python engine across every Unicode codepoint by [`tests/test_vscode_scanner_parity.py`](tests/test_vscode_scanner_parity.py), so the editor and the CLI agree exactly. Load it with `code --extensionDevelopmentPath=editors/vscode` or open the folder and press `F5`.
+## See it in the browser (Chrome / Edge / Brave)
+
+The [`editors/browser`](editors/browser) extension reveals hidden Unicode on the page you are reading. Load it unpacked from `chrome://extensions` (Developer mode). The popup scans pasted text or the current tab, **Reveal** paints `U+XXXX` badges on the page, and **Paste-safe** (off by default) strips hidden characters when you paste into a field while keeping emoji sequences. Nothing is uploaded; the scanner is the same `scan.js` as the editor extension. Full notes: [`editors/browser/README.md`](editors/browser/README.md).
+
## Guard model input (LLM prompt-injection smuggling)
Hidden Unicode — especially Unicode **tag** characters — can smuggle a second instruction into a prompt that a human reviewer will not see. `protect()` strips that payload before the text reaches a model, and can decode the smuggled ASCII so you can log it. It is not a semantic jailbreak detector.
diff --git a/docs/lint.md b/docs/lint.md
index 04a32c32..1d318496 100644
--- a/docs/lint.md
+++ b/docs/lint.md
@@ -89,4 +89,6 @@ runs on text files and receives the staged filenames.
The same JSON contract (`--json`) is what an editor extension consumes to render
inline decorations. The report lists each file, per-category counts, and the
-first locations as `{index, codepoint, category}`.
+first locations as `{index, codepoint, category}`. The VS Code / Cursor
+extension lives in `editors/vscode`. The Chromium extension in `editors/browser`
+reveals hidden Unicode on ordinary web pages and can strip it on paste.
diff --git a/docs/scan.html b/docs/scan.html
index 0195ef0a..0b007977 100644
--- a/docs/scan.html
+++ b/docs/scan.html
@@ -412,6 +412,8 @@
See the bytes.
total: inner.total || findings.length,
findings,
highest: inner.highest_severity || "",
+ truncated: Boolean(inner.truncated),
+ counts: inner.counts || {},
};
}
@@ -428,6 +430,8 @@ See the bytes.
total: result.total,
findings: result.findings,
highest,
+ truncated: false,
+ counts: result.counts || {},
};
}
@@ -465,12 +469,17 @@ See the bytes.
return;
}
copyClean.disabled = false;
- fixTrojan.disabled = !result.findings.some((item) => item.category === "bidi_control");
+ const bidiCount = Number((result.counts && result.counts.bidi_control) || 0);
+ const listedBidi = result.findings.some((item) => item.category === "bidi_control");
+ fixTrojan.disabled = !listedBidi && bidiCount === 0;
if (n === 0) {
summary.innerHTML = "No hidden characters in " + text.length + " code units.";
} else {
const sev = result.highest ? " Highest severity: " + result.highest + " ." : "";
- summary.innerHTML = "Found " + n + " hidden character" + (n === 1 ? "" : "s") + "." + sev;
+ const cap = result.truncated
+ ? " Showing " + result.findings.length + " of " + n + " (list capped)."
+ : "";
+ summary.innerHTML = "Found " + n + " hidden character" + (n === 1 ? "" : "s") + "." + sev + cap;
}
reveal.replaceChildren(...revealNodes(text, result.findings));
findingsEl.replaceChildren(...result.findings.map(findingCard));
diff --git a/docs/scan.js b/docs/scan.js
index f2466c94..f28c001f 100644
--- a/docs/scan.js
+++ b/docs/scan.js
@@ -333,10 +333,13 @@ function sourceRoles(text, language) {
return roles;
}
-function classifyContext(text, offset, role) {
+function classifyContext(text, offset, role, category) {
const length = text.codePointAt(offset) > 0xffff ? 2 : 1;
const prev = neighborBefore(text, offset);
const next = neighborAfter(text, offset, length);
+ if ((role === "comment" || role === "string") && category === CATEGORY_BIDI_CONTROL) {
+ return role;
+ }
if (
isEmojiish(prev.cp) ||
isEmojiish(next.cp) ||
@@ -450,7 +453,7 @@ function scanText(text, categories, language) {
if (category !== null && (selected === null || selected.has(category))) {
total += 1;
counts[category] = (counts[category] || 0) + 1;
- const context = classifyContext(text, offset, roles[offset] || "code");
+ const context = classifyContext(text, offset, roles[offset] || "code", category);
const severity = scoreSeverity(category, context);
const explained = explainFinding(category, context, severity);
findings.push({
diff --git a/docs/website.md b/docs/website.md
index 51f4560c..d2e52a97 100644
--- a/docs/website.md
+++ b/docs/website.md
@@ -38,6 +38,8 @@ Static, JS-first. Paste text or open a file; the page classifies hidden characte
Deploy `scan.html` and `scan.js` next to each other. `file://` works. Under `fuckmark web`, an optional checkbox uses `POST /api/scan` on the local Python engine; text still does not leave the machine. Do not embed raw hidden characters in the HTML file; examples are built with `String.fromCodePoint`.
+The Chromium extension in [`../editors/browser`](../editors/browser) is the same engine in the toolbar: scan the current page, reveal hidden characters, and optionally strip them on paste.
+
### `demo.html` (research walkthrough)
Keep it a static file that works from `file://` with baked CLI samples. The live page must show:
diff --git a/editors/browser/README.md b/editors/browser/README.md
new file mode 100644
index 00000000..c5aa9726
--- /dev/null
+++ b/editors/browser/README.md
@@ -0,0 +1,57 @@
+# FuckMark browser extension (Chromium)
+
+Reveal hidden Unicode on the page you are reading. The scanner is the same
+`fuckmark-hidden-scan-v1` engine as the CLI and the VS Code extension
+(`scan.js` is a byte-for-byte copy). Nothing is uploaded.
+
+It flags:
+
+- **Trojan Source** bidirectional overrides and isolates (CVE-2021-42574)
+- zero-width and invisible spacing characters
+- Unicode **tag** characters (hidden text / prompt-injection smuggling)
+- C0/C1 controls, noncharacters, and lone surrogates
+
+Emoji ZWJ sequences stay intact when **both** neighbors are emoji (or a
+variation selector). A ZWJ next to ordinary letters is stripped.
+
+## Load it unpacked
+
+Chrome, Edge, Brave, or Arc:
+
+1. Open `chrome://extensions` (or `edge://extensions`).
+2. Enable **Developer mode**.
+3. **Load unpacked** and choose this folder (`editors/browser`).
+
+The toolbar button opens the popup. Right-click a page for scan / reveal /
+scan-selection.
+
+## What you get
+
+- **Popup:** paste text, copy security-clean text, or fix Trojan Source (bidi
+ only). **Scan this page** counts hidden characters in visible text nodes.
+ **Reveal** paints `U+XXXX` badges and a highlight on those characters.
+- **Paste-safe:** when enabled, a paste into an input or contenteditable field
+ is rewritten to drop hidden Unicode before it lands. Off by default.
+- **Context menu:** scan this page, reveal, hide, or send the selection to the
+ popup.
+- **Badge:** after a scan or reveal, the toolbar icon shows the finding count.
+
+`chrome://` pages, the Web Store, and other extension pages cannot be scanned.
+The content script runs on `http://` and `https://` only.
+
+## Permissions
+
+- `storage` — paste-safe preference
+- `contextMenus` — right-click actions
+- `activeTab` — talk to the page you invoked the action on
+
+The content script is injected on ordinary web pages so reveal and paste-safe
+can run locally. Text never leaves the machine.
+
+## Related
+
+- CLI and CI: `fuckmark --scan`, `fuckmark lint`, the GitHub Action
+- Local page: `fuckmark web` then `/scan.html`
+- Editor: `editors/vscode`
+
+MIT. Part of [FuckMark](https://github.com/byte271/FuckMark).
diff --git a/editors/browser/background.js b/editors/browser/background.js
new file mode 100644
index 00000000..80bce7d5
--- /dev/null
+++ b/editors/browser/background.js
@@ -0,0 +1,74 @@
+"use strict";
+
+const MENU_SCAN = "fuckmark-scan-page";
+const MENU_REVEAL = "fuckmark-reveal-page";
+const MENU_HIDE = "fuckmark-hide-page";
+const MENU_SCAN_SELECTION = "fuckmark-scan-selection";
+
+function setBadge(total, tabId) {
+ const text = total > 0 ? String(Math.min(total, 99)) : "";
+ const color = total > 0 ? "#c62828" : "#666666";
+ const target = tabId == null ? {} : { tabId: tabId };
+ chrome.action.setBadgeBackgroundColor({ color: color, ...target });
+ chrome.action.setBadgeText({ text: text, ...target });
+}
+
+function sendToTab(tabId, message) {
+ return chrome.tabs.sendMessage(tabId, message).catch(() => null);
+}
+
+chrome.runtime.onInstalled.addListener(() => {
+ chrome.contextMenus.removeAll(() => {
+ chrome.contextMenus.create({
+ id: MENU_SCAN,
+ title: "FuckMark: scan this page",
+ contexts: ["page"],
+ });
+ chrome.contextMenus.create({
+ id: MENU_REVEAL,
+ title: "FuckMark: reveal hidden Unicode",
+ contexts: ["page"],
+ });
+ chrome.contextMenus.create({
+ id: MENU_HIDE,
+ title: "FuckMark: hide reveal",
+ contexts: ["page"],
+ });
+ chrome.contextMenus.create({
+ id: MENU_SCAN_SELECTION,
+ title: "FuckMark: scan selection",
+ contexts: ["selection"],
+ });
+ });
+});
+
+chrome.contextMenus.onClicked.addListener(async (info, tab) => {
+ if (!tab || tab.id == null) return;
+ if (info.menuItemId === MENU_SCAN) {
+ const result = await sendToTab(tab.id, { type: "scan" });
+ if (result && result.ok) setBadge(result.total, tab.id);
+ return;
+ }
+ if (info.menuItemId === MENU_REVEAL) {
+ const result = await sendToTab(tab.id, { type: "reveal" });
+ if (result && result.ok) setBadge(result.total, tab.id);
+ return;
+ }
+ if (info.menuItemId === MENU_HIDE) {
+ await sendToTab(tab.id, { type: "hide" });
+ return;
+ }
+ if (info.menuItemId === MENU_SCAN_SELECTION) {
+ await chrome.storage.session.set({ pendingSelection: String(info.selectionText || "") });
+ chrome.action.openPopup().catch(() => {});
+ }
+});
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (!message || typeof message.type !== "string") return;
+ if (message.type === "badge") {
+ const tabId = sender.tab && sender.tab.id;
+ setBadge(Number(message.total) || 0, tabId);
+ sendResponse({ ok: true });
+ }
+});
diff --git a/editors/browser/content.js b/editors/browser/content.js
new file mode 100644
index 00000000..c881bdba
--- /dev/null
+++ b/editors/browser/content.js
@@ -0,0 +1,284 @@
+"use strict";
+
+const HIGHLIGHT_NAME = "fuckmark-hidden";
+const HOST_ID = "fuckmark-overlay-host";
+const MAX_BADGES = 256;
+
+let pasteSafeEnabled = false;
+let revealOn = false;
+let overlay = null;
+let highlightSet = null;
+
+function pageApi() {
+ return globalThis.FuckMarkPage;
+}
+
+function scanApi() {
+ return pageApi().getScan();
+}
+
+function loadSettings() {
+ if (typeof chrome === "undefined" || !chrome.storage) return;
+ chrome.storage.sync.get({ pasteSafe: false }, (items) => {
+ pasteSafeEnabled = Boolean(items && items.pasteSafe);
+ });
+}
+
+function skipNode(node) {
+ if (!node) return true;
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ if (node.id === HOST_ID) return true;
+ if (pageApi().isSkipTag(node.tagName)) return true;
+ }
+ const parent = node.parentElement;
+ if (parent && pageApi().isSkipTag(parent.tagName)) return true;
+ return false;
+}
+
+function walkTextNodes(root, visit) {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+ acceptNode(node) {
+ if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
+ if (skipNode(node) || skipNode(node.parentElement)) return NodeFilter.FILTER_REJECT;
+ return NodeFilter.FILTER_ACCEPT;
+ },
+ });
+ let node = walker.nextNode();
+ while (node) {
+ visit(node);
+ node = walker.nextNode();
+ }
+}
+
+function scanDocument() {
+ let total = 0;
+ let highest = "";
+ const findings = [];
+ const rank = { info: 1, medium: 2, high: 3, critical: 4 };
+ walkTextNodes(document.body || document.documentElement, (node) => {
+ const result = scanApi().scanText(node.nodeValue, null, "auto");
+ total += result.total;
+ for (const finding of result.findings) {
+ if ((rank[finding.severity] || 0) > (rank[highest] || 0)) highest = finding.severity;
+ if (findings.length < MAX_BADGES) {
+ findings.push({ node, finding });
+ }
+ }
+ });
+ return { total, highest, findings };
+}
+
+function ensureOverlay() {
+ if (overlay) return overlay;
+ const host = document.createElement("div");
+ host.id = HOST_ID;
+ host.setAttribute("aria-hidden", "true");
+ host.style.position = "absolute";
+ host.style.left = "0";
+ host.style.top = "0";
+ host.style.width = "0";
+ host.style.height = "0";
+ host.style.zIndex = "2147483646";
+ host.style.pointerEvents = "none";
+ const shadow = host.attachShadow({ mode: "closed" });
+ const style = document.createElement("style");
+ style.textContent =
+ ".badge{position:absolute;transform:translateY(-110%);font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;padding:2px 4px;border-radius:4px;white-space:nowrap;background:#1a1a1a;color:#ff5555;box-shadow:0 2px 8px rgba(0,0,0,.4);opacity:.95}" +
+ ".badge.high{color:#ff9944}" +
+ ".badge.medium{color:#c8a800}" +
+ ".badge.info{color:#c8c8c4}";
+ const layer = document.createElement("div");
+ shadow.append(style, layer);
+ (document.documentElement || document.body).appendChild(host);
+ overlay = { host, layer };
+ return overlay;
+}
+
+function clearReveal() {
+ revealOn = false;
+ if (highlightSet && typeof CSS !== "undefined" && CSS.highlights) {
+ CSS.highlights.delete(HIGHLIGHT_NAME);
+ }
+ highlightSet = null;
+ if (overlay && overlay.layer) overlay.layer.replaceChildren();
+}
+
+function fallbackRect(node, offset) {
+ const range = document.createRange();
+ const index = Math.max(0, offset - 1);
+ try {
+ range.setStart(node, index);
+ range.setEnd(node, Math.min(node.nodeValue.length, index + 1));
+ const rects = range.getClientRects();
+ if (rects.length) return rects[0];
+ } catch (_err) {
+ return null;
+ }
+ const parent = node.parentElement;
+ return parent ? parent.getBoundingClientRect() : null;
+}
+
+function paintReveal(entries) {
+ const layer = ensureOverlay().layer;
+ layer.replaceChildren();
+ const scrollX = window.scrollX;
+ const scrollY = window.scrollY;
+ const canHighlight = typeof Highlight === "function" && typeof CSS !== "undefined" && CSS.highlights;
+ const set = canHighlight ? new Highlight() : null;
+ let painted = 0;
+ for (const { node, finding } of entries) {
+ if (painted >= MAX_BADGES) break;
+ const start = finding.offset;
+ const end = Math.min(node.nodeValue.length, start + (finding.length || 1));
+ const range = document.createRange();
+ try {
+ range.setStart(node, start);
+ range.setEnd(node, end);
+ } catch (_err) {
+ continue;
+ }
+ if (set) set.add(range);
+ const rects = range.getClientRects();
+ const rect = rects.length ? rects[0] : fallbackRect(node, start);
+ if (!rect) continue;
+ const badge = document.createElement("span");
+ const severity = finding.severity || "medium";
+ badge.className = "badge" + (severity === "critical" ? "" : " " + severity);
+ badge.textContent = scanApi().codepointToken(finding.codepoint);
+ badge.style.left = Math.round(rect.left + scrollX) + "px";
+ badge.style.top = Math.round(rect.top + scrollY) + "px";
+ layer.appendChild(badge);
+ painted += 1;
+ }
+ if (set) {
+ CSS.highlights.set(HIGHLIGHT_NAME, set);
+ highlightSet = set;
+ if (!document.getElementById("fuckmark-highlight-style")) {
+ const style = document.createElement("style");
+ style.id = "fuckmark-highlight-style";
+ style.textContent = "::highlight(" + HIGHLIGHT_NAME + "){background-color:rgba(255,85,85,.38);}";
+ document.documentElement.appendChild(style);
+ }
+ }
+}
+
+function revealPage() {
+ const scanned = scanDocument();
+ revealOn = true;
+ paintReveal(scanned.findings);
+ reportBadge(scanned.total);
+ return scanned;
+}
+
+function hidePage() {
+ clearReveal();
+}
+
+function reportBadge(total) {
+ if (typeof chrome === "undefined" || !chrome.runtime) return;
+ try {
+ chrome.runtime.sendMessage({ type: "badge", total: total });
+ } catch (_err) {
+ return;
+ }
+}
+
+function insertCleaned(target, cleaned) {
+ if (!target) return false;
+ if ((target.tagName === "TEXTAREA" || target.tagName === "INPUT") && target.type !== "password") {
+ const start = target.selectionStart;
+ const end = target.selectionEnd;
+ if (typeof start === "number" && typeof target.setRangeText === "function") {
+ target.setRangeText(cleaned, start, end, "end");
+ target.dispatchEvent(new Event("input", { bubbles: true }));
+ return true;
+ }
+ }
+ if (target.isContentEditable && document.execCommand) {
+ return document.execCommand("insertText", false, cleaned);
+ }
+ return false;
+}
+
+document.addEventListener(
+ "paste",
+ (event) => {
+ if (!pasteSafeEnabled) return;
+ const target = event.target;
+ if (target && target.type === "password") return;
+ const data = event.clipboardData;
+ if (!data) return;
+ const text = data.getData("text/plain");
+ if (!text) return;
+ const result = pageApi().cleanForPaste(text);
+ if (result.removed === 0) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ insertCleaned(event.target, result.cleaned);
+ },
+ true
+);
+
+if (typeof chrome !== "undefined" && chrome.storage) {
+ chrome.storage.onChanged.addListener((changes, area) => {
+ if (area !== "sync" || !changes.pasteSafe) return;
+ pasteSafeEnabled = Boolean(changes.pasteSafe.newValue);
+ });
+}
+
+if (typeof chrome !== "undefined" && chrome.runtime) {
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ if (!message || typeof message.type !== "string") return;
+ if (message.type === "ping") {
+ sendResponse({ ok: true });
+ return;
+ }
+ if (message.type === "setPasteSafe") {
+ pasteSafeEnabled = Boolean(message.enabled);
+ sendResponse({ ok: true, pasteSafe: pasteSafeEnabled });
+ return;
+ }
+ if (message.type === "scan") {
+ const scanned = scanDocument();
+ reportBadge(scanned.total);
+ sendResponse({
+ ok: true,
+ total: scanned.total,
+ highest: scanned.highest,
+ shown: scanned.findings.length,
+ });
+ return;
+ }
+ if (message.type === "reveal") {
+ const scanned = revealPage();
+ sendResponse({
+ ok: true,
+ total: scanned.total,
+ highest: scanned.highest,
+ shown: scanned.findings.length,
+ });
+ return;
+ }
+ if (message.type === "hide") {
+ hidePage();
+ sendResponse({ ok: true });
+ return;
+ }
+ if (message.type === "cleanSelection") {
+ const text = String(message.text || window.getSelection());
+ const result = pageApi().cleanForPaste(text);
+ sendResponse({ ok: true, cleaned: result.cleaned, removed: result.removed });
+ return;
+ }
+ });
+}
+
+loadSettings();
+window.addEventListener("scroll", () => {
+ if (!revealOn) return;
+ paintReveal(scanDocument().findings);
+}, { passive: true });
+window.addEventListener("resize", () => {
+ if (!revealOn) return;
+ paintReveal(scanDocument().findings);
+});
diff --git a/editors/browser/manifest.json b/editors/browser/manifest.json
new file mode 100644
index 00000000..6178543a
--- /dev/null
+++ b/editors/browser/manifest.json
@@ -0,0 +1,24 @@
+{
+ "manifest_version": 3,
+ "name": "FuckMark Hidden Unicode",
+ "short_name": "FuckMark",
+ "version": "0.4.1",
+ "description": "Reveal and strip hidden Unicode on the page you are reading: Trojan Source bidi overrides, zero-width characters, and tag smuggling. Optional paste-safe mode. Nothing is uploaded.",
+ "homepage_url": "https://github.com/byte271/FuckMark",
+ "permissions": ["contextMenus", "storage", "activeTab"],
+ "action": {
+ "default_title": "FuckMark: hidden Unicode",
+ "default_popup": "popup.html"
+ },
+ "background": {
+ "service_worker": "background.js"
+ },
+ "content_scripts": [
+ {
+ "matches": ["http://*/*", "https://*/*"],
+ "js": ["scan.js", "page.js", "content.js"],
+ "run_at": "document_idle",
+ "all_frames": false
+ }
+ ]
+}
diff --git a/editors/browser/page.js b/editors/browser/page.js
new file mode 100644
index 00000000..6790f08b
--- /dev/null
+++ b/editors/browser/page.js
@@ -0,0 +1,123 @@
+"use strict";
+
+function getScan() {
+ if (typeof globalThis !== "undefined" && globalThis.FuckMarkScan) {
+ return globalThis.FuckMarkScan;
+ }
+ if (typeof module !== "undefined" && module.exports && typeof require === "function") {
+ return require("./scan.js");
+ }
+ throw new Error("FuckMark scan.js is not loaded");
+}
+
+const SKIP_TAGS = {
+ SCRIPT: true,
+ STYLE: true,
+ NOSCRIPT: true,
+ IFRAME: true,
+ OBJECT: true,
+ EMBED: true,
+ TEXTAREA: true,
+};
+
+function isSkipTag(name) {
+ return Boolean(SKIP_TAGS[String(name || "").toUpperCase()]);
+}
+
+const ZWJ = 0x200d;
+
+function codePointBefore(text, offset) {
+ if (offset <= 0) return -1;
+ if (offset >= 2) {
+ const cp = text.codePointAt(offset - 2);
+ if (cp > 0xffff) return cp;
+ }
+ return text.codePointAt(offset - 1);
+}
+
+function codePointAfter(text, offset, length) {
+ const next = offset + length;
+ if (next >= text.length) return -1;
+ return text.codePointAt(next);
+}
+
+function isEmojiClusterNeighbor(cp) {
+ if (cp < 0 || cp === ZWJ) return false;
+ if (cp >= 0xfe00 && cp <= 0xfe0f) return true;
+ if (cp >= 0xe0100 && cp <= 0xe01ef) return true;
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff) return true;
+ if (cp >= 0x1f000 && cp <= 0x1faff) return true;
+ if (cp >= 0x2600 && cp <= 0x27bf) return true;
+ return [0x00a9, 0x00ae, 0x203c, 0x2049, 0x2122, 0x2139, 0x3030, 0x303d, 0x3297, 0x3299].includes(cp);
+}
+
+function isKeptEmojiJoiner(text, finding) {
+ if (!finding || finding.category !== "zero_width" || finding.codepoint !== ZWJ) return false;
+ const length = finding.length || 1;
+ const prev = codePointBefore(text, finding.offset);
+ const next = codePointAfter(text, finding.offset, length);
+ return isEmojiClusterNeighbor(prev) && isEmojiClusterNeighbor(next);
+}
+
+function shouldStripFinding(finding, text) {
+ const scan = getScan();
+ const security = new Set(scan.DEFAULT_SECURITY_CATEGORIES);
+ if (!finding || !security.has(finding.category)) return false;
+ if (isKeptEmojiJoiner(text, finding)) return false;
+ if (finding.category === "variation_selector" && finding.severity === "info") return false;
+ return true;
+}
+
+function cleanForPaste(text) {
+ const scan = getScan();
+ const source = String(text || "");
+ const result = scan.scanText(source, null, "auto");
+ const spans = [];
+ for (const finding of result.findings) {
+ if (!shouldStripFinding(finding, source)) continue;
+ spans.push({ start: finding.offset, end: finding.offset + finding.length });
+ }
+ spans.sort((a, b) => a.start - b.start);
+ let out = "";
+ let cursor = 0;
+ let removed = 0;
+ for (const span of spans) {
+ if (span.start < cursor) continue;
+ out += source.slice(cursor, span.start);
+ cursor = span.end;
+ removed += 1;
+ }
+ out += source.slice(cursor);
+ return { cleaned: out, removed, total: result.total, findings: result.findings };
+}
+
+function scanString(text, language) {
+ return getScan().scanText(String(text || ""), null, language || "auto");
+}
+
+function highestSeverity(findings) {
+ const rank = { info: 1, medium: 2, high: 3, critical: 4 };
+ let highest = "";
+ for (const finding of findings || []) {
+ if ((rank[finding.severity] || 0) > (rank[highest] || 0)) highest = finding.severity;
+ }
+ return highest;
+}
+
+const FuckMarkPage = {
+ SKIP_TAGS,
+ isSkipTag,
+ shouldStripFinding,
+ isKeptEmojiJoiner,
+ cleanForPaste,
+ scanString,
+ highestSeverity,
+ getScan,
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = FuckMarkPage;
+}
+if (typeof globalThis !== "undefined") {
+ globalThis.FuckMarkPage = FuckMarkPage;
+}
diff --git a/editors/browser/popup.html b/editors/browser/popup.html
new file mode 100644
index 00000000..ed2af84e
--- /dev/null
+++ b/editors/browser/popup.html
@@ -0,0 +1,122 @@
+
+
+
+
+
+FuckMark
+
+
+
+
+ See the bytes.
+ Scan this page or paste text. Nothing is uploaded.
+
+ Scan this page
+ Reveal
+ Hide
+
+
+ Paste-safe
+
+
+
+
+ Copy security-clean text
+ Fix Trojan Source
+
+ Paste text or scan the page.
+
+ Paste-safe strips hidden Unicode on paste, and keeps ZWJ only inside real emoji sequences. Same engine as fuckmark-hidden-scan-v1.
+
+
+
+
+
+
diff --git a/editors/browser/popup.js b/editors/browser/popup.js
new file mode 100644
index 00000000..28c27ebc
--- /dev/null
+++ b/editors/browser/popup.js
@@ -0,0 +1,173 @@
+"use strict";
+
+const input = document.getElementById("input");
+const summary = document.getElementById("summary");
+const findingsEl = document.getElementById("findings");
+const pasteSafe = document.getElementById("pasteSafe");
+const scanPage = document.getElementById("scanPage");
+const revealPage = document.getElementById("revealPage");
+const hidePage = document.getElementById("hidePage");
+const copyClean = document.getElementById("copyClean");
+const fixTrojan = document.getElementById("fixTrojan");
+const examplesEl = document.getElementById("examples");
+
+const RLO = String.fromCodePoint(0x202E);
+const PDF = String.fromCodePoint(0x202C);
+const EXAMPLES = [
+ { label: "Trojan Source", text: "if (accessLevel != " + RLO + "admin" + PDF + ") {" },
+ { label: "Commenting-out", text: "// " + RLO + " return;" },
+ { label: "Tag smuggling", text: "Approve this request." + String.fromCodePoint(0xE0061, 0xE0062) },
+];
+
+function hasChromeTabs() {
+ return typeof chrome !== "undefined" && chrome.tabs && chrome.tabs.query;
+}
+
+function pageApi() {
+ return globalThis.FuckMarkPage;
+}
+
+function scanApi() {
+ return pageApi().getScan();
+}
+
+async function sendToActive(payload) {
+ if (!hasChromeTabs()) return { ok: false, reason: "no-chrome" };
+ const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
+ const tab = tabs && tabs[0];
+ if (!tab || tab.id == null) return { ok: false, reason: "no-tab" };
+ try {
+ return await chrome.tabs.sendMessage(tab.id, payload);
+ } catch (_err) {
+ return { ok: false, reason: "no-content-script" };
+ }
+}
+
+function renderLocal() {
+ const text = input.value;
+ const result = scanApi().scanText(text, null, "auto");
+ const highest = pageApi().highestSeverity(result.findings);
+ if (!text) {
+ summary.textContent = "Paste text or scan the page.";
+ findingsEl.replaceChildren();
+ copyClean.disabled = true;
+ fixTrojan.disabled = true;
+ return;
+ }
+ copyClean.disabled = false;
+ fixTrojan.disabled = !result.findings.some((item) => item.category === "bidi_control");
+ if (result.total === 0) {
+ summary.innerHTML = "No hidden characters in " + text.length + " code units.";
+ } else {
+ const sev = highest ? " Highest severity: " + highest + " ." : "";
+ summary.innerHTML = "Found " + result.total + " hidden character" + (result.total === 1 ? "" : "s") + "." + sev;
+ }
+ findingsEl.replaceChildren(...result.findings.slice(0, 12).map(findingCard));
+}
+
+function findingCard(finding) {
+ const item = document.createElement("li");
+ const header = document.createElement("header");
+ const code = document.createElement("span");
+ code.textContent = scanApi().codepointToken(finding.codepoint);
+ const sev = document.createElement("span");
+ sev.className = "hit sev-" + (finding.severity || "medium");
+ sev.textContent = finding.severity || "medium";
+ const meta = document.createElement("span");
+ meta.textContent = finding.category + " / " + finding.context;
+ header.append(code, sev, meta);
+ const why = document.createElement("p");
+ why.textContent = finding.why || "";
+ item.append(header, why);
+ return item;
+}
+
+async function copyText(value) {
+ try {
+ await navigator.clipboard.writeText(value);
+ return true;
+ } catch (_err) {
+ return false;
+ }
+}
+
+function pageStatus(result, verb) {
+ if (!result || !result.ok) {
+ summary.textContent =
+ result && result.reason === "no-content-script"
+ ? "This page cannot be scanned (browser UI or missing content script)."
+ : "Could not " + verb + " this page.";
+ return;
+ }
+ if (!result.total) {
+ summary.innerHTML = "No hidden characters on this page.";
+ return;
+ }
+ const sev = result.highest ? " Highest severity: " + result.highest + " ." : "";
+ summary.innerHTML = "Page has " + result.total + " hidden character" + (result.total === 1 ? "" : "s") + "." + sev;
+}
+
+input.addEventListener("input", renderLocal);
+EXAMPLES.forEach((example) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = example.label;
+ button.addEventListener("click", () => {
+ input.value = example.text;
+ renderLocal();
+ });
+ examplesEl.append(button);
+});
+copyClean.addEventListener("click", async () => {
+ const cleaned = pageApi().cleanForPaste(input.value).cleaned;
+ const ok = await copyText(cleaned);
+ summary.textContent = ok ? "Copied security-clean text (emoji sequences kept)." : "Could not copy.";
+});
+fixTrojan.addEventListener("click", async () => {
+ const fixed = scanApi().autofixTrojanSource(input.value);
+ input.value = fixed.cleaned;
+ renderLocal();
+ const ok = await copyText(fixed.cleaned);
+ summary.textContent = ok
+ ? "Stripped " + fixed.removed + " bidi control" + (fixed.removed === 1 ? "" : "s") + " and copied the result."
+ : "Stripped " + fixed.removed + " bidi control" + (fixed.removed === 1 ? "" : "s") + ".";
+});
+scanPage.addEventListener("click", async () => {
+ pageStatus(await sendToActive({ type: "scan" }), "scan");
+});
+revealPage.addEventListener("click", async () => {
+ pageStatus(await sendToActive({ type: "reveal" }), "reveal");
+});
+hidePage.addEventListener("click", async () => {
+ const result = await sendToActive({ type: "hide" });
+ summary.textContent = result && result.ok ? "Reveal hidden." : "Could not hide reveal.";
+});
+pasteSafe.addEventListener("change", async () => {
+ const enabled = pasteSafe.checked;
+ if (typeof chrome !== "undefined" && chrome.storage) {
+ await chrome.storage.sync.set({ pasteSafe: enabled });
+ }
+ await sendToActive({ type: "setPasteSafe", enabled: enabled });
+});
+
+(async function boot() {
+ if (!hasChromeTabs()) {
+ scanPage.disabled = true;
+ revealPage.disabled = true;
+ hidePage.disabled = true;
+ } else {
+ scanPage.classList.add("primary");
+ }
+ if (typeof chrome !== "undefined" && chrome.storage) {
+ const stored = await chrome.storage.sync.get({ pasteSafe: false });
+ pasteSafe.checked = Boolean(stored.pasteSafe);
+ const session = chrome.storage.session
+ ? await chrome.storage.session.get({ pendingSelection: "" })
+ : { pendingSelection: "" };
+ if (session.pendingSelection) {
+ input.value = session.pendingSelection;
+ await chrome.storage.session.set({ pendingSelection: "" });
+ }
+ }
+ renderLocal();
+})();
diff --git a/editors/browser/scan.js b/editors/browser/scan.js
new file mode 100644
index 00000000..f28c001f
--- /dev/null
+++ b/editors/browser/scan.js
@@ -0,0 +1,569 @@
+// Faithful JavaScript port of fuckmark-hidden-scan-v1 (fuckmark/product/scan.py).
+// Kept in lockstep with the Python engine by tests/test_vscode_scanner_parity.py.
+
+"use strict";
+
+const SCAN_ALGORITHM_VERSION = "fuckmark-hidden-scan-v1";
+
+const CATEGORY_BIDI_CONTROL = "bidi_control";
+const CATEGORY_ZERO_WIDTH = "zero_width";
+const CATEGORY_VARIATION_SELECTOR = "variation_selector";
+const CATEGORY_TAG = "tag";
+const CATEGORY_ENCLOSING_MARK = "enclosing_mark";
+const CATEGORY_LINE_SEPARATOR = "line_separator";
+const CATEGORY_DEPRECATED = "deprecated";
+const CATEGORY_FORMAT = "format";
+const CATEGORY_CONTROL = "control";
+const CATEGORY_PRIVATE_USE = "private_use";
+const CATEGORY_NONCHARACTER = "noncharacter";
+const CATEGORY_SURROGATE = "surrogate";
+
+const SCAN_CATEGORIES = [
+ CATEGORY_BIDI_CONTROL,
+ CATEGORY_ZERO_WIDTH,
+ CATEGORY_VARIATION_SELECTOR,
+ CATEGORY_TAG,
+ CATEGORY_ENCLOSING_MARK,
+ CATEGORY_LINE_SEPARATOR,
+ CATEGORY_DEPRECATED,
+ CATEGORY_FORMAT,
+ CATEGORY_CONTROL,
+ CATEGORY_PRIVATE_USE,
+ CATEGORY_NONCHARACTER,
+ CATEGORY_SURROGATE,
+];
+
+const CATEGORY_DESCRIPTIONS = {
+ [CATEGORY_BIDI_CONTROL]: "bidirectional override/isolate (Trojan Source reordering)",
+ [CATEGORY_ZERO_WIDTH]: "zero-width or invisible spacing character",
+ [CATEGORY_VARIATION_SELECTOR]: "variation selector (glyph/steganography carrier)",
+ [CATEGORY_TAG]: "Unicode tag character (hidden text / prompt-injection smuggling)",
+ [CATEGORY_ENCLOSING_MARK]: "enclosing combining mark (alters surrounding glyph)",
+ [CATEGORY_LINE_SEPARATOR]: "line/paragraph separator (breaks parsers, invisible)",
+ [CATEGORY_DEPRECATED]: "deprecated format control or interlinear annotation",
+ [CATEGORY_FORMAT]: "general Unicode format control (Cf)",
+ [CATEGORY_CONTROL]: "C0/C1 control character",
+ [CATEGORY_PRIVATE_USE]: "private-use codepoint (renderer-defined)",
+ [CATEGORY_NONCHARACTER]: "Unicode noncharacter or unassigned codepoint",
+ [CATEGORY_SURROGATE]: "lone surrogate codepoint",
+};
+
+const DEFAULT_SECURITY_CATEGORIES = [
+ CATEGORY_BIDI_CONTROL,
+ CATEGORY_ZERO_WIDTH,
+ CATEGORY_TAG,
+ CATEGORY_CONTROL,
+ CATEGORY_NONCHARACTER,
+ CATEGORY_SURROGATE,
+];
+
+const TROJAN_SOURCE_CATEGORIES = [CATEGORY_BIDI_CONTROL];
+
+const ALLOWED_WHITESPACE = new Set([0x09, 0x0a, 0x0d, 0x20]);
+
+function inRange(cp, lo, hi) {
+ return cp >= lo && cp <= hi;
+}
+
+const BIDI_CONTROL = new Set([0x061c, 0x200e, 0x200f]);
+for (let cp = 0x202a; cp <= 0x202e; cp += 1) BIDI_CONTROL.add(cp);
+for (let cp = 0x2066; cp <= 0x2069; cp += 1) BIDI_CONTROL.add(cp);
+
+const DEPRECATED = new Set([0xfff9, 0xfffa, 0xfffb]);
+for (let cp = 0x206a; cp <= 0x206f; cp += 1) DEPRECATED.add(cp);
+
+const ZERO_WIDTH = new Set([
+ 0x00ad, 0x034f, 0x115f, 0x1160, 0x17b4, 0x17b5, 0x180e, 0x200b, 0x200c,
+ 0x200d, 0x2060, 0x2061, 0x2062, 0x2063, 0x2064, 0x3164, 0xfeff, 0xffa0,
+]);
+
+function isVariationSelector(cp) {
+ return inRange(cp, 0xfe00, 0xfe0f) || inRange(cp, 0xe0100, 0xe01ef);
+}
+
+function isTag(cp) {
+ return inRange(cp, 0xe0000, 0xe007f);
+}
+
+function isNoncharacter(cp) {
+ if (inRange(cp, 0xfdd0, 0xfdef)) return true;
+ const low = cp & 0xffff;
+ return low === 0xfffe || low === 0xffff;
+}
+
+function isEnclosingMark(cp) {
+ // Unicode general category Me. Enumerated to avoid a runtime Unicode DB.
+ return (
+ inRange(cp, 0x0488, 0x0489) ||
+ cp === 0x1abe ||
+ inRange(cp, 0x20dd, 0x20e0) ||
+ inRange(cp, 0x20e2, 0x20e4) ||
+ inRange(cp, 0xa670, 0xa672)
+ );
+}
+
+function isControl(cp) {
+ return inRange(cp, 0x00, 0x1f) || inRange(cp, 0x7f, 0x9f);
+}
+
+function isLineSeparator(cp) {
+ return cp === 0x2028 || cp === 0x2029;
+}
+
+// General category Cf ranges (Unicode 15-era), excluding codepoints already
+// classified above. Mirrors what unicodedata.category returns as "Cf".
+const FORMAT_RANGES = [
+ [0x0600, 0x0605],
+ [0x06dd, 0x06dd],
+ [0x070f, 0x070f],
+ [0x0890, 0x0891],
+ [0x08e2, 0x08e2],
+ [0x110bd, 0x110bd],
+ [0x110cd, 0x110cd],
+ [0x13430, 0x1343f],
+ [0x1bca0, 0x1bca3],
+ [0x1d173, 0x1d17a],
+];
+
+function isFormat(cp) {
+ for (const [lo, hi] of FORMAT_RANGES) {
+ if (inRange(cp, lo, hi)) return true;
+ }
+ return false;
+}
+
+function isPrivateUse(cp) {
+ return (
+ inRange(cp, 0xe000, 0xf8ff) ||
+ inRange(cp, 0xf0000, 0xffffd) ||
+ inRange(cp, 0x100000, 0x10fffd)
+ );
+}
+
+function classify(cp) {
+ if (ALLOWED_WHITESPACE.has(cp)) return null;
+ if (BIDI_CONTROL.has(cp)) return CATEGORY_BIDI_CONTROL;
+ if (DEPRECATED.has(cp)) return CATEGORY_DEPRECATED;
+ if (ZERO_WIDTH.has(cp)) return CATEGORY_ZERO_WIDTH;
+ if (isVariationSelector(cp)) return CATEGORY_VARIATION_SELECTOR;
+ if (isTag(cp)) return CATEGORY_TAG;
+ if (isNoncharacter(cp)) return CATEGORY_NONCHARACTER;
+ if (cp >= 0xd800 && cp <= 0xdfff) return CATEGORY_SURROGATE;
+ if (isEnclosingMark(cp)) return CATEGORY_ENCLOSING_MARK;
+ if (isLineSeparator(cp)) return CATEGORY_LINE_SEPARATOR;
+ if (isControl(cp)) return CATEGORY_CONTROL;
+ if (isFormat(cp)) return CATEGORY_FORMAT;
+ if (isPrivateUse(cp)) return CATEGORY_PRIVATE_USE;
+ return null;
+}
+
+function codepointToken(cp) {
+ return "U+" + cp.toString(16).toUpperCase().padStart(4, "0");
+}
+
+function isEmojiish(cp) {
+ if (cp < 0) return false;
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff) return true;
+ if (cp >= 0x1f000 && cp <= 0x1faff) return true;
+ if (cp >= 0x2600 && cp <= 0x27bf) return true;
+ return cp === 0x200d || [0x00a9, 0x00ae, 0x203c, 0x2049, 0x2122, 0x2139, 0x3030, 0x303d, 0x3297, 0x3299].includes(cp);
+}
+
+function isIdentChar(ch) {
+ return ch === "_" || /\p{L}|\p{N}/u.test(ch);
+}
+
+function neighborBefore(text, offset) {
+ if (offset <= 0) return { cp: -1, ch: "" };
+ if (offset >= 2) {
+ const cp = text.codePointAt(offset - 2);
+ if (cp > 0xffff) return { cp, ch: String.fromCodePoint(cp) };
+ }
+ const cp = text.codePointAt(offset - 1);
+ return { cp, ch: String.fromCodePoint(cp) };
+}
+
+function neighborAfter(text, offset, length) {
+ const nextIndex = offset + length;
+ if (nextIndex >= text.length) return { cp: -1, ch: "" };
+ const cp = text.codePointAt(nextIndex);
+ return { cp, ch: String.fromCodePoint(cp) };
+}
+
+const LANGUAGE_ALIASES = {
+ auto: "auto",
+ javascript: "javascript",
+ js: "javascript",
+ ts: "javascript",
+ typescript: "javascript",
+ jsx: "javascript",
+ tsx: "javascript",
+ c: "c",
+ h: "c",
+ cc: "c",
+ cpp: "c",
+ cxx: "c",
+ java: "c",
+ go: "c",
+ rs: "c",
+ rust: "c",
+ cs: "c",
+ css: "c",
+ jsonc: "c",
+ python: "python",
+ py: "python",
+ pyi: "python",
+ hash: "python",
+ sh: "python",
+ bash: "python",
+ zsh: "python",
+ shell: "python",
+ shellscript: "python",
+ yaml: "python",
+ yml: "python",
+ rb: "python",
+ ruby: "python",
+ toml: "python",
+ html: "html",
+ htm: "html",
+ xml: "html",
+ sql: "sql",
+};
+
+function normalizeLanguage(language) {
+ if (!language) return "auto";
+ const key = String(language).trim().toLowerCase();
+ return LANGUAGE_ALIASES[key] || "auto";
+}
+
+function slashComments(language) {
+ return language === "auto" || language === "javascript" || language === "c" || language === "sql";
+}
+
+function blockComments(language) {
+ return slashComments(language);
+}
+
+function hashComments(language) {
+ return language === "python";
+}
+
+function sqlLineComments(language) {
+ return language === "sql";
+}
+
+function htmlComments(language) {
+ return language === "html";
+}
+
+function sourceRoles(text, language) {
+ const lang = normalizeLanguage(language);
+ const roles = new Array(text.length).fill("code");
+ let index = 0;
+ let inString = "";
+ let escape = false;
+ while (index < text.length) {
+ const ch = text[index];
+ if (inString) {
+ roles[index] = "string";
+ if (escape) {
+ escape = false;
+ index += 1;
+ continue;
+ }
+ if (ch === "\\" && index + 1 < text.length) {
+ escape = true;
+ index += 1;
+ continue;
+ }
+ if (ch === inString) inString = "";
+ index += 1;
+ continue;
+ }
+ const pair = text.slice(index, index + 2);
+ if (slashComments(lang) && pair === "//" && !(index > 0 && text[index - 1] === ":")) {
+ let cursor = index;
+ while (cursor < text.length && text[cursor] !== "\n" && text[cursor] !== "\r") {
+ roles[cursor] = "comment";
+ cursor += 1;
+ }
+ index = cursor;
+ continue;
+ }
+ if (sqlLineComments(lang) && pair === "--") {
+ let cursor = index;
+ while (cursor < text.length && text[cursor] !== "\n" && text[cursor] !== "\r") {
+ roles[cursor] = "comment";
+ cursor += 1;
+ }
+ index = cursor;
+ continue;
+ }
+ if (blockComments(lang) && pair === "/*") {
+ const close = text.indexOf("*/", index + 2);
+ const stop = close >= 0 ? close + 2 : text.length;
+ for (let cursor = index; cursor < stop; cursor += 1) roles[cursor] = "comment";
+ index = stop;
+ continue;
+ }
+ if (hashComments(lang) && ch === "#") {
+ let cursor = index;
+ while (cursor < text.length && text[cursor] !== "\n" && text[cursor] !== "\r") {
+ roles[cursor] = "comment";
+ cursor += 1;
+ }
+ index = cursor;
+ continue;
+ }
+ if (htmlComments(lang) && text.slice(index, index + 4) === "", index + 4);
+ const stop = close >= 0 ? close + 3 : text.length;
+ for (let cursor = index; cursor < stop; cursor += 1) roles[cursor] = "comment";
+ index = stop;
+ continue;
+ }
+ if (ch === '"' || ch === "'" || ch === "`") {
+ inString = ch;
+ roles[index] = "string";
+ index += 1;
+ continue;
+ }
+ index += 1;
+ }
+ return roles;
+}
+
+function classifyContext(text, offset, role, category) {
+ const length = text.codePointAt(offset) > 0xffff ? 2 : 1;
+ const prev = neighborBefore(text, offset);
+ const next = neighborAfter(text, offset, length);
+ if ((role === "comment" || role === "string") && category === CATEGORY_BIDI_CONTROL) {
+ return role;
+ }
+ if (
+ isEmojiish(prev.cp) ||
+ isEmojiish(next.cp) ||
+ (prev.cp >= 0xfe00 && prev.cp <= 0xfe0f) ||
+ (next.cp >= 0xfe00 && next.cp <= 0xfe0f)
+ ) {
+ return "emoji";
+ }
+ if (role === "comment") return "comment";
+ if (role === "string") return "string";
+ if (isIdentChar(prev.ch) || isIdentChar(next.ch)) return "identifier";
+ return "prose";
+}
+
+function scoreSeverity(category, context) {
+ if (category === CATEGORY_TAG) return "critical";
+ if (category === CATEGORY_BIDI_CONTROL) {
+ if (context === "identifier" || context === "comment" || context === "string") return "critical";
+ return "high";
+ }
+ if (category === CATEGORY_ZERO_WIDTH) {
+ if (context === "emoji") return "info";
+ if (context === "identifier") return "high";
+ return "medium";
+ }
+ if (category === CATEGORY_VARIATION_SELECTOR) return context === "emoji" ? "info" : "medium";
+ if (category === CATEGORY_CONTROL || category === CATEGORY_NONCHARACTER || category === CATEGORY_SURROGATE) {
+ return "high";
+ }
+ return "medium";
+}
+
+function explainFinding(category, context, severity) {
+ if (category === CATEGORY_BIDI_CONTROL && context === "identifier") {
+ return {
+ why: "Bidirectional override sits inside an identifier, so the glyphs can read differently than the bytes (Trojan Source).",
+ remedy: "Strip the bidi control and keep the identifier left-to-right.",
+ };
+ }
+ if (category === CATEGORY_BIDI_CONTROL && context === "comment") {
+ return {
+ why: "Bidirectional override sits inside a comment, so commented-out code can appear to run (Trojan Source commenting-out).",
+ remedy: "Strip the bidi control from the comment.",
+ };
+ }
+ if (category === CATEGORY_BIDI_CONTROL && context === "string") {
+ return {
+ why: "Bidirectional override sits inside a string, so the literal can appear to close early (Trojan Source stretched-string).",
+ remedy: "Strip the bidi control from the string.",
+ };
+ }
+ if (category === CATEGORY_BIDI_CONTROL) {
+ return {
+ why: "Bidirectional override can reorder nearby glyphs (Trojan Source class, CVE-2021-42574).",
+ remedy: "Strip U+202A-U+202E / U+2066-U+2069 and rewrite the text left-to-right.",
+ };
+ }
+ if (category === CATEGORY_TAG) {
+ return {
+ why: "Unicode tag characters encode a second ASCII string that models read and humans do not.",
+ remedy: "Strip U+E0020-U+E007F; inspect tag_payload for the smuggled text.",
+ };
+ }
+ if (category === CATEGORY_ZERO_WIDTH && context === "emoji") {
+ return {
+ why: "Zero-width joiner or invisible mark inside an emoji cluster; usually a legitimate emoji sequence.",
+ remedy: "Leave emoji ZWJ sequences unless you are sanitizing for a security boundary.",
+ };
+ }
+ if (category === CATEGORY_ZERO_WIDTH && context === "identifier") {
+ return {
+ why: "Zero-width character splits an identifier, breaking search and some compilers while looking unchanged.",
+ remedy: "Strip the zero-width character from the identifier.",
+ };
+ }
+ if (category === CATEGORY_ZERO_WIDTH) {
+ return {
+ why: "Invisible spacing or joining character that changes the byte stream without changing the glyphs.",
+ remedy: "Strip the zero-width character.",
+ };
+ }
+ if (category === CATEGORY_VARIATION_SELECTOR && context === "emoji") {
+ return {
+ why: "Variation selector tunes an emoji glyph; usually benign.",
+ remedy: "Keep emoji variation selectors unless you are stripping all hidden marks.",
+ };
+ }
+ if (severity === "high") {
+ return {
+ why: "Hidden or non-text codepoint that should not appear in ordinary source or prompts.",
+ remedy: "Strip the character.",
+ };
+ }
+ return {
+ why: CATEGORY_DESCRIPTIONS[category] || "Hidden or format codepoint that is invisible or renderer-defined.",
+ remedy: "Strip the character if this text crosses a trust boundary.",
+ };
+}
+
+function scanText(text, categories, language) {
+ const selected = categories ? new Set(categories) : null;
+ const roles = sourceRoles(text, language);
+ const counts = {};
+ const findings = [];
+ let total = 0;
+ let offset = 0;
+ while (offset < text.length) {
+ const cp = text.codePointAt(offset);
+ const length = cp > 0xffff ? 2 : 1;
+ const category = classify(cp);
+ if (category !== null && (selected === null || selected.has(category))) {
+ total += 1;
+ counts[category] = (counts[category] || 0) + 1;
+ const context = classifyContext(text, offset, roles[offset] || "code", category);
+ const severity = scoreSeverity(category, context);
+ const explained = explainFinding(category, context, severity);
+ findings.push({
+ offset,
+ length,
+ codepoint: cp,
+ category,
+ context,
+ severity,
+ why: explained.why,
+ remedy: explained.remedy,
+ });
+ }
+ offset += length;
+ }
+ return { total, counts, findings };
+}
+
+function cleanText(text, categories) {
+ const selected = categories ? new Set(categories) : null;
+ let out = "";
+ let removed = 0;
+ let offset = 0;
+ while (offset < text.length) {
+ const cp = text.codePointAt(offset);
+ const length = cp > 0xffff ? 2 : 1;
+ const category = classify(cp);
+ if (category !== null && (selected === null || selected.has(category))) {
+ removed += 1;
+ } else {
+ out += text.slice(offset, offset + length);
+ }
+ offset += length;
+ }
+ return { cleaned: out, removed };
+}
+
+function autofixTrojanSource(text) {
+ return cleanText(text, TROJAN_SOURCE_CATEGORIES);
+}
+
+function languageFromDocumentId(languageId) {
+ return normalizeLanguage(languageId);
+}
+
+const LANGUAGE_BY_SUFFIX = {
+ ".js": "javascript",
+ ".mjs": "javascript",
+ ".cjs": "javascript",
+ ".ts": "javascript",
+ ".jsx": "javascript",
+ ".tsx": "javascript",
+ ".c": "c",
+ ".h": "c",
+ ".hh": "c",
+ ".cc": "c",
+ ".cpp": "c",
+ ".cxx": "c",
+ ".java": "c",
+ ".go": "c",
+ ".rs": "c",
+ ".cs": "c",
+ ".css": "c",
+ ".py": "python",
+ ".pyi": "python",
+ ".sh": "python",
+ ".bash": "python",
+ ".zsh": "python",
+ ".yaml": "python",
+ ".yml": "python",
+ ".rb": "python",
+ ".toml": "python",
+ ".html": "html",
+ ".htm": "html",
+ ".xml": "html",
+ ".sql": "sql",
+};
+
+function languageFromPath(path) {
+ const name = String(path || "");
+ const slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
+ const base = slash >= 0 ? name.slice(slash + 1) : name;
+ const dot = base.lastIndexOf(".");
+ if (dot <= 0) return "auto";
+ const suffix = base.slice(dot).toLowerCase();
+ return LANGUAGE_BY_SUFFIX[suffix] || "auto";
+}
+
+const FuckMarkScan = {
+ SCAN_ALGORITHM_VERSION,
+ SCAN_CATEGORIES,
+ CATEGORY_DESCRIPTIONS,
+ DEFAULT_SECURITY_CATEGORIES,
+ TROJAN_SOURCE_CATEGORIES,
+ classify,
+ classifyContext,
+ scoreSeverity,
+ normalizeLanguage,
+ languageFromDocumentId,
+ languageFromPath,
+ sourceRoles,
+ codepointToken,
+ scanText,
+ cleanText,
+ autofixTrojanSource,
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = FuckMarkScan;
+}
+if (typeof globalThis !== "undefined") {
+ globalThis.FuckMarkScan = FuckMarkScan;
+}
diff --git a/editors/vscode/README.md b/editors/vscode/README.md
index f59878dd..7ae9994f 100644
--- a/editors/vscode/README.md
+++ b/editors/vscode/README.md
@@ -59,6 +59,7 @@ Development Host. Packaging for the marketplace uses `vsce package`.
## Related
- CLI and CI: `fuckmark lint`, the GitHub Action, and the pre-commit hook.
+- Browser: Chromium extension in `editors/browser` (popup, page reveal, paste-safe).
- Library and web API: `fuckmark --scan` / `--clean`, `fuckmark normalize`,
`POST /api/scan`, `POST /api/guard`, and `POST /api/normalize`.
- Node guard: `guard.js` (`protect`, `extractTagPayload`) matches `fuckmark.protect`.
diff --git a/editors/vscode/scan.js b/editors/vscode/scan.js
index f2466c94..f28c001f 100644
--- a/editors/vscode/scan.js
+++ b/editors/vscode/scan.js
@@ -333,10 +333,13 @@ function sourceRoles(text, language) {
return roles;
}
-function classifyContext(text, offset, role) {
+function classifyContext(text, offset, role, category) {
const length = text.codePointAt(offset) > 0xffff ? 2 : 1;
const prev = neighborBefore(text, offset);
const next = neighborAfter(text, offset, length);
+ if ((role === "comment" || role === "string") && category === CATEGORY_BIDI_CONTROL) {
+ return role;
+ }
if (
isEmojiish(prev.cp) ||
isEmojiish(next.cp) ||
@@ -450,7 +453,7 @@ function scanText(text, categories, language) {
if (category !== null && (selected === null || selected.has(category))) {
total += 1;
counts[category] = (counts[category] || 0) + 1;
- const context = classifyContext(text, offset, roles[offset] || "code");
+ const context = classifyContext(text, offset, roles[offset] || "code", category);
const severity = scoreSeverity(category, context);
const explained = explainFinding(category, context, severity);
findings.push({
diff --git a/fuckmark/product/scan.py b/fuckmark/product/scan.py
index 84344ee3..a7462e45 100644
--- a/fuckmark/product/scan.py
+++ b/fuckmark/product/scan.py
@@ -214,6 +214,7 @@ class ScanResult:
findings: tuple[HiddenFinding, ...]
truncated: bool
fuckmark_carriers: int
+ highest_severity: str = ""
@property
def detected(self) -> bool:
@@ -230,12 +231,6 @@ def first_hit(self) -> str:
first = self.findings[0]
return f"U+{first.codepoint:04X}@{first.index}({first.category})"
- @property
- def highest_severity(self) -> str:
- if not self.findings:
- return ""
- return max(self.findings, key=lambda item: _SEVERITY_RANK.get(item.severity, 0)).severity
-
def active_categories(self) -> tuple[str, ...]:
return tuple(name for name in SCAN_CATEGORIES if self.counts.get(name, 0) > 0)
@@ -259,6 +254,7 @@ def scan_hidden_characters(
total = 0
truncated = False
carriers = 0
+ peak = ""
for index, character in enumerate(text):
code = ord(character)
if code in approved:
@@ -268,8 +264,10 @@ def scan_hidden_characters(
continue
total += 1
counts[category] += 1
+ context, severity, why, remedy = annotate_finding(text, index, category, roles[index])
+ if _SEVERITY_RANK.get(severity, -1) > _SEVERITY_RANK.get(peak, -1):
+ peak = severity
if len(findings) < max_findings:
- context, severity, why, remedy = annotate_finding(text, index, category, roles[index])
findings.append(
HiddenFinding(
index=index,
@@ -290,6 +288,7 @@ def scan_hidden_characters(
findings=tuple(findings),
truncated=truncated,
fuckmark_carriers=carriers,
+ highest_severity=peak,
)
diff --git a/fuckmark/product/severity.py b/fuckmark/product/severity.py
index 3a2dae79..936dfccc 100644
--- a/fuckmark/product/severity.py
+++ b/fuckmark/product/severity.py
@@ -227,11 +227,13 @@ def _is_ident_char(character: str) -> bool:
return character == "_" or character.isalnum()
-def classify_context(text: str, index: int, role: str = ROLE_CODE) -> str:
+def classify_context(text: str, index: int, role: str = ROLE_CODE, category: str = "") -> str:
prev = text[index - 1] if index > 0 else ""
nxt = text[index + 1] if index + 1 < len(text) else ""
prev_cp = ord(prev) if prev else -1
nxt_cp = ord(nxt) if nxt else -1
+ if role in {ROLE_COMMENT, ROLE_STRING} and category == "bidi_control":
+ return CONTEXT_COMMENT if role == ROLE_COMMENT else CONTEXT_STRING
if _is_emojiish(prev_cp) or _is_emojiish(nxt_cp) or prev_cp in _VS or nxt_cp in _VS:
return CONTEXT_EMOJI
if role == ROLE_COMMENT:
@@ -332,7 +334,7 @@ def annotate_finding(
category: str,
role: str = ROLE_CODE,
) -> tuple[str, str, str, str]:
- context = classify_context(text, index, role)
+ context = classify_context(text, index, role, category)
severity = score_severity(category, context)
why, remedy = explain_finding(category, context, severity)
return context, severity, why, remedy
diff --git a/fuckmark/webui/scan.html b/fuckmark/webui/scan.html
index 0195ef0a..0b007977 100644
--- a/fuckmark/webui/scan.html
+++ b/fuckmark/webui/scan.html
@@ -412,6 +412,8 @@ See the bytes.
total: inner.total || findings.length,
findings,
highest: inner.highest_severity || "",
+ truncated: Boolean(inner.truncated),
+ counts: inner.counts || {},
};
}
@@ -428,6 +430,8 @@ See the bytes.
total: result.total,
findings: result.findings,
highest,
+ truncated: false,
+ counts: result.counts || {},
};
}
@@ -465,12 +469,17 @@ See the bytes.
return;
}
copyClean.disabled = false;
- fixTrojan.disabled = !result.findings.some((item) => item.category === "bidi_control");
+ const bidiCount = Number((result.counts && result.counts.bidi_control) || 0);
+ const listedBidi = result.findings.some((item) => item.category === "bidi_control");
+ fixTrojan.disabled = !listedBidi && bidiCount === 0;
if (n === 0) {
summary.innerHTML = "No hidden characters in " + text.length + " code units.";
} else {
const sev = result.highest ? " Highest severity: " + result.highest + " ." : "";
- summary.innerHTML = "Found " + n + " hidden character" + (n === 1 ? "" : "s") + "." + sev;
+ const cap = result.truncated
+ ? " Showing " + result.findings.length + " of " + n + " (list capped)."
+ : "";
+ summary.innerHTML = "Found " + n + " hidden character" + (n === 1 ? "" : "s") + "." + sev + cap;
}
reveal.replaceChildren(...revealNodes(text, result.findings));
findingsEl.replaceChildren(...result.findings.map(findingCard));
diff --git a/fuckmark/webui/scan.js b/fuckmark/webui/scan.js
index f2466c94..f28c001f 100644
--- a/fuckmark/webui/scan.js
+++ b/fuckmark/webui/scan.js
@@ -333,10 +333,13 @@ function sourceRoles(text, language) {
return roles;
}
-function classifyContext(text, offset, role) {
+function classifyContext(text, offset, role, category) {
const length = text.codePointAt(offset) > 0xffff ? 2 : 1;
const prev = neighborBefore(text, offset);
const next = neighborAfter(text, offset, length);
+ if ((role === "comment" || role === "string") && category === CATEGORY_BIDI_CONTROL) {
+ return role;
+ }
if (
isEmojiish(prev.cp) ||
isEmojiish(next.cp) ||
@@ -450,7 +453,7 @@ function scanText(text, categories, language) {
if (category !== null && (selected === null || selected.has(category))) {
total += 1;
counts[category] = (counts[category] || 0) + 1;
- const context = classifyContext(text, offset, roles[offset] || "code");
+ const context = classifyContext(text, offset, roles[offset] || "code", category);
const severity = scoreSeverity(category, context);
const explained = explainFinding(category, context, severity);
findings.push({
diff --git a/specs/fuckmark-hidden-scan-v1.freeze.json b/specs/fuckmark-hidden-scan-v1.freeze.json
index d642e534..f3e4237d 100644
--- a/specs/fuckmark-hidden-scan-v1.freeze.json
+++ b/specs/fuckmark-hidden-scan-v1.freeze.json
@@ -1,9 +1,9 @@
{
"algorithm_version": "fuckmark-hidden-scan-v1",
- "freeze_record_version": "fuckmark-hidden-scan-v1-freeze-v2",
- "frozen_at_utc": "2026-09-02T00:45:00+00:00",
+ "freeze_record_version": "fuckmark-hidden-scan-v1-freeze-v3",
+ "frozen_at_utc": "2026-09-02T01:50:00+00:00",
"protocol_id": "fuckmark-hidden-scan-v1",
- "protocol_sha256": "bd8c29bb0c25e0e68f07ad721aa139d8bf67988879a2056d715ce40b0d3a8ba3",
- "vectors_file_sha256": "2314747dd0b668b0aa98991243788deacc3a4cabf9ffe86c46b50d2fc35d0bef",
- "vectors_canonical_sha256": "9ff1a3d4538bdbc30579ac65ee36c7d8b4ad701698150ed890f8823116daadc1"
+ "protocol_sha256": "4e594b87ba2f68b929e62fc420b4543186ecc7f71f41f501f1bb646c33c23045",
+ "vectors_file_sha256": "acb8e4ddbd8849979b8f493fabb136fa63e8d4364ce60aedef3ce853006802a3",
+ "vectors_canonical_sha256": "763f2967f68882ec037b6245ad89e166f6b7482292b0e13d774475acb81c5e4a"
}
diff --git a/specs/fuckmark-hidden-scan-v1.protocol.md b/specs/fuckmark-hidden-scan-v1.protocol.md
index cdfa705c..38d5a71e 100644
--- a/specs/fuckmark-hidden-scan-v1.protocol.md
+++ b/specs/fuckmark-hidden-scan-v1.protocol.md
@@ -73,6 +73,8 @@ Roles (optional `language` argument to `scan_hidden_characters`; default
Then:
+- `comment` / `string` from the role mask when the category is `bidi_control`
+ (Trojan Source encodings stay on that role even next to emoji).
- `emoji` if the previous or next scalar is emoji-ish (regional indicators
U+1F1E6-U+1F1FF, U+1F000-U+1FAFF, U+2600-U+27BF, a small BMP emoji set,
ZWJ, or a BMP variation selector U+FE00-U+FE0F).
diff --git a/specs/fuckmark-hidden-scan-v1.vectors.json b/specs/fuckmark-hidden-scan-v1.vectors.json
index a8f7b1f1..a3d6a78b 100644
--- a/specs/fuckmark-hidden-scan-v1.vectors.json
+++ b/specs/fuckmark-hidden-scan-v1.vectors.json
@@ -342,6 +342,42 @@
"severity": "critical"
}
]
+ },
+ {
+ "id": "bidi-slash-comment-beside-emoji",
+ "codepoints": [
+ 47,
+ 47,
+ 128512,
+ 8238
+ ],
+ "expect": [
+ {
+ "index": 3,
+ "codepoint": 8238,
+ "category": "bidi_control",
+ "context": "comment",
+ "severity": "critical"
+ }
+ ]
+ },
+ {
+ "id": "bidi-string-beside-emoji",
+ "codepoints": [
+ 34,
+ 128512,
+ 8238,
+ 34
+ ],
+ "expect": [
+ {
+ "index": 2,
+ "codepoint": 8238,
+ "category": "bidi_control",
+ "context": "string",
+ "severity": "critical"
+ }
+ ]
}
]
}
diff --git a/tests/test_browser_extension.py b/tests/test_browser_extension.py
new file mode 100644
index 00000000..f100da58
--- /dev/null
+++ b/tests/test_browser_extension.py
@@ -0,0 +1,111 @@
+import json
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from fuckmark.product.scan import scan_hidden_characters
+
+
+ROOT = Path(__file__).resolve().parents[1]
+BROWSER = ROOT / "editors" / "browser"
+VSCODE_SCAN = ROOT / "editors" / "vscode" / "scan.js"
+MANIFEST = BROWSER / "manifest.json"
+
+
+def test_browser_scan_js_matches_vscode_engine() -> None:
+ assert (BROWSER / "scan.js").read_text(encoding="utf-8") == VSCODE_SCAN.read_text(encoding="utf-8")
+
+
+def test_manifest_is_mv3_and_local() -> None:
+ payload = json.loads(MANIFEST.read_text(encoding="utf-8"))
+ assert payload["manifest_version"] == 3
+ assert payload["version"] == "0.4.1"
+ assert "clipboardRead" not in payload.get("permissions", [])
+ assert payload["action"]["default_popup"] == "popup.html"
+ assert payload["background"]["service_worker"] == "background.js"
+ scripts = payload["content_scripts"][0]["js"]
+ assert scripts == ["scan.js", "page.js", "content.js"]
+ for name in ("externally_connectable", "update_url", "key"):
+ assert name not in payload
+
+
+def test_extension_files_have_no_hidden_unicode() -> None:
+ for path in BROWSER.iterdir():
+ if not path.is_file():
+ continue
+ if path.suffix not in {".js", ".html", ".json", ".md", ".css"}:
+ continue
+ language = "html" if path.suffix == ".html" else "javascript" if path.suffix == ".js" else "auto"
+ result = scan_hidden_characters(path.read_text(encoding="utf-8"), language=language)
+ assert result.detected is False, path
+
+
+def test_popup_and_readme_document_local_engine() -> None:
+ popup = (BROWSER / "popup.html").read_text(encoding="utf-8")
+ readme = (BROWSER / "README.md").read_text(encoding="utf-8")
+ assert 'script src="scan.js"' in popup
+ assert 'script src="page.js"' in popup
+ assert "Paste-safe" in popup
+ assert "fromCodePoint(0x202E)" in (BROWSER / "popup.js").read_text(encoding="utf-8")
+ assert "Load unpacked" in readme
+ assert "fuckmark-hidden-scan-v1" in readme
+ assert "never leaves" in readme
+ content = (BROWSER / "content.js").read_text(encoding="utf-8")
+ assert "stopImmediatePropagation" in content
+ background = (BROWSER / "background.js").read_text(encoding="utf-8")
+ assert "tabId" in background
+
+
+def test_page_js_paste_safe_and_emoji_keep() -> None:
+ node = shutil.which("node")
+ if node is None:
+ pytest.skip("node is not available")
+ harness = """
+"use strict";
+const page = require(process.argv[2]);
+const rlo = String.fromCodePoint(0x202E);
+const zwj = String.fromCodePoint(0x200D);
+const man = String.fromCodePoint(0x1F468);
+const woman = String.fromCodePoint(0x1F469);
+const ident = page.cleanForPaste("if (x != " + rlo + "admin) {");
+if (ident.removed !== 1) throw new Error("expected one bidi strip, got " + ident.removed);
+if (ident.cleaned.includes(rlo)) throw new Error("bidi remained");
+const emoji = page.cleanForPaste(man + zwj + woman);
+if (emoji.removed !== 0) throw new Error("emoji ZWJ should be kept, removed=" + emoji.removed);
+if (emoji.cleaned !== man + zwj + woman) throw new Error("emoji rewritten");
+const tags = page.cleanForPaste("ok" + String.fromCodePoint(0xE0061));
+if (tags.removed !== 1) throw new Error("tag should strip");
+const grin = String.fromCodePoint(0x1F600);
+const mixed = page.cleanForPaste(grin + zwj + "admin");
+if (mixed.cleaned.includes(zwj)) throw new Error("mixed ZWJ should strip");
+if (mixed.removed < 1) throw new Error("mixed ZWJ removed");
+const scan = page.scanString("// " + rlo, "javascript");
+if (scan.findings[0].context !== "comment") throw new Error("comment context");
+if (scan.findings[0].severity !== "critical") throw new Error("comment severity");
+process.stdout.write("ok");
+"""
+ with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as handle:
+ handle.write(harness)
+ harness_path = handle.name
+ try:
+ completed = subprocess.run(
+ [node, harness_path, str(BROWSER / "page.js")],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=20,
+ )
+ finally:
+ Path(harness_path).unlink(missing_ok=True)
+ assert completed.stdout == "ok"
+
+
+def test_browser_js_syntax() -> None:
+ node = shutil.which("node")
+ if node is None:
+ pytest.skip("node is not available")
+ for name in ("scan.js", "page.js", "content.js", "background.js", "popup.js"):
+ subprocess.run([node, "--check", str(BROWSER / name)], check=True, timeout=15)
diff --git a/tests/test_hidden_scan_spec.py b/tests/test_hidden_scan_spec.py
index dba8f604..952975e5 100644
--- a/tests/test_hidden_scan_spec.py
+++ b/tests/test_hidden_scan_spec.py
@@ -110,6 +110,7 @@ def test_scan_js_syntax() -> None:
copies = (
ROOT / "docs" / "scan.js",
ROOT / "fuckmark" / "webui" / "scan.js",
+ ROOT / "editors" / "browser" / "scan.js",
)
canonical = SCAN_JS.read_text(encoding="utf-8")
for path in copies:
diff --git a/tests/test_scan.py b/tests/test_scan.py
index f7a38ee3..bed0b7ba 100644
--- a/tests/test_scan.py
+++ b/tests/test_scan.py
@@ -319,3 +319,27 @@ def test_autofix_trojan_source_strips_only_bidi() -> None:
assert removed == 1
assert cleaned == "a\u200bb"
assert "\u202e" not in cleaned
+
+
+def test_bidi_in_comment_or_string_stays_critical_beside_emoji() -> None:
+ comment = scan_hidden_characters("//\U0001F600\u202e")
+ assert comment.findings[0].category == CATEGORY_BIDI_CONTROL
+ assert comment.findings[0].context == "comment"
+ assert comment.findings[0].severity == "critical"
+ quoted = scan_hidden_characters('"\U0001F600\u202e"')
+ assert quoted.findings[0].context == "string"
+ assert quoted.findings[0].severity == "critical"
+ emoji = scan_hidden_characters("// \U0001F468\u200d\U0001F469")
+ zwj = next(item for item in emoji.findings if item.category == CATEGORY_ZERO_WIDTH)
+ assert zwj.context == "emoji"
+ assert zwj.severity == "info"
+
+
+def test_highest_severity_includes_hits_past_findings_cap() -> None:
+ text = "\u200b" * 300 + "a\u202eb"
+ result = scan_hidden_characters(text, max_findings=256)
+ assert result.truncated is True
+ assert result.total == 301
+ assert len(result.findings) == 256
+ assert result.counts[CATEGORY_BIDI_CONTROL] == 1
+ assert result.highest_severity == "critical"
diff --git a/tests/test_web.py b/tests/test_web.py
index e6b7c7da..fc4f8553 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -59,6 +59,8 @@ def test_packaged_scan_page_matches_docs_and_editor_engine() -> None:
assert "autofixTrojanSource" in docs_html
assert "DEFAULT_SECURITY_CATEGORIES" in docs_html
assert "fromCodePoint(0x202E)" in docs_html
+ assert "truncated" in docs_html
+ assert "bidi_control" in docs_html
from fuckmark.product.scan import scan_hidden_characters
assert scan_hidden_characters(docs_html, language="html").detected is False