Link to project: https://phishing-triage-assistant-eypuhgx99jea64kirwsrjb.streamlit.app/
A small local tool for triaging suspicious emails. Paste the raw email (headers + body) into the box, click Analyze, and get back:
- a risk level — Low / Medium / High,
- a list of specific red flags, each with the exact text that triggered it,
- a plain-English explanation of the verdict (English or Spanish).
Everything runs on your machine. No email content is sent anywhere.
The tool applies a set of rule-based heuristics to a pasted email and produces a triage verdict — the kind of quick "should I be worried about this?" check you'd do before clicking a link or replying. It looks at sender authentication headers (SPF / DKIM / DMARC), From / Reply-To / Return-Path alignment, display-name and look-alike-domain spoofing, urgency and credential-request language, deceptive links, risky attachments, and Business Email Compromise patterns.
It is aimed at:
- individuals and small teams without a dedicated security team or mail gateway to lean on;
- anyone who wants a structured second opinion on a suspicious message instead of eyeballing it;
- bilingual staff (English/Spanish) who may need a structured, plain-language second opinion on a suspicious message.
- individuals who are not tech-savvy, and would like assistance with deciding what to trust in their inbox, and wanting to know the "why" to do so.
It is not a replacement for enterprise email security, a sandbox, or a malware scanner. It is a triage aid, and the human still makes the call.
I built this with Claude Code, working in iterative, tightly scoped prompts— one capability or fix per prompt, each followed by tests, rather than one large "build me a phishing detector" request.
Rather than relying on the AI to determine what constitutes a threat, I acted as the security architect and reviewer. I oversaw the development of the rule set, per-rule weights, severity levels, and risk thresholds, strictly verifying the AI's logic against real-world threat intelligence sources and principles from my CySA+ training. Claude Code's role was to scaffold the project structure and implement, refactor, and test the rules under my direct supervision. Every heuristic was sanity-checked against actual cybersecurity standards to ensure the AI did not hallucinate security principles, guaranteeing that the final tool reflects verified, industry-standard detection methods as close as possible.
Two reasons, both deliberate:
Privacy. The tool processes potentially sensitive personal and workplace email. With local heuristics, no email content ever leaves the machine or reaches a third-party API — not the headers, not the body, not the links. There is nothing to log, retain, or leak on someone else's server.
Determinism and auditability. Every score can be traced to the exact rules that fired and the exact substring of the email that triggered each one. Given the same input, the output is always identical. An LLM call would give neither guarantee: its reasoning isn't reproducible, isn't fully inspectable, and can't be pointed at as "this rule, this evidence, this weight." For a security triage tool, being able to explain why a message scored the way it did matters as much as the score.
The rules and scoring live in triage/rules.py and triage/scorer.py; those
modules do no I/O and make no network calls.
-
Per-rule evidence checks. For each rule I built an isolated synthetic email that triggers only that rule, then asserted three things: the right rule fired, its severity matched what the rule documents, and the
evidencefield contained a verbatim slice of my input — proving evidence is pulled from the actual matched text, not a hardcoded placeholder. This covers every branch of the multi-variant rules (look-alike domain, display-name spoof, deceptive link host, suspicious attachment). One test deliberately breaks a single rule and confirms the rest of the analysis still completes with a "Rule error (ignored)" flag for just that rule. -
Mixed-severity emails. I tested emails that trip several rules at once to confirm the "top flags" selection (which findings surface first in the explanation) ranks by severity then weight correctly.
-
iCloud Phishing Email — false Low. A real, iCloud phishing email that I utilized came back as low, which means that it was not seen as a scam. after doing more research, I figured out that the source message was encrypted with base64, which is done to bypass email phishing detectors, so it can go straight to one's inbox. I made Claude Code build a Base64 decoder, straight into the assistant, as a way to still read the red flags, and judge the email properly.
-
Steam gift email — false High. A real, fully-authenticated Steam gift email scored High because the lexical "brand name inside the sending domain" rules (
steaminsidesteampowered.com) fired without checking authentication. Fixed by adding a check: when SPF, DKIM, and DMARC all pass and align to the sending domain, those lexical-only rules drop to low severity with an explanation noting the domain is authenticated.steampowered.comnow scores Low. -
LinkedIn notification email — false Medium. A real LinkedIn digest scored Medium because the credential-request rule matched the substring
otpinside anotpToken=URL tracking parameter — a URL artifact, not a request to the reader. Fixed in the parsing step: URLs are stripped from the body text (each replaced with[link]) before any keyword rule runs. The link-based rules still work from the parsed link objects, which keep the real URLs. -
Dedicated BEC rule. Business Email Compromise often has no spoofed domain, no bad link, and no attachment — just words. I added a rule that fires only when urgency/secrecy language and financial language both appear, and capped it at Medium on purpose, with explanation text that tells the reader to verify the request through a separate known channel (call the person back on a number you already have) before acting. It's a prompt for human judgment, not an automated verdict.
-
Fail-closed input handling. The tool distinguishes genuinely unparseable input — a wall of text with no header-like lines and no blank-line body separator — which blocks analysis and returns an error, from a valid headerless body someone pasted without headers, which is analyzed but marked "limited" (sender-authentication and domain-spoofing checks are skipped and the result says so). The failure mode is "refuse or caveat," never "silently score Low."
Stated plainly:
- No live reputation data. No domain-reputation, WHOIS, blocklist, or DNS lookups. A brand-new malicious domain with clean-looking headers can pass.
- No attachment content scanning. It flags risky attachment types and names (executables, double extensions, macro-enabled Office docs, archives), but never opens or inspects attachment contents.
- False positives on legitimate senders. Real small businesses, nonprofits, and solo operators sometimes send from free webmail; legitimate marketing email routinely uses link shorteners. Those rules are intentionally weighted as weak signals, but they can still contribute to a Medium on an otherwise benign message.
- Static heuristics only. If an attack doesn't match an existing rule, the tool won't catch it. It has no ability to generalize to novel patterns.
- A pure database Although I fixed many, many bugs, there are still bound to be more, as it is a pure database scanner that has to be checked, patched, and fixed, each time a new problem arises.
Treat a Low result as "no known patterns matched," not "safe."
Requires Python 3.10+.
pip install -r requirements.txt
streamlit run app.pyThen open the URL Streamlit prints (usually http://localhost:8501). Paste an
email — or use the Load buttons to try the examples in sample_emails/ —
and click Analyze. A language toggle (English / Español) switches the
displayed text without re-running the analysis.
You can also call the scorer directly:
from triage.scorer import analyze_email
result = analyze_email(raw_email_text)
print(result.risk_level, result.score)
for flag in result.red_flags:
print(flag.severity, flag.rule_id, "-", flag.evidence)python -m pytestThe suite is split across:
tests/test_scorer.py— end-to-end scoring, sample emails, the Steam and LinkedIn regression fixtures, fail-closed behavior.tests/test_rule_explanations.py— one isolated email per rule/variant, asserting rule id, severity, and verbatim evidence.tests/test_localization.py— the English/Spanish output layer never changes the score, severity, or evidence, only the displayed text.
Real emails used as test fixtures have had all personal and recipient-identifying data replaced with synthetic values.
phishing-triage-assistant/
├── app.py # Streamlit UI + language toggle
├── triage/
│ ├── parsing.py # raw text -> structured email; URL stripping for keyword rules
│ ├── rules.py # the heuristic rules -> RedFlag list (security logic lives here)
│ ├── scorer.py # analyze_email(), weights -> score -> risk level, fail-closed logic
│ ├── messages.py # EN/ES presentation layer (titles, explanations, UI strings)
│ └── __init__.py
├── sample_emails/ # paste-ready examples (benign + malicious)
├── tests/
│ ├── test_scorer.py
│ ├── test_rule_explanations.py
│ ├── test_localization.py
│ └── fixtures/ # scrubbed real emails used as regression fixtures
├── requirements.txt
└── README.md
Built by a trilingual cybersecurity graduate, and student, (EN / ES / 日本語) as part of a Forward Deployed Engineer portfolio.
0 comments
log in to comment.