@remit/web-client 0.0.120 → 0.0.121
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.121",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -243,6 +243,108 @@ describe("buildAuthenticityIntel", () => {
|
|
|
243
243
|
}
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
describe("a passing signature over a claim that does not hold", () => {
|
|
247
|
+
// The InfoMedics invoice phish: an attacker's own free Atlassian tenant,
|
|
248
|
+
// so SPF/DKIM/DMARC genuinely pass for a domain nobody recognises, and the
|
|
249
|
+
// provider's own filter already called it spam.
|
|
250
|
+
const infoMedics = makeThread({
|
|
251
|
+
fromEmail: "jira@serviceupdatebank.atlassian.net",
|
|
252
|
+
fromName: "InfoMedics",
|
|
253
|
+
subject: "Vordering",
|
|
254
|
+
authenticity: {
|
|
255
|
+
fromDomain: "serviceupdatebank.atlassian.net",
|
|
256
|
+
dkimDomain: "custmx.one.com",
|
|
257
|
+
dkimMismatch: false,
|
|
258
|
+
displayNameCorrespondence: "Unrelated",
|
|
259
|
+
offDomainLinkDomains: ["betaal-vordering.example"],
|
|
260
|
+
},
|
|
261
|
+
} as Partial<RemitImapThreadMessageResponse>);
|
|
262
|
+
|
|
263
|
+
test("is never presented as verified", () => {
|
|
264
|
+
const result = buildAuthenticityIntel(infoMedics, 0);
|
|
265
|
+
assert.notEqual(result.verdict, "aligned");
|
|
266
|
+
assert.doesNotMatch(result.summary, /We verified/i);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("names the display name and the link destination", () => {
|
|
270
|
+
const result = buildAuthenticityIntel(infoMedics, 0);
|
|
271
|
+
assert.equal(result.verdict, "caution");
|
|
272
|
+
assert.match(result.summary, /really was sent by/);
|
|
273
|
+
assert.match(result.summary, /"InfoMedics"/);
|
|
274
|
+
assert.match(result.summary, /betaal-vordering\.example/);
|
|
275
|
+
assert.doesNotMatch(result.summary, /DKIM|SPF|DMARC/i);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a lookalike display name reads as an imitation", () => {
|
|
279
|
+
const result = buildAuthenticityIntel(
|
|
280
|
+
makeThread({
|
|
281
|
+
fromEmail: "billing@1nfomedics.nl",
|
|
282
|
+
fromName: "InfoMedics",
|
|
283
|
+
authenticity: {
|
|
284
|
+
fromDomain: "1nfomedics.nl",
|
|
285
|
+
dkimDomain: "1nfomedics.nl",
|
|
286
|
+
dkimMismatch: false,
|
|
287
|
+
displayNameCorrespondence: "Lookalike",
|
|
288
|
+
offDomainLinkDomains: [],
|
|
289
|
+
},
|
|
290
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
291
|
+
0,
|
|
292
|
+
);
|
|
293
|
+
assert.equal(result.verdict, "caution");
|
|
294
|
+
assert.match(result.summary, /only looks like/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("stays verified when the comparisons agreed", () => {
|
|
298
|
+
const result = buildAuthenticityIntel(
|
|
299
|
+
makeThread({
|
|
300
|
+
fromEmail: "notifications@notifications.github.com",
|
|
301
|
+
fromName: "GitHub",
|
|
302
|
+
authenticity: {
|
|
303
|
+
fromDomain: "notifications.github.com",
|
|
304
|
+
dkimDomain: "github.com",
|
|
305
|
+
dkimMismatch: false,
|
|
306
|
+
displayNameCorrespondence: "Corresponds",
|
|
307
|
+
offDomainLinkDomains: [],
|
|
308
|
+
},
|
|
309
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
310
|
+
0,
|
|
311
|
+
);
|
|
312
|
+
assert.equal(result.verdict, "aligned");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("stays verified when nothing was compared", () => {
|
|
316
|
+
const result = buildAuthenticityIntel(
|
|
317
|
+
makeThread({
|
|
318
|
+
fromEmail: "alice@example.com",
|
|
319
|
+
authenticity: {
|
|
320
|
+
fromDomain: "example.com",
|
|
321
|
+
dkimDomain: "example.com",
|
|
322
|
+
dkimMismatch: false,
|
|
323
|
+
},
|
|
324
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
325
|
+
0,
|
|
326
|
+
);
|
|
327
|
+
assert.equal(result.verdict, "aligned");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("a drifted link list carries no destination to name", () => {
|
|
331
|
+
const result = buildAuthenticityIntel(
|
|
332
|
+
makeThread({
|
|
333
|
+
fromEmail: "alice@example.com",
|
|
334
|
+
authenticity: {
|
|
335
|
+
fromDomain: "example.com",
|
|
336
|
+
dkimDomain: "example.com",
|
|
337
|
+
dkimMismatch: false,
|
|
338
|
+
displayNameCorrespondence: "Corresponds",
|
|
339
|
+
offDomainLinkDomains: "elsewhere.example",
|
|
340
|
+
},
|
|
341
|
+
} as unknown as Partial<RemitImapThreadMessageResponse>),
|
|
342
|
+
0,
|
|
343
|
+
);
|
|
344
|
+
assert.equal(result.verdict, "aligned");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
246
348
|
describe("unparseable sender drives the red tier", () => {
|
|
247
349
|
test("mismatch + addressUnreadable when the domain has no dot", () => {
|
|
248
350
|
const thread = makeThread({
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
RemitImapAddressResponse,
|
|
7
7
|
RemitImapThreadMessageResponse,
|
|
8
8
|
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
+
import { DisplayNameCorrespondence } from "@remit/domain-enums";
|
|
9
10
|
import type {
|
|
10
11
|
AuthenticityIntel,
|
|
11
12
|
IntelligenceData,
|
|
@@ -97,6 +98,65 @@ function buildSenderFlags(
|
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/** The brand the display name asserts, or `undefined` when it asserts none. */
|
|
102
|
+
function claimedBrandOf(
|
|
103
|
+
thread: RemitImapThreadMessageResponse,
|
|
104
|
+
): string | undefined {
|
|
105
|
+
if (!thread.fromName) return undefined;
|
|
106
|
+
if (thread.fromName === thread.fromEmail) return undefined;
|
|
107
|
+
return thread.fromName;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function joinDomains(domains: readonly string[]): string {
|
|
111
|
+
if (domains.length === 1) return domains[0];
|
|
112
|
+
return `${domains.slice(0, -1).join(", ")} and ${domains[domains.length - 1]}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The clauses naming what does not line up on a message whose signature checks
|
|
117
|
+
* out. Empty when everything the backend compared agreed — including when it
|
|
118
|
+
* compared nothing, which is every message the provider's filter did not
|
|
119
|
+
* already call spam.
|
|
120
|
+
*/
|
|
121
|
+
function describeSenderMismatch(
|
|
122
|
+
auth: NonNullable<RemitImapThreadMessageResponse["authenticity"]>,
|
|
123
|
+
claimedBrand: string | undefined,
|
|
124
|
+
): string[] {
|
|
125
|
+
const clauses: string[] = [];
|
|
126
|
+
const correspondence = auth.displayNameCorrespondence;
|
|
127
|
+
|
|
128
|
+
if (claimedBrand) {
|
|
129
|
+
if (correspondence === DisplayNameCorrespondence.Unrelated) {
|
|
130
|
+
clauses.push(
|
|
131
|
+
`The name it shows, "${claimedBrand}", has nothing to do with that domain.`,
|
|
132
|
+
);
|
|
133
|
+
} else if (correspondence === DisplayNameCorrespondence.Lookalike) {
|
|
134
|
+
clauses.push(
|
|
135
|
+
`The name it shows, "${claimedBrand}", only looks like that domain.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const linkDomains = readDomainList(auth.offDomainLinkDomains);
|
|
141
|
+
if (linkDomains.length > 0) {
|
|
142
|
+
clauses.push(`Its links go to ${joinDomains(linkDomains.slice(0, 3))}.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return clauses;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The field is a JSON blob on the message row, so a value written by an older
|
|
150
|
+
* or drifted writer reaches here as whatever it happens to be. Anything that is
|
|
151
|
+
* not a list of non-empty strings carries no destination to name.
|
|
152
|
+
*/
|
|
153
|
+
function readDomainList(value: unknown): string[] {
|
|
154
|
+
if (!Array.isArray(value)) return [];
|
|
155
|
+
return value.filter(
|
|
156
|
+
(entry): entry is string => typeof entry === "string" && entry.length > 0,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
100
160
|
/**
|
|
101
161
|
* Build authenticity intel from the thread message's authenticity field.
|
|
102
162
|
*
|
|
@@ -130,6 +190,20 @@ export function buildAuthenticityIntel(
|
|
|
130
190
|
};
|
|
131
191
|
}
|
|
132
192
|
if (!auth.dkimMismatch) {
|
|
193
|
+
const claimed = claimedBrandOf(thread);
|
|
194
|
+
const unlike = describeSenderMismatch(auth, claimed);
|
|
195
|
+
if (unlike.length > 0) {
|
|
196
|
+
return {
|
|
197
|
+
verdict: "caution",
|
|
198
|
+
fromDomain: auth.fromDomain,
|
|
199
|
+
dkimDomain: auth.dkimDomain,
|
|
200
|
+
claimedBrand: claimed,
|
|
201
|
+
summary: [
|
|
202
|
+
`This message really was sent by ${auth.fromDomain}.`,
|
|
203
|
+
...unlike,
|
|
204
|
+
].join(" "),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
133
207
|
return {
|
|
134
208
|
verdict: "aligned",
|
|
135
209
|
fromDomain: auth.fromDomain,
|
|
@@ -142,10 +216,7 @@ export function buildAuthenticityIntel(
|
|
|
142
216
|
|
|
143
217
|
const fromDomain = auth.fromDomain;
|
|
144
218
|
const dkimDomain = auth.dkimDomain;
|
|
145
|
-
const claimedBrand =
|
|
146
|
-
thread.fromName && thread.fromName !== thread.fromEmail
|
|
147
|
-
? thread.fromName
|
|
148
|
-
: undefined;
|
|
219
|
+
const claimedBrand = claimedBrandOf(thread);
|
|
149
220
|
const summary = claimedBrand
|
|
150
221
|
? `The display name claims "${claimedBrand}", but this message was actually sent from ${dkimDomain ?? "another sender"} — not ${fromDomain}. Real senders use their own address.`
|
|
151
222
|
: `This message claims to be from ${fromDomain}, but it was actually sent from ${dkimDomain ?? "a different sender"}.`;
|