Detections & Verdicts¶
Private beta
Email Security is in private beta. It is not generally available, and
access is enabled per organization — if the ext-email-security extension
is not in your catalog, this product is not turned on for you yet.
While it is in beta, expect the surface described here to move: commands, fields and event shapes may change between releases, and they may change in ways that are not backwards compatible. The MailSec CLI is currently available from the Python SDK's master branch, ahead of a PyPI release. Install it in a virtual environment before running the CLI examples:
python3 -m venv .venv-mailsec
source .venv-mailsec/bin/activate
python -m pip install --upgrade 'git+https://github.com/refractionPOINT/python-limacharlie.git@master'
limacharlie mailsec --help
Use that same installation for hive and secret commands. Credential-file
examples also require jq. For repeatable
scripts, replace master with the tested commit SHA; python -m pip freeze
records the installed revision. Re-read these pages after upgrading.
Talk to us before relying on it in production.
Every message gets exactly one verdict, and the verdict always carries its reasons. This page explains how the reasons are produced, what the rules can see, and how to tune it.
The verdict¶
| Verdict | Meaning |
|---|---|
malicious |
Score at or above the malicious threshold |
suspicious |
Score at or above the suspicious threshold |
graymail |
Bulk or marketing mail: neither an attack nor wanted |
benign |
Below the suspicious threshold, and nothing said "graymail" |
unknown |
No judgement was reached |
error |
Judging failed |
The verdict object on a message carries:
| Field | Meaning |
|---|---|
verdict |
The class above |
score |
0–100 |
top_signals |
Up to five contributing rules, heaviest first, each with rule_id, name and weight. This is the "why this verdict" block |
matched_signals |
Every rule id that matched, including suppressed ones — the hunting surface |
tags |
The deduplicated, sorted tags of the rules that actually contributed |
engine_version |
SHA-256 fingerprint of the scoring rules, scoring policy and engine build |
decided_at |
When |
mode |
Who last decided: auto (the rule pack), analyst (a person), ai (a triage agent) or detonation (link detonation). See Revising a verdict |
campaign_id |
The campaign this message was clustered into, if any |
A number alone is never the answer
A non-benign verdict always populates top_signals. The console renders it
as Why this verdict in the message drawer, and the API returns it on both
the index row (the single heaviest signal) and the detail response (the full
list). A score with no explanation would not be actionable and is not
offered.
The verdict is also an event¶
Every verdict this product reaches is emitted as an EMAIL_VERDICT event, so a
rule that acts on verdicts is written once:
revision/seq |
revision/mode |
What it is |
|---|---|---|
0 |
auto |
What the rule pack decided, emitted at ingest immediately after the message's EMAIL_MESSAGE |
1, 2, … |
analyst |
A human overrode it |
ai |
The AI triage agent overrode it | |
detonation |
Link detonation found something at the other end and overrode it |
The seq 0 event repeats a verdict that is already inside EMAIL_MESSAGE, and
that duplication is deliberate: without it, "tell me when a message is judged
malicious" would be two rules against two paths on two event types — one for the
engine's opinion and one for everything that happened after it — that you would
have to keep in agreement forever.
EMAIL_MESSAGE is still emitted once per message and is still immutable. The
verdict history is the sequence of EMAIL_VERDICT events, which is what lets a
hunt reconstruct what was known at any point in time.
The seq 0 event is an event and nothing else. It is not an entry in the
message's revision history, and a message nobody has overridden reports zero
revisions in the API and the console.
See Events & Automation for the payload and Custom Rules for a rule that uses it.
Revising a verdict¶
The engine's call is the first word, not the last. A person or an AI triage agent can replace it, and the replacement is appended rather than written over the top.
limacharlie mailsec message revise <msg_uuid> \
--verdict malicious --rationale "confirmed credential harvest" --oid $OID
| Field | |
|---|---|
verdict |
Required. malicious, suspicious, graymail, benign or unknown. unknown is an honest abstention that escalates to a human queue. error is refused — it means judgement itself failed, which is an engine fact nobody decides |
mode |
Required. Which seat decided: analyst or ai. auto is refused; the scorer does not override itself. The CLI always sends analyst, because the operator of a CLI is a person — an agent revises with its own key and mode: ai |
rationale |
Required. A non-empty list of short reasons. A class with no reason is a naked verdict, and the same explainability contract applies to a revision as to the engine |
score |
Optional. Omit it and the engine's score stays beside the new class, rather than a made-up number landing in the column a backtest reads |
rationale is bounded rather than refused: at most 10 bullets of 280
characters, clipped on a character boundary with rationale_truncated set. A
verdict is not thrown away over a long explanation.
Who revised is stamped from your authenticated identity and is never read
from the request. mode names the seat, not the person, so a caller misstating
it can only do so beside an actor it did not choose — where the two disagree
visibly.
A no-op is a success, not an error
Re-recording the class, score and mode a message already carries changes
nothing: applied: false, already_current: true, and revision_seq names
the revision that already says it. Re-wording the rationale alone is not a
change. A person confirming an agent's call is one, because the mode
moves.
Revising takes mailsec.act, not mailsec.set — the only place on this
surface where the permission is not read off the "does it touch a mailbox" line.
A revision can move a message into the flagged set, which promotes its evidence
from the 35-day lane to the 400-day one, and that promotion is a ratchet with no
demotion. It also emits an EMAIL_VERDICT that fires every matching rule. That
is the product doing things on the organization's behalf, and mailsec.act is
the single grant an operator revokes to stop an autonomous caller doing them.
The four modes¶
mode |
Who |
|---|---|
auto |
The rule pack, at ingest. Only the engine writes this one |
analyst |
A person, through the console, the CLI or the API |
ai |
An autonomous triage agent calling with its own organization credentials — see AI Triage |
detonation |
Link detonation came back with something the static pass could not know |
The history¶
The revisions are an append-only sequence. Each entry carries its seq, the
verdict and mode, the actor, decided_at, the rationale, and the prior
state it displaced — verdict, score, mode and engine version. The first
revision's prior is the engine's own verdict, so the chain reaches back to
what the pack originally said without a separate lookup.
GET /messages/{msg_uuid}/revisions serves the whole chain, oldest first. It is
deliberately not paginated — a message's revisions are few by nature — but
revisions_truncated reports the pathological history that exceeded the
backend's ceiling. It is gated on mailsec.get: a revision is the product's own
structured record of a decision about a message you can already open, not the
original bytes mailsec.get.eml guards.
The message drawer inlines the most recent revisions, which is what an analyst
reads; the route exists for the rare message with more than the drawer inlines,
and for an audit export that wants the chain entire. A message nobody has
overridden reports revision_count: 0 and an empty history — the engine's own
verdict is on the message, not in its history of disagreements.
Nothing is rewritten. EMAIL_MESSAGE still stands as the record of what the
engine decided at ingest, the row carries the current answer, and each revision
ships an EMAIL_VERDICT at the next seq.
Scoring¶
Each matching scoring rule carries a weight (1–100, how much this evidence is worth) and a confidence (0–100, how often it is right when it fires). The score combines them with diminishing returns rather than a sum:
Rules with the same non-empty shared_fact contribute only their strongest
weighted evidence to this formula. Rules without a group contribute separately;
suppressed matches contribute nothing. Graymail rules use a separate lane.
Each signal removes a fraction of the remaining headroom. A sum would let five weak signals outscore one strong one and would need clamping at 100, which makes every heavily-signalled message look identical. One rule at weight N and confidence 100 scores exactly N, which is the identity every threshold is reasoned about against.
| Threshold | Default | Where to change it |
|---|---|---|
malicious_min |
85 | mailsec_policy/thresholds |
suspicious_min |
45 | mailsec_policy/thresholds |
malicious_min must stay above suspicious_min. Composed policy that inverts
the pair is refused, and an inverted pair reaching the scorer any other way falls
back to the defaults rather than making every suspicious message malicious.
The graymail lane¶
Graymail is a lane, not a score band. Rules classed graymail contribute no
score at all; they set the verdict to graymail only when the score did not
reach suspicious_min and no detection-class rule fired.
That last clause is the point: a newsletter that also carries a credential-phish link is a phish that happens to look like a newsletter, and filing it under "marketing" is filing it where nobody looks.
Exclusions¶
Exclusions are applied before scoring, so a suppressed rule contributes
nothing to the score and never appears in top_signals. It still appears in
matched_signals, so a suppression is auditable rather than invisible.
Exclusions can be scoped by rule id, sender address, sender domain or mailbox, require a written reason, and can carry an expiry — after which they become inert without anyone deleting them. See Policy Reference → Exclusions.
What the rules can read¶
Rules are standard D&R detect blocks evaluated against the Message Data Model — the parsed message — plus the enrichments the pipeline stamped onto it. Enrichments are fields in the message, so a rule reads them as ordinary paths. A replay of the emitted event can read its original stamps; the MailSec rule backtest instead re-parses stored EMLs and does not reconstruct those enrichments. See the Rule Reference for JSON fields, types, and presence rules.
The parsed message¶
headers (including the raw header list, decomposed sender and recipient
addresses, and every domain and IP found), sender (with reply_to_mismatch,
free-mail and disposable flags), recipients, subject, body (HTML and plain,
extracted display text, thread segmentation into the current reply and previous
quoted threads, hidden-text detection), links, attachments, auth (parsed
SPF / DKIM / DMARC / ARC results with alignment) and hops (the parsed Received
chain).
Enrichments¶
| Path | What it carries |
|---|---|
enrichments/sender_profile |
This organization's history with the sender address: first_seen_ts, days_known, msg_count_30d, flagged_count_180d, prevalence (none / new / rare / common) |
enrichments/domain_profile |
The same, keyed on the sender's registrable domain |
enrichments/sender_domain |
The sender domain's registration age, from RDAP with a bounded global cache |
enrichments/link_features[] |
Per link, aligned with links[]: domain, domain_age_days, popularity_bucket (top1k / top100k / top1m / unranked), in_urlhaus, mixed_script (a homograph label mixing writing systems), credentials_in_url (the https://apple.com@evil.example/ trick) |
enrichments/lookalike |
vip_hit (display_name:<name> when the display name matches a VIP whose address does not, email:<addr> when the sender is the VIP), org_domain_distance and brand_domain_distance — edit distances against your own domains and known brands |
enrichments/detonation |
What link detonation found at the other end of a link, when it ran: hops[] with each hop's resolved_ips, connected_addr and tls, a landing (effective_url, title, has_password_input, form_action_hosts, …), or a refusal (kind, reason). Added after the first verdict, so it is absent on a message that was never detonated — which is most of them |
attachments[].explode |
Attachment explosion: recursive children with their own names, hashes, magic types and depth; archive (encrypted, file_count, max_depth_hit); vba (auto_exec, suspicious, hex_strings); qr[].url; ocr_excerpt; yara_matches; the scanners that ran |
Attachment explosion is bounded — a per-message time budget and size and event
caps — and can only ever fail toward "not scanned". explode.scanners names what
actually ran for that attachment, so a rule that depends on a particular kind of
evidence can tell "the scanner found nothing" from "that scanner did not run".
Absent is not benign
An enrichment that could not be resolved is absent, never a reassuring
value. domain_age_days is missing when the registry lookup was unavailable,
rate-limited or a cache miss — which is common — and a
newly-registered-domain rule must therefore test presence as well as a
threshold. Likewise popularity_bucket is empty when the lookup did not run,
which is a different fact from unranked, a positive finding that the domain
really is not in the list. A missing lookup must never become a suspicion.
The sender-history feedback loop, and why it is closed¶
Sender profiles are read before the message is counted, so the stamp answers
"what did this organization know about this sender before this message
arrived". Counting first would make msg_count_30d never zero and first contact
indistinguishable from second contact.
More importantly, the profile's flagged_count_180d counter is only incremented
for verdicts that flag independently of prevalence signals. Rules whose
evidence is the accumulated history carry a prevalence tag, and the counter
is computed with those rules removed.
The reason is a loop observed live before the contract existed: the sender-history rule alone can cross the suspicious threshold, and if a flagged verdict fed the counter, one false positive would re-flag that sender forever — each flag re-incrementing the counter that caused it, never decaying, and in enforce mode quarantining a legitimate sender silently. Counting only the independent lane makes a history rule an amplifier of other evidence and never of itself.
The default rules¶
LimaCharlie's default rules are installed once into dr-mail when you subscribe.
Email Security → Rules is the authoritative catalog for your organization:
it shows the exact current conditions, weight, confidence, phase, tags and
false-positive notes. Every default is editable, disableable and deletable.
Defaults cover impersonation, authentication and sender history, links, attachment threats, suspicious content, detonation evidence and graymail. They are ordinary D&R rules over the Message Data Model, not a separate engine. See Mail Rules for the format, IaC and explicit restoration.
A verdict's engine_version is a SHA-256 fingerprint of the scoring rules,
resolved thresholds, exclusions, VIPs, threat-feed references and clustering policy,
and linked parsing/enrichment library build.
Changing rule content or scoring policy changes the fingerprint. It identifies
the decision configuration; it is not a promise that an external lookup feed
or other message enrichment is unchanged.
Link detonation¶
Static link features answer what a URL looks like. Detonation answers where it actually goes: the redirect chain, the address each hop really connected to, the landing page's certificate, and bounded signals from the page itself.
It is an enrichment, never a gate. No mail is held waiting for it. The
message is judged, EMAIL_MESSAGE is emitted and the automations run first;
detonation happens afterwards and, if what it finds changes the answer, it files
a verdict revision in mode: detonation.
Availability is per region
Detonation needs an isolated, separately-governed analysis environment — the process that fetches attacker-chosen URLs and parses attacker-written HTML holds no mailbox credential, no organization key and no LimaCharlie identity at all. Where that environment is not deployed, detonation is simply absent: nothing is stamped, and it never degrades into a fetch from a service that can reach a mailbox.
What gets detonated¶
Four conditions, all of them required:
The verdict is suspicious |
Not malicious — that has already crossed the threshold that fires automations, so a fetch there takes a slot from the message where the answer would change something. Not benign, for the other end of the same argument |
The message has at least one http/https link |
Other schemes are skipped before anything is queued |
| It is a first ingest, not a redelivery | A re-delivered message has already had its turn |
| The environment is deployed in that region | Otherwise absent |
Up to five distinct URLs per message, taken in message order after duplicates are folded together. Five, because a phishing message's payload link is in the first few and a newsletter has hundreds — and stated honestly: a message whose sixth link is the malicious one is not fully examined.
The URLs come from the message's own link list, after a mail gateway's rewrite has been unwrapped. Detonating the wrapper would describe your mail plumbing rather than the attacker's infrastructure.
Two other things reach the same machinery: an analyst or a rule dispatching
crawl_link, and an
AI triage agent doing the same. Three invocations, one mechanism
— a second detonation path would be a second answer for one message.
One fetch per URL, per organization¶
A URL is claimed before it is fetched, and the claim is organization-wide rather than per message. That is what makes a campaign cheap: four hundred messages carrying one link cost one fetch.
| Outcome | |
|---|---|
| Claimed | This is the first caller. It fetches and writes the result back |
| Fresh | A completed result already exists and is recent enough to reuse. Nothing is fetched |
| In flight | Another caller holds a live claim. Nothing is stamped and nothing waits — mail is never held for detonation |
| Backoff | This URL failed recently and is not retried yet |
Everything about the lane sheds rather than blocks or grows: bounded workers, a bounded queue, and a per-organization cap on how many detonations are in flight at once so one organization's campaign cannot take every slot. A drop costs one message a better verdict, which is a different thing from losing mail, and the two do not share a policy.
A refusal is a result, not an error¶
"This link redirects to the cloud metadata address" is the single most valuable thing detonation produces, so a refusal is a successful detonation carrying a typed reason — never an error a caller would log as infrastructure and discard.
| Reason | |
|---|---|
invalid_url |
The URL could not be parsed |
scheme_not_allowed |
Not http or https |
port_not_allowed |
Not a web port |
credentials_in_url |
Credentials before the host — the https://apple.com@evil.example/ trick |
blocked_address |
A hop resolved to an address that must never be connected to: loopback, link-local, private ranges, the cloud metadata address |
no_usable_address |
The name resolved, and every address it resolved to was blocked. Deliberately its own finding: "this host now points only at an internal address" is not a DNS failure |
dns_failure |
The name did not resolve |
too_many_hops |
The redirect chain ran past its limit |
redirect_loop |
The chain came back to somewhere it had been |
missing_location |
A redirect status with no usable destination |
budget_exceeded |
The target stalled past the time budget — itself evidence |
transport_error |
The connection failed |
(A thirteenth code, disabled, is reserved in the contract for "asked for
where detonation is not deployed". Nothing stamps it on a message — in that
situation nothing is stamped at all — so do not write a rule that waits for it.)
Each hop's real connected address is recorded, so a name that answered differently the second time cannot hide behind the first answer. TLS facts are recorded even when the certificate does not verify — refusing to look at a page because its certificate is untrusted would blind detonation to the pages most likely to be malicious. Oversized pages are truncated and reported as truncated, never refused: refusing would hand an attacker a one-line way to blind detonation by padding a harvest page.
mode: detonation¶
When the evidence changes the class, the message is re-judged in full — the enabled
rule records, your policy, your thresholds — and the new class is filed as a
revision in mode: detonation.
It has the lowest authority of the three revising modes:
| The message's current mode | A detonation revision |
|---|---|
auto, or a previous detonation |
Applies |
ai |
Refused. The evidence is still stamped on the message |
analyst |
Refused. The evidence is still stamped on the message |
| Anything else | Refused. The rule is an allow-list, so a mode this build does not recognize is not overwritten either |
A machine does not overrule a person, or an agent that already looked. The refusal is a satisfied outcome, not a failure: the detonation block lands on the message either way, and only the verdict is left alone.
detonation is not a mode any caller can claim. The
revision API accepts analyst
and ai; this one is stamped by the engine that produced it.
What is kept, and what is not¶
The page body never comes back. What is stored is a hash and size, a title, a bounded excerpt, structural signals, and the redirect chain with each hop's resolved and connected addresses and TLS facts.
Results are always sealed — there is no plaintext mode, and the lane refuses to start without a key. Each result is encrypted under a key derived from your organization's own key, stored under a path that leads with your organization id, and bound to that exact path so that a copied object cannot be opened elsewhere. The environment that does the fetching never holds your organization's key; it writes through a single-object, single-use signed URL and holds no credential of yours at all.
Detonation results are removed by a tenant purge and by the ordinary retention sweep, along with everything else the product holds for you.
Reading it¶
The detonation block lands on the message's enrichments and in the drawer, and rules read it as an ordinary path:
# The landing page asks for a password
op: is
path: enrichments/detonation/landing/has_password_input
value: true
# A link's chain ended somewhere it must never be connected to
op: is
path: enrichments/detonation/refusal/kind
value: blocked_address
The block carries url — which link this describes, and the only field that
says so — plus hops[] (each with host, resolved_ips, connected_addr,
status, next_url and tls), a landing (effective_url, status, title,
body_sha256, body_bytes, body_complete, text_excerpt,
has_password_input, form_count, form_action_hosts), a refusal
(kind, reason) where there was one, elapsed_ms and detonated_at.
Most of these fields are absent rather than false or empty
A boolean like has_password_input, and refusal/kind itself, are omitted
when they have no value — so "the page had no password field" and "no
detonation ran" look identical to a rule that only tests for false. Test
for presence first when the difference matters, exactly as you would for
any other enrichment.
When several of a message's links were detonated, the drawer shows the most damning one — credential harvest first, then a refusal that found a private pivot, then any other refusal, then a landing with a bad certificate. The others are not discarded: each link carries its own resolved chain.
Watching the download itself¶
Detection does not stop at the mail. The most privileged thing anyone can do in
this product is take a person's original message out of it, and that act is
telemetry too: every call to
GET /messages/{msg_uuid}/eml —
served or refused — emits an EMAIL_ACTION with action: get_eml, carrying the
actor, the mailbox, the stated justification, and the bytes handed over.
One download is an analyst doing their job. A hundred in an hour is not, and it is the volume that says so — which is why the byte count is on the event.
# Detect: one identity pulling raw mail in bulk
op: and
rules:
- op: is
path: routing/event_type
value: EMAIL_ACTION
- op: is
path: event/action
value: get_eml
- op: is
path: event/result
value: ok
# Respond
- action: report
name: mailsec-raw-download
detect_data:
actor: '{{ .detect.event.actor }}'
mailbox: '{{ .detect.event.mailbox.address }}'
bytes: '{{ .detect.event.bytes }}'
suppression:
is_global: true
keys:
- 'mailsec-raw-download'
- '{{ .detect.event.actor }}'
max_count: 25
period: 1h
The suppression block is doing the real work: it lets twenty-five downloads an
hour by one identity pass without a detection and reports the twenty-sixth, so
the rule is quiet for normal use and loud for a scrape. Set max_count to what
your team actually does in an hour, not to a number that feels safe.
is_global: true keys the budget on the identity across the whole organization
rather than per sensor — an analyst working two mail connections is still one
analyst.
Templates in detect_data and metadata resolve against the detection, not
the raw event, which is why the paths above are .detect.event.….
Alert on the refusals too — they are the earlier signal
A stolen or over-broad key usually fails before it succeeds. Match
event/result = refused and read event/refused_reason:
permission_denied means somebody without the mailsec.get.eml grant
reached for raw mail, and quota_exceeded means the organization's download
budget was hit. Both are worth a detection on the first occurrence, with no
suppression at all.
# Detect: somebody reached for raw mail without the grant
op: and
rules:
- op: is
path: routing/event_type
value: EMAIL_ACTION
- op: is
path: event/action
value: get_eml
- op: is
path: event/refused_reason
value: permission_denied
Both rules are ordinary platform D&R rules on the edr target, so the same
response arsenal applies — page a channel, open a ticket, disable the key.
Two seats for rules¶
Signal rules run in the collector, before the verdict is emitted. Platform D&R rules run afterwards, on the emitted events, with the whole response arsenal. Same syntax, different seat. See Custom Rules and Events & Automation.