A phishing triage score that explains itself
Every school district has a "Report phishing" button, and every school district has the same problem with it: once people actually use it, someone has to look at every report. Most are marketing mail or a vendor newsletter. A few are real. The real ones need attention fast, and they're easy to miss in a pile of the others.
This fall I rebuilt how our reports get handled. The goal wasn't to let a machine decide what's phishing. It was to put each report in front of a technician with a score and, more importantly, the reasons behind the score, so they can make the call in seconds instead of digging through headers.
What you need
- A self-hosted n8n instance (the community edition is fine)
- A shared mailbox where reported messages land, readable through Microsoft Graph or Gmail
- Somewhere to post results for your techs (we use a Google Chat space)
- A list of the domains and vendors your organization really uses
Why rules instead of AI
I considered handing each message to a language model and asking "is this phishing?" I didn't, for two reasons. First, the reported messages contain staff email and sometimes student information, and I didn't want that leaving our systems for a yes/no answer. Second, a model's answer is hard to argue with. When a tech disagrees with "73% likely phishing", there's nothing to fix.
A rule-based score is boring, and that's the point. Every point comes with a sentence a person can read, and when the score is wrong, I can see exactly which rule caused it and tune it.
How the score works
A small Code node in n8n runs a list of checks against the reported message. Each check that fires adds (or subtracts) points and records why. The total is clamped to 0–100 and mapped to a band:
const factors = [];
const add = (points, reason) => factors.push({ points, reason });
if (spoofsOurDomain) add(35, 'Claims to be from our domain, but Microsoft says we did not send it');
if (lookAlikeDomain) add(25, 'Uses a look-alike of our name: ' + lookAlike);
if (replyToFreemail) add(20, 'Replies go to a personal email account');
if (riskyAttachment) add(30, 'Has a risky attachment type (' + ext + ')');
if (usesShortener) add(15, 'Uses a link shortener that hides the real destination');
if (fromInside) add(-10, 'Sent from inside the organization');
const score = Math.max(0, Math.min(100, factors.reduce((s, f) => s + f.points, 0)));
const band = score >= 60 ? 'Likely phishing'
: score >= 35 ? 'Suspicious, look closer'
: score >= 15 ? 'Low risk, a few flags'
: 'Probably fine';
The full rubric, sanitized with a CONFIG block for your own domain and names, is on GitHub: Phishing Triage Score.
The checks fall into a few groups:
- Sender authentication. I only trust the verdicts Microsoft itself stamped on the message (its own Authentication-Results header), not whatever an attacker might have added further down. Spoofing our own domain is the heaviest single signal.
- Look-alike names. Domains are decoded from punycode, common look-alike characters (Cyrillic "а" for Latin "a", "rn" for "m") are folded, and anything within a couple of edits of our name scores high. Other organizations that legitimately share part of our name score lower.
- Where replies and links go. Reply-to a personal Gmail, links to free hosting or form builders, raw IP addresses, and link shorteners all add points.
- Lures. Mentions of leadership from an outside address, "document" PDFs from strangers, messages with almost no readable text.
- History. If mail from the same sender was reported before, the earlier verdict nudges the score up or down.
What the tech sees
The result posts as a card: the band, the score, the top three reasons in one line, and the full list below. A typical card reads something like "Suspicious, look closer · Replies go to a personal email account · Links go to free website hosting · Mentions leadership but isn't from a district address." From the card, a tech marks it Malicious, Legitimate or Needs Investigation. Malicious reports can kick off a follow-up workflow that finds and pulls the same campaign from other mailboxes.
We're also piloting an email back to the person who reported it, with the result and the reasons. My bet is that closing that loop matters as much as the score: people keep reporting when they hear back, and the reasons teach them what to look for.
Lessons so far
- Use the real address, not the display name. My first version read the sender from the display name, which an attacker controls completely. "Helpdesk" can be anyone.
- Don't double count. "Outside sender" plus "outside link" plus "outside reply-to" can stack into a scary score for an ordinary newsletter. Signals that describe the same thing should share points.
- Keep the lists editable. Trusted vendors, risky top-level domains and file-sharing senders are plain arrays at the top of the script, so tuning doesn't mean touching logic.
- Run new pieces in shadow or test mode first. New workflows run quietly, or send only to me, until their output has been checked against what a person would have decided.
It's still being tuned, and it always will be. But the techs now start from a short, readable list of reasons instead of a raw message, which is the whole point.