uax

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 10, 2026 License: MIT Imports: 13 Imported by: 0

README


title: uax

uax

Client intelligence for Go. Not another User-Agent parser: uax reads the User-Agent and Client Hints and the fetch metadata around them, and tells you what is talking to your server — including when it is lying.

c := uax.ParseRequest(r)

c.Browser.Name    // "Chrome"
c.OS.Name         // "Android"
c.Device.Model    // "SM-S928B"   — verbatim from the request
c.IsAICrawler()   // false
c.Verdict.Risk    // 0.00

Zero dependencies. Zero allocations on the parse path.

Naming the hardware is a separate question, and a separate module. uax extracts the model code; a catalogue turns it into a handset:

import "github.com/bakhod1r/devicex"

p := uax.New(uax.Config{Device: devicex.Describe})
c := p.ParseRequest(r)

c.Device.Brand    // "Samsung"
c.Device.Name     // "Galaxy S24 Ultra"
c.Device.Type     // "Mobile"

Without the hook those stay empty, and Device.Model still reports the code — which is the honest answer when nothing can name it. Twenty-five thousand device names are a large thing to link into a binary that does not want them, and they go stale on a schedule a parser does not share.


Install

go get github.com/bakhod1r/uax

Use

// Pure function: no cache, no shared state, no retention.
c := uax.Parse(ua)

// Preferred: Client Hints are structured, and they survive the User-Agent
// reduction that is steadily emptying the legacy string.
c := uax.ParseRequest(r)

// Configured parser for a server under load.
p := uax.New(uax.Config{
    Cache:   uax.CacheConfig{Enabled: true, Size: 8192},
    Metrics: reporter,
})

HTTP middleware, parsing lazily so routes that ignore the client pay nothing:

mux.Handle("/", middleware.New(p)(handler))

func handler(w http.ResponseWriter, r *http.Request) {
    c := middleware.FromContext(r.Context()) // never nil
}

What it finds

Browser 55 rules — Chrome, Firefox, Safari, Edge, Opera, Brave, Vivaldi, Samsung Internet, Yandex, UC, QQ, Silk, IE; the regional majors (Whale, Coc Coc, Quark, Sogou, 360, Baidu, MIUI, Huawei, OPPO, vivo); the Gecko and Goanna forks (LibreWolf, Pale Moon, Basilisk); the independents (Konqueror, GNOME Web, Falkon, Midori); plus family, variant and mode (headless, WebView)
OS 44 rules — Windows (NT mapped to its release), macOS, iOS, iPadOS, tvOS, watchOS, visionOS, Android, ChromeOS, HarmonyOS, KaiOS, Tizen, webOS, Sailfish, Symbian, ten Linux distributions, the BSDs, Solaris, Haiku, SteamOS, Roku, Chromecast, consoles, and architecture where stated
Device the model code, verbatim from the request; brand, name and form factor come from a catalogue via Config.Device
Bot 41 rules — categorised: SearchEngine, AICrawler, AIAgent, SocialCrawler, SEO, Monitoring, Security
AI GPTBot, ClaudeBot, PerplexityBot, Google-Extended, Bytespider, Amazonbot, CCBot, and the live agents (ChatGPT-User, Claude-User)
In-app 12 rules — Instagram, Facebook, TikTok, Telegram, WeChat, LINE, Snapchat, Slack
Automation 9 rules — Headless Chrome/Firefox, Playwright, Puppeteer, Selenium, Cypress, WebDriver
Form factor Sec-CH-UA-Form-Factors when sent — Desktop, Mobile, Tablet, XR, EInk, Watch, Automotive — stated by the browser rather than inferred
HTTP clients 19 rules — curl, Wget, Go-http-client, python-requests, httpx, okhttp, Java, Postman, axios, Guzzle

187 rules, each shipping with its own test. A rule without one fails the lint suite, so the database cannot rot quietly.

Frozen values are reported as frozen

Chrome's User-Agent Reduction replaced the platform section with constants. Every reduced Android build says Android 10; K whatever it is really running, macOS caps at 10_15_7, and Windows has said NT 10.0 since Windows 10.

Repeating those as observed facts is the most common way a parser lies, so uax does not:

c := uax.Parse("Mozilla/5.0 (Linux; Android 10; K) … Chrome/138.0.0.0 Mobile Safari/537.36")

c.OS.Name          // "Android"  — the platform is still known
c.OS.Version       // zero       — "10" here is a placeholder, not a version
c.OS.Reduced       // true
c.Device.Type      // Mobile     — the form factor survives reduction
c.Device.Model     // ""         — "K" is not a model code
c.Device.Reduced   // true

macOS and Windows are treated differently, because their frozen value is a floor rather than a contradiction: the version is kept and Reduced is set beside it. Sec-CH-UA-Platform-Version and Sec-CH-UA-Model carry the real values, and merging them clears the flag.


The device-name question

SM-S928B contains no hint that it means "Galaxy S24 Ultra". Samsung assigned it. No regex derives it, and no amount of confidence substitutes for looking it up — so this module does not try. It reports what the request carried:

c.Device.Model  // "SM-S928B" — verbatim evidence, never modified

Everything past that is a catalogue's job, and catalogues have their own release schedule: phones ship weekly, parsers do not. Config.Device is the seam. devicex fills it:

p := uax.New(uax.Config{Device: devicex.Describe})

c.Device.Brand  // "Samsung"          — from the code's shape, so a phone released tomorrow still resolves
c.Device.Name   // "Galaxy S24 Ultra" — catalogue only; EMPTY when absent, never guessed
c.Device.Type   // "Tablet"           — SM-X710 is a Galaxy Tab, and the shape says so

The two tiers fail differently on purpose. The brand tier is rules and degrades gracefully; the name tier is a fact about the world, and its failure is silence.

Without the hook, uax device SM-S928B says so plainly rather than guessing:

model  SM-S928B
brand  Samsung
name   (not in the catalogue)

No other Go parser resolves the marketing name at all; uap-go returns Samsung SM-S928B and stops. The difference here is a mechanism with provenance, not a bigger pile of remembered strings.


Spoof detection

Client Hints and the User-Agent are two independent statements. When they disagree, the disagreement is the signal — so merging records it instead of destroying it.

c := uax.ParseRequest(r)

c.Consistency().Findings
// [{Rule: "hints.platform_mismatch", Severity: "critical",
//   Left: "Windows", Right: "macOS"}]

c.Verdict.Risk       // 1.0
c.Verdict.Recommend  // "block" — advice, never a decision

Impossibility rules — Safari on Windows, a non-WebKit engine on iOS, a client claiming two platforms at once — are facts, not heuristics. Weaker signals (no Accept-Language, Chromium without Sec-CH-UA) move risk without ever deciding anything alone.

Trust and risk answer different questions. A well-behaved crawler that names itself is highly trusted and low risk: being a bot is not being a liar.


