diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab90e96..0d6375ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - 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`. - WASM build of `fuckmark-hidden-scan-v1` (`crates/fuckmark-scan`, committed `fuckmark_scan.wasm`). The scan page and the Chromium popup load it when `fetch` works so classification stays on-device; `scan.js` remains the `file://` fallback. Lone UTF-16 surrogates route through the JS engine (UTF-8 cannot carry them). Empty category arrays stay empty selections. The extension CSP allows `wasm-unsafe-eval` so Chromium can instantiate the module. Page reveal and paste-safe keep the synchronous JS port. Rebuild with `crates/fuckmark-scan/build-wasm.sh`. - Language bindings for the same Rust scan ABI: C header (`crates/fuckmark-scan/include/fuckmark_scan.h`), optional Python ctypes loader (`fuckmark.native_scan`), and a Node package under `bindings/node` (WASM + JS fallback). Host library build: `crates/fuckmark-scan/build-native.sh`. Lone UTF-16 surrogates cannot cross the UTF-8 ABI, so Python `scan_text` / `clean_text` delegate those strings to the Python reference scanner, and the WASM wrapper normalizes the JS fallback result to the same `index` / `truncated` / `highest_severity` / `source_length` schema. The C example is linked with `-rpath,$ORIGIN` and written next to `libfuckmark_scan.so`. Overview: `bindings/README.md`. +- OS clipboard watcher (`fuckmark-clipboard-watch-v1`). `fuckmark clipboard scan` / `clean` / `watch` poll the system clipboard (pbpaste, wl-paste, xclip, xsel, PowerShell Get-Clipboard) for the same security category set as `fuckmark lint`. `--clean` rewrites the clipboard in place after a digest re-read so a newer copy is not overwritten. Snapshots hash with UTF-8 `surrogatepass` so lone surrogates do not crash the watcher. Linux read uses `wl-paste` without `--no-newline`. Emoji ZWJ sequences (both neighbors emoji) and info-level variation selectors are kept, matching browser paste-safe. `--json`, `-q`, `--select`, `--once`, `--exit-on-find`, `--interval`, `--max-seconds`. Exit `0` clean, `1` findings, `2` usage, `3` clipboard unavailable. Reference: `docs/clipboard.md`. Python: `evaluate_clipboard_text`, `watch_clipboard`, `clean_clipboard_text` (injectable reader/writer for tests). - `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 ead6c83a..136366b5 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,18 @@ The [`editors/vscode`](editors/vscode) extension reveals hidden Unicode inline a 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 popup prefers `fuckmark_scan.wasm` when it can fetch it; on-page reveal and paste-safe keep `scan.js`. Full notes: [`editors/browser/README.md`](editors/browser/README.md). +## Watch the OS clipboard + +Copied text is a common path for Trojan Source and Unicode-tag smuggling. `fuckmark clipboard` reads the system clipboard and uses the same security category set as `fuckmark lint`. `watch` polls for changes; `--clean` rewrites the clipboard in place. Real emoji ZWJ sequences stay intact. + +```text +fuckmark clipboard scan +fuckmark clipboard clean +fuckmark clipboard watch --clean +``` + +Needs a clipboard tool (`pbpaste`, `wl-paste` / `xclip` / `xsel`, or PowerShell `Get-Clipboard`). Full reference: [`docs/clipboard.md`](docs/clipboard.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/cli.md b/docs/cli.md index 72109046..94a03105 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -68,6 +68,8 @@ fuckmark --clean --file suspect.txt -o clean.txt fuckmark lint src/ fuckmark guard --json < messages.json fuckmark normalize --receipt < notes.txt +fuckmark clipboard scan +fuckmark clipboard watch --clean fuckmark web fuckmark --text "I don’t agree." --status ``` @@ -141,6 +143,20 @@ fuckmark guard --refuse --receipt --json < messages.json `--json` walks every string. `--refuse` exits 1 and writes nothing when hidden Unicode is present. `--report` scans without changing the payload. `--receipt` writes the JSON receipt to stderr. Python: `protect()`, `inspect()`, `Guard`, `HiddenTextRefused`. Reference: [`guard.md`](guard.md). +### `fuckmark clipboard` + +Scan, strip, or poll the OS clipboard for hidden Unicode (Trojan Source bidi, zero-width, tag smuggling). Default categories match `fuckmark lint`. Real emoji ZWJ sequences (both neighbors emoji) and info-level variation selectors are kept. Reference: [`clipboard.md`](clipboard.md). + +```text +fuckmark clipboard scan +fuckmark clipboard scan --json +fuckmark clipboard clean +fuckmark clipboard watch --clean +fuckmark clipboard watch --once --exit-on-find +``` + +`scan` reads once. `clean` rewrites the clipboard in place when findings exist. `watch` polls (default 0.5s) and warns when the clipboard contents change to a hidden payload. `--select security|all|CATS` chooses categories. Exit status for this subcommand: `0` clean, `1` findings (including after a successful clean), `2` usage, `3` clipboard tool missing or failed. + ### `fuckmark web` Open the local browser tool (same UI as `docs/mark.html`). Aimed at beginners who prefer a page over pipes and flags. The server also exposes a Python API: `GET /api/health`, `POST /api/remove-marks`, `POST /api/scan`, `POST /api/guard` (sanitize text or chat messages before a model call), and `POST /api/normalize`. Hidden-Unicode reveal is `/scan.html` (WASM engine with `scan.js` fallback; zero-install copy at `docs/scan.html`). diff --git a/docs/clipboard.md b/docs/clipboard.md new file mode 100644 index 00000000..b2b81f9e --- /dev/null +++ b/docs/clipboard.md @@ -0,0 +1,89 @@ +# FuckMark clipboard — scan, clean, and watch copied text + +`fuckmark clipboard` sits at the OS paste edge. It reads whatever is currently +on the clipboard, runs `fuckmark-hidden-scan-v1`, and can strip hidden Unicode +before you paste into a terminal, editor, or chat box. + +Default categories match `fuckmark lint`: `bidi_control`, `zero_width`, `tag`, +`control`, `noncharacter`, and `surrogate`. That is the Trojan Source / +zero-width / Unicode-tag smuggling set. It does not judge visible text. + +This is the desktop twin of browser paste-safe. It keeps real emoji ZWJ +sequences (both neighbors emoji) and info-level variation selectors. + +## Command line + +```text +fuckmark clipboard scan # read once, report, do not change +fuckmark clipboard scan --json +fuckmark clipboard scan -q +fuckmark clipboard clean # strip findings in place +fuckmark clipboard watch # poll until Ctrl+C +fuckmark clipboard watch --clean +fuckmark clipboard watch --once --exit-on-find +fuckmark clipboard watch --interval 0.25 --max-seconds 30 +fuckmark clipboard watch --select all +``` + +`scan` and `clean` run one clipboard read. `watch` polls every `--interval` +seconds (default 0.5) and warns only when the clipboard *contents change* to a +payload that still has findings. Identical text is not re-alerted. `--once` +exits after the first poll. `--exit-on-find` exits when the first hidden +payload appears. `--max-seconds` stops a watch loop even if nothing was found. +`--clean` re-reads the clipboard immediately before rewriting it, so a newer +copy made during the alert is left alone. + +`--select security` is the default. `--select all` includes variation +selectors, enclosing marks, format controls, and private-use. Pass an explicit +comma-separated subset to match `fuckmark lint --select`. + +`--json` writes a receipt to stdout (`algorithm_version`, `action`, `removed`, +`scan`). Human reports go to stderr. `-q` writes the `fuckmark-scan` machine +line to stdout. + +## Exit status + +| Status | Meaning | +| ---: | ---: | +| 0 | No findings in the selected categories (or the watch loop ended cleanly). | +| 1 | Findings were present. `clean` still returns 1 after a successful rewrite. | +| 2 | Usage error (missing subcommand, bad `--select`, non-positive `--interval`). | +| 3 | No clipboard tool, or the tool failed. Nothing was rewritten. | + +`fuckmark clipboard` is dispatched like `fuckmark lint`. A file named +`clipboard` in the current directory is not read; use `fuckmark --file +clipboard` for that. + +## Clipboard tools + +| Platform | Read | Write (via existing CLI copy) | +| --- | --- | --- | +| macOS | `pbpaste` | `pbcopy` | +| Linux | `wl-paste`, then `xclip -o`, then `xsel` | `wl-copy`, `xclip`, `xsel`, `clip.exe` | +| Windows | PowerShell `Get-Clipboard -Raw` | `clip` (UTF-16) | + +Install one of those tools on a desktop session. Headless CI has no clipboard; +tests inject a fake reader and writer and never call the OS. + +## Python + +```text +from fuckmark.clipboard_watch import ( + clean_clipboard_text, + evaluate_clipboard_text, + watch_clipboard, +) + +alert = evaluate_clipboard_text(copied) +cleaned, removed, _scan = clean_clipboard_text(copied) +code = watch_clipboard( + once=True, + clean=True, + reader=lambda: copied, + writer=sink.append, +) +``` + +`watch_clipboard` accepts `reader`, `writer`, `sleeper`, and `clock` so tests +and embedders do not need a real clipboard. Algorithm id: +`fuckmark-clipboard-watch-v1`. diff --git a/docs/install.md b/docs/install.md index ec735ddd..0bb0dcac 100644 --- a/docs/install.md +++ b/docs/install.md @@ -114,6 +114,6 @@ python -m pytest If the command is not found, use the venv path from the install step, or open a new terminal so PATH updates load. -On Linux, clipboard copy needs `wl-copy`, `xclip`, `xsel`, or `clip.exe`. Stream `--copy` still prints the text if copy fails (exit 3). The paste UI does not print the payload; pipe text if you need stdout. +On Linux, clipboard copy needs `wl-copy`, `xclip`, `xsel`, or `clip.exe`. Stream `--copy` still prints the text if copy fails (exit 3). The paste UI does not print the payload; pipe text if you need stdout. `fuckmark clipboard` also needs a matching read tool (`wl-paste`, `xclip -o`, or `xsel`). Website: [mark.q1z.org](https://mark.q1z.org). Controlled copy: [`website.md`](website.md). diff --git a/fuckmark/cli.py b/fuckmark/cli.py index c9ca1c44..b234259f 100644 --- a/fuckmark/cli.py +++ b/fuckmark/cli.py @@ -32,6 +32,7 @@ scan_human_report, scan_machine_line, ) +from .clipboard_watch import run_clipboard_argv from .guard import run_guard_argv from .lint import run_lint_argv from .product.normalize import run_normalize_argv @@ -231,6 +232,7 @@ def _parser() -> argparse.ArgumentParser: " fuckmark lint src/\n" " fuckmark guard --json < messages.json\n" " fuckmark normalize --receipt < notes.txt\n" + " fuckmark clipboard watch --clean\n" " fuckmark web\n" "\n" "Latin, Greek, Cyrillic, Han, Kana, Hangul syllable, and emoji sites are processed\n" @@ -253,6 +255,7 @@ def _parser() -> argparse.ArgumentParser: "Use fuckmark lint PATHS to scan files/directories and fail on findings (CI, pre-commit).\n" "Use fuckmark guard to strip hidden Unicode from text or JSON before a model call.\n" "Use fuckmark normalize to NFC-fold, optionally skeleton-fold lookalikes, and strip hidden Unicode.\n" + "Use fuckmark clipboard to scan, clean, or watch the OS clipboard for hidden Unicode.\n" "Use fuckmark web to open the local browser tool (beginner-friendly).\n" "That server also runs the Python detect/strip API.\n" "Mn-strip, default-ignorable strip, UnicodeSanitizer combinations, and Cf-strip after UnicodeSanitizer leave Me/Cc/Cf residuals and spaces." @@ -965,6 +968,10 @@ def _run( raw_argv = list(sys.argv[1:] if parser_argv is None else parser_argv) if raw_argv and raw_argv[0] == "web": return run_web_argv(raw_argv[1:], errors) + if raw_argv and raw_argv[0] == "clipboard": + _ensure_utf8(output) + _ensure_utf8(errors) + return run_clipboard_argv(raw_argv[1:], output, errors) if raw_argv and raw_argv[0] == "lint": _ensure_utf8(output) _ensure_utf8(errors) diff --git a/fuckmark/clipboard_watch.py b/fuckmark/clipboard_watch.py new file mode 100644 index 00000000..9d990a06 --- /dev/null +++ b/fuckmark/clipboard_watch.py @@ -0,0 +1,445 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import subprocess +import sys +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import TextIO + +from .product.scan import ( + SCAN_CATEGORIES, + SECURITY_SCAN_CATEGORIES, + HiddenFinding, + ScanResult, + normalize_scan_categories, + scan_dict, + scan_hidden_characters, + scan_human_report, + scan_machine_line, +) + + +CLIPBOARD_EXIT_OK = 0 +CLIPBOARD_EXIT_FINDINGS = 1 +CLIPBOARD_EXIT_USAGE = 2 +CLIPBOARD_EXIT_UNAVAILABLE = 3 +WATCH_ALGORITHM_VERSION = "fuckmark-clipboard-watch-v1" +DEFAULT_INTERVAL_SECONDS = 0.5 +_ZWJ = 0x200D +_SEVERITY_RANK = {"info": 0, "medium": 1, "high": 2, "critical": 3} + + +@dataclass(frozen=True, slots=True) +class ClipboardSnapshot: + text: str + digest: str + + +@dataclass(frozen=True, slots=True) +class ClipboardAlert: + result: ScanResult + cleaned: str + removed: int + + +class ClipboardUnavailableError(RuntimeError): + pass + + +def _digest(text: str) -> str: + return hashlib.sha256(text.encode("utf-8", "surrogatepass")).hexdigest() + + +def snapshot_text(text: str) -> ClipboardSnapshot: + if not isinstance(text, str): + raise TypeError("text must be a string") + return ClipboardSnapshot(text=text, digest=_digest(text)) + + +def _read_commands() -> tuple[tuple[str, ...], ...]: + if sys.platform == "darwin": + return (("pbpaste",),) + if sys.platform == "win32": + return ( + ("powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"), + ("powershell.exe", "-NoProfile", "-Command", "Get-Clipboard -Raw"), + ) + return ( + ("wl-paste",), + ("xclip", "-selection", "clipboard", "-o"), + ("xsel", "--clipboard", "--output"), + ) + + +def _decode_clipboard_bytes(raw: bytes) -> str: + if not raw: + return "" + for encoding in ("utf-8", "utf-16"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + raise ClipboardUnavailableError("clipboard bytes were not valid UTF-8 or UTF-16") + + +def read_clipboard() -> str: + failures: list[str] = [] + for command in _read_commands(): + executable = command[0] + if shutil.which(executable) is None: + continue + try: + completed = subprocess.run( + command, + check=True, + capture_output=True, + timeout=10, + ) + return _decode_clipboard_bytes(completed.stdout) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired, UnicodeError) as error: + failures.append(f"{executable}:{type(error).__name__}") + detail = ", ".join(failures) if failures else "no supported clipboard read command found" + raise ClipboardUnavailableError(detail) + + +def write_clipboard(text: str) -> None: + from .cli import ClipboardUnavailableError as WriteError + from .cli import copy_to_clipboard + + try: + copy_to_clipboard(text) + except WriteError as error: + raise ClipboardUnavailableError(str(error)) from error + + +def _is_emoji_neighbor(codepoint: int) -> bool: + if codepoint < 0 or codepoint == _ZWJ: + return False + if 0xFE00 <= codepoint <= 0xFE0F: + return True + if 0xE0100 <= codepoint <= 0xE01EF: + return True + if 0x1F1E6 <= codepoint <= 0x1F1FF: + return True + if 0x1F000 <= codepoint <= 0x1FAFF: + return True + if 0x2600 <= codepoint <= 0x27BF: + return True + return codepoint in {0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139, 0x3030, 0x303D, 0x3297, 0x3299} + + +def _keep_emoji_joiner(text: str, index: int, codepoint: int) -> bool: + if codepoint != _ZWJ: + return False + prev = ord(text[index - 1]) if index > 0 else -1 + nxt = ord(text[index + 1]) if index + 1 < len(text) else -1 + return _is_emoji_neighbor(prev) and _is_emoji_neighbor(nxt) + + +def _keep_finding(text: str, finding: HiddenFinding) -> bool: + if finding.category == "zero_width" and _keep_emoji_joiner(text, finding.index, finding.codepoint): + return True + return finding.category == "variation_selector" and finding.severity == "info" + + +def _scan_all(text: str) -> ScanResult: + return scan_hidden_characters(text, max_findings=max(len(text), 1)) + + +def clean_clipboard_text(text: str, *, categories: frozenset[str] | None = None) -> tuple[str, int, ScanResult]: + if not isinstance(text, str): + raise TypeError("text must be a string") + selected = frozenset(SECURITY_SCAN_CATEGORIES) if categories is None else categories + result = _scan_all(text) + drop: set[int] = set() + for finding in result.findings: + if finding.category not in selected: + continue + if _keep_finding(text, finding): + continue + drop.add(finding.index) + cleaned = "".join(character for index, character in enumerate(text) if index not in drop) + return cleaned, len(drop), result + + +def evaluate_clipboard_text(text: str, *, categories: frozenset[str] | None = None) -> ClipboardAlert: + selected = frozenset(SECURITY_SCAN_CATEGORIES) if categories is None else categories + cleaned, removed, full = clean_clipboard_text(text, categories=selected) + findings = tuple( + finding + for finding in full.findings + if finding.category in selected and not _keep_finding(text, finding) + ) + counts: dict[str, int] = {} + peak = "" + for finding in findings: + counts[finding.category] = counts.get(finding.category, 0) + 1 + if _SEVERITY_RANK.get(finding.severity, -1) > _SEVERITY_RANK.get(peak, -1): + peak = finding.severity + filtered = ScanResult( + source_length=full.source_length, + total=sum(counts.values()), + counts=counts, + findings=findings, + truncated=False, + fuckmark_carriers=full.fuckmark_carriers, + highest_severity=peak, + ) + return ClipboardAlert(result=filtered, cleaned=cleaned, removed=removed) + + +def watch_clipboard( + *, + interval_seconds: float = DEFAULT_INTERVAL_SECONDS, + once: bool = False, + clean: bool = False, + exit_on_find: bool = False, + max_seconds: float | None = None, + categories: frozenset[str] | None = None, + reader: Callable[[], str] | None = None, + writer: Callable[[str], None] | None = None, + sleeper: Callable[[float], None] | None = None, + clock: Callable[[], float] | None = None, + on_alert: Callable[[ClipboardAlert, ClipboardSnapshot], None] | None = None, + on_clean: Callable[[ClipboardAlert, ClipboardSnapshot], None] | None = None, +) -> int: + if interval_seconds <= 0: + raise ValueError("interval_seconds must be positive") + if max_seconds is not None and max_seconds < 0: + raise ValueError("max_seconds must not be negative") + read = reader or read_clipboard + write = writer or write_clipboard + sleep = sleeper or time.sleep + now = clock or time.monotonic + started = now() + last_digest = "" + while True: + text = read() + snap = snapshot_text(text) + if snap.digest != last_digest: + last_digest = snap.digest + alert = evaluate_clipboard_text(snap.text, categories=categories) + if alert.result.detected: + if on_alert is not None: + on_alert(alert, snap) + if clean and alert.removed: + latest = snapshot_text(read()) + if latest.digest != snap.digest: + last_digest = "" + continue + write(alert.cleaned) + last_digest = snapshot_text(alert.cleaned).digest + if on_clean is not None: + on_clean(alert, snap) + if exit_on_find or once: + return CLIPBOARD_EXIT_FINDINGS + elif once: + return CLIPBOARD_EXIT_OK + if once: + return CLIPBOARD_EXIT_OK + if max_seconds is not None and (now() - started) >= max_seconds: + return CLIPBOARD_EXIT_OK + sleep(interval_seconds) + + +def _resolve_categories(select: str) -> frozenset[str]: + if select.strip().casefold() == "all": + return frozenset(SCAN_CATEGORIES) + names = [item.strip() for item in select.split(",") if item.strip()] + if not names: + raise ValueError("no categories selected") + return normalize_scan_categories(names) + + +def _clipboard_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="fuckmark clipboard", + description=( + "Watch or scan the OS clipboard for hidden Unicode " + "(Trojan Source bidi, zero-width, tag smuggling)." + ), + ) + sub = parser.add_subparsers(dest="command", required=True) + scan = sub.add_parser("scan", help="scan the current clipboard once") + scan.add_argument("--json", action="store_true", dest="json_mode") + scan.add_argument("-q", "--quiet", action="store_true") + scan.add_argument( + "--select", + default="security", + help="comma-separated categories, 'security' (default), or 'all'", + ) + clean = sub.add_parser("clean", help="strip hidden Unicode from the clipboard in place") + clean.add_argument("--json", action="store_true", dest="json_mode") + clean.add_argument("-q", "--quiet", action="store_true") + clean.add_argument("--select", default="security") + watch = sub.add_parser("watch", help="poll the clipboard and warn on new hidden payloads") + watch.add_argument("--interval", type=float, default=DEFAULT_INTERVAL_SECONDS) + watch.add_argument("--once", action="store_true", help="poll once then exit") + watch.add_argument("--clean", action="store_true", help="rewrite the clipboard when findings appear") + watch.add_argument("--exit-on-find", action="store_true") + watch.add_argument("--max-seconds", type=float, default=None) + watch.add_argument("--json", action="store_true", dest="json_mode") + watch.add_argument("-q", "--quiet", action="store_true") + watch.add_argument("--select", default="security") + return parser + + +def _categories_from_select(select: str) -> frozenset[str]: + key = select.strip().casefold() + if key in {"", "security", "default"}: + return frozenset(SECURITY_SCAN_CATEGORIES) + return _resolve_categories(select) + + +def _emit(stream: TextIO, text: str) -> None: + stream.write(text) + if not text.endswith("\n"): + stream.write("\n") + stream.flush() + + +def _report_alert( + alert: ClipboardAlert, + *, + output: TextIO, + errors: TextIO, + json_mode: bool, + quiet: bool, + action: str, +) -> None: + payload = { + "algorithm_version": WATCH_ALGORITHM_VERSION, + "action": action, + "removed": alert.removed, + "scan": scan_dict(alert.result), + } + if json_mode: + _emit(output, json.dumps(payload, ensure_ascii=False)) + return + if quiet: + _emit(output, scan_machine_line(alert.result).rstrip("\n")) + return + _emit(errors, scan_human_report(alert.result).rstrip("\n")) + if action == "cleaned": + _emit(errors, f"FuckMark clipboard: stripped {alert.removed} hidden characters from the clipboard.") + elif alert.result.detected: + _emit(errors, "FuckMark clipboard: hidden Unicode detected in clipboard contents.") + + +def run_clipboard_argv(argv: list[str], output: TextIO, errors: TextIO) -> int: + try: + arguments = _clipboard_parser().parse_args(argv) + except SystemExit as error: + code = error.code + if code is None: + return CLIPBOARD_EXIT_OK + return int(code) if isinstance(code, int) else CLIPBOARD_EXIT_USAGE + try: + categories = _categories_from_select(arguments.select) + except ValueError as error: + errors.write(f"FuckMark: {error}\n") + errors.flush() + return CLIPBOARD_EXIT_USAGE + command = arguments.command + if command == "scan": + try: + text = read_clipboard() + except ClipboardUnavailableError as error: + errors.write(f"FuckMark: clipboard unavailable ({error})\n") + errors.flush() + return CLIPBOARD_EXIT_UNAVAILABLE + alert = evaluate_clipboard_text(text, categories=categories) + _report_alert( + alert, + output=output, + errors=errors, + json_mode=arguments.json_mode, + quiet=arguments.quiet, + action="scan", + ) + return CLIPBOARD_EXIT_FINDINGS if alert.result.detected else CLIPBOARD_EXIT_OK + if command == "clean": + try: + text = read_clipboard() + except ClipboardUnavailableError as error: + errors.write(f"FuckMark: clipboard unavailable ({error})\n") + errors.flush() + return CLIPBOARD_EXIT_UNAVAILABLE + alert = evaluate_clipboard_text(text, categories=categories) + if alert.removed: + try: + write_clipboard(alert.cleaned) + except ClipboardUnavailableError as error: + errors.write(f"FuckMark: clipboard write failed ({error})\n") + errors.flush() + return CLIPBOARD_EXIT_UNAVAILABLE + _report_alert( + alert, + output=output, + errors=errors, + json_mode=arguments.json_mode, + quiet=arguments.quiet, + action="cleaned" if alert.removed else "scan", + ) + return CLIPBOARD_EXIT_FINDINGS if alert.result.detected else CLIPBOARD_EXIT_OK + if command == "watch": + if arguments.interval <= 0: + errors.write("FuckMark: --interval must be positive\n") + errors.flush() + return CLIPBOARD_EXIT_USAGE + if arguments.max_seconds is not None and arguments.max_seconds < 0: + errors.write("FuckMark: --max-seconds must not be negative\n") + errors.flush() + return CLIPBOARD_EXIT_USAGE + if not arguments.quiet and not arguments.json_mode and not arguments.once: + _emit(errors, "FuckMark clipboard: watching for hidden Unicode. Ctrl+C to stop.") + + def on_alert(alert: ClipboardAlert, _snap: ClipboardSnapshot) -> None: + _report_alert( + alert, + output=output, + errors=errors, + json_mode=arguments.json_mode, + quiet=arguments.quiet, + action="watch", + ) + + def on_clean(alert: ClipboardAlert, _snap: ClipboardSnapshot) -> None: + if arguments.json_mode or arguments.quiet: + return + _emit( + errors, + f"FuckMark clipboard: stripped {alert.removed} hidden characters from the clipboard.", + ) + + try: + return watch_clipboard( + interval_seconds=arguments.interval, + once=arguments.once, + clean=arguments.clean, + exit_on_find=arguments.exit_on_find, + max_seconds=arguments.max_seconds, + categories=categories, + on_alert=on_alert, + on_clean=on_clean, + ) + except ClipboardUnavailableError as error: + errors.write(f"FuckMark: clipboard unavailable ({error})\n") + errors.flush() + return CLIPBOARD_EXIT_UNAVAILABLE + except ValueError as error: + errors.write(f"FuckMark: {error}\n") + errors.flush() + return CLIPBOARD_EXIT_USAGE + except KeyboardInterrupt: + errors.write("\n") + errors.flush() + return CLIPBOARD_EXIT_OK + errors.write("FuckMark: unknown clipboard command\n") + errors.flush() + return CLIPBOARD_EXIT_USAGE diff --git a/tests/test_clipboard_watch.py b/tests/test_clipboard_watch.py new file mode 100644 index 00000000..d0b1c408 --- /dev/null +++ b/tests/test_clipboard_watch.py @@ -0,0 +1,371 @@ +import json +from io import StringIO + +import pytest + +from fuckmark.cli import main +from fuckmark.clipboard_watch import ( + CLIPBOARD_EXIT_FINDINGS, + CLIPBOARD_EXIT_OK, + CLIPBOARD_EXIT_UNAVAILABLE, + CLIPBOARD_EXIT_USAGE, + WATCH_ALGORITHM_VERSION, + ClipboardUnavailableError, + clean_clipboard_text, + evaluate_clipboard_text, + read_clipboard, + run_clipboard_argv, + snapshot_text, + watch_clipboard, + write_clipboard, +) +from fuckmark.product.scan import SCAN_CATEGORIES, SECURITY_SCAN_CATEGORIES + + +FAMILY = "\U0001F468\u200d\U0001F469" +ZWJ = "\u200d" +TROJAN = "if (x != \u202eadmin\u202c)" +TAGGED = "visible" + "".join(chr(0xE0000 + ord(ch)) for ch in "PWN") + + +class FakeClock: + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def _run(argv: list[str], reader=None, writer=None, monkeypatch=None) -> tuple[int, str, str]: + if reader is not None: + monkeypatch.setattr("fuckmark.clipboard_watch.read_clipboard", reader) + if writer is not None: + monkeypatch.setattr("fuckmark.clipboard_watch.write_clipboard", writer) + out, err = StringIO(), StringIO() + code = run_clipboard_argv(argv, out, err) + return code, out.getvalue(), err.getvalue() + + +def test_evaluate_flags_trojan_and_tags_not_plain() -> None: + plain = evaluate_clipboard_text("ordinary clipboard") + assert plain.result.detected is False + assert plain.removed == 0 + assert plain.cleaned == "ordinary clipboard" + hidden = evaluate_clipboard_text(TROJAN) + assert hidden.result.detected is True + assert hidden.result.counts["bidi_control"] == 2 + assert hidden.cleaned == "if (x != admin)" + tagged = evaluate_clipboard_text(TAGGED) + assert tagged.result.detected is True + assert tagged.result.counts["tag"] == 3 + assert tagged.cleaned == "visible" + + +def test_evaluate_keeps_emoji_zwj_and_strips_lone_zwj() -> None: + kept = evaluate_clipboard_text(FAMILY) + assert kept.result.detected is False + assert kept.removed == 0 + assert kept.cleaned == FAMILY + cleaned, removed, _full = clean_clipboard_text("a" + ZWJ + "b") + assert cleaned == "ab" + assert removed == 1 + lone = evaluate_clipboard_text("a" + ZWJ + "b") + assert lone.result.detected is True + assert lone.cleaned == "ab" + + +def test_evaluate_keeps_emoji_variation_selector_when_all_selected() -> None: + text = "\u2764\ufe0f" + default = evaluate_clipboard_text(text) + assert default.result.detected is False + assert default.cleaned == text + selected = evaluate_clipboard_text(text, categories=frozenset(SCAN_CATEGORIES)) + assert selected.result.detected is False + assert selected.removed == 0 + assert selected.cleaned == text + prose = evaluate_clipboard_text("star\ufe0f", categories=frozenset(SCAN_CATEGORIES)) + assert prose.result.detected is True + assert prose.cleaned == "star" + + +def test_evaluate_select_can_ignore_zero_width() -> None: + text = "a" + ZWJ + "b" + alert = evaluate_clipboard_text(text, categories=frozenset({"bidi_control"})) + assert alert.result.detected is False + assert alert.removed == 0 + assert alert.cleaned == text + + +def test_default_categories_match_security_set() -> None: + alert = evaluate_clipboard_text("x") + assert set(SECURITY_SCAN_CATEGORIES) <= { + "bidi_control", + "zero_width", + "tag", + "control", + "noncharacter", + "surrogate", + } + assert alert.result.total == 0 + + +def test_snapshot_digest_changes_with_hidden_payload() -> None: + visible = snapshot_text("admin") + hidden = snapshot_text("\u202eadmin") + assert visible.digest != hidden.digest + assert len(visible.digest) == 64 + + +def test_snapshot_and_watch_accept_lone_surrogates() -> None: + lone = "\ud800" + snap = snapshot_text(lone) + assert len(snap.digest) == 64 + assert snap.digest != snapshot_text("").digest + alert = evaluate_clipboard_text(lone) + assert alert.result.detected is True + assert alert.result.counts["surrogate"] == 1 + assert alert.cleaned == "" + writes: list[str] = [] + code = watch_clipboard(once=True, clean=True, reader=lambda: lone, writer=writes.append) + assert code == CLIPBOARD_EXIT_FINDINGS + assert writes == [""] + + +def test_linux_clipboard_read_keeps_wl_paste_newlines(monkeypatch) -> None: + monkeypatch.setattr("fuckmark.clipboard_watch.sys.platform", "linux") + from fuckmark.clipboard_watch import _read_commands + + commands = _read_commands() + assert commands[0] == ("wl-paste",) + assert all(command != ("wl-paste", "--no-newline") for command in commands) + + +def test_watch_clean_does_not_overwrite_newer_clipboard() -> None: + texts = ["a" + ZWJ, "fresh"] + idx = {"i": 0} + writes: list[str] = [] + + def reader() -> str: + i = min(idx["i"], len(texts) - 1) + idx["i"] += 1 + return texts[i] + + code = watch_clipboard(once=True, clean=True, reader=reader, writer=writes.append) + assert writes == [] + assert code == CLIPBOARD_EXIT_OK + assert idx["i"] >= 2 + + +def test_watch_once_clean_and_findings() -> None: + assert watch_clipboard(once=True, reader=lambda: "hello") == CLIPBOARD_EXIT_OK + assert watch_clipboard(once=True, reader=lambda: "a" + ZWJ) == CLIPBOARD_EXIT_FINDINGS + + +def test_watch_clean_rewrites_and_keeps_emoji() -> None: + store = {"text": "ok" + ZWJ} + writes: list[str] = [] + + def writer(text: str) -> None: + writes.append(text) + store["text"] = text + + code = watch_clipboard( + once=True, + clean=True, + reader=lambda: store["text"], + writer=writer, + ) + assert code == CLIPBOARD_EXIT_FINDINGS + assert store["text"] == "ok" + assert writes == ["ok"] + family_store = {"text": FAMILY} + family_writes: list[str] = [] + family_code = watch_clipboard( + once=True, + clean=True, + reader=lambda: family_store["text"], + writer=lambda text: family_writes.append(text), + ) + assert family_code == CLIPBOARD_EXIT_OK + assert family_writes == [] + assert family_store["text"] == FAMILY + + +def test_watch_alerts_once_per_digest_then_stops_on_find() -> None: + texts = ["plain", "plain", "a" + ZWJ, "a" + ZWJ] + idx = {"i": 0} + alerts: list[int] = [] + clock = FakeClock() + + def reader() -> str: + i = min(idx["i"], len(texts) - 1) + idx["i"] += 1 + return texts[i] + + code = watch_clipboard( + interval_seconds=0.5, + exit_on_find=True, + reader=reader, + sleeper=clock.sleep, + clock=clock, + on_alert=lambda alert, _snap: alerts.append(alert.result.total), + ) + assert code == CLIPBOARD_EXIT_FINDINGS + assert alerts == [1] + + +def test_watch_max_seconds_returns_ok_without_realerting() -> None: + alerts: list[str] = [] + clock = FakeClock() + code = watch_clipboard( + interval_seconds=0.5, + max_seconds=1.0, + reader=lambda: "a" + ZWJ, + sleeper=clock.sleep, + clock=clock, + on_alert=lambda alert, snap: alerts.append(snap.digest), + ) + assert code == CLIPBOARD_EXIT_OK + assert len(alerts) == 1 + + +def test_watch_rejects_non_positive_interval() -> None: + with pytest.raises(ValueError): + watch_clipboard(interval_seconds=0, once=True, reader=lambda: "") + with pytest.raises(ValueError): + watch_clipboard(max_seconds=-1, once=True, reader=lambda: "") + + +def test_cli_scan_json_and_dispatch(monkeypatch) -> None: + monkeypatch.setattr("fuckmark.clipboard_watch.read_clipboard", lambda: TROJAN) + out, err = StringIO(), StringIO() + code = run_clipboard_argv(["scan", "--json"], out, err) + assert code == CLIPBOARD_EXIT_FINDINGS + payload = json.loads(out.getvalue()) + assert payload["algorithm_version"] == WATCH_ALGORITHM_VERSION + assert payload["action"] == "scan" + assert payload["removed"] == 2 + assert payload["scan"]["found"] is True + assert payload["scan"]["counts"]["bidi_control"] == 2 + dispatched_out, dispatched_err = StringIO(), StringIO() + dispatched = main( + StringIO(""), + dispatched_out, + error_stream=dispatched_err, + argv=("clipboard", "scan", "-q"), + ) + assert dispatched == CLIPBOARD_EXIT_FINDINGS + assert "fuckmark-scan" in dispatched_out.getvalue() + assert "found=yes" in dispatched_out.getvalue() + + +def test_cli_scan_plain_is_ok(monkeypatch) -> None: + code, _out, err = _run(["scan"], reader=lambda: "nothing hidden", monkeypatch=monkeypatch) + assert code == CLIPBOARD_EXIT_OK + assert "no hidden characters found" in err + + +def test_cli_clean_rewrites(monkeypatch) -> None: + store = {"text": "hi" + ZWJ} + writes: list[str] = [] + monkeypatch.setattr("fuckmark.clipboard_watch.read_clipboard", lambda: store["text"]) + monkeypatch.setattr( + "fuckmark.clipboard_watch.write_clipboard", + lambda text: writes.append(text) or store.update(text=text), + ) + out, err = StringIO(), StringIO() + code = main(StringIO(""), out, error_stream=err, argv=("clipboard", "clean", "--json")) + assert code == CLIPBOARD_EXIT_FINDINGS + assert store["text"] == "hi" + assert writes == ["hi"] + payload = json.loads(out.getvalue()) + assert payload["action"] == "cleaned" + assert payload["removed"] == 1 + + +def test_cli_watch_once_clean(monkeypatch) -> None: + store = {"text": "x" + ZWJ} + monkeypatch.setattr("fuckmark.clipboard_watch.read_clipboard", lambda: store["text"]) + monkeypatch.setattr( + "fuckmark.clipboard_watch.write_clipboard", + lambda text: store.update(text=text), + ) + out, err = StringIO(), StringIO() + code = run_clipboard_argv(["watch", "--once", "--clean", "-q"], out, err) + assert code == CLIPBOARD_EXIT_FINDINGS + assert store["text"] == "x" + assert "fuckmark-scan" in out.getvalue() + + +def test_cli_unavailable_is_exit_three(monkeypatch) -> None: + def boom() -> str: + raise ClipboardUnavailableError("no supported clipboard read command found") + + code, _out, err = _run(["scan"], reader=boom, monkeypatch=monkeypatch) + assert code == CLIPBOARD_EXIT_UNAVAILABLE + assert "clipboard unavailable" in err + + +def test_cli_usage_errors(monkeypatch) -> None: + code, _out, err = _run(["scan", "--select", "not_a_category"], reader=lambda: "x", monkeypatch=monkeypatch) + assert code == CLIPBOARD_EXIT_USAGE + assert "unknown scan categories" in err + bad_interval, _out2, err2 = _run( + ["watch", "--once", "--interval", "0"], + reader=lambda: "x", + monkeypatch=monkeypatch, + ) + assert bad_interval == CLIPBOARD_EXIT_USAGE + assert "interval" in err2 + missing = run_clipboard_argv([], StringIO(), StringIO()) + assert missing == CLIPBOARD_EXIT_USAGE + + +def test_cli_help_mentions_clipboard_watch(capsys) -> None: + with pytest.raises(SystemExit) as result: + main(argv=("--help",)) + assert result.value.code == 0 + rendered = capsys.readouterr().out + assert "fuckmark clipboard" in rendered + assert "OS clipboard" in rendered + + +def test_read_clipboard_missing_tools(monkeypatch) -> None: + monkeypatch.setattr("fuckmark.clipboard_watch.shutil.which", lambda _name: None) + with pytest.raises(ClipboardUnavailableError) as error: + read_clipboard() + assert "no supported clipboard read command found" in str(error.value) + + +def test_read_clipboard_decodes_utf8(monkeypatch) -> None: + monkeypatch.setattr("fuckmark.clipboard_watch._read_commands", lambda: (("fake-paste",),)) + monkeypatch.setattr("fuckmark.clipboard_watch.shutil.which", lambda _name: "/bin/fake-paste") + + def fake_run(_command, **_kwargs): + completed = type("Completed", (), {})() + completed.stdout = ("hello" + ZWJ).encode("utf-8") + return completed + + monkeypatch.setattr("fuckmark.clipboard_watch.subprocess.run", fake_run) + assert read_clipboard() == "hello" + ZWJ + + +def test_write_clipboard_wraps_cli_error(monkeypatch) -> None: + def boom(_text: str) -> None: + from fuckmark.cli import ClipboardUnavailableError as WriteError + + raise WriteError("no supported clipboard command found") + + monkeypatch.setattr("fuckmark.cli.copy_to_clipboard", boom) + with pytest.raises(ClipboardUnavailableError): + write_clipboard("x") + + +def test_snapshot_rejects_non_string() -> None: + with pytest.raises(TypeError): + snapshot_text(1) + with pytest.raises(TypeError): + clean_clipboard_text(1)