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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 11 additions & 2 deletions docs/scan.html
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ <h1>See the bytes.</h1>
total: inner.total || findings.length,
findings,
highest: inner.highest_severity || "",
truncated: Boolean(inner.truncated),
counts: inner.counts || {},
};
}

Expand All @@ -428,6 +430,8 @@ <h1>See the bytes.</h1>
total: result.total,
findings: result.findings,
highest,
truncated: false,
counts: result.counts || {},
};
}

Expand Down Expand Up @@ -465,12 +469,17 @@ <h1>See the bytes.</h1>
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 <strong>" + text.length + "</strong> code units.";
} else {
const sev = result.highest ? " Highest severity: <strong>" + result.highest + "</strong>." : "";
summary.innerHTML = "Found <strong>" + n + "</strong> hidden character" + (n === 1 ? "" : "s") + "." + sev;
const cap = result.truncated
? " Showing " + result.findings.length + " of " + n + " (list capped)."
: "";
summary.innerHTML = "Found <strong>" + n + "</strong> hidden character" + (n === 1 ? "" : "s") + "." + sev + cap;
}
reveal.replaceChildren(...revealNodes(text, result.findings));
findingsEl.replaceChildren(...result.findings.map(findingCard));
Expand Down
7 changes: 5 additions & 2 deletions docs/scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions docs/website.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 57 additions & 0 deletions editors/browser/README.md
Original file line number Diff line number Diff line change
@@ -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).
74 changes: 74 additions & 0 deletions editors/browser/background.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Loading
Loading