Performance

Measured on an Apple M4 Pro, Go 1.26, against the full 187-rule database:

BenchmarkParseTo/chrome_windows      972 ns/op     0 B/op   0 allocs/op
BenchmarkParseTo/chrome_android     1093 ns/op     0 B/op   0 allocs/op
BenchmarkParseTo/firefox_macos       772 ns/op     0 B/op   0 allocs/op
BenchmarkParseTo/googlebot           532 ns/op     0 B/op   0 allocs/op
BenchmarkParseTo/curl                251 ns/op     0 B/op   0 allocs/op
BenchmarkParseTo/empty                21 ns/op     0 B/op   0 allocs/op
BenchmarkCacheHit                     23 ns/op     0 B/op   0 allocs/op

Against the ecosystem, same input, same machine:

ns/op allocs/op what it resolved from that string
uax 1093 0 Chrome 138.0.0.0 · Android 15 · SM-S928B · Mobile (plus Samsung Galaxy S24 Ultra with a catalogue)
mssola/user_agent 786 20 Chrome 138.0.0.0 · Android 15 · platform Linux
ua-parser/uap-go 1412 8 Chrome Mobile 138 · Android 15 · Samsung · SM-S928B
mileusna/useragent 1426 16 Chrome 138.0.0.0 · Android 15 · SM-S928B
avct/uasurfer 1612 1 Chrome 138 · Android 15 · phone

mssola is faster because it resolves less — no device at all. Compared like for like it wins on the work it skips. mileusna returns the model code but no brand; uap-go returns both but no marketing name, which no published Go parser resolves. Reproduce the table with make compare.

Zero allocations is enforced, not observed: TestAllocationBudget fails the build if ParseTo ever allocates.

Why it stays flat. The input is tokenised once into a fixed stack buffer, and each token is then looked up once in a combined index that answers for all seven categories simultaneously. Cost tracks input length, not rule count: curl/8.4.0 costs 251 ns and a full Chrome-on-Android string 1093 ns, against the same 187 rules. Adding a thousand more would not move either number. A conventional regex-list parser is O(rules × length) and gets slower with every rule anyone contributes.


Design commitments

Never invent. Unknown model → empty Device.Name. Unknown client → Kind is Unknown, not "Desktop by elimination". Everything derivable only from a lookup is empty when the lookup misses. Facts that cannot be derived from the input are not this module's to assert: they live in a catalogue that carries their provenance.

Never lie about certainty. Every resolved field carries a Confidence and a Source (ua, hints, both, inferred). A bot's identity is a claim, and the verdict says so explicitly until uax/verify checks it against DNS and published address ranges.

No hidden state. uax.Parse is pure. Caching, recording and metrics are all opt-in, because a cache is a global side effect on what is otherwise a function, it retains attacker-supplied strings, and it makes benchmarks measure the cache instead of the parser.

No network, ever, in the core. No request, no file, no environment read. The unknown-token recorder writes to memory the caller owns, after redacting versions and opaque identifiers. See SECURITY.md.

Every rule is proven. Classification lives in internal/table, in Go, and a rule without a test fails the build — along with a rule that has no priority, no confidence, two match clauses, or an ID its category already uses.


CLI

go install github.com/bakhod1r/uax/cmd/uax@latest

uax parse   "Mozilla/5.0 (…)"    # one-line summary
uax explain "Mozilla/5.0 (…)"    # fields, confidence, verdict, contradictions
uax analyze access.log           # what actually reaches your service
uax device  SM-S928B             # resolve a bare model code
uax db                           # rule counts
uax robots --deny ai             # robots.txt from the bot database

uax robots is the bot database written in a different syntax. The token a rule matches on is the token a crawler answers to in robots.txt, so the file cannot drift away from the parser — a test asserts every emitted name parses back to the bot it claims to block.

$ uax robots --deny ai --sitemap https://example.com/sitemap.xml
# Generated by uax from its bot database. Advisory only: robots.txt is
# obeyed voluntarily, and a crawler that ignores it is unaffected.

# AIAgent
User-agent: ChatGPT-User
Disallow: /
User-agent: Claude-User
Disallow: /
…
# AICrawler
User-agent: CCBot
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: GPTBot
Disallow: /
…
Sitemap: https://example.com/sitemap.xml

The two categories are separated because they are different questions. An AICrawler collects training data; an AIAgent is fetching a page a person asked for, right now. Blocking the second blocks a user.

Same selection is available to callers as robots.Agents, so a WAF rule set or an nginx map can be built from it instead.

$ uax explain "Mozilla/5.0 (Linux; Android 15; SM-S928B) … Chrome/138.0.0.0 Mobile Safari/537.36"
FIELD     VALUE             CONFIDENCE
kind      Human
browser   Chrome 138.0.0.0  0.95
  family  Chrome
  source  ua
engine    Blink 537.36      0.95
os        Android 15        0.96
device    Mobile            0.99
  brand   Samsung
  model   SM-S928B
  name    Galaxy S24 Ultra

verdict: allow  trust=0.70  risk=0.00

The CLI wires in devicex, which is where Galaxy S24 Ultra comes from. The library on its own reports the model code and leaves the name empty.


MCP server

cmd/uax-mcp hands the same answers to a model, over the Model Context Protocol. It exists because a User-Agent is exactly the kind of string a model will read and guess at, and the strings are built to defeat guessing: every browser claims Mozilla/5.0 for historical reasons, and nothing in SM-S928B suggests Galaxy S24 Ultra.

go install github.com/bakhod1r/uax/cmd/uax-mcp@latest
{
  "mcpServers": {
    "uax": { "command": "uax-mcp" }
  }
}
Tool Answers
parse_user_agent Which browser, engine, OS, device, bot or library sent this — each field with its confidence and source
check_consistency Whether the client's claims contradict each other, with a trust/risk verdict
lookup_device What handset a bare model code names
database_info What the rule database covers, its fingerprint, and the catalogue's snapshot date

An unrecognised string and an unknown model code come back as tool errors, not as empty fields — the difference between "no" and "no answer" is one a model acts on. Nothing here reaches the network, reads a file or retains a request.


Testing your own code

uatest builds fixtures that are internally consistent — right engine token, plausible version, model code matching its brand — and every builder is round-trip verified against the parser.

ua := uatest.Chrome(138, uatest.Android(15, uatest.GalaxyS24Ultra))
r  := uatest.Request(ua, uatest.WithHints("Google Chrome", 138, "Android", true)...)

Contributing a rule

Add the rule to its category in internal/table/rules.go, keeping the slice ordered by descending priority — the resolver stops at the first match, so order decides which rule wins:

{ID: "my_browser", Name: "My Browser", Family: "Chrome", Engine: "Blink",
    Token: "MyBrowser", VersionFrom: "token", Priority: 220, Confidence: 0.95},

