On November 1, 2021, researchers Nicholas Boucher and Ross Anderson at the University of Cambridge disclosed a class of attack they named "Trojan Source," tracked as CVE-2021-42574 for bidirectional control characters and CVE-2021-42694 for homoglyphs. The flaw isn't in any compiler or interpreter — it's in how nearly every text renderer displays Unicode. Characters like RLO (U+202E) and LRO (U+202D) instruct a renderer to flip the visual order of surrounding text, so a source file can display one way in an editor or GitHub diff while the parser reads a completely different token order underneath. A single invisible character can hide a smuggled statement inside what looks like a comment, or flip a string literal so "admin" reads as authorized when the executed logic checks something else entirely. Red Hat (RHSB-2021-007), Atlassian, and Mozilla all issued advisories within days. ESLint itself never shipped a first-party rule — the ask has sat open as eslint/eslint issue #15240 since the disclosure. This post walks through the plugin-based and CI-level controls that actually close the gap for JavaScript and TypeScript codebases today.
What exactly are bidi control characters, and why do they bypass code review?
Bidi control characters exist for a legitimate reason: mixing left-to-right scripts like English with right-to-left scripts like Arabic or Hebrew in the same line of text requires a way to tell the renderer which direction to display each run of characters. Unicode defines several — RLO/LRO force a direction, RLI/LRI/FSI isolate a run, and PDI pops back to the previous state. The problem is that JavaScript and TypeScript grammars place zero restrictions on where these characters can appear in source, including inside string literals and comments. A reviewer looking at a GitHub pull request diff sees rendered text, not raw bytes — so a comment that visually reads // isAdmin = false; can, with an embedded RLO character, actually contain the token sequence for isAdmin = true; in the underlying file that Node or a bundler parses. The code review process itself becomes the blind spot, because the human and the machine are reading two different orderings of the same bytes.
Why doesn't ESLint catch this by default?
ESLint's built-in rule set was designed around style and correctness — undefined variables, unreachable code, unused imports — not around adversarial manipulation of the character stream itself. The closest built-in rule, no-irregular-whitespace, flags unusual whitespace characters but was never extended to cover the specific bidi control block (U+202A–U+202E, U+2066–U+2069) or the broader set of invisible Unicode categories attackers can abuse, like zero-width joiners and variation selectors. That gap is exactly why eslint/eslint issue #15240 was filed as a feature request rather than treated as a bug fix, and why, as of this writing, it remains an open ask rather than shipped core functionality. Relying on no-irregular-whitespace alone gives a false sense of coverage: it was written to catch stray tabs and non-breaking spaces in formatting-sensitive code, not to model an attacker deliberately hiding logic from a diff viewer.
Which ESLint plugin actually detects Trojan Source patterns?
The community closed the gap ESLint's core left open. eslint-plugin-anti-trojan-source is purpose-built for this: according to its own documentation, it flags a broad set of confusable and invisible characters — reported at roughly 277 distinct code points — spanning bidi controls, zero-width characters, and other Unicode format characters that have no legitimate reason to appear unescaped in typical application source. Installing it is a normal ESLint plugin addition:
npm install --save-dev eslint-plugin-anti-trojan-source
Then enable it in your flat config (eslint.config.js) or legacy .eslintrc:
{ plugins: ["anti-trojan-source"], rules: { "anti-trojan-source/no-bidi": "error" } }
The key operational decision is severity: this rule should run at error, not warn, because a bidi character in application source is essentially never intentional in typical JS/TS codebases — legitimate RTL-language UI strings are handled by i18n libraries and CSS direction properties, not raw control characters embedded in logic files. Treat any hit as a build-blocking finding requiring manual review, not a style nit to batch-fix later.
How should this fit into CI instead of just an editor plugin?
An editor-only check is insufficient because the entire point of the attack is that the character is invisible in normal rendering — a developer who never widens their editor's "show invisible characters" setting will merge it without noticing, and a reviewer looking at a rendered GitHub diff sees the spoofed version, not the raw bytes. The rule needs to run where nobody can skip it: as a required check in CI on every pull request, using the same eslint-plugin-anti-trojan-source configuration set to error so a detection fails the build rather than just annotating a log line. Teams should also add a lightweight pre-commit hook running the same rule locally, since catching the character before it's pushed is cheaper than catching it after a PR is open. For defense in depth, pairing the ESLint rule with a raw git diff scan for non-ASCII control-character ranges in CI catches cases where a file bypasses ESLint entirely, such as JSON or config files ESLint isn't configured to lint.
Is this still a live risk in 2026, or was it patched years ago?
Trojan Source was never something a single patch could fully close, because the underlying behavior — bidi control characters affecting visual rendering — is a Unicode Standard feature, not a bug in any one tool. What changed since 2021 is defense-in-depth: many editors and terminals now render bidi control characters with visible markers, and some Git hosting platforms added warnings for commits containing them. But those mitigations are inconsistent across tools and don't cover every code path a file travels — a raw curl, an IDE without the warning enabled, or a CI log viewer can still show the spoofed rendering. The underlying grammar of JavaScript and TypeScript still permits these characters anywhere a string or comment can appear, which is why linting at the source level, rather than relying on the display layer to warn you, remains the durable control. Safeguard's SAST engine already scans JS and TS source as part of standard coverage, and pairing that pipeline with an explicit bidi/homoglyph rule set gives teams a single place to enforce this check consistently across every repository rather than depending on each engineer's editor configuration.