Predicates

Functions that inspect text and return boolean or structured results without modifying the input.

detect_scripts

detect_scripts

detect_scripts(text: str) -> list[Script]

Return the set of Unicode scripts present in text, in order of first appearance.

Parameters:
  • text (str) –

    Input string.

Returns:
  • list[Script]

    List of :class:Script enum values, ordered by first appearance.

Examples:

>>> detect_scripts("Hello")
[Script.LATIN]
>>> detect_scripts("Hello Мир")
[Script.LATIN, Script.CYRILLIC]

inspect_auto_lang

inspect_auto_lang

inspect_auto_lang(text: str) -> dict[str, str | list[str] | None]

Inspect how lang="auto" would resolve for the given text.

Use this to audit or log the detection decision made by the three-stage auto-detection pipeline.

Parameters:
  • text (str) –

    Input string.

Returns:
  • dict[str, str | list[str] | None]

    Dict with keys:

  • dict[str, str | list[str] | None]
    • script: primary non-Latin script name, or None
  • dict[str, str | list[str] | None]
    • chosen_lang: resolved language code, or None
  • dict[str, str | list[str] | None]
    • reason: one of "unambiguous_script", "discriminator", "script_default", "latin_discriminator", "no_detection"
  • dict[str, str | list[str] | None]
    • discriminators_hit: list of discriminator characters found

Examples:

>>> inspect_auto_lang("Київ")["chosen_lang"]
'uk'
>>> inspect_auto_lang("Москва")["reason"]
'script_default'
from disarm import inspect_auto_lang

inspect_auto_lang("Київ")
# {'script': 'Cyrillic', 'chosen_lang': 'uk', 'reason': 'discriminator', 'discriminators_hit': ['ї']}

inspect_auto_lang("Москва")
# {'script': 'Cyrillic', 'chosen_lang': 'ru', 'reason': 'script_default', 'discriminators_hit': []}

inspect_auto_lang("hello")
# {'script': None, 'chosen_lang': None, 'reason': 'no_detection', 'discriminators_hit': []}

See Language Detection for details.


is_mixed_script

is_mixed_script

is_mixed_script(text: str) -> bool

True if text contains characters from more than one Unicode script.

Parameters:
  • text (str) –

    Input string.

Returns:
  • bool

    True if multiple scripts detected (excluding Common/Inherited).

Examples:

>>> is_mixed_script("Hello")
False
>>> is_mixed_script("Hello Мир")  # Latin + Cyrillic
True

has_bidi_conflict

has_bidi_conflict

has_bidi_conflict(text: str) -> bool

True if text mixes strong left-to-right and strong right-to-left characters.