Then add the test that proves it, in internal/table/tests.go:

{RuleID: "my_browser", UA: "Mozilla/5.0 (…) MyBrowser/3.2 Safari/537.36",
    Expect: map[string]string{"browser": "My Browser"}},

Then go test ./.... A rule with no test does not compile past the lint suite.

Device names are not contributed here at all. They are a catalogue, they carry a provenance, and they live in devicex.

License

MIT — see LICENSE.

This module ships no device names. They are imported data rather than original work, so they live in devicex with their provenance attached. NOTICE.md records the specifications these rules implement.

Documentation

Overview

Package uax turns a request into a description of the client behind it.

It reads the User-Agent string, the Client Hints headers, and the fetch and content-negotiation metadata that travel with them, and reports the browser, operating system, device, bot, in-app WebView, automation framework or HTTP library responsible — along with how confident it is, whether the client's claims contradict each other, and what it suggests doing about that.

Getting started

c := uax.Parse(r.UserAgent())
fmt.Println(c.Browser.Name, c.OS.Name, c.Device.Name)

Prefer ParseRequest wherever the whole request is available. Client Hints are structured, are what browser vendors intend servers to read, and survive the User-Agent reduction that is steadily emptying the legacy string:

c := uax.ParseRequest(r)
if c.IsAICrawler() { … }

Configuration

The package-level functions are pure: no cache, no recorder, no shared state. A server that wants caching, metrics or custom rules builds its own parser:

p := uax.New(uax.Config{
    Cache:   uax.CacheConfig{Enabled: true},
    Metrics: myReporter,
})

What this package will not do

It never invents facts. A device model that is not in the database yields an empty Device.Name rather than a guess, because a wrong device name is worse than none. Predicates require positive evidence: an unrecognised client is unknown, not a desktop by elimination. And everything a client says about itself is a claim — Bot.Name is what the client typed, not proof of who sent the request.

Rules

Classification rules live in internal/table, in Go. They are reviewed as code, and a set of lint tests holds the line that once belonged to a generator: every rule has a unique ID within its category, exactly one match clause, a priority, a confidence in (0,1], and at least one test proving it. A rule without a test fails the build.

Devices

Naming hardware is a catalogue problem, not a parsing one — "SM-S928B" does not contain "Galaxy S24 Ultra", and no rule can derive it — and catalogues move on their own schedule, since phones ship weekly and parsers do not. So this package holds no device names. It extracts the model code a request carries and hands it to whatever the caller configured:

import "github.com/bakhod1r/devicex"

p := uax.New(uax.Config{Device: devicex.Describe})

Without Config.Device, Device.Model still reports the code the request carried. Only the marketing name, brand and form factor go unanswered, which is the honest result of having nothing to answer them with.

Index

Constants

View Source
const (
	// DefaultMaxInputLength is a generous bound on a real User-Agent, which
	// rarely exceeds 512 bytes. It exists to cap the work a hostile client can
	// cause, not to reject unusual browsers.
	DefaultMaxInputLength = 4096

	// DefaultCacheSize applies when caching is enabled without a size.
	DefaultCacheSize = 4096
)
View Source
const (
	HeaderCHUA                = "Sec-CH-UA"
	HeaderCHUAMobile          = "Sec-CH-UA-Mobile"
	HeaderCHUAPlatform        = "Sec-CH-UA-Platform"
	HeaderCHUAPlatformVersion = "Sec-CH-UA-Platform-Version"
	HeaderCHUAModel           = "Sec-CH-UA-Model"
	HeaderCHUAArch            = "Sec-CH-UA-Arch"
	HeaderCHUABitness         = "Sec-CH-UA-Bitness"
	HeaderCHUAWoW64           = "Sec-CH-UA-WoW64"
	HeaderCHUAFullVersionList = "Sec-CH-UA-Full-Version-List"
	HeaderCHUAFormFactors     = "Sec-CH-UA-Form-Factors"
)

Header names for every hint this package understands.

View Source
const AcceptCH = "" /* 131-byte string literal not displayed */

AcceptCH is the header value asking a browser to send the high-entropy hints this package can use. Serve it as `Accept-CH: <AcceptCH>` to receive the OS version and device model that a modern User-Agent no longer contains.

View Source
const DefaultRecorderLimit = 4096

DefaultRecorderLimit bounds how many distinct shapes a recorder retains. Without a bound, a hostile client could grow the map without limit simply by sending a novel token on every request.

View Source
const Release = "0.1.0"

Release is the version of this library, matching its Git tag.

It is a constant rather than build-time linker flags so that `go get` of a tagged module reports the same value however it was built.

Variables

This section is empty.

Functions

func DatabaseRules

func DatabaseRules() int

DatabaseRules is the total number of compiled rules.

func DatabaseVersion

func DatabaseVersion() string

DatabaseVersion is a fingerprint of the compiled rule database.

A classification is only reproducible if you know what it was classified against, and the rules change more often than the code does. Logging this alongside a verdict makes a decision from six months ago explicable: two runs that report the same fingerprint saw the same rules.

It is derived from the rules themselves rather than maintained by hand, so it cannot be forgotten. The value is stable for a given database and carries no meaning beyond equality — it is not ordered, and a difference says the rules changed without saying how.

func ParseTo

func ParseTo(dst *Client, raw string)

ParseTo parses into a caller-supplied Client, allocating nothing. Use it on hot paths that can reuse a Client between requests; Parse is the ordinary entry point.

func Redact

func Redact(token string) string

Redact reduces a token to its shape: digit runs become "N", and long opaque identifiers become "X".

"MyApp/3.14.159"      -> "MyApp/N.N.N"
"Build/a91f0c2e77b14" -> "Build/X"

The point is to keep what identifies a kind of client while dropping what could identify an installation, so the result is safe to log, aggregate and share upstream as a rule request.

Types

type AI

type AI struct {
	Name     string      `json:"name,omitempty"`
	Provider string      `json:"provider,omitempty"`
	Category BotCategory `json:"category,omitempty"` // AICrawler or AIAgent
}

AI describes an AI system fetching the resource. It is derived from the bot rule that matched rather than stored separately, so the two can never drift.

type AcceptProfile

type AcceptProfile struct {
	Accept         string `json:"accept,omitempty"`
	AcceptLanguage string `json:"accept_language,omitempty"`
	AcceptEncoding string `json:"accept_encoding,omitempty"`
}

AcceptProfile represents the client's content negotiation headers.

func ParseAcceptProfile

func ParseAcceptProfile(accept, acceptLanguage, acceptEncoding string) *AcceptProfile

ParseAcceptProfile creates an AcceptProfile from the provided headers.

func (*AcceptProfile) IsMissingLanguage

func (a *AcceptProfile) IsMissingLanguage() bool

IsMissingLanguage returns true if a language header was expected but not provided. Most legitimate browsers send an Accept-Language header.

