@bobfrankston/mailx-types 0.1.57 → 0.1.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +58 -0
- package/index.js +205 -0
- package/package.json +1 -1
- package/trust.d.ts +66 -0
- package/trust.js +228 -0
package/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* This is the contract between client and server.
|
|
5
5
|
*/
|
|
6
6
|
export { CONTACT_RULES } from "./contact-rules.js";
|
|
7
|
+
export { assessMessageTrust } from "./trust.js";
|
|
8
|
+
export type { TrustFinding, TrustInput } from "./trust.js";
|
|
7
9
|
export type { MailxApi } from "./mailx-api.js";
|
|
8
10
|
export { addContactsDenylistEntry, addContactsPreferredEntry, type PreferredContactEntry, type CloudReadFn, type CloudWriteFn, } from "./contacts-config.js";
|
|
9
11
|
export { expandRecipients, splitRecipients, isAddressToken, extractAddress, } from "./groups.js";
|
|
@@ -497,4 +499,60 @@ export declare function formatSender(addr: {
|
|
|
497
499
|
text: string;
|
|
498
500
|
claimed: string | null;
|
|
499
501
|
};
|
|
502
|
+
export interface InviteAttendee {
|
|
503
|
+
name: string;
|
|
504
|
+
email: string;
|
|
505
|
+
/** NEEDS-ACTION | ACCEPTED | DECLINED | TENTATIVE | DELEGATED */
|
|
506
|
+
status: string;
|
|
507
|
+
rsvp: boolean;
|
|
508
|
+
}
|
|
509
|
+
export interface ParsedInvite {
|
|
510
|
+
/** REQUEST (an invitation), REPLY (someone's RSVP), CANCEL, PUBLISH… */
|
|
511
|
+
method: string;
|
|
512
|
+
uid: string;
|
|
513
|
+
sequence: number;
|
|
514
|
+
summary: string;
|
|
515
|
+
description: string;
|
|
516
|
+
location: string;
|
|
517
|
+
/** ISO 8601. All-day events carry a bare date (no time part). */
|
|
518
|
+
start: string;
|
|
519
|
+
end: string;
|
|
520
|
+
/** IANA zone from TZID, when the invite specified one. */
|
|
521
|
+
tzid: string;
|
|
522
|
+
allDay: boolean;
|
|
523
|
+
organizer: {
|
|
524
|
+
name: string;
|
|
525
|
+
email: string;
|
|
526
|
+
};
|
|
527
|
+
attendees: InviteAttendee[];
|
|
528
|
+
/** Raw RRULE, if the event repeats. Rendered verbatim — turning it into
|
|
529
|
+
* prose is a separate job and getting it subtly wrong is worse than
|
|
530
|
+
* showing the rule. */
|
|
531
|
+
rrule: string;
|
|
532
|
+
status: string;
|
|
533
|
+
/** True when the invite was produced by Google Calendar. Google's own
|
|
534
|
+
* HTML body carries working Yes/No/Maybe links, so the UI must NOT add a
|
|
535
|
+
* second set of RSVP controls for these — see inviteHasGoogleRsvp. */
|
|
536
|
+
fromGoogle: boolean;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Parse the VEVENT out of a text/calendar part.
|
|
540
|
+
*
|
|
541
|
+
* Deliberately hand-rolled rather than pulling in an ICS library: mailx-types
|
|
542
|
+
* is the zero-dependency package shared with Android, and an invite card needs
|
|
543
|
+
* a dozen properties, not full RFC 5545 coverage. Returns null when there is
|
|
544
|
+
* no VEVENT to show.
|
|
545
|
+
*/
|
|
546
|
+
export declare function parseInvite(ics: string): ParsedInvite | null;
|
|
547
|
+
/**
|
|
548
|
+
* Does the message body already carry Google's own Yes/No/Maybe controls?
|
|
549
|
+
*
|
|
550
|
+
* Google Calendar invitations embed working RSVP links
|
|
551
|
+
* (`calendar.google.com/calendar/event?action=RESPOND&rst=1|2|3`). When they
|
|
552
|
+
* are present the UI must show the invite summary but NOT a second set of RSVP
|
|
553
|
+
* buttons — two responders racing to set PARTSTAT is worse than one
|
|
554
|
+
* (Bob 2026-08-22: "Google's own responder handles it so you might detect that
|
|
555
|
+
* case specially").
|
|
556
|
+
*/
|
|
557
|
+
export declare function inviteHasGoogleRsvp(bodyHtml: string): boolean;
|
|
500
558
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// this barrel so a single source-of-truth (contact-rules.jsonc) drives
|
|
8
8
|
// junk-contact filtering on every platform.
|
|
9
9
|
export { CONTACT_RULES } from "./contact-rules.js";
|
|
10
|
+
export { assessMessageTrust } from "./trust.js";
|
|
10
11
|
// Shared contacts.jsonc mutations — one implementation for desktop + Android.
|
|
11
12
|
export { addContactsDenylistEntry, addContactsPreferredEntry, } from "./contacts-config.js";
|
|
12
13
|
// Group-name expansion for recipient fields. Lets users type a group name
|
|
@@ -105,6 +106,23 @@ export function sanitizeHtml(html) {
|
|
|
105
106
|
clean = clean.replace(/<select\b[^>]*>[\s\S]*?<\/select>/gi, "[blocked: select]");
|
|
106
107
|
clean = clean.replace(/<button\b[^>]*>([\s\S]*?)<\/button>/gi, "$1");
|
|
107
108
|
clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
|
|
109
|
+
// Disarm image maps. An <area> hotspot makes part of an image a link with
|
|
110
|
+
// no underlined text, no visible URL, and nothing to hover — and a forger
|
|
111
|
+
// sizes it to cover the whole picture, so there is no safe pixel to click.
|
|
112
|
+
// Two phishes on 2026-08-25 used exactly that: a "shared document" card
|
|
113
|
+
// whose every pixel was <area coords="0,0,10000,10000"> pointing at an
|
|
114
|
+
// open redirector. This sanitizer stripped their forms and inputs and left
|
|
115
|
+
// that armed.
|
|
116
|
+
//
|
|
117
|
+
// Image maps have no legitimate use in mail, so this is unconditional
|
|
118
|
+
// rather than a size threshold — nothing to tune and nothing to slip past
|
|
119
|
+
// by picking a smaller rectangle. The image still renders; only its hidden
|
|
120
|
+
// clickability goes. The reader is told rather than left to wonder why the
|
|
121
|
+
// picture stopped responding: assessMessageTrust (trust.ts) raises
|
|
122
|
+
// hidden-link-overlay from the RAW html and the viewer explains it.
|
|
123
|
+
// (Claude Code 2026-08-25)
|
|
124
|
+
clean = clean.replace(/<map\b[^>]*>[\s\S]*?<\/map>/gi, "");
|
|
125
|
+
clean = clean.replace(/\s+usemap\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
108
126
|
return { html: clean, hasRemoteContent };
|
|
109
127
|
}
|
|
110
128
|
/**
|
|
@@ -529,4 +547,191 @@ export function formatSender(addr) {
|
|
|
529
547
|
return { text: address, claimed: null };
|
|
530
548
|
return { text: `${name} <${address}>`, claimed: displayNameClaimsOtherAddress(name, address) };
|
|
531
549
|
}
|
|
550
|
+
/** Unfold RFC 5545 continuation lines: a line beginning with a space or tab
|
|
551
|
+
* continues the previous one. Google folds ATTENDEE lines mid-parameter, so
|
|
552
|
+
* skipping this loses attendees entirely. */
|
|
553
|
+
function unfoldIcs(text) {
|
|
554
|
+
const raw = (text || "").replace(/\r\n/g, "\n").split("\n");
|
|
555
|
+
const out = [];
|
|
556
|
+
for (const line of raw) {
|
|
557
|
+
if (/^[ \t]/.test(line) && out.length)
|
|
558
|
+
out[out.length - 1] += line.slice(1);
|
|
559
|
+
else
|
|
560
|
+
out.push(line);
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
/** RFC 5545 TEXT unescaping: \\n is a newline, and \\, \\; \\, are literals. */
|
|
565
|
+
function unescapeIcs(v) {
|
|
566
|
+
return (v || "")
|
|
567
|
+
.replace(/\\n/gi, "\n")
|
|
568
|
+
.replace(/\\([;,\\])/g, "$1");
|
|
569
|
+
}
|
|
570
|
+
/** Split "KEY;PARAM=x;PARAM2=y:value" into its three pieces. */
|
|
571
|
+
function splitIcsLine(line) {
|
|
572
|
+
// The first colon that is not inside a quoted parameter ends the name part.
|
|
573
|
+
let inQuote = false, colon = -1;
|
|
574
|
+
for (let i = 0; i < line.length; i++) {
|
|
575
|
+
const c = line[i];
|
|
576
|
+
if (c === '"')
|
|
577
|
+
inQuote = !inQuote;
|
|
578
|
+
else if (c === ":" && !inQuote) {
|
|
579
|
+
colon = i;
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (colon < 0)
|
|
584
|
+
return null;
|
|
585
|
+
const namePart = line.slice(0, colon);
|
|
586
|
+
const value = line.slice(colon + 1);
|
|
587
|
+
const bits = namePart.split(";");
|
|
588
|
+
const key = (bits.shift() || "").toUpperCase();
|
|
589
|
+
const params = {};
|
|
590
|
+
for (const b of bits) {
|
|
591
|
+
const eq = b.indexOf("=");
|
|
592
|
+
if (eq < 0)
|
|
593
|
+
continue;
|
|
594
|
+
params[b.slice(0, eq).toUpperCase()] = b.slice(eq + 1).replace(/^"|"$/g, "");
|
|
595
|
+
}
|
|
596
|
+
return { key, params, value };
|
|
597
|
+
}
|
|
598
|
+
/** ICS timestamp → ISO 8601. Handles 20260823T080000 (local/zoned),
|
|
599
|
+
* 20260823T133939Z (UTC) and 20260823 (all-day). Zoned values keep their
|
|
600
|
+
* wall-clock reading; the caller pairs them with `tzid`. */
|
|
601
|
+
function icsDateToIso(v, isUtc) {
|
|
602
|
+
const m = (v || "").match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/);
|
|
603
|
+
if (!m)
|
|
604
|
+
return "";
|
|
605
|
+
const [, y, mo, d, hh, mm, ss, z] = m;
|
|
606
|
+
if (!hh)
|
|
607
|
+
return `${y}-${mo}-${d}`;
|
|
608
|
+
const base = `${y}-${mo}-${d}T${hh}:${mm}:${ss}`;
|
|
609
|
+
return (z || isUtc) ? base + "Z" : base;
|
|
610
|
+
}
|
|
611
|
+
function parseCalAddress(value, params) {
|
|
612
|
+
const email = (value || "").replace(/^mailto:/i, "").trim();
|
|
613
|
+
return { name: (params.CN || "").trim() || email, email };
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Parse the VEVENT out of a text/calendar part.
|
|
617
|
+
*
|
|
618
|
+
* Deliberately hand-rolled rather than pulling in an ICS library: mailx-types
|
|
619
|
+
* is the zero-dependency package shared with Android, and an invite card needs
|
|
620
|
+
* a dozen properties, not full RFC 5545 coverage. Returns null when there is
|
|
621
|
+
* no VEVENT to show.
|
|
622
|
+
*/
|
|
623
|
+
export function parseInvite(ics) {
|
|
624
|
+
if (!ics || !/BEGIN:VEVENT/i.test(ics))
|
|
625
|
+
return null;
|
|
626
|
+
const lines = unfoldIcs(ics);
|
|
627
|
+
const out = {
|
|
628
|
+
method: "", uid: "", sequence: 0, summary: "", description: "", location: "",
|
|
629
|
+
start: "", end: "", tzid: "", allDay: false,
|
|
630
|
+
organizer: { name: "", email: "" }, attendees: [], rrule: "", status: "",
|
|
631
|
+
fromGoogle: false,
|
|
632
|
+
};
|
|
633
|
+
let inEvent = false;
|
|
634
|
+
// VTIMEZONE blocks contain their own DTSTART/RRULE — those describe the
|
|
635
|
+
// zone's DST rules, not the meeting. Ignore everything inside them.
|
|
636
|
+
let depthNonEvent = 0;
|
|
637
|
+
for (const line of lines) {
|
|
638
|
+
const up = line.toUpperCase();
|
|
639
|
+
if (up.startsWith("BEGIN:VEVENT")) {
|
|
640
|
+
inEvent = true;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (up.startsWith("END:VEVENT")) {
|
|
644
|
+
inEvent = false;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (!inEvent && (up.startsWith("BEGIN:VTIMEZONE") || up.startsWith("BEGIN:VALARM"))) {
|
|
648
|
+
depthNonEvent++;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (up.startsWith("END:VTIMEZONE") || up.startsWith("END:VALARM")) {
|
|
652
|
+
if (depthNonEvent)
|
|
653
|
+
depthNonEvent--;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (inEvent && up.startsWith("BEGIN:VALARM")) {
|
|
657
|
+
depthNonEvent++;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const p = splitIcsLine(line);
|
|
661
|
+
if (!p)
|
|
662
|
+
continue;
|
|
663
|
+
if (!inEvent) {
|
|
664
|
+
if (p.key === "METHOD")
|
|
665
|
+
out.method = p.value.trim().toUpperCase();
|
|
666
|
+
else if (p.key === "PRODID" && /google/i.test(p.value))
|
|
667
|
+
out.fromGoogle = true;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (depthNonEvent)
|
|
671
|
+
continue; // inside VALARM
|
|
672
|
+
switch (p.key) {
|
|
673
|
+
case "SUMMARY":
|
|
674
|
+
out.summary = unescapeIcs(p.value);
|
|
675
|
+
break;
|
|
676
|
+
case "DESCRIPTION":
|
|
677
|
+
out.description = unescapeIcs(p.value);
|
|
678
|
+
break;
|
|
679
|
+
case "LOCATION":
|
|
680
|
+
out.location = unescapeIcs(p.value);
|
|
681
|
+
break;
|
|
682
|
+
case "UID":
|
|
683
|
+
out.uid = p.value.trim();
|
|
684
|
+
break;
|
|
685
|
+
case "STATUS":
|
|
686
|
+
out.status = p.value.trim().toUpperCase();
|
|
687
|
+
break;
|
|
688
|
+
case "RRULE":
|
|
689
|
+
out.rrule = p.value.trim();
|
|
690
|
+
break;
|
|
691
|
+
case "SEQUENCE":
|
|
692
|
+
out.sequence = parseInt(p.value, 10) || 0;
|
|
693
|
+
break;
|
|
694
|
+
case "DTSTART":
|
|
695
|
+
out.start = icsDateToIso(p.value.trim(), false);
|
|
696
|
+
out.tzid = p.params.TZID || out.tzid;
|
|
697
|
+
out.allDay = (p.params.VALUE || "").toUpperCase() === "DATE" || !/T/.test(p.value);
|
|
698
|
+
break;
|
|
699
|
+
case "DTEND":
|
|
700
|
+
out.end = icsDateToIso(p.value.trim(), false);
|
|
701
|
+
out.tzid = out.tzid || p.params.TZID || "";
|
|
702
|
+
break;
|
|
703
|
+
case "ORGANIZER":
|
|
704
|
+
out.organizer = parseCalAddress(p.value, p.params);
|
|
705
|
+
break;
|
|
706
|
+
case "ATTENDEE": {
|
|
707
|
+
const a = parseCalAddress(p.value, p.params);
|
|
708
|
+
out.attendees.push({
|
|
709
|
+
name: a.name,
|
|
710
|
+
email: a.email,
|
|
711
|
+
status: (p.params.PARTSTAT || "NEEDS-ACTION").toUpperCase(),
|
|
712
|
+
rsvp: (p.params.RSVP || "").toUpperCase() === "TRUE",
|
|
713
|
+
});
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
if (!out.summary && !out.start)
|
|
719
|
+
return null;
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Does the message body already carry Google's own Yes/No/Maybe controls?
|
|
724
|
+
*
|
|
725
|
+
* Google Calendar invitations embed working RSVP links
|
|
726
|
+
* (`calendar.google.com/calendar/event?action=RESPOND&rst=1|2|3`). When they
|
|
727
|
+
* are present the UI must show the invite summary but NOT a second set of RSVP
|
|
728
|
+
* buttons — two responders racing to set PARTSTAT is worse than one
|
|
729
|
+
* (Bob 2026-08-22: "Google's own responder handles it so you might detect that
|
|
730
|
+
* case specially").
|
|
731
|
+
*/
|
|
732
|
+
export function inviteHasGoogleRsvp(bodyHtml) {
|
|
733
|
+
if (!bodyHtml)
|
|
734
|
+
return false;
|
|
735
|
+
return /calendar\.google\.com\/calendar\/event\?action=RESPOND/i.test(bodyHtml);
|
|
736
|
+
}
|
|
532
737
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
package/trust.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a message looks forged.
|
|
3
|
+
*
|
|
4
|
+
* Not a spam filter — a spam filter guesses, and a guess that fires on real
|
|
5
|
+
* mail teaches you to click past it. Everything here is EVIDENCE: a verdict
|
|
6
|
+
* your own mail server already reached, or a construct in the message that
|
|
7
|
+
* legitimate mail does not contain. Each finding names what was found and what
|
|
8
|
+
* it means, so the reader can check it rather than trust a score.
|
|
9
|
+
*
|
|
10
|
+
* Written 2026-08-25 after two phishes landed looking clean in the reader
|
|
11
|
+
* (Bob: "this is an attack — could you do a better job of identifying it as
|
|
12
|
+
* bogus?"). Both carried all of the following, and mailx surfaced NONE of it:
|
|
13
|
+
*
|
|
14
|
+
* X-Spam-Flag: YES <- his own server, score 6.7/4.5
|
|
15
|
+
* Authentication-Results: ... spf=fail
|
|
16
|
+
* From: bobip@bobf.frankston.com <- his own address, forged
|
|
17
|
+
* To: bobip@bobf.frankston.com
|
|
18
|
+
* Return-Path: <3EDTkWNvbY@visionsurveysqld.com.au>
|
|
19
|
+
* <area coords="0,0,10000,10000" href="http://.../redirect.php?newurl=...">
|
|
20
|
+
*
|
|
21
|
+
* No header in that list was read anywhere in the codebase, and the one
|
|
22
|
+
* impersonation check that existed (displayNameClaimsOtherAddress) needs a
|
|
23
|
+
* display name, which these messages did not have. The whole visible card was
|
|
24
|
+
* one hidden link through an open redirector, with the victim's own address
|
|
25
|
+
* base64'd into the fragment so the landing page could prefill it.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NOT included: bare SPF failure on mail that isn't claiming to
|
|
28
|
+
* be you. Forwarded mail fails SPF constantly, and flagging that would be
|
|
29
|
+
* exactly the cry-wolf banner this is trying not to be.
|
|
30
|
+
*/
|
|
31
|
+
/** One piece of evidence that a message is not what it claims to be. */
|
|
32
|
+
export interface TrustFinding {
|
|
33
|
+
/** Stable identifier — for tests, logs, and any future suppression UI. */
|
|
34
|
+
id: "server-spam-verdict" | "self-spoof" | "hidden-link-overlay" | "redirector-link";
|
|
35
|
+
/** `danger`: near-certain forgery. `caution`: worth a second look. */
|
|
36
|
+
severity: "danger" | "caution";
|
|
37
|
+
/** One line, naming what was found. Never "this message is suspicious". */
|
|
38
|
+
text: string;
|
|
39
|
+
/** The specific evidence — a header value, a host, a score. */
|
|
40
|
+
detail: string;
|
|
41
|
+
}
|
|
42
|
+
export interface TrustInput {
|
|
43
|
+
/** Every header line, in order, as mailparser's `headerLines`. Repeated
|
|
44
|
+
* headers matter: Authentication-Results and Received both appear more
|
|
45
|
+
* than once and the interesting one is not always the first. */
|
|
46
|
+
headerLines: {
|
|
47
|
+
key: string;
|
|
48
|
+
line: string;
|
|
49
|
+
}[];
|
|
50
|
+
/** Envelope From address. */
|
|
51
|
+
fromAddress: string;
|
|
52
|
+
/** Addresses belonging to the reader — their own aliases. Used only to
|
|
53
|
+
* recognise a message claiming to BE them. */
|
|
54
|
+
ownAddresses: string[];
|
|
55
|
+
/** Sanitized HTML body, for the link-construct checks. */
|
|
56
|
+
bodyHtml: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Everything findable about this message, worst first.
|
|
60
|
+
*
|
|
61
|
+
* Returns an empty array for ordinary mail — the caller then shows nothing,
|
|
62
|
+
* rather than a reassuring "looks fine" this cannot support. Absence of
|
|
63
|
+
* evidence is not a clean bill of health.
|
|
64
|
+
*/
|
|
65
|
+
export declare function assessMessageTrust(input: TrustInput): TrustFinding[];
|
|
66
|
+
//# sourceMappingURL=trust.d.ts.map
|
package/trust.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a message looks forged.
|
|
3
|
+
*
|
|
4
|
+
* Not a spam filter — a spam filter guesses, and a guess that fires on real
|
|
5
|
+
* mail teaches you to click past it. Everything here is EVIDENCE: a verdict
|
|
6
|
+
* your own mail server already reached, or a construct in the message that
|
|
7
|
+
* legitimate mail does not contain. Each finding names what was found and what
|
|
8
|
+
* it means, so the reader can check it rather than trust a score.
|
|
9
|
+
*
|
|
10
|
+
* Written 2026-08-25 after two phishes landed looking clean in the reader
|
|
11
|
+
* (Bob: "this is an attack — could you do a better job of identifying it as
|
|
12
|
+
* bogus?"). Both carried all of the following, and mailx surfaced NONE of it:
|
|
13
|
+
*
|
|
14
|
+
* X-Spam-Flag: YES <- his own server, score 6.7/4.5
|
|
15
|
+
* Authentication-Results: ... spf=fail
|
|
16
|
+
* From: bobip@bobf.frankston.com <- his own address, forged
|
|
17
|
+
* To: bobip@bobf.frankston.com
|
|
18
|
+
* Return-Path: <3EDTkWNvbY@visionsurveysqld.com.au>
|
|
19
|
+
* <area coords="0,0,10000,10000" href="http://.../redirect.php?newurl=...">
|
|
20
|
+
*
|
|
21
|
+
* No header in that list was read anywhere in the codebase, and the one
|
|
22
|
+
* impersonation check that existed (displayNameClaimsOtherAddress) needs a
|
|
23
|
+
* display name, which these messages did not have. The whole visible card was
|
|
24
|
+
* one hidden link through an open redirector, with the victim's own address
|
|
25
|
+
* base64'd into the fragment so the landing page could prefill it.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NOT included: bare SPF failure on mail that isn't claiming to
|
|
28
|
+
* be you. Forwarded mail fails SPF constantly, and flagging that would be
|
|
29
|
+
* exactly the cry-wolf banner this is trying not to be.
|
|
30
|
+
*/
|
|
31
|
+
/** First value of a header, case-insensitive. */
|
|
32
|
+
function header(lines, name) {
|
|
33
|
+
const want = name.toLowerCase();
|
|
34
|
+
for (const h of lines) {
|
|
35
|
+
if ((h.key || "").toLowerCase() !== want)
|
|
36
|
+
continue;
|
|
37
|
+
const colon = h.line.indexOf(":");
|
|
38
|
+
return colon < 0 ? "" : h.line.slice(colon + 1).trim();
|
|
39
|
+
}
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
/** All values of a repeated header. */
|
|
43
|
+
function headerAll(lines, name) {
|
|
44
|
+
const want = name.toLowerCase();
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const h of lines) {
|
|
47
|
+
if ((h.key || "").toLowerCase() !== want)
|
|
48
|
+
continue;
|
|
49
|
+
const colon = h.line.indexOf(":");
|
|
50
|
+
if (colon >= 0)
|
|
51
|
+
out.push(h.line.slice(colon + 1).trim());
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function bare(addr) {
|
|
56
|
+
const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
|
|
57
|
+
return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Did the receiving server's spam filter already say yes?
|
|
61
|
+
*
|
|
62
|
+
* The highest-value signal available, and it costs nothing: the mail server
|
|
63
|
+
* ran SpamAssassin before delivery and wrote the answer into the message.
|
|
64
|
+
* mailx was throwing it away.
|
|
65
|
+
*
|
|
66
|
+
* Only a POSITIVE verdict is trusted, never a negative one. Anyone can inject
|
|
67
|
+
* `X-Spam-Flag: NO` into a message they send, and treating that as a clean
|
|
68
|
+
* bill of health would hand attackers a switch for turning the check off. A
|
|
69
|
+
* forged YES only flags the forger's own mail, so the asymmetry is safe.
|
|
70
|
+
*/
|
|
71
|
+
function serverSpamVerdict(lines) {
|
|
72
|
+
const flag = header(lines, "x-spam-flag");
|
|
73
|
+
const status = header(lines, "x-spam-status");
|
|
74
|
+
if (!/^yes/i.test(flag) && !/^yes/i.test(status))
|
|
75
|
+
return null;
|
|
76
|
+
// "Yes, score=6.7 required=4.5 tests=..." — quote the numbers when they
|
|
77
|
+
// are there, so the reader sees how emphatic the verdict was.
|
|
78
|
+
const score = status.match(/score=([-\d.]+)/)?.[1];
|
|
79
|
+
const required = status.match(/required=([-\d.]+)/)?.[1];
|
|
80
|
+
return {
|
|
81
|
+
id: "server-spam-verdict",
|
|
82
|
+
severity: "danger",
|
|
83
|
+
text: "Your mail server classified this as spam before delivering it.",
|
|
84
|
+
detail: score && required
|
|
85
|
+
? `SpamAssassin score ${score}, threshold ${required}`
|
|
86
|
+
: (status || flag || "X-Spam-Flag: YES"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Does the message claim to come from the reader themselves, while failing the
|
|
91
|
+
* authentication that would prove it?
|
|
92
|
+
*
|
|
93
|
+
* Either half alone is ordinary — people really do mail themselves notes, and
|
|
94
|
+
* forwarded mail really does fail SPF. Together they are not: mail genuinely
|
|
95
|
+
* from your own account authenticates as your own account. This is the most
|
|
96
|
+
* common shape of targeted phish, because a From line showing your own address
|
|
97
|
+
* defeats every visual check a reader makes.
|
|
98
|
+
*
|
|
99
|
+
* Requires an EXPLICIT failure. A message with no Authentication-Results at
|
|
100
|
+
* all (internal mail that never crossed a trust boundary) is not evidence of
|
|
101
|
+
* anything and must not fire this.
|
|
102
|
+
*/
|
|
103
|
+
function selfSpoof(input) {
|
|
104
|
+
const from = bare(input.fromAddress);
|
|
105
|
+
if (!from)
|
|
106
|
+
return null;
|
|
107
|
+
const own = new Set(input.ownAddresses.map(bare).filter(Boolean));
|
|
108
|
+
if (!own.has(from))
|
|
109
|
+
return null;
|
|
110
|
+
const auth = headerAll(input.headerLines, "authentication-results").join(" ; ");
|
|
111
|
+
const failed = [
|
|
112
|
+
/\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
|
|
113
|
+
/\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
|
|
114
|
+
/\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : "",
|
|
115
|
+
].filter(Boolean);
|
|
116
|
+
if (!failed.length)
|
|
117
|
+
return null;
|
|
118
|
+
// Name the envelope sender when it disagrees — that is the address that
|
|
119
|
+
// actually sent this, and seeing it beside the claim is the whole story.
|
|
120
|
+
const envelope = bare(header(input.headerLines, "return-path"));
|
|
121
|
+
const envelopeNote = envelope && envelope !== from ? `; actually sent by ${envelope}` : "";
|
|
122
|
+
return {
|
|
123
|
+
id: "self-spoof",
|
|
124
|
+
severity: "danger",
|
|
125
|
+
text: `This claims to be from your own address, ${from}, but it did not come from your account.`,
|
|
126
|
+
detail: `${failed.join(" and ")} authentication failed${envelopeNote}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Bigger than any real image. An honest layout never needs a hotspot this
|
|
130
|
+
* large; a forger picks an absurd number so they don't have to know the
|
|
131
|
+
* rendered size. Not a tuned threshold — a sanity bound. */
|
|
132
|
+
const OVERLAY_MIN_EDGE_PX = 2000;
|
|
133
|
+
/**
|
|
134
|
+
* Is an entire image covered by one invisible link?
|
|
135
|
+
*
|
|
136
|
+
* `<area coords="0,0,10000,10000">` turns every pixel of an image into a link
|
|
137
|
+
* with no underlined text, no visible URL, and no safe area to click. Real
|
|
138
|
+
* mail uses image maps almost never, and never at that size.
|
|
139
|
+
*/
|
|
140
|
+
function hiddenLinkOverlay(bodyHtml) {
|
|
141
|
+
for (const m of bodyHtml.matchAll(/<area\b[^>]*>/gi)) {
|
|
142
|
+
const tag = m[0];
|
|
143
|
+
const href = tag.match(/href\s*=\s*["']([^"']+)["']/i)?.[1];
|
|
144
|
+
if (!href)
|
|
145
|
+
continue;
|
|
146
|
+
const coords = (tag.match(/coords\s*=\s*["']([^"']+)["']/i)?.[1] || "")
|
|
147
|
+
.split(/[,\s]+/).map(Number).filter(n => Number.isFinite(n));
|
|
148
|
+
if (coords.length < 4)
|
|
149
|
+
continue;
|
|
150
|
+
const width = Math.abs(coords[2] - coords[0]);
|
|
151
|
+
const height = Math.abs(coords[3] - coords[1]);
|
|
152
|
+
if (width < OVERLAY_MIN_EDGE_PX || height < OVERLAY_MIN_EDGE_PX)
|
|
153
|
+
continue;
|
|
154
|
+
let host = href;
|
|
155
|
+
try {
|
|
156
|
+
host = new URL(href).hostname;
|
|
157
|
+
}
|
|
158
|
+
catch { /* keep the raw href */ }
|
|
159
|
+
return {
|
|
160
|
+
id: "hidden-link-overlay",
|
|
161
|
+
severity: "danger",
|
|
162
|
+
text: "The picture in this message is one large hidden link — clicking anywhere on it goes to the same place.",
|
|
163
|
+
detail: `${width}x${height} clickable area over the image, pointing at ${host}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Does a link route through a redirector carrying its real destination in the
|
|
170
|
+
* query string?
|
|
171
|
+
*
|
|
172
|
+
* `castanet-sa.fr/modules/babel/redirect.php?newurl=https%3A%2F%2Fevil.example`
|
|
173
|
+
* borrows a real site's reputation: the visible host is a legitimate business
|
|
174
|
+
* whose redirect script was left open, and the destination appears only
|
|
175
|
+
* percent-encoded inside a parameter. Reporting the ENDPOINT is the point — a
|
|
176
|
+
* reader cannot be expected to URL-decode a query string.
|
|
177
|
+
*/
|
|
178
|
+
function redirectorLink(bodyHtml) {
|
|
179
|
+
for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
|
180
|
+
let url;
|
|
181
|
+
try {
|
|
182
|
+
url = new URL(m[1]);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!/^https?:$/.test(url.protocol))
|
|
188
|
+
continue;
|
|
189
|
+
for (const [, value] of url.searchParams) {
|
|
190
|
+
let target;
|
|
191
|
+
try {
|
|
192
|
+
target = new URL(value);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!/^https?:$/.test(target.protocol))
|
|
198
|
+
continue;
|
|
199
|
+
if (target.hostname === url.hostname)
|
|
200
|
+
continue; // same-site, not laundering
|
|
201
|
+
return {
|
|
202
|
+
id: "redirector-link",
|
|
203
|
+
severity: "caution",
|
|
204
|
+
text: `A link hides where it goes: it passes through ${url.hostname} and ends at ${target.hostname}.`,
|
|
205
|
+
detail: `${url.hostname} -> ${target.hostname}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Everything findable about this message, worst first.
|
|
213
|
+
*
|
|
214
|
+
* Returns an empty array for ordinary mail — the caller then shows nothing,
|
|
215
|
+
* rather than a reassuring "looks fine" this cannot support. Absence of
|
|
216
|
+
* evidence is not a clean bill of health.
|
|
217
|
+
*/
|
|
218
|
+
export function assessMessageTrust(input) {
|
|
219
|
+
const findings = [
|
|
220
|
+
serverSpamVerdict(input.headerLines),
|
|
221
|
+
selfSpoof(input),
|
|
222
|
+
hiddenLinkOverlay(input.bodyHtml || ""),
|
|
223
|
+
redirectorLink(input.bodyHtml || ""),
|
|
224
|
+
].filter(Boolean);
|
|
225
|
+
const rank = { danger: 0, caution: 1 };
|
|
226
|
+
return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=trust.js.map
|