Skip to content

Commit 9d85b3c

Browse files
Merge pull request #5 from madijonovsardorbek544-cmyk/enhance-phishing-detection-mechanism
Improve phishing risk detection and safety UX
2 parents 0c3a035 + 088d925 commit 9d85b3c

8 files changed

Lines changed: 197 additions & 30 deletions

File tree

src/analyzer.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,44 @@ describe('analyzeMessage', () => {
2222
expect(result.falsePositiveWarning).toMatch(/legitimate message/i);
2323
});
2424

25+
it('flags the exact bank/card phishing sentence as high risk with expected tactics', () => {
26+
const message = 'We detected unauthorized login activity on your account. Your card is temporarily locked. Click here immediately to verify your identity.';
27+
const result = analyzeMessage({ ...base, context: 'payment', platform: 'SMS', message });
28+
const labels = result.detectedTactics.map((x) => x.label);
29+
expect(result.score).toBeGreaterThanOrEqual(55);
30+
expect(['high', 'critical']).toContain(result.level);
31+
expect(labels).toContain('Account security threat');
32+
expect(labels).toContain('Identity verification request');
33+
expect(labels).toContain('Click/action pressure');
34+
expect(labels).toContain('Urgency pressure');
35+
expect(labels).toContain('Financial account/card risk');
36+
expect(result.safeNextSteps.join(' ')).toMatch(/Do not click/i);
37+
expect(result.safeNextSteps.join(' ')).toMatch(/official bank, app/i);
38+
});
39+
40+
it('does not leave account suspension phishing as low risk', () => {
41+
const result = analyzeMessage({ ...base, context: 'other', message: 'Your account has been suspended. Verify now to restore access.' });
42+
expect(result.level).not.toBe('low');
43+
expect(result.score).toBeGreaterThanOrEqual(28);
44+
});
45+
46+
it('flags OTP stealing as high risk', () => {
47+
const result = analyzeMessage({ ...base, context: 'other', platform: 'SMS', message: 'Your verification code is required to prevent account closure. Send OTP now.' });
48+
expect(result.score).toBeGreaterThanOrEqual(55);
49+
expect(['high', 'critical']).toContain(result.level);
50+
expect(result.detectedTactics.map((x) => x.label)).toContain('Credential/OTP risk');
51+
});
52+
53+
it('keeps a normal official student portal reminder low risk', () => {
54+
const result = analyzeMessage({ ...base, context: 'admission', message: 'Your university orientation schedule is available in the official student portal.' });
55+
expect(result.level).toBe('low');
56+
});
57+
58+
it('does not over-score a legitimate scholarship deadline with official website guidance', () => {
59+
const result = analyzeMessage({ ...base, context: 'scholarship', message: 'Reminder: the scholarship application deadline is Friday. Log in through the official university website.' });
60+
expect(['low', 'medium']).toContain(result.level);
61+
});
62+
2563
it('flags scholarship guarantee scams', () => {
2664
const result = analyzeMessage({ ...base, context: 'scholarship', platform: 'WhatsApp', message: 'Congratulations dear applicant, full scholarship guaranteed. Pay processing fee today by mobile money.' });
2765
expect(result.detectedTactics.map((x) => x.label)).toContain('Unrealistic guarantee');

src/data/demoMessages.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ export interface DemoMessage {
88
}
99

1010
export const demoMessages: DemoMessage[] = [
11+
{
12+
id: 'bank-card-phishing',
13+
label: 'Bank/card phishing',
14+
description: 'Synthetic demo: account threat, card lock, click pressure, and identity verification request.',
15+
input: {
16+
message: 'Synthetic demo sample: We detected unauthorized login activity on your account. Your card is temporarily locked. Click here immediately to verify your identity.',
17+
language: 'English',
18+
countryRegion: 'Student/family country or region',
19+
destinationCountry: 'United States',
20+
platform: 'SMS',
21+
context: 'payment',
22+
claimedAuthority: 'Bank Security Team',
23+
senderDomainOrLink: '',
24+
},
25+
},
1126
{
1227
id: 'scholarship-fee',
1328
label: 'Scholarship fee scam',

src/lib/analyzer/analyzeMessage.ts

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ function has(pattern: RegExp, text: string): boolean {
1212
return pattern.test(text);
1313
}
1414

15+
function evidenceFor(pattern: RegExp, text: string): string[] {
16+
pattern.lastIndex = 0;
17+
return Array.from(new Set(text.match(pattern) ?? [])).slice(0, 5).map((x) => x.trim());
18+
}
19+
1520
function riskArea(hit: boolean, score: number, elevated: string, low: string, evidence: string[]): RiskArea {
1621
return { level: levelForScore(hit ? Math.max(score, 45) : Math.min(score, 20)), summary: hit ? elevated : low, evidence };
1722
}
@@ -22,6 +27,70 @@ function domainRisk(input: CheckInput, text: string): string[] {
2227
return Array.from(new Set(matches)).slice(0, 5);
2328
}
2429

30+
const accountLockedOrSuspendedPattern = /\b(account locked|account has been suspended|account suspended|account disabled|account restricted|temporary lock|temporarily locked|card locked|card suspended|payment card blocked|bank account frozen|account closure)\b/i;
31+
const verifyIdentityPattern = /\b(verify your identity|confirm your identity|identity verification|verify account|verify your account|confirm account|confirm your account|re-verify|KYC|account recovery|security check)\b/i;
32+
const clickHerePattern = /\b(click here|tap here|follow this link|open link|verify now|confirm now|restore access|unlock account|secure your account|send otp now)\b/i;
33+
34+
interface CombinationBoost {
35+
id: string;
36+
label: string;
37+
description: string;
38+
weight: number;
39+
when: (matched: Set<string>, text: string, linkEvidence: string[]) => boolean;
40+
evidence: (text: string, linkEvidence: string[]) => string[];
41+
}
42+
43+
const combinationBoosts: CombinationBoost[] = [
44+
{
45+
id: 'comboUrgencyIdentity',
46+
label: 'Combination boost: urgency + identity verification',
47+
description: 'Urgent timing plus identity verification is a common phishing pressure pattern.',
48+
weight: 8,
49+
when: (matched) => matched.has('urgencyPressure') && matched.has('identityVerificationRequest'),
50+
evidence: (text) => [...evidenceFor(/\b(urgent|immediately|now|deadline|required to prevent)\b/gi, text), ...evidenceFor(/\b(verify your identity|identity verification|verify account|security check)\b/gi, text)],
51+
},
52+
{
53+
id: 'comboAccountAction',
54+
label: 'Combination boost: account threat + click/action pressure',
55+
description: 'Account lock, suspension, or unauthorized login language paired with a click/action request increases risk.',
56+
weight: 10,
57+
when: (matched) => matched.has('accountSecurityThreat') && matched.has('clickActionPressure'),
58+
evidence: (text) => [...evidenceFor(/\b(unauthorized login|account suspended|temporarily locked|card locked|account closure)\b/gi, text), ...evidenceFor(/\b(click here|verify now|restore access|send otp now)\b/gi, text)],
59+
},
60+
{
61+
id: 'comboFinancialIdentity',
62+
label: 'Combination boost: financial account risk + identity verification',
63+
description: 'Card, bank, refund, charge, or transaction language combined with identity verification is a high-value phishing pattern.',
64+
weight: 10,
65+
when: (matched) => matched.has('financialAccountRisk') && matched.has('identityVerificationRequest'),
66+
evidence: (text) => [...evidenceFor(/\b(card|bank account|transaction|refund|charge|fraud alert)\b/gi, text), ...evidenceFor(/\b(verify your identity|identity verification|verify account)\b/gi, text)],
67+
},
68+
{
69+
id: 'comboCredentialAction',
70+
label: 'Combination boost: credential/OTP risk + click/action pressure',
71+
description: 'Codes, passwords, PINs, or 2FA language paired with action pressure can indicate credential theft.',
72+
weight: 10,
73+
when: (matched) => matched.has('credentialOrOtpRisk') && matched.has('clickActionPressure'),
74+
evidence: (text) => [...evidenceFor(/\b(password|OTP|one-time code|verification code|recovery code|login code|2FA|PIN)\b/gi, text), ...evidenceFor(/\b(click here|verify now|confirm now|send otp now)\b/gi, text)],
75+
},
76+
{
77+
id: 'comboLockedVerify',
78+
label: 'Combination boost: locked/suspended account + verify identity',
79+
description: 'Locked or suspended account language plus identity verification creates account-recovery pressure.',
80+
weight: 10,
81+
when: (_matched, text) => accountLockedOrSuspendedPattern.test(text) && verifyIdentityPattern.test(text),
82+
evidence: (text) => [...evidenceFor(accountLockedOrSuspendedPattern, text), ...evidenceFor(verifyIdentityPattern, text)],
83+
},
84+
{
85+
id: 'comboLinkUrgency',
86+
label: 'Combination boost: link/action pressure + urgency',
87+
description: 'A suspicious link/domain or “click here” style instruction combined with urgency increases risk even without a visible URL.',
88+
weight: 8,
89+
when: (matched, text, linkEvidence) => (matched.has('suspiciousLinkDomain') || linkEvidence.length > 0 || clickHerePattern.test(text)) && matched.has('urgencyPressure'),
90+
evidence: (text, linkEvidence) => [...linkEvidence, ...evidenceFor(clickHerePattern, text), ...evidenceFor(/\b(urgent|immediately|now|deadline|required to prevent)\b/gi, text)],
91+
},
92+
];
93+
2594
export function validateCheckInput(input: CheckInput): string[] {
2695
const errors: string[] = [];
2796
if (!input.message.trim()) errors.push('Paste a suspicious message to analyze.');
@@ -36,38 +105,55 @@ export function analyzeMessage(input: CheckInput): CheckResult {
36105
const text = normalizedMessage(input);
37106
let score = 0;
38107
const detectedTactics: DetectedTactic[] = [];
108+
const matchedIds = new Set<string>();
39109

40110
for (const rule of analyzerRules) {
41111
const regexMatch = rule.pattern ? has(rule.pattern, text) : false;
42112
const functionalMatch = rule.applies?.(input, text) ?? false;
43113
if (regexMatch || functionalMatch) {
44114
const evidence = extractEvidence(rule, input, text);
45115
score += rule.weight;
116+
matchedIds.add(rule.id);
46117
detectedTactics.push({ id: rule.id, label: rule.label, description: rule.description, weight: rule.weight, evidence });
47118
}
48119
}
49120

50121
if (input.context === 'visa' || input.context === 'payment' || input.context === 'scholarship') score += 3;
51122
if (input.senderDomainOrLink && !/\.edu\b|\.gov\b|\.ac\.|canada\.ca|gov\.uk|homeaffairs\.gov\.au|ets\.org|ielts\.org|collegeboard\.org/i.test(input.senderDomainOrLink)) score += 6;
52123

124+
const linkEvidence = domainRisk(input, text);
125+
for (const boost of combinationBoosts) {
126+
if (boost.when(matchedIds, text, linkEvidence)) {
127+
score += boost.weight;
128+
detectedTactics.push({ id: boost.id, label: boost.label, description: boost.description, weight: boost.weight, evidence: boost.evidence(text, linkEvidence) });
129+
}
130+
}
131+
53132
const finalScore = clampScore(score);
54133
const level = levelForScore(finalScore);
55-
const sensitiveEvidence = text.match(/\b(passport|national id|DOB|date of birth|bank statement|card number|CVV|password|login|I-20|CAS|SEVIS|biometrics)\b/gi) ?? [];
56-
const paymentEvidence = text.match(/\b(pay|fee|deposit|tuition|refund|wire|western union|moneygram|mobile money|gift card|crypto|bitcoin|usdt|personal account)\b/gi) ?? [];
57-
const linkEvidence = domainRisk(input, text);
134+
const sensitiveEvidence = evidenceFor(/\b(passport|national id|DOB|date of birth|bank statement|card number|CVV|password|login|I-20|CAS|SEVIS|biometrics|verify your identity|identity verification|KYC)\b/gi, text);
135+
const paymentEvidence = evidenceFor(/\b(pay|fee|deposit|tuition|refund|wire|western union|moneygram|mobile money|gift card|crypto|bitcoin|usdt|personal account)\b/gi, text);
136+
const accountSecurityEvidence = evidenceFor(/\b(unauthorized login|suspicious login|unusual activity|account locked|account suspended|account disabled|account restricted|temporary lock|temporarily locked|card locked|card suspended|payment card blocked|bank account frozen|account closure)\b/gi, text);
137+
const credentialEvidence = evidenceFor(/\b(password|OTP|one-time code|verification code|recovery code|login code|2FA|PIN)\b/gi, text);
138+
const financialAccountEvidence = evidenceFor(/\b(card|debit card|credit card|bank card|bank account|transaction|payment method|account balance|refund|charge|unauthorized transaction|fraud alert)\b/gi, text);
139+
const actionPressureEvidence = evidenceFor(/\b(click here|tap here|follow this link|open link|verify now|confirm now|restore access|unlock account|secure your account|immediately|act now)\b/gi, text);
58140

59141
return {
60142
score: finalScore,
61143
level,
62144
detectedTactics: detectedTactics.length ? detectedTactics : [{ id: 'noStrongRule', label: 'No strong rule matched', description: 'The text did not match strong scam-risk indicators. Continue normal verification because legitimate messages can still be spoofed.', weight: 0, evidence: [] }],
63145
fakeAuthorityType: detectedTactics.some((x) => x.id === 'authorityImpersonation') || input.claimedAuthority ? authorityByContext[input.context] : 'No explicit authority detected, but verify any sender identity.',
64-
sensitiveDataRisk: riskArea(sensitiveEvidence.length > 0, finalScore, 'Elevated: message appears to request identity, visa, login, school, or financial data.', 'No direct sensitive-data request detected in the text.', Array.from(new Set(sensitiveEvidence)).slice(0, 5)),
65-
paymentRisk: riskArea(paymentEvidence.length > 0, finalScore, 'Elevated: message references a fee, deposit, refund, tuition payment, or risky payment channel.', 'No direct payment request detected in the text.', Array.from(new Set(paymentEvidence)).slice(0, 5)),
66-
linkDomainRisk: riskArea(linkEvidence.length > 0, finalScore, 'Elevated: link, short link, chat link, or suspicious domain pattern detected.', 'No obvious link/domain pattern detected; still verify sender domains manually.', linkEvidence),
146+
sensitiveDataRisk: riskArea(sensitiveEvidence.length > 0, finalScore, 'Elevated: message appears to request identity, visa, login, school, or financial data.', 'No direct sensitive-data request detected in the text.', sensitiveEvidence),
147+
paymentRisk: riskArea(paymentEvidence.length > 0, finalScore, 'Elevated: message references a fee, deposit, refund, tuition payment, or risky payment channel.', 'No direct payment request detected in the text.', paymentEvidence),
148+
linkDomainRisk: riskArea(linkEvidence.length > 0 || matchedIds.has('clickActionPressure'), finalScore, 'Elevated: link, short link, chat link, suspicious domain pattern, or click/action language detected.', 'No obvious link/domain pattern detected; still verify sender domains manually.', linkEvidence.length ? linkEvidence : actionPressureEvidence),
149+
accountSecurityRisk: riskArea(accountSecurityEvidence.length > 0, finalScore, 'Elevated: message claims unauthorized activity, account closure, or locked/suspended access.', 'No account lock, suspension, or unauthorized-login claim detected.', accountSecurityEvidence),
150+
credentialRisk: riskArea(credentialEvidence.length > 0, finalScore, 'Elevated: message references passwords, OTPs, login codes, PINs, or 2FA details.', 'No password, OTP, PIN, or login-code request detected.', credentialEvidence),
151+
financialAccountRisk: riskArea(financialAccountEvidence.length > 0, finalScore, 'Elevated: message references cards, bank accounts, transactions, refunds, charges, or fraud alerts.', 'No direct card, bank-account, transaction, refund, or charge language detected.', financialAccountEvidence),
152+
actionPressureRisk: riskArea(actionPressureEvidence.length > 0, finalScore, 'Elevated: message pushes clicking, verifying now, restoring access, or immediate action.', 'No direct click/action pressure detected.', actionPressureEvidence),
67153
crossBorderAdaptationPattern: detectedTactics.some((x) => x.id === 'crossBorderBureaucracyConfusion') ? 'The message mixes real cross-border education, visa, testing, payment, or document terms in a way that can pressure families unfamiliar with the destination-country process.' : 'No strong cross-border bureaucracy pattern detected, but students should still verify with official destination-country and institution channels.',
68154
confidenceLevel: confidenceFor(detectedTactics.length, finalScore),
69155
falsePositiveWarning: 'This tool reports risk indicators, not certainty. A legitimate message can contain deadlines or payment language; verify through official channels before acting.',
70-
safeNextSteps: buildSafeNextSteps(input, level),
156+
safeNextSteps: buildSafeNextSteps(input, level, matchedIds),
71157
officialVerificationScript: buildVerificationScript(input),
72158
whatNotToDo,
73159
trustedAdultNote: 'If you are a minor or feel pressured, ask a trusted adult, parent/guardian, school counselor, or admissions adviser to review the message with you before responding.',

0 commit comments

Comments
 (0)