func (*AcceptProfile) PreferredLanguage

func (a *AcceptProfile) PreferredLanguage() string

PreferredLanguage is the base language the client most wants, as a two- or three-letter subtag: "en-GB;q=0.9, fr;q=1.0" reports "fr".

It reads the whole header rather than only its first entry, because the order a client writes its languages in is not the order it prefers them — that is what the q-value is for. Entries without one default to q=1.0, as RFC 9110 says, and ties keep the order the client wrote.

The wildcard "*" is not a language and is never returned. A malformed q-value makes that one entry unusable rather than the whole header: a client that garbles its preferences is still a client with preferences.

type Action

type Action string

Action is what uax suggests doing with a client.

It is advice, never a decision. This package sees one request's headers; it cannot see rate, reputation, session history or business context, and any of those may reasonably overrule it. The caller decides.

const (
	ActionAllow     Action = "allow"
	ActionChallenge Action = "challenge"
	ActionRateLimit Action = "rate_limit"
	ActionBlock     Action = "block"
)

type App

type App struct {
	Name       string  `json:"name,omitempty"`
	Category   string  `json:"category,omitempty"`
	Version    Version `json:"version,omitempty"`
	Confidence float32 `json:"confidence,omitempty"`
}

App is the native application hosting an embedded WebView.

type Automation

type Automation struct {
	Name       string  `json:"name,omitempty"`
	Category   string  `json:"category,omitempty"` // Headless, Framework, Embedded
	Version    Version `json:"version,omitempty"`
	Confidence float32 `json:"confidence,omitempty"`
}

Automation is a headless browser or a browser-automation framework.

Only self-declared markers are reported. A driver configured to hide is invisible here by design, and the absence of an Automation value is therefore never evidence that a client is human.

type Bot

type Bot struct {
	Name       string      `json:"name,omitempty"`
	Category   BotCategory `json:"category,omitempty"`
	Provider   string      `json:"provider,omitempty"`
	Version    Version     `json:"version,omitempty"`
	Confidence float32     `json:"confidence,omitempty"`
}

Bot is a self-identified crawler, agent or scraper.

Every field here is a claim made by the client. A User-Agent saying "Googlebot" is not proof; the uax/verify module exists to turn that claim into a verified fact using DNS and published address ranges.

func (*Bot) IsAICrawler

func (b *Bot) IsAICrawler() bool

IsAICrawler reports whether the bot collects data for a model, either for training or for live retrieval.

func (*Bot) IsSEO

func (b *Bot) IsSEO() bool

IsSEO reports whether the bot belongs to commercial SEO tooling.

func (*Bot) IsSearchEngine

func (b *Bot) IsSearchEngine() bool

IsSearchEngine reports whether the bot indexes for a search product.

func (*Bot) IsSocialCrawler

func (b *Bot) IsSocialCrawler() bool

IsSocialCrawler reports whether the bot is building a link preview.

type BotCategory

type BotCategory string

BotCategory says what a bot is for. It carries far more decision value than a bare "is a bot" boolean: a search crawler, an AI trainer and a scraper all warrant different handling.

const (
	BotSearchEngine  BotCategory = "SearchEngine"
	BotAICrawler     BotCategory = "AICrawler"
	BotAIAgent       BotCategory = "AIAgent"
	BotSocialCrawler BotCategory = "SocialCrawler"
	BotSEO           BotCategory = "SEO"
	BotMonitoring    BotCategory = "Monitoring"
	BotSecurity      BotCategory = "Security"
	BotGeneric       BotCategory = "Generic"
)

type BrandHint

type BrandHint struct {
	Brand   string  `json:"brand"`
	Version Version `json:"version"`
}

BrandHint is one entry of a Sec-CH-UA brand list.

type Browser

type Browser struct {
	Name       string  `json:"name,omitempty"`
	Family     string  `json:"family,omitempty"`
	Version    Version `json:"version,omitempty"`
	Mode       string  `json:"mode,omitempty"` // Headless, WebView, "" for a normal browser
	Confidence float32 `json:"confidence,omitempty"`
	Source     Source  `json:"source,omitempty"`
}

Browser is the resolved browser identity.

Family groups derivatives under their base (Edge, Brave and Vivaldi all have the family Chrome); Name is the specific product. Mode distinguishes a headless or WebView instance of an otherwise ordinary browser.

type Cache

type Cache struct {
	// contains filtered or unexported fields
}

Cache is a simple, thread-safe LRU cache for parsed Clients.

func NewCache

func NewCache(maxEntries int) *Cache

NewCache creates a new LRU cache.

func (*Cache) Get

func (c *Cache) Get(key string) (*Client, bool)

Get looks up a key's value from the cache.

func (*Cache) Set

func (c *Cache) Set(key string, value *Client)

Set adds a value to the cache.

type CacheConfig

type CacheConfig struct {
	Enabled bool
	// Size is the maximum number of entries. Zero selects DefaultCacheSize.
	Size int
}

CacheConfig controls result memoisation.

type CategorySummary

type CategorySummary struct {
	Category string `json:"category"`
	Rules    int    `json:"rules"`
}

CategorySummary is how many rules one category holds.

func DatabaseSummary

func DatabaseSummary() []CategorySummary

DatabaseSummary reports the size of the compiled rule database.

It exists so a deployment can record which database produced a given result: rules change between releases, and a classification from six months ago is only reproducible if you know what it was classified against.

type Client

type Client struct {
	Browser Browser `json:"browser,omitempty"`
	OS      OS      `json:"os,omitempty"`
	Device  Device  `json:"device,omitempty"`
	Engine  Engine  `json:"engine,omitempty"`

	Bot        *Bot        `json:"bot,omitempty"`
	AI         *AI         `json:"ai,omitempty"`
	App        *App        `json:"app,omitempty"`
	Automation *Automation `json:"automation,omitempty"`
	HTTPClient *HTTPClient `json:"http_client,omitempty"`

	Hints         *Hints         `json:"client_hints,omitempty"`
	Fetch         *FetchContext  `json:"fetch,omitempty"`
	AcceptProfile *AcceptProfile `json:"accept_profile,omitempty"`

	Kind    Kind     `json:"kind,omitempty"`
	Verdict *Verdict `json:"verdict,omitempty"`

	// SpoofScore is 0 when nothing contradicts itself and rises towards 1 as
	// the client's claims stop fitting together. See Consistency for the
	// individual findings behind the number.
	SpoofScore float32 `json:"spoof_score,omitempty"`

	// Raw is the input as parsed. It is the truncated form when Truncated is
	// set, so it always matches what the fields above were derived from.
	Raw       string `json:"raw,omitempty"`
	Truncated bool   `json:"truncated,omitempty"`
	// contains filtered or unexported fields
}

Client is everything uax could determine about one caller.