This is the precondition for Unicode Bidi display-reordering (UAX #9) — the structural signal behind "BiDi Swap"-style spoofs, where an LTR brand label sits beside an RTL domain (e.g. "varonis.com.ו.קום"). Unlike a bidi-override (U+202x) check, it fires on the real letters: Latin / Cyrillic / Greek / CJK are left-to-right; Hebrew / Arabic / Syriac / Thaana / N'Ko are right-to-left; digits, punctuation and combining marks are neutral and never create a conflict on their own.

A False result is not a safety guarantee.

.. warning:: This is not the RLO check. Because it reads letters, it is structurally blind to the U+202x overrides — the classic extension spoof "invoice\u202Egpj.exe" returns False here. The two conditions are disjoint; a string can satisfy either, both, or neither.

To cover an override instead, use :func:inspect_anomalies (kind bidi) to detect and :func:strip_bidi to remove. Note :func:strip_bidi does not close this function's case: on a real-letter conflict it returns the input unchanged, because there is no format character to remove.

Parameters:
  • text (str) –

    Input string.

Returns:
  • bool

    True if both a strong-LTR and a strong-RTL character are present.

Examples:

>>> has_bidi_conflict("hello")
False
>>> has_bidi_conflict("helloא")  # Latin + Hebrew
True
>>> has_bidi_conflict("invoice\u202Egpj.exe")  # RLO override, not letters
False
>>> inspect_anomalies("invoice\u202Egpj.exe").kinds  # this is the check
['bidi']

is_confusable

is_confusable

is_confusable(text: str, *, target_script: str = 'latin', greedy: bool | None = None, preferred_aliases: list[str] | None = None) -> bool

True if text contains characters confusable with target-script characters.

Parameters:
  • text (str) –

    Input string.

  • target_script (str, default: 'latin' ) –

    Script to check confusability against. Currently only "latin" is supported; any other value raises DisarmError.

  • greedy (bool | None, default: None ) –

    confusable_homoglyphs compatibility — ignored, with a DeprecationWarning when explicitly passed. disarm always checks all characters.

  • preferred_aliases (list[str] | None, default: None ) –

    confusable_homoglyphs compatibility — ignored, with a DeprecationWarning when explicitly passed. disarm uses its own script detection engine.

Returns:
  • bool

    True if any confusable homoglyphs are present.

Raises:
  • DisarmError

    If target_script is not "latin".

Examples:

>>> is_confusable("pаypal")  # Cyrillic а looks like Latin a
True
>>> is_confusable("paypal")  # all genuine Latin
False

unmapped_confusables

unmapped_confusables

unmapped_confusables(*, target_script: str = 'latin') -> frozenset[str]

Every upstream confusable source disarm's bundled table does not fold (#563).

Read this as exposure, not as a score. A tool at 95% per-source coverage is not 95% safe — it is one query away from the other 5%, and this set is where an adaptive attacker goes when the mapped sources stop working.

Most of the set is out of scope rather than missing: a source whose upstream target is non-Latin has no business in the to-Latin table. Cross-reference :data:disarm.CONFUSABLES_VERSION and docs/provenance.md before reading any one codepoint as a defect.

The set includes five ASCII characters — %, 0, 1, I and m. TR39 is a skeleton transform (m→rn, I/1→l, 0→O), so those are upstream sources; disarm does not apply those rows because folding a legitimate ASCII m to rn corrupts prose. Nothing is filtered out here: a coverage report that quietly drops rows reads as coverage it does not have.

Parameters:
  • target_script (str, default: 'latin' ) –

    Which bundled table to report against — "latin" (default) or "cyrillic". The two have genuinely different coverage.

Returns:
  • frozenset[str]

    A frozenset of single-character strings.

Raises:
  • InvalidArgumentError

    If target_script is not a supported script.

Examples:

>>> unmapped = unmapped_confusables()
>>> "а" in unmapped  # Cyrillic а IS folded, so it is not exposure
False
>>> "m" in unmapped  # TR39 skeleton source m→rn, deliberately not applied
True

find_unmapped_confusables

find_unmapped_confusables

find_unmapped_confusables(text: str, *, target_script: str = 'latin') -> list[tuple[str, int]]

Find confusable sources in text that disarm's table does not fold (#563).

The confusables analogue of :func:find_untranslatable, and it follows the same convention: (character, byte_offset) pairs in order of appearance. This is what turns :func:unmapped_confusables from a global number into something answerable against your own traffic.

Composition runs exactly as it does in :func:normalize_confusables, so a decomposed homoglyph whose precomposed form is mapped counts as covered rather than as a gap — otherwise the report would disagree with what the transform does. Offsets are anchored in text, never in the composed intermediate.

Ordinary English will report the letter m; see :func:unmapped_confusables for why that is deliberate.

Parameters:
  • text (str) –

    Input Unicode string.

  • target_script (str, default: 'latin' ) –

    Which bundled table to report against (default "latin").

Returns:
  • list[tuple[str, int]]

    List of (char, byte_offset) for each unmapped confusable source.

Raises:
  • TypeError

    If text is not a str.

  • InvalidArgumentError

    If target_script is not a supported script.

Examples:

>>> find_unmapped_confusables("pаypal")  # Cyrillic а folds — covered
[]
>>> find_unmapped_confusables("hello")
[]

is_ascii

is_ascii

is_ascii(text: str) -> bool

True if all characters are in U+0000–U+007F.

Parameters:
  • text (str) –

    Input string.

Returns:
  • bool

    True if the string is pure ASCII.

Examples:

>>> is_ascii("hello 123")
True
>>> is_ascii("café")
False

is_case_fold_stable

is_case_fold_stable

is_case_fold_stable(text: str) -> bool

True if text is a stable identity key under case folding.

Answers fold_case(text) == text.lower(). A False result says some other string folds to the same value, so a table keyed on this one can collide — groß.txt and gross.txt are the pair node-tar collided on (CVE-2026-23950), and ſtraße/straße and file/file are the same shape. Roughly 2,000 code points behave this way, including every Latin ligature, , the micro sign, and all of Cherokee (whose fold direction runs small→capital, so both cases move).

This is a fact about the string, not an accusation. groß is an ordinary German word, so a False here is not a report of an attack and the predicate is deliberately kept out of :func:has_anomalies. What to do about it is the caller's decision: reserve both forms, reject the name, or key the table on :func:fold_case rather than str.lower().

str.lower() is the correct comparison basis and str.casefold() is not: casefolding performs the very transform under test, so a predicate written against it is True everywhere.

Answers about disarm's own folding table (Unicode 16.0), so it also reports False for characters your Python's str.lower() knows about and that table does not — which is a collision hazard for the same reason.

A True result is not a uniqueness guarantee: two distinct stable strings can still collide under some other normalization.

Parameters:
  • text (str) –

    Input string.

Returns:
  • bool

    True if full case folding and simple lowercasing agree on text.

Examples:

>>> is_case_fold_stable("gross.txt")
True
>>> is_case_fold_stable("groß.txt")
False
>>> is_case_fold_stable("ΟΔΟΣ")  # Greek final sigma: οδος vs οδοσ
False
from disarm import is_case_fold_stable

is_case_fold_stable("gross.txt")   # True
is_case_fold_stable("groß.txt")    # False — folds to gross.txt, so the two collide
is_case_fold_stable("file")         # False — folds to file
is_case_fold_stable("ΟΔΟΣ")        # False — lowercases to οδος, folds to οδοσ

Use it before a name becomes a key: a reservation table, a username registry, an extraction path. False says the value shares its folded form with some other string, which is the precondition node-tar's PathReservations guard missed in CVE-2026-23950. It says nothing about intent, since groß is an ordinary German word, so the predicate is kept out of anomaly detection and the response is the caller's to choose: reserve both forms, reject the name, or key the table on fold_case instead of str.lower().


is_normalized

is_normalized

is_normalized(text: str, *, form: NormalizationForm = 'NFC') -> bool

True if text is already in the specified normalization form.

Parameters:
  • text (str) –

    Input string.

  • form (NormalizationForm, default: 'NFC' ) –

    Normalization form — "NFC", "NFD", "NFKC", or "NFKD".

Returns:
  • bool

    True if the string is already normalized.

Examples:

>>> is_normalized("café")  # NFC by default
True
>>> is_normalized("e\u0301", form="NFC")  # NFD decomposed
False

is_zalgo

is_zalgo

is_zalgo(text: str, *, threshold: int = 3) -> bool

Detect whether text contains zalgo-style combining mark abuse.

Returns True if any base character has more than threshold consecutive combining marks in NFD decomposition.

Parameters:
  • text (str) –

    Input string to check.

  • threshold (int, default: 3 ) –

    Maximum allowed combining marks per base character (default: 3). Vietnamese has 2 marks in NFD — the default is safe for all legitimate scripts.

Returns:
  • bool

    True if zalgo-style stacking is detected.

Examples:

>>> is_zalgo("café")
False
>>> is_zalgo("Việt Nam")
False
>>> is_zalgo("ḧ̸̡̢̧̛̗̱̜̼̯̞̙́̑̾̊̿̏̒̓̕ě̵̢̧̛̗̱̜̼̯̞̙̈́̑̾̊̿̏̒̓̕l̸̡̢̧̛̗̱̜̼̯̞̙̈́̑̾̊̿̏̒̓̕l̸̡̢̧̛̗̱̜̼̯̞̙̈́̑̾̊̿̏̒̓̕ơ̵̢̧̗̱̜̼̯̞̙̈́̑̾̊̿̏̒̓̕")
True
from disarm import is_zalgo

is_zalgo("café")          # False (1 combining mark — normal)
is_zalgo("Việt Nam")      # False (2 combining marks — normal)
# Zalgo: 'a' with 20 stacked combining graves
is_zalgo("a" + "\u0300" * 20)  # True

is_suspicious_hostname

Renamed from is_safe_hostname in 0.9.1 — with the boolean inverted

If you are upgrading from is_safe_hostname, the return value's polarity was flipped (safesuspicious); a mechanical rename silently reverses your allow/deny branch. See the Upgrading guide.

is_suspicious_hostname

is_suspicious_hostname(hostname: str, *, contractions: bool = False) -> tuple[bool, HostnameAnalysis]

Flag a hostname as suspicious for Unicode homoglyph spoofing.

Returns (suspicious, analysis) where analysis is a HostnameAnalysis with attributes:

  • suspicious: bool — True if a problem was detected (mixed-script, a bundled-table confusable, or a bidi-direction conflict). Because the confusable check is an any-character screen, this flags essentially every hostname with a non-Latin letter — legitimate (москва.рф) as well as spoofs — so it is a maximally conservative screen, not a precise verdict.
  • scripts: list[str] — Unicode scripts found across all labels.
  • mixed_script: bool — True if any single label contains more than one script.
  • has_confusables: bool — True if confusable homoglyphs found.
  • bidi_conflict: bool — True if the decoded hostname mixes strong left-to-right and strong right-to-left characters (the "BiDi Swap" reorder precondition). Folded into suspicious.
  • bidi_control: bool — True if the decoded hostname carries a UAX #9 bidi control character: an override (U+202D/U+202E), embedding (U+202AU+202C), isolate (U+2066U+2069) or directional mark (U+200E/U+200F/U+061C). Disjoint from bidi_conflict, which reads strong-direction letters only and is therefore blind to the RLO extension spoof. IDNA2008 disallows every character in the set, so this is folded into suspicious and the characters are stripped from canonical.
  • has_invisible: bool — True if the decoded hostname carries an invisible character of any class: zero-width (U+200B-U+200D, U+2060-U+2064, U+FEFF, U+180E), tag (U+E0000-U+E007F), variation selector (U+FE00-U+FE0F, U+E0100-U+E01EF), noncharacter (U+FDD0-U+FDEF and the last two of every plane), or private use (U+E000-U+F8FF, planes 15 and 16). Disjoint from bidi_control — these carry no direction at all, so neither bidi field can see them. RFC 5892 puts the tag, variation-selector, noncharacter and private-use classes in DISALLOWED outright, which is what justifies including private use and variation selectors here. U+200C/U+200D are the exception — CONTEXTJ, so conditionally permitted; the screen flags them anyway as a deliberate fail-closed policy. Folded into suspicious. They are removed per label before any other field is computed, so a hostname whose only non-ASCII is an invisible no longer reports a phantom script (U+FEFF sits in the Arabic Presentation Forms block, U+FDD0 in its range).
  • cross_label_script: bool — True if the labels span more than one distinct script. Broader and noisier than bidi_conflict (it fires on benign IDN ccTLDs like google.рф), so it is not folded into suspicious; exposed for caller policy.
  • label_scripts: list[list[str]] — per-label resolved scripts, left to right.
  • whole_script_confusable: bool — True if any label is a whole-script confusable: single-script, non-Latin, whose confusable skeleton is entirely Latin (e.g. Cyrillic аррӏеapple). A graded signal, not a verdict — on its own it fires on short non-Latin ccTLDs (руpy) and on real words (осаoca), so it is not folded into suspicious.
  • label_whole_script_confusable: list[bool] — per-label flags, parallel to label_scripts, so a caller can exclude the TLD label. The precise, low-false-positive policy is wsc(non-TLD label) and TLD-is-Latin (plus a caller-supplied protected-name list for the irreducible оса-style case).
  • canonical: str — Latin-normalized form of the hostname.

A hostname is flagged suspicious if any single label is mixed-script (draws on more than one Unicode script, excluding Common/Inherited), contains confusable homoglyphs, or has a bidi-direction conflict (bidi_conflict), carries a bidi control character (bidi_control), or carries a zero-width/invisible character (has_invisible). The mixed-script rule is conservative and fails closed: it flags benign combinations such as Latin+CJK as well as spoofing ones, so a caller wanting a more permissive policy can inspect the mixed_script and scripts fields and decide for itself.

A False (not-suspicious) result is not a safety guarantee. It means only that no mixed-script label and no confusable from the bundled TR39 table was found. Confusables outside the bundled table are not detected and report not-suspicious. Base allow/deny decisions on the granular findings (including whole_script_confusable) plus your own policy — a detector can attest the presence of a problem, never the absence of all problems.

Parameters:
  • hostname (str) –

    Hostname string to check (e.g. "example.com").

  • contractions (bool, default: False ) –

    Also fold ASCII digraphs that can impersonate a single letter — rn to m, vv to w, cl to d — into canonical, so arnazon.com canonicalizes to amazon.com (#562).

    Off by default, and deliberately confined to hostnames. Unconditional contraction is worse than none: rn to m is right for arnazon and wrong for earnings, turnip and born. A hostname is the one place where the threat model justifies those false positives and there is no running prose to corrupt, so this is not reachable from :func:normalize_confusables at all.

    Matching is leftmost-longest, and applied per label, so a digraph can never form across a dot.

Returns:
  • tuple[bool, HostnameAnalysis]

    Tuple of (suspicious, analysis) where analysis is a HostnameAnalysis.

Examples:

>>> suspicious, analysis = is_suspicious_hostname("google.com")
>>> suspicious
False
>>> analysis.canonical
'google.com'
>>> _s, a = is_suspicious_hostname("arnazon.com", contractions=True)
>>> a.canonical
'amazon.com'

HostnameAnalysis

The second element of the tuple returned by is_suspicious_hostname():

Attribute Type Description
suspicious bool True if any label is mixed-script, contains a Latin-confusable character, or the hostname has a bidi-direction conflict, a bidi control character, or a zero-width/invisible character. An any-character confusable screen — it flags essentially every non-Latin hostname, so it is a maximally conservative screen, not a precise verdict
scripts list[str] Unicode scripts found across all labels
mixed_script bool True if any single label contains more than one script
has_confusables bool True if any label contains a Latin-confusable character
bidi_conflict bool True if the decoded hostname mixes strong LTR and RTL characters (the "BiDi Swap" precondition); folded into suspicious
bidi_control bool True if the decoded hostname carries a UAX #9 bidi control character — override (U+202D/U+202E), embedding (U+202AU+202C), isolate (U+2066U+2069) or directional mark (U+200E/U+200F/U+061C). Disjoint from bidi_conflict, which reads strong-direction letters only. Folded into suspicious; the characters are stripped from canonical
has_invisible bool True if the decoded hostname carries an invisible character of any class: zero-width (U+200BU+200D, U+2060U+2064, U+FEFF, U+180E), tag (U+E0000U+E007F), variation selector (U+FE00U+FE0F, U+E0100U+E01EF), noncharacter (U+FDD0U+FDEF and the last two of every plane), private use (U+E000U+F8FF, planes 15 and 16). Disjoint from bidi_control: these carry no direction at all. RFC 5892 puts the tag, variation-selector, noncharacter and private-use classes in DISALLOWED outright; U+200C/U+200D are CONTEXTJ (conditionally permitted) and the screen flags them anyway, as a deliberate fail-closed policy. Folded into suspicious, and removed per label before any other field is computed, so they never reach scripts, mixed_script or canonical
cross_label_script bool True if the labels span more than one script; broader/noisier than bidi_conflict (fires on benign IDN ccTLDs like google.рф), so not folded into suspicious
label_scripts list[list[str]] Per-label resolved scripts, left to right
whole_script_confusable bool True if any label is single-script, non-Latin, whose confusable skeleton is entirely Latin (аррӏеapple). A graded signal, not a verdictnot folded into suspicious (fires on руpy, осаoca)
label_whole_script_confusable list[bool] Per-label whole-script-confusable flags, parallel to label_scripts (exclude the TLD label for the precise policy)
canonical str Latin-normalized form of the hostname
from disarm import is_suspicious_hostname

suspicious, analysis = is_suspicious_hostname("google.com")
# suspicious = False, analysis.canonical = "google.com"

suspicious, analysis = is_suspicious_hostname("gооgle.com")  # Cyrillic о's
# suspicious = True, analysis.mixed_script = True, analysis.has_confusables = True

# Whole-script spoof: an all-Cyrillic label whose skeleton is Latin
suspicious, analysis = is_suspicious_hostname("аррӏе.com")
# analysis.whole_script_confusable = True
# analysis.label_whole_script_confusable = [True, False]  # spoof label, then the TLD
# analysis.canonical = "apple.com"

suspicious is a maximally conservative screen: because the confusable check is an any-character test and the most frequent Cyrillic/Greek letters are TR39 confusables, it flags essentially every non-Latin hostname — москва.рф as readily as аррӏе.com. A not-suspicious result is not a safety guarantee, and a suspicious one is not a precise verdict. For whole-script spoofs, use whole_script_confusable / label_whole_script_confusable: the precise, low-false-positive policy is whole_script_confusable(non-TLD label) ∧ (TLD is Latin/ASCII), applied by the caller — disarm deliberately does not model registrable boundaries (no PSL), and the irreducible оса-style case (a real word that skeletons to Latin) needs a caller-supplied protected-name list. See the Threat Model.