@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.223
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 +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/ga4-measurement-protocol.js +41 -28
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +25 -4
- package/src/lib/server/host-visitor-records.js +39 -11
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +52 -36
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +11 -7
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +49 -9
- package/src/lib/server/payment-fraud-signal.js +192 -48
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/render-monitor.d.ts +166 -0
- package/src/lib/server/render-monitor.js +412 -0
- package/src/lib/server/render-monitor.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +7 -0
- package/src/lib/server/risk-notice.js +6 -0
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +50 -9
- package/src/lib/server/web-risk.js +290 -155
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -3
- package/src/tenancy.js +5 -3
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/crm-email-activity.d.ts +0 -118
- package/src/lib/server/crm-email-activity.js +0 -148
- package/src/lib/server/crm-email-activity.js.map +0 -1
- package/src/lib/server/crm-next-activity.d.ts +0 -74
- package/src/lib/server/crm-next-activity.js +0 -194
- package/src/lib/server/crm-next-activity.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -19,7 +19,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
19
19
|
*
|
|
20
20
|
* `@aglyn/shared-util-email/link-reputation` decides which hosts are foreign
|
|
21
21
|
* and turns a listing into a strong `web-risk-link` signal. This is the store
|
|
22
|
-
* half: the `uris.search` client, the cache, the
|
|
22
|
+
* half: the `uris.search` client, the cache, the switches.
|
|
23
23
|
*
|
|
24
24
|
* ## The client
|
|
25
25
|
*
|
|
@@ -29,52 +29,83 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
29
29
|
* SOCIAL_ENGINEERING, MALWARE and UNWANTED_SOFTWARE as the platform's own
|
|
30
30
|
* service account — the firebase-admin credential every other server-side
|
|
31
31
|
* Google call here already uses (`client-error-report.ts`), so there is no
|
|
32
|
-
* key of its own.
|
|
33
|
-
*
|
|
32
|
+
* key of its own. What is sent depends on the lookup mode, below.
|
|
33
|
+
*
|
|
34
|
+
* ## The lookup mode (AGL-3459)
|
|
35
|
+
*
|
|
36
|
+
* `platformSettings/webRisk.lookupMode`:
|
|
37
|
+
*
|
|
38
|
+
* - `'host'` — the default, and anything that is not `'url'`. Only
|
|
39
|
+
* `https://<host>/` is ever sent.
|
|
40
|
+
* - `'url'` — the host first, from the cache or a call; then, for a host
|
|
41
|
+
* that is not itself listed, each of its links' addresses as
|
|
42
|
+
* `normalizeReputationLink` reduced them: scheme, host and path, never a
|
|
43
|
+
* query string, a fragment or a `user:password@`. A listed address is a
|
|
44
|
+
* hit on a clean host, so a kit at a path on a compromised site holds the
|
|
45
|
+
* page. At most `MAX_REPUTATION_URLS_PER_LOOKUP` addresses per lookup,
|
|
46
|
+
* inside the same deadline as the hosts.
|
|
47
|
+
*
|
|
48
|
+
* `'url'` sends more than a host to Google, so it stays off until the
|
|
49
|
+
* Subprocessors page names that purpose — see the `webrisk.googleapis.com`
|
|
50
|
+
* entry in `apps/console/constants/subprocessor-inventory.ts`.
|
|
34
51
|
*
|
|
35
52
|
* ## Not evidence when it fails
|
|
36
53
|
*
|
|
37
54
|
* A call is given {@link WEB_RISK_TIMEOUT_MS}, and a whole lookup
|
|
38
|
-
* {@link WEB_RISK_DEADLINE_MS}
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* line rather than one
|
|
55
|
+
* {@link WEB_RISK_DEADLINE_MS} — hosts and addresses together. A host or an
|
|
56
|
+
* address whose answer did not arrive, or whose call failed, is UNKNOWN: it
|
|
57
|
+
* holds nothing and logs a warning. A credential the API refuses (the API is
|
|
58
|
+
* not enabled on a self-hosted project, say) backs the lookup off for a
|
|
59
|
+
* quarter of an hour, so a misconfiguration is one log line rather than one
|
|
60
|
+
* per page.
|
|
43
61
|
*
|
|
44
62
|
* ## The cache
|
|
45
63
|
*
|
|
46
|
-
* Per host, in two layers:
|
|
64
|
+
* Per host and per address, in two layers, with the same rules for both:
|
|
47
65
|
*
|
|
48
|
-
* - this process's memory — a
|
|
49
|
-
* which Web Risk sets and which may not be exceeded; a clean
|
|
66
|
+
* - this process's memory — a listing until the answer's `expireTime`,
|
|
67
|
+
* which Web Risk sets and which may not be exceeded; a clean answer for
|
|
50
68
|
* {@link WEB_RISK_MEMORY_CLEAN_TTL_MS}, short so a listing the daily
|
|
51
|
-
* re-check writes reaches every warm render within minutes
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
69
|
+
* re-check writes reaches every warm render within minutes. Capped at
|
|
70
|
+
* {@link WEB_RISK_MEMORY_MAX_ENTRIES}, then started again;
|
|
71
|
+
* - the store, server-only, a clean answer kept {@link WEB_RISK_CLEAN_TTL_MS}:
|
|
72
|
+
* `webRiskVerdicts/{host}`, bounded by the number of distinct foreign
|
|
73
|
+
* hosts and rewritten in place; and `webRiskUrlVerdicts/{sha256(address)}`,
|
|
74
|
+
* whose answers the daily re-check deletes a day after they expire
|
|
75
|
+
* ({@link reapExpiredWebRiskUrlVerdicts}), because addresses are many more
|
|
76
|
+
* than hosts and a path is worth keeping no longer than it is used.
|
|
55
77
|
*
|
|
56
|
-
* Read in one `getAll` per lookup, so a page's
|
|
57
|
-
* trip, and nothing at all once they are in memory.
|
|
78
|
+
* Read in one `getAll` per lookup, so a page's hosts and addresses cost one
|
|
79
|
+
* round trip, and nothing at all once they are in memory.
|
|
58
80
|
*
|
|
59
81
|
* ## The kill switch
|
|
60
82
|
*
|
|
61
83
|
* `platformSettings/webRisk` `{ enabled: false }` stops every lookup within a
|
|
62
84
|
* minute — every host reads unknown, so nothing holds on it — without a
|
|
63
85
|
* deploy. Absent or anything else means on.
|
|
64
|
-
*=========================================*/ import {
|
|
65
|
-
import {
|
|
86
|
+
*=========================================*/ import { createHash } from "crypto";
|
|
87
|
+
import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
|
|
88
|
+
import { LINK_THREAT_TYPES, MAX_REPUTATION_HOSTS_PER_LOOKUP, MAX_REPUTATION_URLS_PER_LOOKUP, normalizeReputationHost, normalizeReputationLink, pickReputationUrls, setLinkReputationLookup } from "@aglyn/shared-util-email/link-reputation";
|
|
89
|
+
import { boundedAwait } from "@aglyn/shared-util-http/bounded-await";
|
|
66
90
|
import { getApp } from "firebase-admin/app";
|
|
67
91
|
import firebaseAdmin from "./firebase-admin.js";
|
|
68
92
|
export const WEB_RISK_SEARCH_ENDPOINT = 'https://webrisk.googleapis.com/v1/uris:search';
|
|
69
|
-
export const WEB_RISK_CACHE_COLLECTION = 'webRiskVerdicts';
|
|
70
|
-
/**
|
|
93
|
+
/** One answer per host. */ export const WEB_RISK_CACHE_COLLECTION = 'webRiskVerdicts';
|
|
94
|
+
/** One answer per address, in `'url'` mode (AGL-3459), keyed by the address's SHA-256. */ export const WEB_RISK_URL_CACHE_COLLECTION = 'webRiskUrlVerdicts';
|
|
95
|
+
/**
|
|
96
|
+
* The switches: `{ enabled: false }` turns every lookup off;
|
|
97
|
+
* `lookupMode: 'url'` looks up links' addresses as well as their hosts.
|
|
98
|
+
*/ export const WEB_RISK_SETTINGS_COLLECTION = 'platformSettings';
|
|
71
99
|
export const WEB_RISK_SETTINGS_DOC = 'webRisk';
|
|
72
100
|
/** One call's budget. */ export const WEB_RISK_TIMEOUT_MS = 1000;
|
|
73
|
-
/** One lookup's budget, however many hosts it asks about. */ export const WEB_RISK_DEADLINE_MS = 1200;
|
|
101
|
+
/** One lookup's budget, however many hosts and addresses it asks about. */ export const WEB_RISK_DEADLINE_MS = 1200;
|
|
102
|
+
/** The service account's token exchange; a cached token answers at once. */ export const WEB_RISK_TOKEN_DEADLINE_MS = 3000;
|
|
74
103
|
/** How long a clean answer is kept in the store. */ export const WEB_RISK_CLEAN_TTL_MS = 12 * 60 * 60000;
|
|
75
104
|
/** How long a clean answer is trusted from this process's memory. */ export const WEB_RISK_MEMORY_CLEAN_TTL_MS = 10 * 60000;
|
|
76
105
|
/** A listing whose answer named no `expireTime` is kept this long. */ export const WEB_RISK_LISTED_FALLBACK_TTL_MS = 5 * 60000;
|
|
77
|
-
/**
|
|
106
|
+
/** The most answers this process remembers before it starts again. */ export const WEB_RISK_MEMORY_MAX_ENTRIES = 20000;
|
|
107
|
+
/** How long past its expiry an address's stored answer is deleted. */ export const WEB_RISK_URL_VERDICT_GRACE_MS = 24 * 60 * 60000;
|
|
108
|
+
/** How long the switches' answer is trusted. */ const SWITCH_TTL_MS = 60000;
|
|
78
109
|
/** How long a refused credential stops the lookup. */ const REFUSED_BACKOFF_MS = 15 * 60000;
|
|
79
110
|
/** How long a rate limit stops the lookup. */ const THROTTLED_BACKOFF_MS = 60000;
|
|
80
111
|
/** At most one warning per this long, so an outage is not a log flood. */ const WARN_EVERY_MS = 60000;
|
|
@@ -108,8 +139,7 @@ export const WEB_RISK_SETTINGS_DOC = 'webRisk';
|
|
|
108
139
|
return `https://${host}/`;
|
|
109
140
|
}
|
|
110
141
|
/** The default client: `uris.search` over HTTPS with a bearer token. */ export function createWebRiskHttpClient(options) {
|
|
111
|
-
var
|
|
112
|
-
const request = (_options_fetch = options.fetch) != null ? _options_fetch : fetch;
|
|
142
|
+
var _options_timeoutMs;
|
|
113
143
|
const timeoutMs = (_options_timeoutMs = options.timeoutMs) != null ? _options_timeoutMs : WEB_RISK_TIMEOUT_MS;
|
|
114
144
|
return {
|
|
115
145
|
async searchUri (uri) {
|
|
@@ -119,24 +149,26 @@ export const WEB_RISK_SETTINGS_DOC = 'webRisk';
|
|
|
119
149
|
const url = new URL((_options_endpoint = options.endpoint) != null ? _options_endpoint : WEB_RISK_SEARCH_ENDPOINT);
|
|
120
150
|
for (const type of LINK_THREAT_TYPES)url.searchParams.append('threatTypes', type);
|
|
121
151
|
url.searchParams.set('uri', uri);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
152
|
+
// A real deadline over the request AND its body: the signal alone did
|
|
153
|
+
// not bound Next's patched fetch (AGL-3565). It still cancels the
|
|
154
|
+
// request where it can.
|
|
155
|
+
const answer = await boundedAwait(async (signal)=>{
|
|
156
|
+
var _options_fetch;
|
|
157
|
+
const response = await ((_options_fetch = options.fetch) != null ? _options_fetch : fetch)(url.toString(), {
|
|
126
158
|
method: 'GET',
|
|
127
159
|
headers: {
|
|
128
160
|
Authorization: `Bearer ${token}`,
|
|
129
161
|
Accept: 'application/json'
|
|
130
162
|
},
|
|
131
|
-
signal
|
|
163
|
+
signal
|
|
132
164
|
});
|
|
133
165
|
if (!response.ok) {
|
|
134
166
|
throw new WebRiskHttpError(response.status, await response.text().catch(()=>''));
|
|
135
167
|
}
|
|
136
168
|
return parseWebRiskSearch(await response.json());
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
169
|
+
}, timeoutMs, null, 'web-risk.search');
|
|
170
|
+
if (!answer) throw new Error(`Web Risk did not answer within ${timeoutMs} ms`);
|
|
171
|
+
return answer;
|
|
140
172
|
}
|
|
141
173
|
};
|
|
142
174
|
}
|
|
@@ -146,10 +178,11 @@ export const WEB_RISK_SETTINGS_DOC = 'webRisk';
|
|
|
146
178
|
*/ async function serviceAccountAccessToken() {
|
|
147
179
|
try {
|
|
148
180
|
var _ref;
|
|
149
|
-
var _this;
|
|
150
181
|
const credential = getApp().options.credential;
|
|
151
182
|
if (!credential) return null;
|
|
152
|
-
|
|
183
|
+
// A token exchange with Google, on a page render's path (AGL-3565).
|
|
184
|
+
const token = await boundedAwait(credential.getAccessToken(), WEB_RISK_TOKEN_DEADLINE_MS, null, 'web-risk.access-token');
|
|
185
|
+
return (_ref = token == null ? void 0 : token.access_token) != null ? _ref : null;
|
|
153
186
|
} catch (unused) {
|
|
154
187
|
return null;
|
|
155
188
|
}
|
|
@@ -163,13 +196,13 @@ function activeClient() {
|
|
|
163
196
|
});
|
|
164
197
|
return defaultClient;
|
|
165
198
|
}
|
|
166
|
-
const memory = new Map();
|
|
167
|
-
let
|
|
199
|
+
/** Hosts and addresses share one memory: an address always holds `://`, a host never does. */ const memory = new Map();
|
|
200
|
+
let settingsMemo = null;
|
|
168
201
|
let backoffUntilMs = 0;
|
|
169
202
|
let lastWarnAtMs = 0;
|
|
170
|
-
/** Test seam: forget the caches, the
|
|
203
|
+
/** Test seam: forget the caches, the switches, the back-off; optionally stand in a client. */ export function resetWebRiskForTests(client) {
|
|
171
204
|
memory.clear();
|
|
172
|
-
|
|
205
|
+
settingsMemo = null;
|
|
173
206
|
backoffUntilMs = 0;
|
|
174
207
|
lastWarnAtMs = 0;
|
|
175
208
|
clientOverride = client;
|
|
@@ -178,16 +211,31 @@ let lastWarnAtMs = 0;
|
|
|
178
211
|
function warn(nowMs, message, error) {
|
|
179
212
|
if (nowMs - lastWarnAtMs < WARN_EVERY_MS) return;
|
|
180
213
|
lastWarnAtMs = nowMs;
|
|
181
|
-
console.warn(`[web-risk] ${message} —
|
|
214
|
+
console.warn(`[web-risk] ${message} — it reads unknown, which is not evidence`, error != null ? error : '');
|
|
182
215
|
}
|
|
183
|
-
/**
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
216
|
+
/**
|
|
217
|
+
* The switches, read at most once a minute. An unreadable document is on,
|
|
218
|
+
* in `'host'` mode; a mode that is not exactly `'url'` is `'host'`.
|
|
219
|
+
*/ async function webRiskSettings(nowMs) {
|
|
220
|
+
if (settingsMemo && nowMs - settingsMemo.atMs < SWITCH_TTL_MS) return settingsMemo;
|
|
221
|
+
const settings = await firebaseAdmin.app().firestore().collection(WEB_RISK_SETTINGS_COLLECTION).doc(WEB_RISK_SETTINGS_DOC).get().then((snapshot)=>({
|
|
222
|
+
off: snapshot.get('enabled') === false,
|
|
223
|
+
mode: snapshot.get('lookupMode') === 'url' ? 'url' : 'host'
|
|
224
|
+
}), ()=>({
|
|
225
|
+
off: false,
|
|
226
|
+
mode: 'host'
|
|
227
|
+
}));
|
|
228
|
+
settingsMemo = _extends({}, settings, {
|
|
188
229
|
atMs: nowMs
|
|
189
|
-
};
|
|
190
|
-
return
|
|
230
|
+
});
|
|
231
|
+
return settings;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The lookup mode in force (AGL-3459): `'url'` only while the lookup is on
|
|
235
|
+
* and `platformSettings/webRisk.lookupMode` is exactly `'url'`.
|
|
236
|
+
*/ export async function webRiskLookupMode(nowMs = Date.now()) {
|
|
237
|
+
const settings = await webRiskSettings(nowMs);
|
|
238
|
+
return settings.off ? 'host' : settings.mode;
|
|
191
239
|
}
|
|
192
240
|
/**
|
|
193
241
|
* Hosts that are the platform's own: every site's subdomain on the tenant
|
|
@@ -200,8 +248,15 @@ function warn(nowMs, message, error) {
|
|
|
200
248
|
function isPlatformHost(host) {
|
|
201
249
|
return platformReputationExclusions().some((domain)=>host === domain || host.endsWith(`.${domain}`));
|
|
202
250
|
}
|
|
203
|
-
function
|
|
251
|
+
/** An address's document id in {@link WEB_RISK_URL_CACHE_COLLECTION}: an address holds `/`, a hash does not. */ export function webRiskUrlVerdictId(url) {
|
|
252
|
+
return createHash('sha256').update(url).digest('hex');
|
|
253
|
+
}
|
|
254
|
+
function isAddress(key) {
|
|
255
|
+
return key.includes('://');
|
|
256
|
+
}
|
|
257
|
+
/** A stored answer, or null. An address's must name the address it was asked for. */ function verdictFrom(data, url) {
|
|
204
258
|
if (!data) return null;
|
|
259
|
+
if (url !== undefined && data['url'] !== url) return null;
|
|
205
260
|
const threats = Array.isArray(data['threats']) ? LINK_THREAT_TYPES.filter((type)=>data['threats'].includes(type)) : [];
|
|
206
261
|
const checkedAtMs = Number(data['checkedAtMs']);
|
|
207
262
|
const expiresAtMs = Number(data['expiresAtMs']);
|
|
@@ -212,25 +267,31 @@ function verdictFrom(data) {
|
|
|
212
267
|
expiresAtMs
|
|
213
268
|
};
|
|
214
269
|
}
|
|
215
|
-
function remember(
|
|
216
|
-
memory.
|
|
270
|
+
function remember(key, verdict, nowMs) {
|
|
271
|
+
if (memory.size >= WEB_RISK_MEMORY_MAX_ENTRIES && !memory.has(key)) memory.clear();
|
|
272
|
+
memory.set(key, _extends({}, verdict, {
|
|
217
273
|
memoryUntilMs: verdict.threats.length ? verdict.expiresAtMs : Math.min(verdict.expiresAtMs, nowMs + WEB_RISK_MEMORY_CLEAN_TTL_MS)
|
|
218
274
|
}));
|
|
219
275
|
}
|
|
276
|
+
function fromMemory(key, nowMs) {
|
|
277
|
+
const known = memory.get(key);
|
|
278
|
+
return known && nowMs < known.memoryUntilMs ? known : null;
|
|
279
|
+
}
|
|
220
280
|
/**
|
|
221
|
-
* The reputation of each host
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* the
|
|
281
|
+
* The reputation of each host — and, in `'url'` mode, of each address on a
|
|
282
|
+
* host that is not itself listed: listed, clean, or unknown. Platform hosts
|
|
283
|
+
* are clean without asking. Never throws, and never waits on the API longer
|
|
284
|
+
* than the deadline: a call still running then finishes in the background
|
|
285
|
+
* and fills the caches for the next asker.
|
|
225
286
|
*
|
|
226
287
|
* "Never throws" covers the store as well as the API: a missing app, a
|
|
227
288
|
* settings read that fails before it is a promise, or a cache reference
|
|
228
289
|
* that cannot be built all read every host as unknown. The page review
|
|
229
290
|
* calls this before its own fail-closed `try`, so a throw here would take
|
|
230
291
|
* down every page with a link off the site.
|
|
231
|
-
*/ export async function
|
|
292
|
+
*/ export async function lookupLinkReputation(hosts, options = {}) {
|
|
232
293
|
try {
|
|
233
|
-
return await
|
|
294
|
+
return await lookupLinkReputationOrThrow(hosts, options);
|
|
234
295
|
} catch (error) {
|
|
235
296
|
var _options_nowMs;
|
|
236
297
|
warn((_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now(), 'a lookup failed before it could answer', error);
|
|
@@ -245,8 +306,9 @@ function remember(host, verdict, nowMs) {
|
|
|
245
306
|
};
|
|
246
307
|
}
|
|
247
308
|
}
|
|
248
|
-
async function
|
|
249
|
-
var _options_nowMs;
|
|
309
|
+
async function lookupLinkReputationOrThrow(hosts, options) {
|
|
310
|
+
var _options_nowMs, _options_maxUrls;
|
|
311
|
+
var _options_urls;
|
|
250
312
|
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
251
313
|
const result = {
|
|
252
314
|
hits: [],
|
|
@@ -264,119 +326,192 @@ async function lookupHostReputationOrThrow(hosts, options) {
|
|
|
264
326
|
else result.unknown.push(host);
|
|
265
327
|
}
|
|
266
328
|
if (!wanted.length) return result;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
329
|
+
let urls = ((_options_urls = options.urls) == null ? void 0 : _options_urls.length) ? pickReputationUrls(options.urls, wanted, (_options_maxUrls = options.maxUrls) != null ? _options_maxUrls : MAX_REPUTATION_URLS_PER_LOOKUP) : [];
|
|
330
|
+
const hostOf = new Map(urls.map((url)=>{
|
|
331
|
+
var _ref;
|
|
332
|
+
var _normalizeReputationLink;
|
|
333
|
+
return [
|
|
334
|
+
url,
|
|
335
|
+
(_ref = (_normalizeReputationLink = normalizeReputationLink(url)) == null ? void 0 : _normalizeReputationLink.host) != null ? _ref : ''
|
|
336
|
+
];
|
|
337
|
+
}));
|
|
338
|
+
// What is known before the API is asked, host or address.
|
|
339
|
+
const known = new Map();
|
|
340
|
+
const unknownTo = (keys)=>keys.filter((key)=>{
|
|
341
|
+
const verdict = fromMemory(key, nowMs);
|
|
342
|
+
if (verdict) known.set(key, verdict);
|
|
343
|
+
return !verdict;
|
|
271
344
|
});
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
let
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
345
|
+
// 1. This process's memory, for the hosts.
|
|
346
|
+
let missingHosts = unknownTo(wanted);
|
|
347
|
+
let missingUrls = [];
|
|
348
|
+
let mayAsk = true;
|
|
349
|
+
// 2. The switches — read only when a host is left to ask about, or
|
|
350
|
+
// addresses wait on the mode. Off reads everything left as unknown.
|
|
351
|
+
if (missingHosts.length || urls.length) {
|
|
352
|
+
const settings = await webRiskSettings(nowMs);
|
|
353
|
+
if (settings.off || settings.mode !== 'url') urls = [];
|
|
354
|
+
if (settings.off) mayAsk = false;
|
|
355
|
+
// An address on a host already known to be listed adds nothing.
|
|
356
|
+
urls = urls.filter((url)=>{
|
|
357
|
+
var _hostOf_get;
|
|
358
|
+
var _known_get;
|
|
359
|
+
return !((_known_get = known.get((_hostOf_get = hostOf.get(url)) != null ? _hostOf_get : '')) == null ? void 0 : _known_get.threats.length);
|
|
360
|
+
});
|
|
361
|
+
// 1b. This process's memory, for the addresses.
|
|
362
|
+
missingUrls = unknownTo(urls);
|
|
288
363
|
}
|
|
289
|
-
// 3. The store, in one round trip.
|
|
364
|
+
// 3. The store, hosts and addresses in one round trip.
|
|
290
365
|
const firestore = firebaseAdmin.app().firestore();
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
366
|
+
const hostCollection = firestore.collection(WEB_RISK_CACHE_COLLECTION);
|
|
367
|
+
const urlCollection = firestore.collection(WEB_RISK_URL_CACHE_COLLECTION);
|
|
368
|
+
if (mayAsk && (missingHosts.length || missingUrls.length)) {
|
|
369
|
+
const keys = [
|
|
370
|
+
...missingHosts,
|
|
371
|
+
...missingUrls
|
|
372
|
+
];
|
|
373
|
+
try {
|
|
374
|
+
const snapshots = await firestore.getAll(...keys.map((key)=>isAddress(key) ? urlCollection.doc(webRiskUrlVerdictId(key)) : hostCollection.doc(key)));
|
|
375
|
+
snapshots.forEach((snapshot, index)=>{
|
|
376
|
+
const key = keys[index];
|
|
377
|
+
const data = snapshot.exists ? snapshot.data() : undefined;
|
|
378
|
+
const verdict = verdictFrom(data, isAddress(key) ? key : undefined);
|
|
379
|
+
if (!verdict || nowMs >= verdict.expiresAtMs) return;
|
|
380
|
+
remember(key, verdict, nowMs);
|
|
381
|
+
known.set(key, verdict);
|
|
382
|
+
});
|
|
383
|
+
missingHosts = missingHosts.filter((host)=>!known.has(host));
|
|
384
|
+
missingUrls = missingUrls.filter((url)=>!known.has(url));
|
|
385
|
+
} catch (error) {
|
|
386
|
+
warn(nowMs, 'the verdict cache could not be read', error);
|
|
298
387
|
}
|
|
299
|
-
missing = missing.filter((host)=>{
|
|
300
|
-
const verdict = stored.get(host);
|
|
301
|
-
if (!verdict) return true;
|
|
302
|
-
remember(host, verdict, nowMs);
|
|
303
|
-
settle(host, verdict);
|
|
304
|
-
return false;
|
|
305
|
-
});
|
|
306
|
-
} catch (error) {
|
|
307
|
-
warn(nowMs, 'the verdict cache could not be read', error);
|
|
308
388
|
}
|
|
309
|
-
|
|
310
|
-
//
|
|
389
|
+
// 4. The API, everything left at once, within the deadline: each host
|
|
390
|
+
// first, then — unless the host itself is listed — its addresses.
|
|
391
|
+
const answered = new Map();
|
|
311
392
|
const client = activeClient();
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
393
|
+
if (mayAsk && client && nowMs >= backoffUntilMs && (missingHosts.length || missingUrls.length)) {
|
|
394
|
+
var _options_deadlineMs;
|
|
395
|
+
let looked = 0;
|
|
396
|
+
const ask = async (key)=>{
|
|
397
|
+
if (Date.now() < backoffUntilMs) return null;
|
|
398
|
+
looked += 1;
|
|
399
|
+
try {
|
|
400
|
+
var _answer_expireTimeMs, _hostOf_get;
|
|
401
|
+
const answer = await client.searchUri(isAddress(key) ? key : webRiskUriForHost(key));
|
|
402
|
+
const checkedAtMs = Date.now();
|
|
403
|
+
const verdict = {
|
|
404
|
+
threats: answer.threats,
|
|
405
|
+
checkedAtMs,
|
|
406
|
+
expiresAtMs: answer.threats.length ? (_answer_expireTimeMs = answer.expireTimeMs) != null ? _answer_expireTimeMs : checkedAtMs + WEB_RISK_LISTED_FALLBACK_TTL_MS : checkedAtMs + WEB_RISK_CLEAN_TTL_MS
|
|
407
|
+
};
|
|
408
|
+
answered.set(key, verdict);
|
|
409
|
+
remember(key, verdict, checkedAtMs);
|
|
410
|
+
const document = isAddress(key) ? urlCollection.doc(webRiskUrlVerdictId(key)).set(_extends({
|
|
411
|
+
host: (_hostOf_get = hostOf.get(key)) != null ? _hostOf_get : '',
|
|
412
|
+
url: key
|
|
413
|
+
}, verdict, {
|
|
414
|
+
listed: verdict.threats.length > 0
|
|
415
|
+
})) : hostCollection.doc(key).set(_extends({
|
|
416
|
+
host: key
|
|
417
|
+
}, verdict, {
|
|
418
|
+
listed: verdict.threats.length > 0
|
|
419
|
+
}));
|
|
420
|
+
await document.catch((error)=>warn(checkedAtMs, 'a verdict could not be cached', error));
|
|
421
|
+
return verdict;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (error instanceof WebRiskHttpError && [
|
|
424
|
+
401,
|
|
425
|
+
403,
|
|
426
|
+
404
|
|
427
|
+
].includes(error.status)) {
|
|
428
|
+
backoffUntilMs = Date.now() + REFUSED_BACKOFF_MS;
|
|
429
|
+
console.error('[web-risk] the API refused the credential — lookups paused for 15 minutes', error);
|
|
430
|
+
} else if (error instanceof WebRiskHttpError && error.status === 429) {
|
|
431
|
+
backoffUntilMs = Date.now() + THROTTLED_BACKOFF_MS;
|
|
432
|
+
warn(Date.now(), 'the API is rate limiting', error);
|
|
433
|
+
} else if (error instanceof WebRiskUnavailableError) {
|
|
434
|
+
backoffUntilMs = Date.now() + REFUSED_BACKOFF_MS;
|
|
435
|
+
warn(Date.now(), 'no credential to call the API with', error);
|
|
436
|
+
} else {
|
|
437
|
+
warn(Date.now(), `the lookup of ${key} failed or timed out`, error);
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
351
440
|
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
441
|
+
};
|
|
442
|
+
const pipelines = wanted.map(async (host)=>{
|
|
443
|
+
var _known_get;
|
|
444
|
+
const hostVerdict = (_known_get = known.get(host)) != null ? _known_get : missingHosts.includes(host) ? await ask(host) : null;
|
|
445
|
+
if (hostVerdict == null ? void 0 : hostVerdict.threats.length) return;
|
|
446
|
+
await Promise.all(missingUrls.filter((url)=>hostOf.get(url) === host).map((url)=>ask(url)));
|
|
447
|
+
});
|
|
448
|
+
await boundedAwait(Promise.allSettled(pipelines), (_options_deadlineMs = options.deadlineMs) != null ? _options_deadlineMs : WEB_RISK_DEADLINE_MS, undefined, 'web-risk.lookup');
|
|
449
|
+
result.looked = looked;
|
|
450
|
+
const late = [
|
|
451
|
+
...missingHosts,
|
|
452
|
+
...missingUrls
|
|
453
|
+
].filter((key)=>!answered.has(key)).length;
|
|
454
|
+
if (late) warn(Date.now(), `${late} host(s) or address(es) had no answer within the deadline`);
|
|
455
|
+
}
|
|
456
|
+
// 5. The answer. An address on a listed host is not reported: the host's
|
|
457
|
+
// listing already names it.
|
|
458
|
+
const verdictOf = (key)=>{
|
|
459
|
+
var _known_get;
|
|
460
|
+
return (_known_get = known.get(key)) != null ? _known_get : answered.get(key);
|
|
461
|
+
};
|
|
462
|
+
for (const host of wanted){
|
|
463
|
+
const verdict = verdictOf(host);
|
|
464
|
+
if (!verdict) result.unknown.push(host);
|
|
465
|
+
else if (verdict.threats.length) result.hits.push({
|
|
466
|
+
host,
|
|
467
|
+
threats: verdict.threats
|
|
468
|
+
});
|
|
469
|
+
else result.clean.push(host);
|
|
470
|
+
}
|
|
471
|
+
for (const url of urls){
|
|
472
|
+
var _hostOf_get;
|
|
473
|
+
var _verdictOf;
|
|
474
|
+
const host = (_hostOf_get = hostOf.get(url)) != null ? _hostOf_get : '';
|
|
475
|
+
if ((_verdictOf = verdictOf(host)) == null ? void 0 : _verdictOf.threats.length) continue;
|
|
476
|
+
const verdict = verdictOf(url);
|
|
477
|
+
if (!verdict) result.unknown.push(url);
|
|
478
|
+
else if (verdict.threats.length) result.hits.push({
|
|
479
|
+
host,
|
|
480
|
+
url,
|
|
481
|
+
threats: verdict.threats
|
|
482
|
+
});
|
|
483
|
+
else result.clean.push(url);
|
|
367
484
|
}
|
|
368
|
-
result.unknown.push(...late);
|
|
369
|
-
if (late.length) warn(Date.now(), `${late.length} host(s) had no answer within the deadline`);
|
|
370
485
|
return result;
|
|
371
486
|
}
|
|
487
|
+
/**
|
|
488
|
+
* Delete the addresses' stored answers that expired more than
|
|
489
|
+
* {@link WEB_RISK_URL_VERDICT_GRACE_MS} ago (AGL-3459), at most `limit` at a
|
|
490
|
+
* time. The daily re-check calls it once per walk. A host's answers are not
|
|
491
|
+
* reaped: they are few and rewritten in place. Never throws.
|
|
492
|
+
*/ export async function reapExpiredWebRiskUrlVerdicts(options = {}) {
|
|
493
|
+
var _options_nowMs, _options_firestore;
|
|
494
|
+
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
495
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
496
|
+
try {
|
|
497
|
+
var _options_limit;
|
|
498
|
+
const stale = await firestore.collection(WEB_RISK_URL_CACHE_COLLECTION).where('expiresAtMs', '<', nowMs - WEB_RISK_URL_VERDICT_GRACE_MS).limit((_options_limit = options.limit) != null ? _options_limit : 500).get();
|
|
499
|
+
await Promise.all(stale.docs.map((snapshot)=>snapshot.ref.delete()));
|
|
500
|
+
return stale.docs.length;
|
|
501
|
+
} catch (error) {
|
|
502
|
+
console.warn('[web-risk] expired address verdicts could not be deleted', error);
|
|
503
|
+
return 0;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
372
506
|
/**
|
|
373
507
|
* Puts this lookup behind the shared screen's reputation seam, so the send
|
|
374
508
|
* seam (`screenTenantMessage`) asks it. Called by
|
|
375
509
|
* `installOutboundScreenGate`, at that module's load.
|
|
376
510
|
*/ export function installLinkReputationLookup() {
|
|
377
511
|
if (typeof setLinkReputationLookup !== 'function') return;
|
|
378
|
-
setLinkReputationLookup((hosts, options)=>
|
|
379
|
-
deadlineMs: options == null ? void 0 : options.deadlineMs
|
|
512
|
+
setLinkReputationLookup((hosts, options)=>lookupLinkReputation(hosts, {
|
|
513
|
+
deadlineMs: options == null ? void 0 : options.deadlineMs,
|
|
514
|
+
urls: options == null ? void 0 : options.urls
|
|
380
515
|
}));
|
|
381
516
|
}
|
|
382
517
|
|