Fields that describe an identity always present in a request — browser, OS, device, engine — are values. Fields that describe something that may simply not be there are pointers, so "no bot was detected" and "a bot with empty fields" can never be confused.

func Parse

func Parse(raw string) *Client

Parse parses a User-Agent string.

It is a pure function: no cache, no shared state, no retention of the input. For a configured parser — caching, recording, metrics, or skipping work you do not need — use New.

func ParseRequest

func ParseRequest(r *http.Request) *Client

ParseRequest parses a request's User-Agent together with its Client Hints, fetch metadata and Accept headers.

Client Hints are authoritative where present: they are structured, and they survive the User-Agent reduction that is steadily hollowing out the legacy string. Where hints and the User-Agent disagree, both are kept and the disagreement itself is recorded as a spoofing signal.

func (*Client) Attrs

func (c *Client) Attrs() []slog.Attr

Attrs returns the client as structured log attributes.

The keys are stable and namespaced, so dashboards and alerts built on them keep working across releases. Empty fields are omitted rather than logged as "": absence should not occupy a column.

func (*Client) Coarse

func (c *Client) Coarse() *Client

Coarse returns a copy of the client with the narrowing details removed: exact device model and name, version components below the major, and the architecture.

Use it when the client description is going somewhere it will be retained — analytics, logs, a data warehouse. What survives is enough to answer "which browsers and platforms do my users have", which is what those systems are usually for, without carrying a per-visitor identifier along with it.

func (*Client) Consistency

func (c *Client) Consistency() Consistency

Consistency returns the coherence check for this client.

func (*Client) IsAICrawler

func (c *Client) IsAICrawler() bool

IsAICrawler reports whether an AI system is fetching, either to collect data or to answer a user's live request.

func (*Client) IsAndroid

func (c *Client) IsAndroid() bool

IsAndroid reports whether the client runs Android, including Android TV.

func (*Client) IsAutomated

func (c *Client) IsAutomated() bool

IsAutomated reports whether a browser-automation framework was declared.

func (*Client) IsBot

func (c *Client) IsBot() bool

IsBot reports whether the client identified itself as a crawler, agent or scraper. This is a claim by the client, not a verified fact.

func (*Client) IsCLI

func (c *Client) IsCLI() bool

IsCLI reports whether the client is an interactive command-line tool.

func (*Client) IsConsole

func (c *Client) IsConsole() bool

IsConsole reports whether the client is a games console.

func (*Client) IsDesktop

func (c *Client) IsDesktop() bool

IsDesktop reports whether the client is a desktop or laptop.

func (*Client) IsHeadless

func (c *Client) IsHeadless() bool

IsHeadless reports whether the client declared itself headless.

func (*Client) IsIOS

func (c *Client) IsIOS() bool

IsIOS reports whether the client runs iOS or iPadOS.

func (*Client) IsLinux

func (c *Client) IsLinux() bool

IsLinux reports whether the client runs a Linux distribution. Android is a separate platform here, as callers almost always mean desktop Linux.

func (*Client) IsMacOS

func (c *Client) IsMacOS() bool

IsMacOS reports whether the client runs macOS.

func (*Client) IsMobile

func (c *Client) IsMobile() bool

IsMobile reports whether the client is a phone.

func (*Client) IsSuspicious

func (c *Client) IsSuspicious() bool

IsSuspicious reports whether anything about the client failed to add up.

func (*Client) IsTV

func (c *Client) IsTV() bool

IsTV reports whether the client is a television.

func (*Client) IsTablet

func (c *Client) IsTablet() bool

IsTablet reports whether the client is a tablet.

func (*Client) IsTool

func (c *Client) IsTool() bool

IsTool reports whether the client is any non-browser HTTP client.

func (*Client) IsWebView

func (c *Client) IsWebView() bool

IsWebView reports whether the page is rendered inside an embedded browser.

func (*Client) IsWindows

func (c *Client) IsWindows() bool

IsWindows reports whether the client runs Windows.

func (*Client) LogValue

func (c *Client) LogValue() slog.Value

LogValue makes Client usable directly as a slog value:

logger.Info("request", "client", client)

func (*Client) Privacy

func (c *Client) Privacy() Privacy

Privacy reports how identifying this client's description is.

The rating counts the specific, narrowing facts the client disclosed. An exact device model is the strongest of them: it can separate one visitor from nearly everyone else, which is precisely why Chromium stopped sending it by default and why it now requires an explicit Accept-CH request.

func (*Client) ToJSON

func (c *Client) ToJSON() []byte

ToJSON returns the JSON representation of the Client.

type Config

type Config struct {
	// Cache memoises results by raw User-Agent. Production traffic repeats a
	// few thousand distinct strings across millions of requests, so this is
	// usually worth enabling on a request path — but only when the caller has
	// decided the memory ceiling is acceptable.
	Cache CacheConfig

	// MaxInputLength bounds how much of an input is examined. Longer input is
	// truncated and flagged via Client.Truncated rather than rejected, because
	// a parser that errors on a hostile string is a parser that hands the
	// caller a decision it cannot make. Zero selects DefaultMaxInputLength.
	MaxInputLength int

	// Recorder collects unrecognised product tokens so a deployment can improve
	// its own rules. It is nil by default: recording is a deliberate choice,
	// never something that happens because a caller did not opt out.
	Recorder *Recorder

	// Metrics receives counters. Nil means no metrics are emitted.
	Metrics MetricsReporter

	// Registry layers custom rules over the built-in database. Nil uses the
	// built-in rules alone.
	Registry *Registry

	// Device names the hardware behind a model code. It is nil by default, and
	// then Device.Model reports the code the request carried while the name,
	// brand and form factor stay empty.
	//
	// This package holds no device catalogue. "SM-S928B" does not contain
	// "Galaxy S24 Ultra" — a manufacturer assigned that mapping, so answering
	// it needs published data, and published data moves on its own schedule.
	// github.com/bakhod1r/devicex is such a catalogue and satisfies this signature
	// directly:
	//
	//	p := uax.New(uax.Config{Device: devicex.Describe})
	//
	// The signature carries no types from either package, so neither has to
	// import the other. It is called with the extracted model code and the raw
	// User-Agent: the code answers Android, and the User-Agent answers the
	// hardware that publishes no code and names itself in prose instead, like
	// "Macintosh" or "PlayStation 5". Either argument may be empty.
	//
	// model is the identifier the request actually carried, which is not
	// always the code passed in: hardware that publishes no code names itself
	// in prose, and "iPhone" is both the name and the whole identifier. A hook
	// that cannot point at something in the request returns model empty, and
	// Device.Model then reports the extracted code, or nothing.
	//
	// deviceType is "Mobile", "Tablet", "Desktop", "Console" or empty. It must
	// return ok == false rather than guess: a wrong device name is worse than
	// no device name, because it is a fact-shaped value a caller will store.
	Device func(code, ua string) (name, brand, model, family, deviceType string, confidence float64, ok bool)

	// SkipDevice omits device resolution entirely, including the model code.
	// Useful on hot paths that only need browser and OS.
	SkipDevice bool

	// SkipBot omits bot, AI, automation and HTTP-client resolution.
	SkipBot bool
}

Config configures a Parser. The zero value is valid and is what the package-level functions use: no cache, no recorder, no shared state.

Caching is opt-in on purpose. A cache is a global mutable side effect on what is otherwise a pure function, it makes benchmarks report the cache rather than the parser, and it retains attacker-supplied strings in memory. Callers that want it should say so, and size it themselves.

type Consistency

type Consistency struct {
	Findings []Finding `json:"findings,omitempty"`
	Score    float32   `json:"score"` // 0 = coherent, 1 = impossible
}

Consistency is the result of checking a client's claims against each other.

It answers "does this client's story hold together", which is a different and more answerable question than "is this client lying". A clean result is not proof of honesty; a dirty one is strong evidence of editing.

func CheckConsistency

func CheckConsistency(c *Client) Consistency

CheckConsistency evaluates a client against every impossibility rule and combines the result with any disagreement already found between the User-Agent and Client Hints.

func (Consistency) Has

func (c Consistency) Has(rule string) bool

Has reports whether a specific rule fired.

func (Consistency) OK

func (c Consistency) OK() bool

OK reports whether nothing contradicted anything else.

type Device

type Device struct {
	Type       DeviceType `json:"type,omitempty"`
	Brand      string     `json:"brand,omitempty"`
	Model      string     `json:"model,omitempty"`
	Name       string     `json:"name,omitempty"`
	Family     string     `json:"family,omitempty"`
	Series     string     `json:"series,omitempty"`
	Year       int        `json:"year,omitempty"`
	Confidence float32    `json:"confidence,omitempty"`
	Source     Source     `json:"source,omitempty"`

	// Reduced reports that the browser replaced the model code with a
	// placeholder ("K"). The handset exists and has a code; this client simply
	// declined to state it, which is different from a code nobody recognised.
	// Sec-CH-UA-Model carries the real one and clears this flag.
	Reduced bool `json:"reduced,omitempty"`
}

Device is the resolved hardware.

Model and Name are deliberately separate and must not be conflated:

Model is the verbatim code from the User-Agent ("SM-S928B"). It is evidence
and never changes.

Name is the marketing name ("Galaxy S24 Ultra"). It comes from a lookup
table, because no rule can derive one from the other. When the table has no
entry, Name stays empty — it is never approximated, since a wrong device
name is worse than none at all.

type DeviceType

type DeviceType string

DeviceType is the physical form factor of the client.

const (
	DeviceUnknown  DeviceType = ""
	DeviceDesktop  DeviceType = "Desktop"
	DeviceMobile   DeviceType = "Mobile"
	DeviceTablet   DeviceType = "Tablet"
	DeviceTV       DeviceType = "TV"
	DeviceConsole  DeviceType = "Console"
	DeviceWatch    DeviceType = "Watch"
	DeviceCar      DeviceType = "Car"
	DeviceXR       DeviceType = "XR"   // headsets and glasses that replace or augment the view
	DeviceEInk     DeviceType = "EInk" // slow screen refresh, little or no colour
	DeviceEmbedded DeviceType = "Embedded"
	DeviceBot      DeviceType = "Bot"
)

type Engine

type Engine struct {
	Name       string  `json:"name,omitempty"`
	Version    Version `json:"version,omitempty"`
	Confidence float32 `json:"confidence,omitempty"`
}

Engine is the layout engine.

type FetchContext

type FetchContext struct {
	Site string `json:"site,omitempty"`
	Mode string `json:"mode,omitempty"`
	Dest string `json:"dest,omitempty"`
	User string `json:"user,omitempty"`
}

FetchContext represents the parsed Sec-Fetch-* headers.

func ParseFetchContext

func ParseFetchContext(site, mode, dest, user string) *FetchContext

ParseFetchContext parses the Sec-Fetch headers.

func (*FetchContext) IsNavigation

func (f *FetchContext) IsNavigation() bool

IsNavigation returns true if this fetch represents a top-level document navigation.

type Finding

type Finding struct {
	Rule     string   `json:"rule"`
	Severity Severity `json:"severity"`
	Detail   string   `json:"detail,omitempty"`
	Left     string   `json:"left,omitempty"`
	Right    string   `json:"right,omitempty"`
}

Finding is one internal contradiction, with the evidence behind it.

Rule is a stable identifier, safe to log, alert on and assert in tests. It never changes spelling once released, so a dashboard built on it keeps working.

type HTTPClient

type HTTPClient struct {
	Name       string  `json:"name,omitempty"`
	Category   string  `json:"category,omitempty"` // CLI, Library, Package
	Version    Version `json:"version,omitempty"`
	Confidence float32 `json:"confidence,omitempty"`
}

HTTPClient is a non-browser client: a CLI tool, or an HTTP library embedded in an application.

type Hints

type Hints struct {
	// Brands is the Sec-CH-UA list with GREASE entries removed. It contains
	// significant-version numbers only ("138"), unless FullVersionList was
	// requested.
	Brands []BrandHint `json:"brands,omitempty"`

	// FullVersionList is Sec-CH-UA-Full-Version-List: the same brands carrying
	// complete versions ("138.0.7204.92"). Requires Accept-CH.
	FullVersionList []BrandHint `json:"full_version_list,omitempty"`

	Mobile          bool   `json:"mobile"`
	Platform        string `json:"platform,omitempty"`         // "Windows", "macOS", "Android"
	PlatformVersion string `json:"platform_version,omitempty"` // Requires Accept-CH
	Model           string `json:"model,omitempty"`            // Requires Accept-CH
	Arch            string `json:"arch,omitempty"`             // Requires Accept-CH
	Bitness         string `json:"bitness,omitempty"`          // Requires Accept-CH

	// WoW64 is Sec-CH-UA-WoW64: a 32-bit browser running on 64-bit Windows.
	// It describes the process, not the machine — a WoW64 client still reports
	// Bitness "64", because that is what the platform is. Requires Accept-CH.
	WoW64 bool `json:"wow64,omitempty"`

	// FormFactors is Sec-CH-UA-Form-Factors: what shape the device physically
	// is, stated rather than inferred. Requires Accept-CH.
	//
	// Values are kept exactly as sent, including ones this package does not
	// recognise, so a hint added to the specification after this release is
	// still visible to callers.
	FormFactors []string `json:"form_factors,omitempty"`
}

Hints is the parsed User-Agent Client Hints set.

Client Hints matter because the legacy User-Agent string is being frozen and hollowed out: modern Chromium reports "Android 10; K" for every phone regardless of the real OS version or model. The hints carry the truth, but only the low-entropy ones are sent by default — the rest arrive only after the server requests them with Accept-CH.

