Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Scam Detection Engine

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.


Contents


Quick start

npm install
npm start

The 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\"}"
{
  "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): ..."
  ]
}

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.


Architecture

                       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.


The module contract

Every detection module exposes exactly this:

{
  name: string,
  analyze(message) => Promise<{ report: object, signals: Signal[] }>
}
  • message is the normalized message: { type, sender, subject, body, text, meta, stats }.
  • report is whatever the module wants to show the caller. It is passed through to the response untouched and is never interpreted by the engine.
  • signals are 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 } }

The modules

Module 1 — TRAI header verification (src/modules/trai/)

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:

  1. classifies the sender locally (senderHeader.js) — is it even a DLT header (VK-HDFCBK), or a mobile number, a short code, an email address?
  2. asks the external service to verify it, via the provider;
  3. 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.

Module 2 — URL analyzer (src/modules/url/)

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, 0o, 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.

Module 3 — Multilingual scam analyzer (src/modules/ai/)

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/sdk

then 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.

Module 4 — Image OCR pipeline (src/services/normalizer.service.js)

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.

Module 5 — Risk aggregator (src/modules/risk/)

Combines every signal into LOW / MEDIUM / HIGH plus a 0–100 score, and explains each point. See below.


How scoring works

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.


Configuration

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.


Project layout

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

Testing

npm test              # 142 tests
npm run test:coverage # ~96% statements
npm run build         # syntax check + app loads cleanly

The 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.


Replacing a module

Say you want to replace the URL analyzer with a machine-learning classifier:

  1. Build an object with { name, analyze(message) } returning { report, signals }.
  2. Emit existing URL_* codes from utils/signals.js for anything already weighted. For genuinely new findings, add a code there and a weight in modules/risk/weights.js — until you do, the signal is reported but scores zero, which is the safe default.
  3. 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.


Not in scope

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 From address (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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages