scripts/hidden_character_scanner.py — Hidden Character Scanner
From version 0.1.1 of Hidden Character Scanner, shown publicly by its creator. 13.7 KiB · SHA-256 62c84602b6865d27dbc41e33a549635a6e8c34670304604d11aaca385af52c0d.
#!/usr/bin/env python3
# By Zero Width Lab (zerowidthlab.com)
# Version: 0.1.1
"""Read-only scanner for concealed Unicode in user-selected text."""
from __future__ import annotations
import argparse
import errno
import json
import os
import stat
import sys
import unicodedata
from pathlib import Path
__author__ = "Zero Width Lab"
DEFAULT_MAX_BYTES = 1024 * 1024
SKIP_DIRS = {".git", "node_modules", ".venv", "dist", "build"}
CODE_SUFFIXES = {
".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".json",
".yaml", ".yml", ".toml", ".html", ".css", ".sh", ".rs",
".go", ".java", ".c", ".h", ".cpp", ".cs", ".rb", ".php",
}
ZERO_WIDTH = set(range(0x200B, 0x200E)) | {0x2060, 0xFEFF}
BIDI = {0x200E, 0x200F, 0x061C} | set(range(0x202A, 0x202F)) | set(range(0x2066, 0x206A))
WHITESPACE = {0x00A0, 0x202F, 0x205F, 0x3000} | set(range(0x2000, 0x200B))
EMBED_OPEN = set(range(0x202A, 0x202F)) - {0x202C}
ISOLATE_OPEN = set(range(0x2066, 0x2069))
class CliError(Exception):
"""Expected input or usage failure."""
class SafeArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> None:
raise CliError(message)
def display_text(value: object) -> str:
"""Render untrusted text on one visible ASCII line."""
return json.dumps(str(value), ensure_ascii=True)[1:-1].replace(chr(0x7F), "\\u007f")
def codepoint(value: str) -> str:
return f"U+{ord(value):04X}"
def emoji_base(value: str) -> bool:
number = ord(value)
return (value in "0123456789#*"
or 0x1F000 <= number <= 0x1FAFF or 0x2600 <= number <= 0x27BF
or number in {0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139}
or 0x2194 <= number <= 0x21AA or 0x231A <= number <= 0x23FA)
def finding(category: str, severity: str, value: str, file_name: str,
line: int, column: int) -> dict[str, object]:
return {
"category": category,
"severity": severity,
"code_point": codepoint(value),
"unicode_name": unicodedata.name(value, "UNASSIGNED"),
"file": file_name,
"line": line,
"column": column,
}
def scan_text(text: str, file_name: str, code_file: bool) -> list[dict[str, object]]:
results: list[dict[str, object]] = []
offset = 0
for line_number, line in enumerate(text.splitlines(keepends=True), 1):
opens: list[tuple[str, int]] = []
token: list[tuple[str, int]] = []
def flush_token() -> None:
if not token:
return
scripts = set()
for char, _ in token:
name = unicodedata.name(char, "")
if name.startswith("LATIN "):
scripts.add("LATIN")
elif name.startswith("CYRILLIC "):
scripts.add("CYRILLIC")
elif name.startswith("GREEK "):
scripts.add("GREEK")
if "LATIN" in scripts and ("CYRILLIC" in scripts or "GREEK" in scripts):
for char, column in token:
name = unicodedata.name(char, "")
if name.startswith(("CYRILLIC ", "GREEK ")):
results.append(finding("mixed-script", "medium", char,
file_name, line_number, column))
token.clear()
for index, char in enumerate(line):
column = index + 1
number = ord(char)
if char.isalpha() or (token and unicodedata.category(char).startswith("M")):
token.append((char, column))
else:
flush_token()
category = None
if number == 0xFEFF and offset + index == 0:
continue
if number in ZERO_WIDTH:
category = "zero-width"
elif number in BIDI:
category = "bidirectional-control"
elif 0xE0000 <= number <= 0xE007F:
category = "unicode-tag"
elif (0xFE00 <= number <= 0xFE0F or 0xE0100 <= number <= 0xE01EF):
previous = line[index - 1] if index else ""
if not previous or not emoji_base(previous):
category = "variation-selector"
elif code_file and unicodedata.category(char) == "Cf":
category = "format-character"
elif code_file and number in WHITESPACE:
category = "unusual-whitespace"
if category:
results.append(finding(category, "medium", char, file_name,
line_number, column))
if number in EMBED_OPEN:
opens.append(("embedding", len(results) - 1))
elif number in ISOLATE_OPEN:
opens.append(("isolate", len(results) - 1))
elif number == 0x202C:
if opens and opens[-1][0] == "embedding":
opens.pop()
elif number == 0x2069:
if opens and opens[-1][0] == "isolate":
opens.pop()
flush_token()
for _, result_index in opens:
results[result_index]["category"] = "trojan-source"
results[result_index]["severity"] = "high"
offset += len(line)
return results
def skip(result: dict[str, object], label: str, reason: str, incomplete: bool = False) -> None:
result["skipped"].append({"path": label, "reason": reason})
if incomplete:
result["incomplete"] = True
def scan_entry(parent_fd: int, name: str, label: str, args: argparse.Namespace,
result: dict[str, object], explicit: bool = False) -> None:
try:
info = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
if stat.S_ISLNK(info.st_mode):
skip(result, label, "symlink", explicit)
return
if stat.S_ISDIR(info.st_mode):
if name in SKIP_DIRS or (name.startswith(".") and not args.include_hidden and name != "."):
skip(result, label, "excluded directory")
return
directory_fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=parent_fd)
try:
with os.scandir(directory_fd) as entries:
children = sorted((entry.name for entry in entries))
for child in children:
scan_entry(directory_fd, child, os.path.join(label, child), args, result)
finally:
os.close(directory_fd)
return
if not stat.S_ISREG(info.st_mode):
skip(result, label, "not a regular file")
return
if name.startswith(".") and not args.include_hidden:
skip(result, label, "hidden file")
return
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent_fd)
with os.fdopen(descriptor, "rb") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
skip(result, label, "not a regular file")
return
data = stream.read(args.max_file_bytes + 1)
if len(data) > args.max_file_bytes:
skip(result, label, "size limit", True)
return
if b"\x00" in data:
skip(result, label, "binary NUL")
return
try:
content = data.decode("utf-8")
except UnicodeError:
skip(result, label, "invalid UTF-8")
return
result["scanned_files"] += 1
result["findings"].extend(scan_text(content, label, Path(name).suffix.lower() in CODE_SUFFIXES))
except OSError as error:
if error.errno == errno.ELOOP:
skip(result, label, "symlink", explicit)
else:
skip(result, label, f"unreadable: {error.strerror or error.__class__.__name__}", True)
def scan_path(path: Path, args: argparse.Namespace, result: dict[str, object]) -> None:
parts = path.parts
if not parts:
parts = (".",)
root = "/" if path.is_absolute() else "."
parent_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
try:
for component in parts[:-1]:
if component == "/":
continue
try:
if stat.S_ISLNK(os.stat(component, dir_fd=parent_fd,
follow_symlinks=False).st_mode):
skip(result, str(path), "symlink", True)
return
next_fd = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=parent_fd)
except OSError as error:
skip(result, str(path), "symlink" if error.errno == errno.ELOOP else "unreadable parent", True)
return
os.close(parent_fd)
parent_fd = next_fd
scan_entry(parent_fd, parts[-1], str(path), args, result, explicit=True)
finally:
os.close(parent_fd)
def scan_stdin(args: argparse.Namespace, result: dict[str, object]) -> None:
data = sys.stdin.buffer.read(args.max_file_bytes + 1)
if len(data) > args.max_file_bytes:
result["skipped"].append({"path": "<stdin>", "reason": "size limit"})
result["incomplete"] = True
return
try:
content = data.decode("utf-8")
except UnicodeError as error:
raise CliError(f"stdin is not UTF-8: {error}") from error
result["scanned_files"] += 1
result["findings"].extend(scan_text(content, "<stdin>", False))
def scan(args: argparse.Namespace) -> int:
if any(path != "-" for path in args.paths):
require_file_scan_support()
if args.max_file_bytes < 1:
raise CliError("--max-file-bytes must be positive")
result: dict[str, object] = {"findings": [], "skipped": [], "scanned_files": 0,
"incomplete": False}
for raw_path in args.paths:
if raw_path == "-":
scan_stdin(args, result)
else:
scan_path(Path(raw_path), args, result)
result["summary"] = {"findings": len(result["findings"]),
"scanned_files": result["scanned_files"],
"skipped": len(result["skipped"]),
"incomplete": result["incomplete"]}
if args.json:
print(json.dumps(result, ensure_ascii=True, indent=2).replace(chr(0x7F), "\\u007f"))
else:
for item in result["findings"]:
print(f"{display_text(item['file'])}:{item['line']}:{item['column']} "
f"{item['severity']} {item['category']} {item['code_point']} "
f"{display_text(item['unicode_name'])}")
for item in result["skipped"]:
print(f"Skipped {display_text(item['path'])}: {display_text(item['reason'])}")
summary = result["summary"]
print(f"Findings: {summary['findings']}; scanned: {summary['scanned_files']}; "
f"skipped: {summary['skipped']}; incomplete: {str(summary['incomplete']).lower()}")
if result["incomplete"] and not args.allow_partial:
return 2
return 1 if result["findings"] else 0
def require_file_scan_support() -> None:
if (os.name != "posix"
or not getattr(os, "O_NOFOLLOW", 0)
or not getattr(os, "O_DIRECTORY", 0)
or not {os.open, os.stat}.issubset(getattr(os, "supports_dir_fd", set()))
or os.stat not in getattr(os, "supports_follow_symlinks", set())
or os.scandir not in getattr(os, "supports_fd", set())):
raise CliError("File scanning requires POSIX directory-descriptor and no-follow support "
"(macOS or Linux); Windows is unsupported. Use stdin-only scan or decode-tags.")
def decode_tags(args: argparse.Namespace) -> int:
if args.text == "-":
data = sys.stdin.buffer.read(DEFAULT_MAX_BYTES + 1)
if len(data) > DEFAULT_MAX_BYTES:
raise CliError("decode-tags stdin exceeds the 1048576-byte (1 MiB) limit")
try:
text = data.decode("utf-8")
except UnicodeError as error:
raise CliError("decode-tags stdin is not UTF-8") from error
else:
text = args.text
decoded = "".join(chr(ord(char) - 0xE0000) for char in text
if 0xE0000 <= ord(char) <= 0xE007F)
print(display_text(decoded))
return 0
def main(argv: list[str] | None = None) -> int:
parser = SafeArgumentParser(
description="Scan local text for hidden Unicode, offline and read-only.",
epilog="By Zero Width Lab (zerowidthlab.com)")
commands = parser.add_subparsers(dest="command", required=True, parser_class=SafeArgumentParser)
scan_parser = commands.add_parser("scan", help="Scan files, directories or stdin",
epilog="By Zero Width Lab (zerowidthlab.com)")
scan_parser.add_argument("paths", nargs="+", metavar="PATH")
scan_parser.add_argument("--json", action="store_true", help="Print JSON")
scan_parser.add_argument("--max-file-bytes", type=int, default=DEFAULT_MAX_BYTES,
help="File and scan stdin byte limit (default: 1048576)")
scan_parser.add_argument("--include-hidden", action="store_true")
scan_parser.add_argument("--allow-partial", action="store_true")
decode_parser = commands.add_parser("decode-tags", help="Reveal ASCII carried by Unicode tag characters",
description="Decode supplied text or UTF-8 stdin (maximum 1048576 bytes).",
epilog="By Zero Width Lab (zerowidthlab.com)")
decode_parser.add_argument("text", metavar="TEXT|-")
try:
args = parser.parse_args(argv)
return scan(args) if args.command == "scan" else decode_tags(args)
except (CliError, OSError) as error:
print(f"Error: {display_text(error)}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
License: MIT (read it).
MIT
MIT LicenseCopyright (c) 2026 Zero Width Lab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.