func ParseClientHints

func ParseClientHints(secChUA, secChUAMobile, secChUAPlatform string) *Hints

ParseClientHints parses the three low-entropy hints a browser sends without being asked. Prefer ParseHints, which reads the complete set.

func ParseHints

func ParseHints(h http.Header) *Hints

ParseHints reads every Client Hint from a header set. It returns nil when the request carries no hints at all, so "no hints" is distinguishable from "hints with empty values".

func (*Hints) Brand

func (h *Hints) Brand() (BrandHint, bool)

Brand returns the significant brand: the entry that is not Chromium and not GREASE, which is the browser the user actually installed.

type Kind

type Kind string

Kind is the top-level answer to "what is talking to me".

const (
	KindUnknown    Kind = "Unknown"
	KindHuman      Kind = "Human"
	KindBot        Kind = "Bot"
	KindAICrawler  Kind = "AICrawler"
	KindAIAgent    Kind = "AIAgent"
	KindTool       Kind = "Tool"
	KindAutomation Kind = "Automation"
)

type MetricsReporter

type MetricsReporter interface {
	// IncParse counts inputs actually parsed, excluding cache hits.
	IncParse()

	// IncCacheHit counts results served from the cache.
	IncCacheHit()

	// IncBotDetected counts clients that identified themselves as a bot,
	// crawler or AI agent.
	IncBotDetected()

	// IncSpoofDetected counts clients whose claims contradict each other.
	IncSpoofDetected()
}

MetricsReporter receives operational counters. Supply one via Config.Metrics to feed Prometheus, OpenTelemetry, or anything else.

The counters describe work the parser actually performed: a cache hit increments IncCacheHit and nothing else, because no parsing happened. That makes the ratio between IncParse and IncCacheHit meaningful, which is the number worth graphing.

type NoopReporter

type NoopReporter struct{}

NoopReporter discards every counter. It is useful as an embedded field when implementing only some of the interface.

func (NoopReporter) IncBotDetected

func (NoopReporter) IncBotDetected()

func (NoopReporter) IncCacheHit

func (NoopReporter) IncCacheHit()

func (NoopReporter) IncParse

func (NoopReporter) IncParse()

The bodies are empty because discarding is the whole behaviour.

func (NoopReporter) IncSpoofDetected

func (NoopReporter) IncSpoofDetected()

type OS

type OS struct {
	Name    string  `json:"name,omitempty"`
	Family  string  `json:"family,omitempty"`
	Version Version `json:"version,omitempty"`
	Arch    string  `json:"arch,omitempty"` // amd64, arm64, x86, arm — only when stated

	// Bitness is the platform's CPU width, "32" or "64", exactly as
	// Sec-CH-UA-Bitness stated it. It describes the machine, not the process:
	// a 32-bit browser on a 64-bit Windows reports bitness 64 and sets
	// Sec-CH-UA-WoW64 instead. Empty unless the client sent the hint.
	Bitness string `json:"bitness,omitempty"`

	Confidence float32 `json:"confidence,omitempty"`
	Source     Source  `json:"source,omitempty"`

	// Reduced reports that the platform version in the User-Agent is frozen by
	// the browser rather than observed from the system. Chrome's User-Agent
	// Reduction did this deliberately: every reduced Android build reports 10,
	// macOS caps at 10.15.7, and Windows has reported NT 10.0 since Windows 10.
	//
	// When Reduced is set, Version is either absent (Android, where the value
	// is a placeholder that contradicts reality) or a floor (macOS and Windows,
	// where the real version is the stated one or higher). Sec-CH-UA-Platform-
	// Version carries the true value and clears this flag.
	Reduced bool `json:"reduced,omitempty"`
}

OS is the resolved operating system.

type Parser

type Parser struct {
	// contains filtered or unexported fields
}

Parser parses User-Agents under a fixed configuration.

A Parser is safe for concurrent use. The rule tables it reads are immutable after init, so parsing takes no locks; only an enabled cache synchronises, and it shards to keep contention off the hot path.

func Default

func Default() *Parser

Default returns the parser backing the package-level functions: no cache, no recorder, no metrics. It is safe for concurrent use and holds no state.

func New

func New(cfg Config) *Parser

New returns a Parser using cfg.

func (*Parser) Parse

func (p *Parser) Parse(raw string) *Client

Parse parses a User-Agent string under the parser's configuration.

func (*Parser) ParseRequest

func (p *Parser) ParseRequest(r *http.Request) *Client

ParseRequest parses an http.Request under the parser's configuration.

func (*Parser) ParseTo

func (p *Parser) ParseTo(dst *Client, raw string)

ParseTo parses into dst, reusing its storage. dst is fully overwritten, so a stale Client can be handed back without clearing it first.

type Privacy

type Privacy struct {
	Risk PrivacyRisk `json:"risk"`

	// Identifiers lists the fields that narrow this client down, so a caller
	// can see exactly what drove the rating rather than trusting a number.
	Identifiers []string `json:"identifiers,omitempty"`
}

Privacy summarises how identifying a parsed client is.

type PrivacyRisk

type PrivacyRisk string

PrivacyRisk is how identifying a client's self-description is.

It is a coarse ordering, not a measurement. Real fingerprinting entropy depends on the population a visitor is drawn from — a Samsung phone is unremarkable in a consumer audience and conspicuous in a fleet of managed laptops — and this package sees one request, never the population. Treat it as "how much did this client volunteer about itself", which is a question a single request can answer.

const (
	PrivacyLow    PrivacyRisk = "low"
	PrivacyMedium PrivacyRisk = "medium"
	PrivacyHigh   PrivacyRisk = "high"
)

type Reason

type Reason struct {
	Code   string  `json:"code"`
	Weight float32 `json:"weight"`
	Detail string  `json:"detail,omitempty"`
}

Reason is one weighted input to a verdict, carrying a stable code so it can be logged, alerted on and asserted in tests.

type Recorder

type Recorder struct {
	// contains filtered or unexported fields
}

Recorder collects product tokens that matched no rule.

It answers the question every deployment eventually asks: what is my traffic sending that the database does not know about? Feeding those tokens back into internal/table is how the rules improve.

Two deliberate choices make it safe to run in production:

Nothing is recorded unless Config.Recorder is set. There is no default
recorder, no default-on telemetry and no network access anywhere in this
package — the tokens go into memory the caller owns, and nowhere else.

Versions and long opaque identifiers are redacted before storage, so
"MyApp/3.14.159" and a per-install build ID collapse into one shape. What is
kept describes a class of client, not an individual one.

func NewRecorder

func NewRecorder() *Recorder

NewRecorder returns a recorder holding at most DefaultRecorderLimit shapes.

func NewRecorderWithLimit

func NewRecorderWithLimit(limit int) *Recorder

