The central backend for an AI-powered phishing and scam detection platform.
It exposes one REST endpoint, POST /analyze, which accepts an SMS, an email,
or OCR text extracted from a screenshot, runs it through a set of independent
analysis modules, and returns a risk band, a numeric score, and a reason for
every point it added.
The engine only orchestrates. It contains no detection logic of its own: each module decides what to look for, the risk module decides what it is worth, and the engine just wires them together.
- Quick start
- Architecture
- The module contract
- The modules
- How scoring works
- Configuration
- Project layout
- Testing
- Replacing a module
- Not in scope
npm install
npm startThe engine listens on http://localhost:3000 and runs with zero
configuration — the TRAI integration and the AI analyzer both default to
offline providers, so nothing external is required to get a verdict.
curl -X POST http://localhost:3000/analyze -H "Content-Type: application/json" -d "{\"type\":\"sms\",\"sender\":\"VK-HDFCBK\",\"subject\":\"\",\"body\":\"Dear customer, update your KYC immediately at https://secure-hdfc-login.xyz\"}"Note what happened to the sender: VK-HDFCBK is a genuinely registered TRAI
header, which would normally lower the score. The engine withheld that credit
because the message links to a domain impersonating the same bank — a
registered header pointing at a lookalike domain is the signature of a spoofed
or compromised sender, not a reason for trust.
Full request/response reference: docs/API.md.
POST /analyze
|
routes -> controller
|
validation (utils/validate)
|
┌─────────────────────┐
│ Detection Engine │ normalize -> fan out -> aggregate
└─────────┬───────────┘
|
normalizer (Module 4: OCR / SMS / email -> one shape)
|
┌────────────────────┼────────────────────┐
│ │ │ (run concurrently)
Module 1 Module 2 Module 3
TRAI header URL analyzer AI scam analyzer
(HTTP client) (offline) (pluggable provider)
│ │ │
└──────── signals ───┴──── signals ───────┘
|
Module 5: Risk aggregator
(weights, combinations, bands)
|
risk + score + reasons
Three design decisions carry most of the weight:
1. Modules report facts; the risk module assigns points.
A detector never says "this is worth 30 points". It emits a signal — a stable
code such as URL_LOOKALIKE_DOMAIN — plus a human-readable message, the
supporting evidence, and a scale from 0 to 1 expressing its own confidence.
Module 5 owns the weights. This is what makes detectors genuinely replaceable:
rewrite the URL analyzer as a machine-learning model and, as long as it emits
the same signal codes, scoring keeps working untouched.
2. Every module is injected.
createDetectionEngine({ trai, url, ai, risk }) accepts any object with the
right shape, so tests drive the engine with stubs and production swaps
providers by configuration.
3. A failing module degrades, it does not fail the request.
Each module runs inside a guard. If the TRAI service is down or the AI provider
times out, that module reports available: false, contributes no points, and
the engine still returns a verdict from the remaining evidence. A partial
answer beats a 500.
Every detection module exposes exactly this:
{
name: string,
analyze(message) => Promise<{ report: object, signals: Signal[] }>
}messageis the normalized message:{ type, sender, subject, body, text, meta, stats }.reportis whatever the module wants to show the caller. It is passed through to the response untouched and is never interpreted by the engine.signalsare what actually drive the score.
A signal looks like this:
{
code: 'URL_SUSPICIOUS_TLD', // from utils/signals.js — the shared vocabulary
message: 'Domain "x.xyz" uses the low-cost .xyz top-level domain...',
severity: 'high' | 'medium' | 'low' | 'info',
evidence: { url: '...', tld: 'xyz' },
scale: 0.7 // 0..1 confidence multiplier
}The risk module has a different, simpler contract, because it never sees a message:
{ name: string, aggregate(signals) => { risk, score, reasons, breakdown } }Integration layer only. The verification service lives in a separate repository and is reached over HTTP; this module never implements verification itself. It does three things:
- classifies the sender locally (
senderHeader.js) — is it even a DLT header (VK-HDFCBK), or a mobile number, a short code, an email address? - asks the external service to verify it, via the provider;
- translates the answer into signals.
The local classification step matters: a mobile number can never be a registered commercial header, so there is no point calling the registry, and the result is a signal in its own right.
Providers (providers/), selected by TRAI_PROVIDER:
| Provider | Behaviour |
|---|---|
mock (default) |
In-memory registry of real bank headers. No network. |
http |
GET {TRAI_BASE_URL}{TRAI_VERIFY_PATH}?sender=VK-HDFCBK, expecting { "verified": true, "entityName": "HDFC BANK LIMITED" }. A 404 means "not registered"; a 5xx, a timeout or a connection failure means "unknown", which adds no risk in either direction. |
If the upstream contract ever changes, providers/httpProvider.js is the only
file that changes.
Extracts every URL from the message and analyses each one without ever touching the network — no DNS, no HTTP, no shortener expansion. Everything is derived from the string, which makes it fast, deterministic and side-effect free.
Extraction handles both explicit links (https://x.xyz/kyc) and bare hosts
(x.xyz/kyc), which scammers use interchangeably, and is guarded by a known-TLD
list so ordinary prose is not mistaken for a domain.
Each URL then runs through independent heuristics (heuristics.js):
| Check | Signal |
|---|---|
Plain http, or no scheme at all |
URL_NO_HTTPS |
| Known URL shortener | URL_SHORTENER |
| Raw IPv4/IPv6 host | URL_RAW_IP |
| Deep subdomain chains | URL_EXCESSIVE_SUBDOMAINS |
| Free or cheap abuse-prone TLD (two tiers) | URL_SUSPICIOUS_TLD |
| Brand keyword on a domain the brand does not own, or a near-miss spelling of a real one | URL_LOOKALIKE_DOMAIN |
| Punycode host | URL_PUNYCODE |
| Hostname mixing scripts (Latin + Cyrillic, …) | URL_MIXED_SCRIPT |
| Unusually long URL | URL_EXCESSIVE_LENGTH |
| Several links in one message | URL_MULTIPLE_URLS |
| Query parameters that harvest credentials, chain a redirect, or pre-fill identity | URL_SUSPICIOUS_QUERY_PARAM |
Credentials before @ in the authority |
URL_EMBEDDED_CREDENTIALS |
| Non-standard port | URL_NON_STANDARD_PORT |
Lookalike detection normalises confusable characters before comparing —
Cyrillic а → a, 0 → o, and l/1/i collapse together — so
k0tak-secure.xyz and f1ipkart-offers.top are both caught. Short brand
keywords (sbi, pnb) are matched only on token boundaries, so
businessdaily.com is not flagged.
Reference data lives in data/ (shorteners, suspicious TLDs, impersonated
brands, TLD parsing) so it can be swapped for a live feed without code changes.
Reports detected language, scam intent, confidence and reasoning. The module itself contains no model logic — it wraps a provider and converts the verdict into signals.
The provider contract (providers/provider.contract.js) is the whole point of
this module:
{ name, analyze({ text, type, sender, subject }) => Promise<AiAnalysisResult> }Whatever a provider returns is passed through normalizeResult(), so a
misbehaving provider can never corrupt the engine's output — an out-of-range
confidence is clamped, an unrecognised intent becomes unknown.
| Provider | Behaviour |
|---|---|
mock (default) |
Offline, deterministic. Scores a multilingual lexicon grouped by manipulation tactic (urgency, threat, credential request, KYC pretext, reward lure, job/investment bait, link pressure, impersonation, payment demand) across English, romanised Hindi, and Devanagari/Tamil/Telugu/Bengali script. URLs are stripped before scoring — those belong to Module 2. |
claude |
Anthropic Claude via the official SDK, constrained with structured outputs so the response always matches the schema. |
The Claude provider treats the message as untrusted data: the system prompt states that a message instructing the model to change its verdict is itself evidence of manipulation, and the message body is delimited in the user turn.
The SDK is an optional dependency — install it only if you use this provider:
npm install @anthropic-ai/sdkthen set AI_PROVIDER=claude and ANTHROPIC_API_KEY. No engine code changes.
If a provider hangs, the module gives up after AI_TIMEOUT_MS and reports
AI_ANALYSIS_FAILED, which scores zero.
The engine does not perform OCR. A separate OCR service extracts text from
a screenshot and posts it as {"type": "ocr", "body": "<text>"}.
The normalizer is where the promise "OCR text is analysed exactly like SMS
text" is kept structurally rather than by discipline: it collapses all three
input types into one canonical message, so there is no if (ocr) branch
anywhere in any detector. A test asserts that the same body submitted as sms
and as ocr produces an identical risk and score.
The one OCR-specific step is repairing artefacts that would otherwise break URL
extraction — OCR engines routinely emit example . com, https : / /, and
URLs split across a line break.
Combines every signal into LOW / MEDIUM / HIGH plus a 0–100 score, and
explains each point. See below.
weights.js is the entire risk policy, in one file.
1. Each signal contributes weight × scale.
URL_LOOKALIKE_DOMAIN 32 AI_SCAM_INTENT 32
URL_RAW_IP 22 HEADER_UNVERIFIED 22
URL_MIXED_SCRIPT 20 HEADER_MALFORMED 18
URL_SHORTENER 15 AI_SUSPICIOUS_INTENT 15
URL_NO_HTTPS 8 URL_MULTIPLE_URLS 6
HEADER_VERIFIED -12 AI_BENIGN -10
2. Trust credits are conditional. Negative weights (a registered header, a benign AI verdict) are withheld when any high-severity signal contradicts them. Otherwise a verified header would launder a phishing link.
3. Combination rules add points for evidence that means more together. A lookalike domain is bad; scam wording is bad; the two together are phishing.
| Rule | Fires when | Points |
|---|---|---|
BRAND_IMPERSONATION_WITH_SCAM_WORDING |
lookalike / punycode / mixed-script domain and scam or suspicious wording | +15 |
UNREGISTERED_SENDER_WITH_RISKY_LINK |
unregistered or malformed sender and a high-risk link | +10 |
OBFUSCATED_LINK_WITH_SCAM_WORDING |
shortener / raw IP / embedded credentials and scam wording | +10 |
CREDENTIAL_HARVEST_SETUP |
credential-harvesting query parameters and scam wording | +10 |
4. Bands. score >= 65 is HIGH, >= 35 is MEDIUM, otherwise LOW.
Both thresholds are configurable.
5. Everything is explained. reasons lists every counted contribution,
strongest first, prefixed with its points — the score can be reconstructed by
adding up the reasons, and a test asserts exactly that. scoring.contributions
additionally shows signals that scored nothing, and why (a withheld credit, a
neutral signal, or a signal code with no configured weight).
An unrecognised signal code scores zero and is reported rather than ignored, so a newly added detector can never silently move the score before it has been deliberately weighted.
Copy .env.example to .env. Every value has a working default.
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
HTTP port |
NODE_ENV |
development |
Environment |
LOG_LEVEL |
info |
error | warn | info | debug |
TRAI_PROVIDER |
mock |
mock | http |
TRAI_BASE_URL |
http://localhost:4000 |
External TRAI service |
TRAI_VERIFY_PATH |
/verify |
Verification path |
TRAI_API_KEY |
— | Sent as Authorization: Bearer when set |
TRAI_TIMEOUT_MS |
2500 |
Hard timeout on the lookup |
AI_PROVIDER |
mock |
mock | claude |
AI_TIMEOUT_MS |
8000 |
Hard timeout on AI analysis |
ANTHROPIC_API_KEY |
— | Required for AI_PROVIDER=claude |
ANTHROPIC_MODEL |
claude-opus-5 |
Model id |
RISK_MEDIUM_THRESHOLD |
35 |
Lower bound of MEDIUM |
RISK_HIGH_THRESHOLD |
65 |
Lower bound of HIGH |
GET /health/modules reports which provider is actually wired behind each
module — the fastest way to confirm a deployment is talking to the real
services rather than the offline stubs.
src/
app.js Express app (no listen — importable by tests)
server.js Process entry point, graceful shutdown
config/ All environment reading, in one place
routes/ Route definitions
controllers/ Thin: validate, delegate, respond
services/
detectionEngine.service.js The orchestrator
normalizer.service.js Module 4 — one shape for SMS/email/OCR
modules/
trai/ Module 1 — integration layer + providers
url/ Module 2 — extractor, heuristics, reference data
ai/ Module 3 — provider contract, mock + Claude
risk/ Module 5 — weights, combinations, aggregation
middleware/ Request id, error handling
utils/ signals (shared vocabulary), validation, errors,
HTTP client, logger
tests/ Unit + integration tests
docs/API.md API reference
npm test # 142 tests
npm run test:coverage # ~96% statements
npm run build # syntax check + app loads cleanlyThe suite covers each module in isolation (heuristics, providers, scoring rules), the orchestration contract (modules receive identical input, run concurrently, and a thrown module does not fail the request), and the HTTP surface end to end via supertest.
Nothing in the suite touches the network: the TRAI HTTP provider is driven
through an injected fetch, and the Claude provider through a fake SDK client.
Some tests deliberately exercise failure paths, so npm test prints a few
structured error log lines. That output is expected.
Say you want to replace the URL analyzer with a machine-learning classifier:
- Build an object with
{ name, analyze(message) }returning{ report, signals }. - Emit existing
URL_*codes fromutils/signals.jsfor anything already weighted. For genuinely new findings, add a code there and a weight inmodules/risk/weights.js— until you do, the signal is reported but scores zero, which is the safe default. - Inject it:
createDetectionEngine({ url: myClassifier }).
Nothing else changes. The same three steps apply to every module, and the
providers inside Modules 1 and 3 can be swapped the same way at a finer grain
(TRAI_PROVIDER, AI_PROVIDER) without even that much.
Deliberately not built here, per the project brief:
- No frontend. This is a backend service only.
- No TRAI verification implementation. Only the integration layer; the service itself lives in its own repository.
- No OCR. The engine consumes text an OCR service has already extracted.
- No Guardian functionality.
Known gaps worth a future module, rather than silent assumptions:
- The email sender address is not analysed as a domain. Module 2 analyses
URLs in the message body, so a lookalike domain in the
Fromaddress (security@icicibank-verify.top) is currently only reported, not scored. - The URL analyzer is static by design; expanding shorteners or resolving DNS would need a separate, network-aware module.
- The brand and shortener lists are static reference data, not live feeds.
{ "risk": "HIGH", "score": 68, "language": "English", "headerVerification": { "verified": true, "entityName": "HDFC BANK LIMITED", "...": "..." }, "urlAnalysis": { "urlCount": 1, "uniqueDomains": ["secure-hdfc-login.xyz"], "...": "..." }, "aiAnalysis": { "scamIntent": "suspicious", "confidence": 0.65, "...": "..." }, "reasons": [ "+32: Domain \"secure-hdfc-login.xyz\" contains the brand name \"hdfc\" but is not an official HDFC Bank domain", "+15: The message impersonates a known brand in its link *and* uses scam wording - the classic phishing combination", "+11: Domain \"secure-hdfc-login.xyz\" uses the low-cost .xyz top-level domain, frequently used in scam campaigns", "+10: AI analysis found the message suspicious (65% confidence): ..." ] }