익스클루시브
Lookalike <mv-lookalike>
신뢰하는 대상을 사칭하는 모든 것을 잡아내는 유사 문자열 탐지입니다. 이메일, 채팅 스레드, 폼 또는 붙여넣기 대상을 감싸고 신뢰하는 신원을 지정하면(trusted="Acme Bank <acme-bank.com>, @quillpay.com, @harrowfield, Payroll <DE89 3704 …>"), 범위 안의 모든 링크, 발신자, 이메일 주소, @핸들, 패키지 또는 사용자 이름, IBAN, 지갑 주소가 그와 비교됩니다. 일반 텍스트는 물론 입력하거나 붙여넣는 중인 필드도 포함됩니다.
문화적 레퍼런스
빨간 모자, 샤를 페로 (1697, 동화). 늑대는 할머니의 침대에 누워 거의 그럴듯해 보이지만, 소녀는 너무 늦기 전에 맞지 않는 세부 사항을 하나씩 알아챕니다. UI는 모든 링크, 발신자, 계정을 사용자가 신뢰하는 대상과 비교하고, 클릭하기 전에 맞지 않는 세부 사항을 하나씩 짚어 줍니다.
작동 방식
신뢰하는 대상을 사칭하는 모든 것을 잡아내는 유사 문자열 탐지입니다. 이메일, 채팅 스레드, 폼 또는 붙여넣기 대상을 감싸고 신뢰하는 신원을 지정하면(trusted="Acme Bank <acme-bank.com>, @quillpay.com, @harrowfield, Payroll <DE89 3704 …>"), 범위 안의 모든 링크, 발신자, 이메일 주소, @핸들, 패키지 또는 사용자 이름, IBAN, 지갑 주소가 그와 비교됩니다. 일반 텍스트는 물론 입력하거나 붙여넣는 중인 필드도 포함됩니다. 간결한 혼동 문자 표를 이용한 동형 문자(키릴 문자 “а”, “o” 대신 쓴 숫자 “0”, “l” 대신 쓴 대문자 “I”, “m” 대신 쓴 “rn”, 전각 문자와 악센트가 붙은 글자), 퓨니코드, 오타(가중 편집 거리로 판별한 추가, 누락, 뒤바뀜, 대체된 글자), 덧붙인 단어(“acme-bank-secure.com”, 유인 단어 표시), TLD 바꿔치기, 브랜드를 서브도메인으로 쓰는 수법과 “[email protected]” 사용자 정보 속임수, 실제 주소가 속하지 않은 브랜드를 내세우는 표시 이름과 링크 텍스트, 그리고 지갑 주소 포이즈닝(앞부분과 끝부분은 같고 가운데가 다른 주소)을 잡아냅니다. 의심스러운 항목에는 물결 밑줄과 글이 적힌 인라인 경고 버튼이 붙습니다. 그 패널은 의심스러운 문자열을 신뢰하는 문자열 위에 겹쳐 놓고 서로 다른 글자마다 강조하고 번호를 붙인 뒤, 맞지 않는 세부 사항을 하나씩 쉬운 말로 설명합니다(“The “а” here is Cyrillic (U+0430), not the Latin letter “a”.”). 판정 등급(probably fine, looks like X, impersonates X)과 동작(Go to the real X, 필드용 Use the real value, Continue anyway(취소 가능한 mv-lookalike-proceed), Report)도 제공합니다. 경고가 붙은 링크를 클릭하면 이 확인 단계로 가로챈 뒤 네이티브 방식으로 다시 실행합니다. 분석은 순수 함수이며 DOM에 의존하지 않으므로(compare(a, b), check(value, trusted), checkLink(href, text, trusted), MvLookalike.compare로도 사용 가능), 같은 규칙을 서버에서도 실행할 수 있습니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-lookalike>) |
| 상태 | 안정 |
| 키트 | 신뢰와 개인정보 보호 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, phishing, spoofing, impersonation, homoglyph, confusables, punycode, idn, typosquatting, lookalike-domain, link-safety, email-security, sender-verification, display-name, iban, wallet, address-poisoning, edit-distance, diff, security, trust |
When to use
- An inbox, support desk or chat shows links and senders that could impersonate your brand, your bank or your vendors
- A payout or wire form must catch a payee email, IBAN or wallet address that is one character off a saved one
- Admins paste domains, package names or usernames into allowlists and a near miss would open a hole
- A server check and the UI must explain a spoofed link with the same rules and the same wording
Avoid when
- The problem is hidden characters inside a typed value (zero-width spaces, exotic spaces, bidi controls), not imitation of a known name → use Invisibles instead
- There is no list of trusted identities to compare against: generic phishing detection needs a server-side reputation service
- A critical form value just needs to be echoed back for confirmation before submitting → use Read Back instead
설치
node scripts/add.mjs lookalike --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["lookalike"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dismiss.js, core/dom.js, core/element.js, core/position.js, components/lookalike/lookalike.js, components/lookalike/lookalike.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-lookalike trusted="Acme Bank <acme-bank.com>">
<p>Your card is locked. Unlock it at <a href="https://acme-bank-secure.com/login">acme-bank-secure.com</a>.</p>
</mv-lookalike>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="lk-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#lk-demo { display:grid; gap:1rem; align-content:start; font-size:.875rem }
#lk-demo .lk-grid { display:grid; grid-template-columns:minmax(0,1.35fr) minmax(0,1fr); gap:1rem; align-items:start }
#lk-demo .lk-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#lk-demo .lk-bar { display:flex; align-items:center; gap:.5rem; height:2.75rem; padding:0 1rem; border-bottom:1px solid var(--mv-border); color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#lk-demo .lk-bar strong { color:var(--mv-fg); font-size:.8125rem; font-weight:600 }
#lk-demo .lk-bar .mv-badge { margin-inline-start:auto }
#lk-demo .lk-mail-head { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.875rem; padding:1rem 1.125rem .875rem; border-bottom:1px solid var(--mv-border) }
#lk-demo .lk-avatar { display:grid; place-items:center; width:2.25rem; height:2.25rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.8125rem; font-weight:600 }
#lk-demo .lk-subject { margin:0 0 .25rem; font-size:1rem; font-weight:650; letter-spacing:-.01em; line-height:1.3 }
#lk-demo .lk-meta { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.7 }
#lk-demo .lk-meta b { color:var(--mv-fg-subtle); font-weight:500; display:inline-block; min-width:2.25rem }
#lk-demo .lk-from { color:var(--mv-fg); font-weight:500 }
#lk-demo .lk-body { padding:1rem 1.125rem 1.125rem; line-height:1.65; color:var(--mv-fg) }
#lk-demo .lk-body > p { margin:0 0 .75rem }
#lk-demo .lk-body > p:last-child { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
#lk-demo .lk-body a:not(.mv-button) { color:var(--mv-accent); text-decoration-color:color-mix(in oklab,var(--mv-accent) 40%,transparent); text-underline-offset:.2em }
#lk-demo .lk-form { display:grid; gap:.875rem; padding:1rem 1.125rem 1.125rem }
#lk-demo .lk-field { display:grid; gap:.375rem; min-width:0 }
#lk-demo .lk-field > span:first-child { color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#lk-demo .lk-row { display:flex; align-items:center; gap:.25rem; min-width:0 }
#lk-demo .lk-row .mv-input { flex:1 1 auto; min-width:0 }
#lk-demo .lk-mono { font-family:var(--mv-font-mono); font-size:.8125rem }
#lk-demo .lk-chat { display:grid; gap:.625rem; padding:1rem 1.125rem 1.125rem; max-height:13rem; overflow:auto }
#lk-demo .lk-msg { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.625rem; align-items:start }
#lk-demo .lk-msg .lk-avatar { width:1.75rem; height:1.75rem; font-size:.6875rem }
#lk-demo .lk-msg-who { margin:0; font-size:.75rem; font-weight:600 }
#lk-demo .lk-msg-who span { color:var(--mv-fg-subtle); font-weight:400; margin-inline-start:.375rem }
#lk-demo .lk-msg-text { margin:.125rem 0 0; line-height:1.55 }
#lk-demo .lk-controls { display:flex; align-items:center; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#lk-demo .lk-controls .lk-sep { flex:1 1 0 }
#lk-demo .lk-ctl { display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#lk-demo .lk-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); flex:1 1 100% }
#lk-demo .mv-choice { font-size:.8125rem }
@media (max-width:52rem) { #lk-demo .lk-grid { grid-template-columns:minmax(0,1fr) } }
</style>
<mv-lookalike id="lk-main" show-verified trusted="Acme Bank <acme-bank.com>, Quillpay <quillpay.com>, Harrowfield <harrowfield.io>, @harrowfield, Harrowfield payroll <DE89 3704 0044 0532 0130 00>, Treasury wallet <0x52908400098527886E0F7030069857D2E4169EE7>">
<div class="lk-grid">
<!-- An inbox message: sender, links and plain-text addresses are all checked -->
<article class="lk-card" aria-label="Email message">
<div class="lk-bar"><strong>Inbox</strong> · Finance shared mailbox <span class="mv-badge" data-variant="outline">Sep 24, 2026</span></div>
<header class="lk-mail-head">
<span class="lk-avatar" aria-hidden="true">AB</span>
<div style="min-width:0">
<h3 class="lk-subject">Unusual sign-in on your business account</h3>
<p class="lk-meta"><b>From</b> <span class="lk-from" data-lookalike>Acme Bank Security <[email protected]></span><br>
<b>To</b> [email protected]</p>
</div>
</header>
<div class="lk-body">
<p>Hi Maya, we noticed a sign-in to your Acme Bank business account from São Paulo, Brazil on Sep 23, 2026 at 11:42 PM.</p>
<p>If this wasn’t you, <a href="https://acmе-bank.com/secure/verify">confirm your identity</a> within 24 hours to avoid a hold on outgoing wires. You can also review recent activity at <a href="https://acme-bank.com.account-review.net/login">acme-bank.com/activity</a>.</p>
<p>Card payments on your account are processed by quillpay.co. For anything else, our help center is at <a href="https://acme-bank.com/help">acme-bank.com/help</a>.</p>
<p>Acme Bank · 400 Harbor Boulevard, Boston, MA 02210</p>
</div>
</article>
<div style="display:grid;gap:1rem;min-width:0">
<!-- A payment form: fields are checked as you type or paste -->
<form class="lk-card" aria-label="Send a payment" onsubmit="return false">
<div class="lk-bar"><strong>Send a payment</strong> · Payouts</div>
<div class="lk-form">
<label class="lk-field"><span>Payee email</span>
<span class="lk-row"><input class="mv-input" type="email" value="[email protected]" autocomplete="off"></span></label>
<label class="lk-field"><span>Payee IBAN</span>
<span class="lk-row"><input class="mv-input lk-mono" data-lookalike value="DE89 3704 0044 0532 0180 00" autocomplete="off" spellcheck="false"></span></label>
<label class="lk-field"><span>Treasury wallet (USDC)</span>
<span class="lk-row"><input class="mv-input lk-mono" data-lookalike value="0x5290c1b7f04a9d3e6628fb05ac91d7e30b4a9EE7" autocomplete="off" spellcheck="false"></span></label>
<label class="lk-field"><span>Try it: type or paste any link</span>
<span class="lk-row"><input class="mv-input" type="url" value="https://www.quillpay-support.com/invoices" autocomplete="off" spellcheck="false"></span></label>
</div>
</form>
<!-- A team chat: new messages are scanned as they arrive -->
<section class="lk-card" aria-label="Team chat">
<div class="lk-bar"><strong>#payouts</strong> · Team chat</div>
<div class="lk-chat" id="lk-chat">
<div class="lk-msg"><span class="lk-avatar" aria-hidden="true">JO</span><div><p class="lk-msg-who">Jonas Okafor<span>9:12 AM</span></p><p class="lk-msg-text">September payroll is approved, @harrowfield can release it.</p></div></div>
</div>
</section>
</div>
</div>
</mv-lookalike>
<div class="lk-controls">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="lk-intercept" checked> Confirm before opening flagged links</label>
<span class="lk-ctl"><span id="lk-th-label">Flag from</span>
<mv-segmented id="lk-threshold" aria-labelledby="lk-th-label" name="threshold" value="lookalike">
<button value="fine">Similar</button>
<button value="lookalike">Look-alike</button>
<button value="impersonation">Impersonation</button>
</mv-segmented>
</span>
<span class="lk-sep"></span>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="lk-incoming">Receive a chat message</button>
<p class="lk-log" id="lk-log" aria-live="polite">Click a flag, or a flagged link, to see what doesn’t match.</p>
</div>
<script type="module">
const lk = document.getElementById("lk-main");
const log = document.getElementById("lk-log");
const say = (text) => { log.textContent = text; };
// Demo only: never actually open the fake addresses.
lk.addEventListener("mv-lookalike-proceed", (e) => {
e.preventDefault();
lk.close();
say(`mv-lookalike-proceed · would continue to ${e.detail.href ?? e.detail.value} (cancelled in this demo)`);
});
lk.addEventListener("mv-lookalike-report", (e) => say(`mv-lookalike-report · ${e.detail.verdict}: ${e.detail.value}`));
lk.addEventListener("mv-lookalike-found", (e) => say(`mv-lookalike-found · ${e.detail.result.title}: ${e.detail.value}`));
lk.addEventListener("click", (e) => {
const real = e.target.closest?.(".mv-lookalike-real");
if (real) { e.preventDefault(); say(`Would open the real site: ${real.href}`); }
});
document.getElementById("lk-intercept").addEventListener("change", (e) => { lk.intercept = e.target.checked ? "true" : "false"; });
document.getElementById("lk-threshold").addEventListener("mv-change", (e) => { lk.threshold = e.detail.value; });
const incoming = [
["HT", "Harrowfield Treasury", "@harr0wfield here: urgent, update the payout wallet before 5 PM using the form at quillpay.help/payouts"],
["PL", "Priya Lal", "Invoices for Q3 are on harrowfield.io/finance, and the vendor list is at quillpaay.com/vendors"],
["AK", "Ana Kovač", "Heads-up, I got a text from acme-bnak.com asking me to confirm a transfer. Ignored it."],
];
let next = 0;
document.getElementById("lk-incoming").addEventListener("click", () => {
const [initials, who, text] = incoming[next++ % incoming.length];
const chat = document.getElementById("lk-chat");
const time = new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
const msg = document.createElement("div");
msg.className = "lk-msg";
const avatar = Object.assign(document.createElement("span"), { className: "lk-avatar", textContent: initials });
avatar.setAttribute("aria-hidden", "true");
const body = document.createElement("div");
const name = Object.assign(document.createElement("p"), { className: "lk-msg-who", textContent: who });
name.append(Object.assign(document.createElement("span"), { textContent: time }));
body.append(name, Object.assign(document.createElement("p"), { className: "lk-msg-text", textContent: text }));
msg.append(avatar, body);
chat.append(msg);
chat.scrollTop = chat.scrollHeight;
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
trusted | comma-separated list | Trusted identities. Each entry is a domain (acme-bank.com, also trusts its subdomains and email addresses), an email domain (@quillpay.com), an address ([email protected], trusted by its domain), a handle or name (@harrowfield, left-pad) or an account (IBAN, 0x… or bc1… wallet). Prefix a label with the email syntax to name it in messages and buttons: “Acme Bank <acme-bank.com>”. The identities property replaces this list when set. | |
threshold | fine | lookalike | impersonation | lookalike | Lowest verdict that gets a flag (and link interception). fine also flags faint resemblances (“Probably fine”), impersonation only flags deliberate disguises. |
intercept | "true" | "false" | true | Clicks (and middle clicks) on flagged links open the explanation as a confirmation instead of navigating. Continue anyway replays the click natively, so target, rel and client-side routers behave as usual. |
scan | space-separated: links text fields | links text fields | What is checked: links (a[href] with http(s):, mailto: or //), text (domains, emails, @handles, IBANs and 0x wallets written in plain text, wrapped in a span.mv-lookalike-mark only when flagged) and fields (input[type=email], input[type=url] and any input or textarea with data-lookalike, on input, paste, change and blur). Elements with data-lookalike are always checked. |
show-verified | boolean | Exact matches of a trusted identity get a small shield-check mark with a “Verified: {label}” text alternative. | |
validate | boolean | Flagged fields get a custom validity message (form submission is blocked) until the user picks Use the real value or Continue anyway. | |
data-lookalike (on content) | empty | value | Checks this element: its text (“Acme Bank Security <[email protected]>” is parsed as display name + address), the attribute value if given, a link’s href, or a field’s value. data-lookalike-name="…" adds a display name to check against the address. data-lookalike-ignore skips a subtree. | |
data-lookalike-verdict (set on content) | impersonation | lookalike | fine | trusted | Set by the component on every flagged link, mark, element or field (styleable); data-lookalike-ack once the user chose Continue anyway. | |
data-found | boolean | Set on the element while at least one suspicious item is flagged. |
Properties
| Name | 유형 | Description |
|---|---|---|
identities | Array<string | { value, label?, href? }> | Trusted identities as data (replaces the trusted attribute). href overrides the “Go to the real X” destination (defaults to https://<domain>). Read it to get the parsed list with kinds. |
results | Array<{ element, value, verdict, result, acknowledged, reported }> | Every flagged or verified item currently in scope (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text, announcement and difference message ({label}, {value}, {c}, {t}, {count}… placeholders; “{c}” renders as inline code). English defaults. |
threshold / intercept / scan / showVerified / validate | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
MvLookalike.compare(candidate, trusted, { strings? }) | Static and pure (also exported as compare): compares one value with one identity. Returns { verdict: "trusted" | "impersonation" | "lookalike" | "fine" | "unrelated", score (0-1 resemblance), kind (domain | email | name | account), value, display, trusted, label, href, title, diffs: [{ n, type, key, vars, message }], rows: { shown, candidate, trusted } (segments { text, n, type, gap } for side-by-side rendering) }. No DOM, safe in Node, Deno, workers. |
MvLookalike.check(value, trusted[], options?) | Static and pure (also exported as check): compares a value with every identity and returns the most telling result (an exact match wins). checkLink(href, text, trusted) also flags link texts that claim another address. |
check(value) | Same as the static check, against this element’s identities. |
rescan() | Re-checks the whole scope now. Content added later (a new chat message, a re-render) is picked up automatically by a MutationObserver. |
open(element) / close() | Opens the explanation for a flagged link, field or marked element, or closes it. |
Events
| Name | Description |
|---|---|
mv-lookalike-found | A suspicious item was flagged (initial scan, new content or a field value). detail: { element, kind: "link" | "text" | "marked" | "field", value, verdict, result }. |
mv-lookalike-proceed | Cancelable. The user chose Continue anyway. detail: { element, kind, value, href (links), verdict, result }. preventDefault() keeps the warning (and, for an intercepted link, does not open it). |
mv-lookalike-report | The user chose Report (once per item; the flag then reads “· Reported”). detail: { element, kind, value, verdict, result }. Send it to your abuse or security endpoint. |
CSS classes
| Name | Description |
|---|---|
mv-lookalike-flag | Inline flag <button> placed right after the item (data-verdict, aria-expanded, data-ack, data-reported): an icon, .mv-lookalike-flag-text and a visually hidden verdict. |
mv-lookalike-mark | Span wrapped around a suspicious address found in plain text (never around unflagged text; unwrapped when no longer flagged). |
mv-lookalike-verified | Shield-check mark after exact matches with show-verified. |
mv-lookalike-panel | Explanation panel (non-modal dialog in the Popover API top layer, data-verdict, data-intercept): -head, -icon, -kicker, -title, -text, -close, -compare (rows of -label + -value with .mv-lookalike-diff / .mv-lookalike-gap and numbered .mv-lookalike-num), -diffs (the numbered explanations, .mv-lookalike-code), -status, -actions (-report, -proceed, -real, -use). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-lookalike-danger | var(--mv-danger) | Tone of impersonations: underline, flag, highlights. |
--mv-lookalike-warn | var(--mv-warning) | Tone of look-alikes. |
--mv-lookalike-ok | var(--mv-success) | Tone of verified marks and of the trusted row’s highlights. |
Accessibility
Every flag is a real <button> with visible words (“Impersonation”, “Look-alike”, “Similar”) plus a visually hidden verdict (“: Impersonates Acme Bank. Show the differences.”), aria-haspopup="dialog", aria-expanded and aria-controls; the wavy underline is never the only signal. The panel is a labelled, described non-modal dialog inserted right after the flag (outside any label, link or button), so Tab moves on naturally; it receives focus when opened from a flag, and on an intercepted link focus goes to the safe action (Go to the real X) rather than Continue anyway. Escape or Close folds it and returns focus to the flag or link; an outside click or moving focus elsewhere closes it too. Differences are spelled out in text, never shown by color alone: the side-by-side rows keep the real characters, each difference is numbered in both rows and in an ordered list of sentences that name the character, its script and code point (“The “а” here is Cyrillic (U+0430), not the Latin letter “a”.”), what was added, missing, swapped or replaced; the numbers are aria-hidden duplicates of the list order. Flagged fields get an extra aria-describedby text with the verdict, an optional custom validity message (validate), and a polite announcement when a typed or pasted value becomes suspicious; new suspicious content (a chat message) is announced politely once per batch; the initial scan is silent. Verified marks carry a text alternative. The one-by-one reveal of the differences and highlights is disabled under reduced motion (OS or data-motion="reduce"), and forced-colors mode keeps highlights as system Mark colors with outlines.