NewRecorderWithLimit returns a recorder holding at most limit distinct shapes. Once full it stops adding new ones and keeps counting the ones it has, so memory is bounded regardless of what arrives.

func (*Recorder) Len

func (r *Recorder) Len() int

Len returns how many distinct shapes are held.

func (*Recorder) Record

func (r *Recorder) Record(token string)

Record stores the redacted shape of a token.

func (*Recorder) Reset

func (r *Recorder) Reset()

Reset discards everything recorded so far.

func (*Recorder) Top

func (r *Recorder) Top(n int) []Unknown

Top returns the n most frequent shapes, most frequent first. Ties break by shape so the output is stable and diffable.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry holds custom rules layered over the built-in database.

Registering rebuilds the compiled indexes and swaps them in atomically, so a registry can be updated while requests are in flight: readers never block and never observe a half-built database.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty registry that resolves exactly like the built-in database until rules are added.

func (*Registry) RegisterApp

func (r *Registry) RegisterApp(rules ...Rule)

RegisterApp adds in-app WebView rules.

func (*Registry) RegisterAutomation

func (r *Registry) RegisterAutomation(rules ...Rule)

RegisterAutomation adds automation and headless rules.

func (*Registry) RegisterBot

func (r *Registry) RegisterBot(rules ...Rule)

RegisterBot adds bot rules.

func (*Registry) RegisterBrowser

func (r *Registry) RegisterBrowser(rules ...Rule)

RegisterBrowser adds browser rules.

func (*Registry) RegisterEngine

func (r *Registry) RegisterEngine(rules ...Rule)

RegisterEngine adds layout engine rules.

func (*Registry) RegisterHTTPClient

func (r *Registry) RegisterHTTPClient(rules ...Rule)

RegisterHTTPClient adds HTTP client and CLI tool rules.

func (*Registry) RegisterOS

func (r *Registry) RegisterOS(rules ...Rule)

RegisterOS adds operating-system rules.

func (*Registry) Reset

func (r *Registry) Reset()

Reset removes every custom rule, returning the registry to the built-in database.

type Rule

type Rule struct {
	ID   string
	Name string

	Family   string
	Engine   string
	Brand    string
	Model    string
	Series   string
	Category string
	Provider string
	Type     string
	Year     int

	Token    string
	Prefix   string
	Contains string

	// VersionFrom selects a version-extraction strategy: "token",
	// "version_product", "rv", "apple", "nt", "cros" or "suffix". Empty means
	// the rule carries no version.
	VersionFrom string

	// Priority decides which rule wins when several match. Built-in rules use
	// 50–320; a custom rule at 1000 always wins.
	Priority int

	// Confidence must be in (0,1]. It is reported verbatim on the resolved
	// field, so it should reflect how much the match really proves.
	Confidence float32
}

Rule is a caller-supplied classification rule.

It exists so a deployment can teach uax about clients no public database will ever carry — an internal mobile app, a partner's crawler, a company HTTP client. Custom rules are merged with the built-in database and compete on Priority alone, so a rule can also override a built-in one by outranking it.

Exactly one match form must be set:

Token    exact match against a product token or a comment field
Prefix   the token or field starts with this string
Contains substring anywhere in the input — correct, but the slowest form

type Severity

type Severity string

Severity is how much weight a finding carries.

const (
	SeverityLow      Severity = "low"
	SeverityMedium   Severity = "medium"
	SeverityHigh     Severity = "high"
	SeverityCritical Severity = "critical"
)

type Source

type Source string

Source records where a resolved field came from. Client Hints are authoritative; a value inferred from a sibling field is not.

const (
	SourceNone     Source = ""
	SourceUA       Source = "ua"
	SourceHints    Source = "hints"
	SourceBoth     Source = "both"
	SourceInferred Source = "inferred"
)

type Unknown

type Unknown struct {
	Shape string `json:"shape"`
	Count int    `json:"count"`
}

Unknown is one recorded shape and how often it was seen.

type Verdict

type Verdict struct {
	Kind      Kind     `json:"kind"`
	Trust     float32  `json:"trust"`
	Risk      float32  `json:"risk"`
	Reasons   []Reason `json:"reasons,omitempty"`
	Recommend Action   `json:"recommend"`
}

Verdict is the actionable summary of everything uax determined.

The two scores answer different questions and should not be conflated:

Trust is how much the client's self-description can be believed. A verified
browser sending coherent hints scores high. An honest, well-behaved crawler
also scores high — being a bot is not being a liar.

Risk is how likely this request is to be hostile. A client whose claims
contradict each other scores high, whatever it claims to be.

func ComputeVerdict

func ComputeVerdict(c *Client) *Verdict

ComputeVerdict derives a verdict from an already-parsed client.

func VerdictFrom

func VerdictFrom(c *Client, cons Consistency) *Verdict

VerdictFrom derives a verdict from a client and a coherence check already performed on it.

A verdict is built on top of the consistency findings, so ComputeVerdict has to run the spoof rules to produce one. Callers that display the findings as well — the CLI, the MCP server, ParseRequest — would otherwise run them twice per request. Pass a Consistency from any source but this client and the verdict describes a request that never arrived.

func (*Verdict) Has

func (v *Verdict) Has(code string) bool

Has reports whether a reason code is present.

type Version

type Version struct {
	Major        int    `json:"major,omitempty"`
	Minor        int    `json:"minor,omitempty"`
	Patch        int    `json:"patch,omitempty"`
	KnownVersion bool   `json:"known_version,omitempty"`
	Raw          string `json:"raw,omitempty"`
}

Version represents a parsed version string.

func ParseVersion

func ParseVersion(s string) Version

ParseVersion parses a version string (e.g., "1.2.3") without allocating memory for parts.

func (Version) AtLeast

func (v Version) AtLeast(major int, rest ...int) bool

AtLeast checks if the version is greater than or equal to the specified parts.

func (Version) Compare

func (v Version) Compare(o Version) int

Compare returns -1 if v < o, 0 if v == o, and 1 if v > o.

func (Version) Known

func (v Version) Known() bool

Known returns true if the version was successfully parsed.

func (Version) String

func (v Version) String() string

String returns the raw version string.

Directories

Path Synopsis
cmd
uax command
Command uax inspects User-Agent strings from the command line.
Command uax inspects User-Agent strings from the command line.
uax-mcp command
Command uax-mcp exposes uax over the Model Context Protocol.
Command uax-mcp exposes uax over the Model Context Protocol.
internal
table
Package table is the classification database.
Package table is the classification database.
Package middleware attaches a parsed uax.Client to the request context.
Package middleware attaches a parsed uax.Client to the request context.
echo module
fiber module
gin module
Package robots renders a robots.txt from what uax knows about crawlers.
Package robots renders a robots.txt from what uax knows about crawlers.
Package uatest builds realistic User-Agent strings and requests for tests.
Package uatest builds realistic User-Agent strings and requests for tests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL