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.

+
+ + + +
+
+ +
+ +
+
+ + +
+

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