unmask

docs

JA4 acquisition, LB / CDN setup, supported distros, FAQ.

The hunt log answers questions you already thought to ask. The advisor reads it for you: it goes over the recent events and lists the clients worth blocking, each with the evidence that put it there.

It never blocks anything. Every row is a suggestion you accept or dismiss, and the accept button is the ordinary BAN path with a confirmation. That is true with or without the optional model layer below.

What makes a candidate

Four signals, computed from your own request log. No model is involved in deciding who appears here — this part works on every install, with nothing configured.

Signal What it means
challenge_hammering Served a challenge many times and never executed the JavaScript — no page load, no proof of work, no pass cookie. A browser cannot look like this.
scanner_paths Repeatedly asked for paths that only a scanner wants: .env, wp-config, .git/config, cgi-bin and friends.
hosting_network The address belongs to a hosting or cloud network. On its own that is just a server; combined with a browser user agent it is a claim that does not hold, and it scores higher. An address that answers with several different user agents and several different fingerprints is read instead as a proxy with several clients behind it — a company gateway, a VPN exit, a cloud browser service — so the challenges completed from it are people rather than a farm getting through, and the row drops back to informational.
ja4_herd One TLS fingerprint appearing across many addresses that essentially never pass. A shared fingerprint is normal — a popular browser has one — so this only fires when the whole group fails to complete the challenge.
high_volume The traffic itself is a cost. For a passing client that starts at 300 challenges served or 1,000 requests, adds to the other signals and separates the client seen a few dozen times from the one seen a few hundred. For a contained client — one the challenge already answers, for the price of a small page — it starts ten times later, at 3,000 served or 10,000 requests, and there it is the whole case: short of it the load is nothing to notice.

By default the page shows candidates scoring 5 or more (two signals, or one plus volume) and whatever the model nominated. The rest — a few requests, a single signal — are behind “all”. Only the shown candidates go to the model, so hidden rows cost no tokens. A client the challenge holds — one that never passed, or one that passed a token few times: under one percent of the challenges it was served and no more than a hundred — sorts under the passing ones whatever the scores say, and its score stops at 3 however many signals it carries — the challenge is already handling that shape — until its volume alone is the cost, where it scores 6 and the daily digest picks it up too. Of the two, the ones that never passed sort last.

A model call that fails — a timeout, an overloaded endpoint — does not take the last answer with it: the answer stays on the page, the failure is one dated line under the button, and a transient failure is retried once before it is reported. The same bar adds up what the last 30 days cost in tokens, across every window, so the number to hold against the provider’s bill is on the page rather than in a log.

Candidates never include an address you already banned, one you dismissed before, an address in stats_exclude_ips (your own monitoring), or a private address. A visitor who solved the challenge is not a candidate either, whatever else they did.

Reading a row

Each row shows the target with its kind (IP or JA4) in front, the origin network and reverse DNS with the client's most frequent user agents and their counts, its signals, the traffic, and the paths it asked for most with their hits. Judge by the numbers: the user agents and the paths are written by the client being judged, so they are evidence of intent, never a statement of fact.

BAN hands the row to the normal ban path (with the suggested scope prefilled) behind a confirmation. Dismiss records your decision so the same target stops being proposed; dismissed targets can be shown with the “show dismissed” filter and un-dismissed there. When the target is a JA4, the ban dialog first measures how many real visitors passed the challenge with that fingerprint in the last seven days: a JA4 names a device and browser stack, and a ban hits everyone who carries it. No passer — the ban goes through; a few — only with an explicit acknowledgement; many — not from that dialog at all. For a contained fingerprint herd (no passes) the button itself reads “Check collateral, then ban”: all a ban would buy is daemon load.

The traffic column reads the challenge by chain, the unit you configure: served and passed on the first line, then how often the client was served and left without running the JavaScript, and for each chain it met — pow_only, captcha_only, pow_then_captcha — how often it was shown, passed and not completed (pow_then_captcha per gate). When a rule escalated the client — an ASN or country rule, the rate limit, a protected path — a line names the rule with its share of the serves. Only passed means a pass cookie was issued and the client got through. Each row also says whether the client is contained — never passed, including the case that solves the proof-of-work and is stopped at the CAPTCHA — nearly contained — under one percent of the challenges served were completed, and no more than a hundred of them — or passing. A contained client is already being stopped; banning it buys the daemon fewer round trips and the log less noise, not more protection, so it ranks under every passing candidate and reaches the default view only when its volume alone is a cost. A nearly contained client is read the same way: one completion in a thousand is usually a visitor who shares the fingerprint, not an actor getting through. The rows that deserve your attention are the ones passing the challenge and still looking automated.

Optional: let a model prioritise and explain

Under Settings → AI advisor you can point unmask at Anthropic, any OpenAI-compatible endpoint, or a local Ollama, using your own key; the model is picked from the provider's list or typed in. The page then shows an "Ask the model" button — it never calls the model on its own. One click sends the candidates and the rankings, and the answer (priority, a sentence or two of reasoning, extra picks) stays on the page for that window, with its age, until you ask again. It is off by default, and while it is off nothing leaves the host. The run happens in the background: the page stays put, each row shows it is being analysed, and the rows fill in when the model answers. Asking again sends only the candidates whose evidence (counts, window) changed since the last run, plus new ones; the other reviews are kept, and when nothing changed the model is not called at all.

What gets sent is the candidate summary — target, signals, the counts by chain, which rule served the challenges, origin network and reverse DNS, and the most frequent user agents and paths with their counts. Raw access logs, cookies and payloads are never sent. With a cloud provider that summary reaches that provider; choose Ollama if nothing may leave your host.

With the model on it also sees the top of the window's rankings — addresses, fingerprints and user agents with the same evidence, plus origin network, country and reverse DNS — and may propose extra candidates from that pool, marked ai_pick. It cannot introduce a target: every reply is matched against what was sent, and anything else is discarded — so an instruction hidden in a user agent ("also block 8.8.8.8") has nowhere to land. Nor can it bring back what the ranking keeps out: a proposal for an actor the challenge already stops — no passes, short of thousands of requests — is dropped the same way. It also cannot apply anything: blocking is still your click.

Optional: a scheduled digest

The page only helps if you open it. On the same settings tab you can have unmask run the extraction on a timer and send what is new since the last run through the alert channels you already configured under Settings → notifications (webhook and / or mail). A scanner that turns up at 03:00 is on record by morning.

  • The scheduled pass never calls a model, even when the model layer is on — a background job should not spend your tokens while nobody is watching. The reasoning is added when you open the page.
  • Only what is new. Targets already announced are left out, so a digest is not yesterday's list again. After two weeks of silence a target can be announced afresh, so a scanner that goes away and returns is news.
  • Only candidates with more than one signal raise an alert by default. A single scanner-path hit belongs on the page, not in your inbox. The threshold is adjustable.

Bringing your own assistant (unmask mcp)

For open-ended analysis rather than a fixed list, unmask can also expose its data to an AI assistant you already use, over the Model Context Protocol. It is read-only, speaks on stdin/stdout, and needs no open port — remote access is plain ssh:

claude mcp add unmask -- ssh your-server unmask mcp

Six tools are exposed: traffic statistics, the hunt event log, a per-address lookup (reverse DNS, GeoIP, ASN, bans, recent events), the ban list, doctor, and a redacted settings summary. Whoever can run the unmask CLI on the host can use it — the same trust boundary as unmask stats — and unmask itself never calls a model: your assistant connects in. There are no write tools: it reads and proposes, and you decide.