@vigilbase/cli 0.2.4 → 0.2.6
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/CLOUDFLARE.md +9 -2
- package/COMMANDS.md +5 -5
- package/LOCAL.md +87 -12
- package/ONE.md +2 -2
- package/README.md +13 -10
- package/dist/commands/cloudflare.js +79 -13
- package/dist/commands/helpers.d.ts +4 -1
- package/dist/commands/local.js +44 -23
- package/dist/lib/checkup-report-limits.d.ts +16 -0
- package/dist/lib/checkup-report-limits.js +16 -0
- package/dist/lib/checkup-report.d.ts +118 -0
- package/dist/lib/checkup-report.js +503 -0
- package/dist/lib/format.d.ts +2 -0
- package/dist/lib/format.js +1 -1
- package/dist/lib/one-error-hints.d.ts +1 -0
- package/dist/lib/one-error-hints.js +10 -0
- package/dist/lib/tui.d.ts +4 -0
- package/dist/lib/tui.js +2 -2
- package/dist/local/cloudflare-checkup/cf/allowlist.js +5 -2
- package/dist/local/cloudflare-checkup/collector.js +5 -5
- package/dist/local/cloudflare-checkup/contract.d.ts +481 -20
- package/dist/local/cloudflare-checkup/contract.js +111 -27
- package/dist/local/cloudflare-checkup/email-verification.d.ts +8 -1
- package/dist/local/cloudflare-checkup/email-verification.js +0 -5
- package/dist/local/cloudflare-checkup/identity-verification.d.ts +11 -0
- package/dist/local/cloudflare-checkup/identity-verification.js +65 -0
- package/dist/local/cloudflare-checkup/snapshot-file.js +13 -1
- package/dist/local/cloudflare-checkup/submit-client.d.ts +6 -3
- package/dist/local/cloudflare-checkup/submit-client.js +17 -4
- package/dist/local/cloudflare-checkup/terminal-ui.d.ts +10 -3
- package/dist/local/cloudflare-checkup/terminal-ui.js +13 -3
- package/package.json +1 -1
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* Line references below are to `packages/tools/src/cloudflare-checkup/scanner.ts`
|
|
23
23
|
* before the read-client refactor; the function names are the stable anchor.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Optional protection entries and DNS hostname coverage extend V1; older V1
|
|
26
|
+
* snapshots remain accepted with the same required entry checks.
|
|
27
27
|
*/
|
|
28
28
|
import { z } from 'zod';
|
|
29
29
|
// ============================================================================
|
|
@@ -43,6 +43,8 @@ export const LOCAL_AVAILABILITY_PATH = '/api/v1/tools/cloudflare-checkup/local-s
|
|
|
43
43
|
export const LOCAL_REQUEST_PATH = '/api/v1/tools/cloudflare-checkup/local-session/request';
|
|
44
44
|
/** The CLI trades the verification token and the emailed code for a session code. */
|
|
45
45
|
export const LOCAL_VERIFY_PATH = '/api/v1/tools/cloudflare-checkup/local-session/verify';
|
|
46
|
+
/** Exchange a signed, run-scoped One identity proof for an upload session. */
|
|
47
|
+
export const LOCAL_IDENTITY_PATH = '/api/v1/tools/cloudflare-checkup/local-session/identity';
|
|
46
48
|
/** `vbcv1_` + base64url_nopad(iv[12] ‖ AES-256-GCM(runId[16] ‖ iat[4]) ‖ tag[16]): names a run awaiting its emailed code. */
|
|
47
49
|
export const VERIFICATION_TOKEN_REGEX = /^vbcv1_[A-Za-z0-9_-]{64}$/;
|
|
48
50
|
/** The six-digit code One emails for a tool run. */
|
|
@@ -77,7 +79,7 @@ export const SNAPSHOT_LIMITS = Object.freeze({
|
|
|
77
79
|
maxFutureSkewSeconds: 300,
|
|
78
80
|
// cf prints cursor-only lists without the cursor; a list this long may be
|
|
79
81
|
// truncated. Lowered to the server's per_page maximum observed in launch gate 1.
|
|
80
|
-
rulesetListTruncationAt:
|
|
82
|
+
rulesetListTruncationAt: 50,
|
|
81
83
|
});
|
|
82
84
|
// The 19 zone settings the scanner reads (scanZone 664-732; phase functions).
|
|
83
85
|
export const ZONE_SETTING_IDS = [
|
|
@@ -151,6 +153,17 @@ export const ZONE_SINGLE_KINDS = [
|
|
|
151
153
|
'zone_schema_validation_settings',
|
|
152
154
|
'zone_schema_validation_schemas',
|
|
153
155
|
];
|
|
156
|
+
export const OPTIONAL_ZONE_KINDS = [
|
|
157
|
+
'zone_certificate_packs',
|
|
158
|
+
'zone_custom_certificates',
|
|
159
|
+
'zone_api_operations',
|
|
160
|
+
];
|
|
161
|
+
export const SNAPSHOT_PROTECTION_COVERAGE_DESCRIPTORS = {
|
|
162
|
+
zone_certificate_packs: { label: 'Edge certificate packs', affects: ['certificate_coverage', 'certificate_expiry'] },
|
|
163
|
+
zone_custom_certificates: { label: 'Custom edge certificates', affects: ['certificate_coverage', 'certificate_expiry'] },
|
|
164
|
+
zone_api_operations: { label: 'API operation schema enforcement', affects: ['api_enforcement_coverage'] },
|
|
165
|
+
zone_effective_waf_inputs: { label: 'Effective WAF scope and overrides', affects: ['managed_ruleset_coverage', 'managed_ruleset_overrides'] },
|
|
166
|
+
};
|
|
154
167
|
// cf 1.0.0-beta.13 has no command for these reads; they are always `not_collected`.
|
|
155
168
|
export const NOT_COLLECTABLE_ZONE_KINDS = [
|
|
156
169
|
'zone_legacy_firewall_rules',
|
|
@@ -163,6 +176,7 @@ export const SNAPSHOT_KINDS = [
|
|
|
163
176
|
...ACCOUNT_KINDS,
|
|
164
177
|
'zone_setting',
|
|
165
178
|
...ZONE_SINGLE_KINDS,
|
|
179
|
+
...OPTIONAL_ZONE_KINDS,
|
|
166
180
|
'zone_ruleset',
|
|
167
181
|
...NOT_COLLECTABLE_ZONE_KINDS,
|
|
168
182
|
];
|
|
@@ -304,7 +318,7 @@ export const AccountData = z.object({ id: hex32, name: text(256), type: token(32
|
|
|
304
318
|
settings: z.object({ enforce_twofactor: z.boolean().optional() }).strict().optional() }).strict();
|
|
305
319
|
export const ZoneData = z.object({ id: hex32, name: hostname, status: token(32).optional(), paused: z.boolean().optional(),
|
|
306
320
|
type: token(32).optional(), development_mode: z.number().int().optional(),
|
|
307
|
-
plan: z.object({ name: text(64) }).strict().optional(), account: z.object({ id: hex32, name: text(256) }).strict() }).strict();
|
|
321
|
+
plan: z.object({ name: text(64).optional(), legacy_id: token(64).optional() }).strict().optional(), account: z.object({ id: hex32, name: text(256) }).strict() }).strict();
|
|
308
322
|
export const MemberData = z.object({ status: z.literal('pending').optional(), email: email.optional(),
|
|
309
323
|
user: z.object({ two_factor_authentication_enabled: z.literal(false) }).strict().optional() }).strict();
|
|
310
324
|
export const AccessRuleData = z.object({ mode: token(32).optional(),
|
|
@@ -316,11 +330,24 @@ export const TunnelData = z.object({ status: token(32).optional(), is_deleted: z
|
|
|
316
330
|
export const SettingData = z.object({ value: z.union([scalar,
|
|
317
331
|
z.record(token(64), z.union([scalar, z.record(token(64), scalar)]))]).optional() }).strict();
|
|
318
332
|
export const DnssecData = z.object({ status: token(32).optional(), algorithm: z.union([token(16), z.number().int()]).optional() }).strict();
|
|
319
|
-
export const DnsRecordData = z.
|
|
320
|
-
|
|
321
|
-
|
|
333
|
+
export const DnsRecordData = z.union([
|
|
334
|
+
z.object({ type: z.enum(['A', 'AAAA']), name: hostname.optional(),
|
|
335
|
+
content: z.string().min(1).max(64).regex(IP_CONTENT_RE), proxied: z.literal(true).optional() }).strict(),
|
|
336
|
+
z.object({ type: z.literal('CNAME'), name: hostname.optional(), proxied: z.literal(true) }).strict(),
|
|
337
|
+
]);
|
|
338
|
+
export const CertificateData = z.object({ status: token(64).optional(), hosts: list(hostname).optional(),
|
|
339
|
+
expires_on: iso.optional() }).strict();
|
|
340
|
+
export const CertificatePackData = CertificateData.extend({ certificates: list(CertificateData).optional() }).strict();
|
|
341
|
+
export const ApiOperationData = z.object({
|
|
342
|
+
features: z.object({ schema_info: z.object({
|
|
343
|
+
active_schema: z.object({ id: token(64) }).strict().optional(),
|
|
344
|
+
mitigation_action: z.enum(['block', 'log', 'none']).optional(),
|
|
345
|
+
}).strict().optional() }).strict().optional(),
|
|
346
|
+
}).strict();
|
|
347
|
+
const rulesetKind = z.enum(['managed', 'zone', 'root', 'custom']);
|
|
348
|
+
export const RulesetHeadData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional(), kind: rulesetKind.optional() }).strict();
|
|
322
349
|
export const RulesetDetailData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional(),
|
|
323
|
-
rulesBody: bodyRef.optional() }).strict();
|
|
350
|
+
kind: rulesetKind.optional(), rulesBody: bodyRef.optional() }).strict();
|
|
324
351
|
export const PageRuleData = z.object({ actions: z.array(z.object({ id: z.enum(LEGACY_PAGE_RULE_ACTIONS) }).strict()).max(32).optional() }).strict();
|
|
325
352
|
// `stale_zone_configuration` is its key list in API order: the scanner prints
|
|
326
353
|
// `Object.keys(...)`, and the serialiser sorts object keys, so a `{key: true}`
|
|
@@ -339,8 +366,8 @@ const needleJoin = z.string().max(200).refine((v) => {
|
|
|
339
366
|
const ruleShape = {
|
|
340
367
|
action: z.string().regex(RULE_ACTION_RE).optional(), // non-conforming raw actions become 'other'
|
|
341
368
|
enabled: z.literal(false).optional(),
|
|
342
|
-
expression: z.enum(['true', 'ip.geoip.country in']).optional(),
|
|
343
|
-
action_parameters: z.object({ id:
|
|
369
|
+
expression: z.enum(['true', 'false', 'ip.geoip.country in']).optional(),
|
|
370
|
+
action_parameters: z.object({ id: hex32.optional(),
|
|
344
371
|
name: needleJoin.optional() }).strict().optional(),
|
|
345
372
|
id: token(64).optional(),
|
|
346
373
|
ref: text(128).optional(),
|
|
@@ -354,7 +381,7 @@ export const RuleRunData = z.object({
|
|
|
354
381
|
// Collector-side ruleset detail: rules are projected but not yet encoded into
|
|
355
382
|
// `ruleBodies` (`assembleSnapshot` does that once every zone is collected).
|
|
356
383
|
export const ProjectedRulesetDetailData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional(),
|
|
357
|
-
rules: z.array(ProjectedRuleData).max(SNAPSHOT_LIMITS.maxRulesPerBody) }).strict();
|
|
384
|
+
kind: rulesetKind.optional(), rules: z.array(ProjectedRuleData).max(SNAPSHOT_LIMITS.maxRulesPerBody) }).strict();
|
|
358
385
|
// ============================================================================
|
|
359
386
|
// Entries and the snapshot
|
|
360
387
|
// ============================================================================
|
|
@@ -387,6 +414,9 @@ export const SnapshotEntrySchema = z.discriminatedUnion('kind', [
|
|
|
387
414
|
Z('zone_page_shield', result(enabledOnly)),
|
|
388
415
|
Z('zone_schema_validation_settings', result(SchemaValidationSettingsData)),
|
|
389
416
|
Z('zone_schema_validation_schemas', result(counted)),
|
|
417
|
+
Z('zone_certificate_packs', z.union([result(list(CertificatePackData)), notCollected])),
|
|
418
|
+
Z('zone_custom_certificates', z.union([result(list(CertificateData)), notCollected])),
|
|
419
|
+
Z('zone_api_operations', z.union([result(list(ApiOperationData)), notCollected])),
|
|
390
420
|
Z('zone_legacy_firewall_rules', notCollected),
|
|
391
421
|
Z('zone_legacy_filters', notCollected),
|
|
392
422
|
Z('zone_legacy_waf_packages', notCollected),
|
|
@@ -500,7 +530,7 @@ export function validateSnapshotSemantics(snapshot, ctx) {
|
|
|
500
530
|
const key = snapshotEntryKey(entry, accountId);
|
|
501
531
|
if (seen.has(key))
|
|
502
532
|
issue('duplicate_entry', ['entries', i], 'snapshot_incomplete');
|
|
503
|
-
else if (!expected.has(key))
|
|
533
|
+
else if (!expected.has(key) && !OPTIONAL_ZONE_KINDS.includes(entry.kind))
|
|
504
534
|
issue('unexpected_entry', ['entries', i], 'snapshot_incomplete');
|
|
505
535
|
seen.add(key);
|
|
506
536
|
});
|
|
@@ -522,7 +552,7 @@ export function validateSnapshotSemantics(snapshot, ctx) {
|
|
|
522
552
|
runs.forEach((run, j) => {
|
|
523
553
|
const path = ['ruleBodies', ref, j];
|
|
524
554
|
const action = run.action?.toLowerCase();
|
|
525
|
-
if (run.expression !== undefined && action !== 'block')
|
|
555
|
+
if (run.expression !== undefined && action !== 'block' && !(action === 'execute' && run.expression === 'false'))
|
|
526
556
|
issue('expression_on_non_block_rule', [...path, 'expression']);
|
|
527
557
|
if (run.action_parameters !== undefined && run.action !== 'execute') {
|
|
528
558
|
issue('action_parameters_on_non_execute_rule', [...path, 'action_parameters']);
|
|
@@ -587,6 +617,8 @@ export function validateSnapshotSemantics(snapshot, ctx) {
|
|
|
587
617
|
if (entry.kind !== 'zone_dns_records' || !entry.result.ok)
|
|
588
618
|
return;
|
|
589
619
|
entry.result.data.forEach((record, r) => {
|
|
620
|
+
if (record.type !== 'A' && record.type !== 'AAAA')
|
|
621
|
+
return;
|
|
590
622
|
const path = ['entries', i, 'result', 'data', r, 'content'];
|
|
591
623
|
const pseudonym = parsePseudonym(record.content);
|
|
592
624
|
if (pseudonym === null) {
|
|
@@ -769,6 +801,9 @@ export function snapshotRequestKey(kind, ids) {
|
|
|
769
801
|
case 'zone_dns_records': return `GET /zones/${z}/dns_records`;
|
|
770
802
|
case 'zone_universal_ssl': return `GET /zones/${z}/ssl/universal/settings`;
|
|
771
803
|
case 'zone_total_tls': return `GET /zones/${z}/acm/total_tls`;
|
|
804
|
+
case 'zone_certificate_packs': return `GET /zones/${z}/ssl/certificate_packs?status=all`;
|
|
805
|
+
case 'zone_custom_certificates': return `GET /zones/${z}/custom_certificates`;
|
|
806
|
+
case 'zone_api_operations': return `GET /zones/${z}/api_gateway/operations?feature=schema_info&per_page=50`;
|
|
772
807
|
case 'zone_rulesets': return `GET /zones/${z}/rulesets`;
|
|
773
808
|
case 'zone_ruleset': return `GET /zones/${z}/rulesets/${ids.rulesetId ?? ''}`;
|
|
774
809
|
case 'zone_access_rules': return `GET /zones/${z}/firewall/access_rules/rules`;
|
|
@@ -887,7 +922,7 @@ export function projectAccount(raw) {
|
|
|
887
922
|
}
|
|
888
923
|
/**
|
|
889
924
|
* `zones` (all pages merged, API order): `id, name, status, paused, type,
|
|
890
|
-
* development_mode, plan.name, account.{id,name}`. zoneCtxFor (4338) prints the
|
|
925
|
+
* development_mode, plan.{name,legacy_id}, account.{id,name}`. zoneCtxFor (4338) prints the
|
|
891
926
|
* account; createZoneStatusCheck (1960-1990), createDevModeCheck (2165-2197),
|
|
892
927
|
* createDNSModeCheck (3218-3234) and assembleCheckupResult (5099-5102) read the rest.
|
|
893
928
|
* `paused` is only tested for truthiness.
|
|
@@ -906,8 +941,9 @@ export function projectZones(raw) {
|
|
|
906
941
|
type: zone.type ?? undefined,
|
|
907
942
|
development_mode: zone.development_mode ?? undefined,
|
|
908
943
|
});
|
|
909
|
-
if (plan && printedIfTruthy(plan.name) !== undefined)
|
|
910
|
-
out.plan = { name: plan.name };
|
|
944
|
+
if (plan && (printedIfTruthy(plan.name) !== undefined || printedIfTruthy(plan.legacy_id) !== undefined)) {
|
|
945
|
+
out.plan = assignDefined({}, { name: printedIfTruthy(plan.name), legacy_id: printedIfTruthy(plan.legacy_id) });
|
|
946
|
+
}
|
|
911
947
|
out.account = { id: account.id, name: account.name };
|
|
912
948
|
return out;
|
|
913
949
|
}));
|
|
@@ -1113,28 +1149,60 @@ export function projectDnssec(raw) {
|
|
|
1113
1149
|
});
|
|
1114
1150
|
}
|
|
1115
1151
|
/**
|
|
1116
|
-
* `zone_dns_records` (all pages, API order):
|
|
1152
|
+
* `zone_dns_records` (all pages, API order): `(A|AAAA) && content` records and
|
|
1153
|
+
* proxied CNAME hostnames without their targets.
|
|
1117
1154
|
* findOriginExposure and detectOriginIpReuseAcrossZones (3307-3373) read
|
|
1118
1155
|
* `type`, `content` (as a Map key and through `maskIp`), `proxied` (truthiness)
|
|
1119
|
-
* and `name
|
|
1120
|
-
*
|
|
1156
|
+
* and `name`. Certificate coverage reads proxied hostnames. Address `content`
|
|
1157
|
+
* stays raw here and is pseudonymised by
|
|
1121
1158
|
* `createIpPseudonymizer` (via `assembleSnapshot`) once every zone is collected.
|
|
1122
1159
|
*/
|
|
1123
1160
|
export function projectDnsRecords(raw) {
|
|
1124
1161
|
return runProjector(list(DnsRecordData), () => {
|
|
1125
1162
|
const records = rawItems(raw, Number.MAX_SAFE_INTEGER)
|
|
1126
|
-
.filter((record) => (record.type === 'A' || record.type === 'AAAA') && record.content)
|
|
1163
|
+
.filter((record) => ((record.type === 'A' || record.type === 'AAAA') && record.content) || (record.type === 'CNAME' && record.proxied))
|
|
1127
1164
|
.map((record) => {
|
|
1128
|
-
const out = { type: record.type
|
|
1165
|
+
const out = { type: record.type };
|
|
1166
|
+
if (record.type !== 'CNAME')
|
|
1167
|
+
out.content = record.content;
|
|
1129
1168
|
if (record.proxied)
|
|
1130
1169
|
out.proxied = true;
|
|
1131
|
-
|
|
1170
|
+
if (record.name)
|
|
1132
1171
|
out.name = record.name;
|
|
1133
1172
|
return out;
|
|
1134
1173
|
});
|
|
1135
1174
|
return records.length > SNAPSHOT_LIMITS.maxListItems ? tooLarge() : records;
|
|
1136
1175
|
});
|
|
1137
1176
|
}
|
|
1177
|
+
function certificateFields(raw) {
|
|
1178
|
+
return assignDefined({}, { status: raw.status, hosts: raw.hosts, expires_on: raw.expires_on });
|
|
1179
|
+
}
|
|
1180
|
+
export function projectCertificatePacks(raw) {
|
|
1181
|
+
return runProjector(list(CertificatePackData), () => rawItems(raw).map((pack) => {
|
|
1182
|
+
const out = certificateFields(pack);
|
|
1183
|
+
if (pack.certificates !== undefined)
|
|
1184
|
+
out.certificates = rawItems(pack.certificates).map(certificateFields);
|
|
1185
|
+
return out;
|
|
1186
|
+
}));
|
|
1187
|
+
}
|
|
1188
|
+
export function projectCustomCertificates(raw) {
|
|
1189
|
+
return runProjector(list(CertificateData), () => rawItems(raw).map((certificate) => certificateFields(certificate)));
|
|
1190
|
+
}
|
|
1191
|
+
export function projectApiOperations(raw) {
|
|
1192
|
+
return runProjector(list(ApiOperationData), () => rawItems(raw).map((operation) => {
|
|
1193
|
+
if (operation.features === undefined)
|
|
1194
|
+
return {};
|
|
1195
|
+
const features = rawObject(operation.features);
|
|
1196
|
+
if (features.schema_info === undefined)
|
|
1197
|
+
return { features: {} };
|
|
1198
|
+
const info = rawObject(features.schema_info);
|
|
1199
|
+
const schemaInfo = assignDefined({}, { mitigation_action: info.mitigation_action });
|
|
1200
|
+
if (info.active_schema !== undefined && info.active_schema !== null) {
|
|
1201
|
+
schemaInfo.active_schema = { id: rawObject(info.active_schema).id };
|
|
1202
|
+
}
|
|
1203
|
+
return { features: { schema_info: schemaInfo } };
|
|
1204
|
+
}));
|
|
1205
|
+
}
|
|
1138
1206
|
/**
|
|
1139
1207
|
* `zone_universal_ssl`, `zone_total_tls`, `zone_page_shield`: `enabled`.
|
|
1140
1208
|
* The checks read `enabled !== false` (3797) and `enabled === true` (2697,
|
|
@@ -1152,11 +1220,14 @@ function rulesetHead(ruleset) {
|
|
|
1152
1220
|
out.phase = ruleset.phase;
|
|
1153
1221
|
if (typeof ruleset.name === 'string' && ruleset.name.toLowerCase().includes('ddos'))
|
|
1154
1222
|
out.name = 'ddos';
|
|
1223
|
+
if (rulesetKind.safeParse(ruleset.kind).success)
|
|
1224
|
+
out.kind = ruleset.kind;
|
|
1155
1225
|
return out;
|
|
1156
1226
|
}
|
|
1157
1227
|
/**
|
|
1158
1228
|
* `zone_rulesets` heads: `id, phase`, `name:'ddos'` iff the name contains
|
|
1159
|
-
* "ddos";
|
|
1229
|
+
* "ddos"; the managed `kind` marker is retained to distinguish vendor definitions
|
|
1230
|
+
* from customer configuration, while `version` is dropped. `name` is read only by
|
|
1160
1231
|
* createDDoSCheck (2511); `phase` at 2326, 2511, 2559-2564, 3446 and 4019.
|
|
1161
1232
|
*/
|
|
1162
1233
|
export function projectRulesetHeads(raw) {
|
|
@@ -1184,6 +1255,8 @@ function projectRule(rule, phase) {
|
|
|
1184
1255
|
out.action = RULE_ACTION_RE.test(action) ? action : 'other';
|
|
1185
1256
|
if (rule.enabled === false)
|
|
1186
1257
|
out.enabled = false;
|
|
1258
|
+
if (action === 'execute' && typeof rule.expression === 'string' && rule.expression.trim().toLowerCase() === 'false')
|
|
1259
|
+
out.expression = 'false';
|
|
1187
1260
|
if (action?.toLowerCase() === 'block' && typeof rule.expression === 'string') {
|
|
1188
1261
|
const expression = rule.expression.toLowerCase();
|
|
1189
1262
|
if (expression.trim() === 'true')
|
|
@@ -1197,7 +1270,7 @@ function projectRule(rule, phase) {
|
|
|
1197
1270
|
const executeName = String(params.name || '').toLowerCase();
|
|
1198
1271
|
const needles = MANAGED_NAME_NEEDLES.filter((needle) => executeName.includes(needle));
|
|
1199
1272
|
const actionParameters = {};
|
|
1200
|
-
if (
|
|
1273
|
+
if (HEX32_REGEX.test(executeId))
|
|
1201
1274
|
actionParameters.id = executeId;
|
|
1202
1275
|
if (needles.length > 0)
|
|
1203
1276
|
actionParameters.name = needles.join(' | ');
|
|
@@ -1338,6 +1411,9 @@ export const PROJECTORS = {
|
|
|
1338
1411
|
zone_page_shield: projectEnabledOnly,
|
|
1339
1412
|
zone_schema_validation_settings: projectSchemaValidationSettings,
|
|
1340
1413
|
zone_schema_validation_schemas: projectCounted,
|
|
1414
|
+
zone_certificate_packs: projectCertificatePacks,
|
|
1415
|
+
zone_custom_certificates: projectCustomCertificates,
|
|
1416
|
+
zone_api_operations: projectApiOperations,
|
|
1341
1417
|
};
|
|
1342
1418
|
export const NOT_COLLECTED_RESULT = Object.freeze({ ok: false, reason: 'not_collected' });
|
|
1343
1419
|
export function httpErrorResult(status) {
|
|
@@ -1533,15 +1609,17 @@ export function assembleSnapshot(input) {
|
|
|
1533
1609
|
if (entry.kind !== 'zone_dns_records' || entry.zoneId !== zoneId || !entry.result.ok)
|
|
1534
1610
|
continue;
|
|
1535
1611
|
const records = entry.result.data;
|
|
1536
|
-
const
|
|
1612
|
+
const addresses = records.filter((record) => record.type === 'A' || record.type === 'AAAA');
|
|
1613
|
+
const invalidContent = addresses.some((record) => !pseudonymFormatOk(record.content));
|
|
1537
1614
|
const pseudonyms = invalidContent
|
|
1538
1615
|
? { ok: false, detail: 'invalid_output' }
|
|
1539
|
-
: pseudonymizer.pseudonymizeAll(
|
|
1616
|
+
: pseudonymizer.pseudonymizeAll(addresses.map((record) => record.content));
|
|
1617
|
+
let addressIndex = 0;
|
|
1540
1618
|
pseudonymized.set(entry, {
|
|
1541
1619
|
kind: 'zone_dns_records',
|
|
1542
1620
|
zoneId,
|
|
1543
1621
|
result: pseudonyms.ok
|
|
1544
|
-
? { ok: true, data: records.map((record
|
|
1622
|
+
? { ok: true, data: records.map((record) => record.type === 'CNAME' ? record : { ...record, content: pseudonyms.data[addressIndex++] }) }
|
|
1545
1623
|
: collectionFailedResult(pseudonyms.detail),
|
|
1546
1624
|
});
|
|
1547
1625
|
}
|
|
@@ -1554,6 +1632,10 @@ export function assembleSnapshot(input) {
|
|
|
1554
1632
|
listed[entry.zoneId] = entry.result.data.map((head) => head.id);
|
|
1555
1633
|
}
|
|
1556
1634
|
const rank = new Map(expectedEntryKeys(accountId, zoneIds, listed).map((key, index) => [key, index]));
|
|
1635
|
+
for (const zoneId of zoneIds) {
|
|
1636
|
+
for (const kind of OPTIONAL_ZONE_KINDS)
|
|
1637
|
+
rank.set(snapshotRequestKey(kind, { accountId, zoneId }), rank.size);
|
|
1638
|
+
}
|
|
1557
1639
|
const ordered = entries
|
|
1558
1640
|
.map((entry, index) => ({ entry, index, rank: rank.get(snapshotEntryKey(entry, accountId)) ?? Number.MAX_SAFE_INTEGER }))
|
|
1559
1641
|
.sort((a, b) => a.rank - b.rank || a.index - b.index)
|
|
@@ -1672,6 +1754,8 @@ export const LocalSnapshotResponseSchema = z.object({ success: z.literal(true),
|
|
|
1672
1754
|
resultsUrl: z.url().max(2048).optional() });
|
|
1673
1755
|
// Email verification in the CLI. Request bodies are strict: the server refuses fields it does not know.
|
|
1674
1756
|
export const LocalRequestBodySchema = z.object({ email: z.string().min(3).max(254), locale: z.enum(LOCAL_EMAIL_LOCALES) }).strict();
|
|
1757
|
+
export const LocalIdentityBodySchema = z.object({ runId: z.uuid(), email: z.email().max(254),
|
|
1758
|
+
proof: z.string().min(1).max(2048), locale: z.enum(LOCAL_EMAIL_LOCALES) }).strict();
|
|
1675
1759
|
export const LocalVerifyBodySchema = z.object({ verification: z.string().regex(VERIFICATION_TOKEN_REGEX),
|
|
1676
1760
|
code: z.string().regex(EMAIL_CODE_REGEX) }).strict();
|
|
1677
1761
|
// `expiresAt` is when the emailed code (and so the token) stops working.
|
|
@@ -45,7 +45,14 @@ export declare function emailLocale(env: NodeJS.ProcessEnv): LocalEmailLocale;
|
|
|
45
45
|
* token, the emailed code and the session code out of `JSON.stringify` and
|
|
46
46
|
* `util.inspect`.
|
|
47
47
|
*/
|
|
48
|
-
export
|
|
48
|
+
export interface VerifiedRequester {
|
|
49
|
+
readonly requester: string;
|
|
50
|
+
readonly sessionCode: string;
|
|
51
|
+
readonly expiresAt: string;
|
|
52
|
+
readonly renewable: boolean;
|
|
53
|
+
renew(): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
export declare class VerifiedEmail implements VerifiedRequester {
|
|
49
56
|
#private;
|
|
50
57
|
/** The masked address from vigilbase.com, safe to print. */
|
|
51
58
|
readonly requester: string;
|
|
@@ -38,11 +38,6 @@ export function emailLocale(env) {
|
|
|
38
38
|
const language = value.trim().toLowerCase();
|
|
39
39
|
return language.startsWith('pt') ? 'pt-PT' : language.startsWith('ar') ? 'ar' : 'en';
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
42
|
-
* A verified email and the session code it earned. Private fields keep the
|
|
43
|
-
* token, the emailed code and the session code out of `JSON.stringify` and
|
|
44
|
-
* `util.inspect`.
|
|
45
|
-
*/
|
|
46
41
|
export class VerifiedEmail {
|
|
47
42
|
#client;
|
|
48
43
|
#token;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CliConfig } from '../../lib/config.js';
|
|
2
|
+
import type { LocalEmailLocale } from './contract.js';
|
|
3
|
+
import type { VerifiedRequester } from './email-verification.js';
|
|
4
|
+
import { type VerificationClient } from './submit-client.js';
|
|
5
|
+
export declare function verifySignedInRequester(options: {
|
|
6
|
+
config: CliConfig;
|
|
7
|
+
client: VerificationClient;
|
|
8
|
+
locale: LocalEmailLocale;
|
|
9
|
+
email?: string;
|
|
10
|
+
warn(message: string): void;
|
|
11
|
+
}): Promise<VerifiedRequester | null>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Reuse the CLI login through a live, run-scoped proof from One. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { getOptionalOneAccessToken } from '../../auth/store.js';
|
|
5
|
+
import { VigilbaseApiError } from '../../lib/client.js';
|
|
6
|
+
import { VigilbaseOneClient } from '../../lib/one-client.js';
|
|
7
|
+
import { LocalCheckupError } from './errors.js';
|
|
8
|
+
import { displayRequester } from './submit-client.js';
|
|
9
|
+
const IdentityProofSchema = z.object({ success: z.literal(true), identity: z.object({
|
|
10
|
+
runId: z.uuid(), email: z.email().max(254), proof: z.string().min(1).max(2048),
|
|
11
|
+
}) });
|
|
12
|
+
class IdentityRequester {
|
|
13
|
+
#session;
|
|
14
|
+
#renewed = false;
|
|
15
|
+
#exchange;
|
|
16
|
+
constructor(session, exchange) {
|
|
17
|
+
this.#session = session;
|
|
18
|
+
this.#exchange = exchange;
|
|
19
|
+
}
|
|
20
|
+
get requester() { return displayRequester(this.#session.requester); }
|
|
21
|
+
get sessionCode() { return this.#session.code; }
|
|
22
|
+
get expiresAt() { return this.#session.expiresAt; }
|
|
23
|
+
get renewable() { return !this.#renewed; }
|
|
24
|
+
async renew() {
|
|
25
|
+
if (this.#renewed)
|
|
26
|
+
throw new LocalCheckupError('SESSION_EXPIRED', 'The checkup session expired again after it was renewed.');
|
|
27
|
+
this.#renewed = true;
|
|
28
|
+
this.#session = await this.#exchange();
|
|
29
|
+
}
|
|
30
|
+
toJSON() { return { requester: this.requester }; }
|
|
31
|
+
}
|
|
32
|
+
export async function verifySignedInRequester(options) {
|
|
33
|
+
const saved = await getOptionalOneAccessToken(options.config);
|
|
34
|
+
if (saved.problem)
|
|
35
|
+
options.warn(saved.problem);
|
|
36
|
+
if (!saved.accessToken)
|
|
37
|
+
return null;
|
|
38
|
+
const one = new VigilbaseOneClient(options.config, saved.accessToken, true);
|
|
39
|
+
const runId = randomUUID();
|
|
40
|
+
const proof = async () => {
|
|
41
|
+
const response = await one.requestJson('/api/tools/cloudflare-checkup/local-identity', {
|
|
42
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ runId }),
|
|
43
|
+
});
|
|
44
|
+
const parsed = IdentityProofSchema.safeParse(response);
|
|
45
|
+
if (!parsed.success || parsed.data.identity.runId !== runId) {
|
|
46
|
+
throw new LocalCheckupError('VERIFICATION_INVALID', 'One returned an invalid checkup identity proof.');
|
|
47
|
+
}
|
|
48
|
+
return parsed.data.identity;
|
|
49
|
+
};
|
|
50
|
+
let identity;
|
|
51
|
+
try {
|
|
52
|
+
identity = await proof();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
// A revoked or unverified login can still run the public email flow.
|
|
56
|
+
if (error instanceof VigilbaseApiError && [401, 403].includes(error.status))
|
|
57
|
+
return null;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (options.email && options.email.toLowerCase() !== identity.email.toLowerCase())
|
|
61
|
+
return null;
|
|
62
|
+
const exchange = async () => options.client.exchangeIdentity({ ...await proof(), locale: options.locale });
|
|
63
|
+
const session = await options.client.exchangeIdentity({ ...identity, locale: options.locale });
|
|
64
|
+
return new IdentityRequester(session, exchange);
|
|
65
|
+
}
|
|
@@ -178,9 +178,21 @@ export function dataCategories(snapshot) {
|
|
|
178
178
|
for (const record of entry.result.data) {
|
|
179
179
|
if (record.name !== undefined)
|
|
180
180
|
categories.dnsHostnames++;
|
|
181
|
-
|
|
181
|
+
if (record.type === 'A' || record.type === 'AAAA')
|
|
182
|
+
ips.add(record.content);
|
|
182
183
|
}
|
|
183
184
|
break;
|
|
185
|
+
case 'zone_certificate_packs':
|
|
186
|
+
for (const pack of entry.result.data) {
|
|
187
|
+
categories.dnsHostnames += pack.hosts?.length ?? 0;
|
|
188
|
+
for (const certificate of pack.certificates ?? [])
|
|
189
|
+
categories.dnsHostnames += certificate.hosts?.length ?? 0;
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
case 'zone_custom_certificates':
|
|
193
|
+
for (const certificate of entry.result.data)
|
|
194
|
+
categories.dnsHostnames += certificate.hosts?.length ?? 0;
|
|
195
|
+
break;
|
|
184
196
|
default:
|
|
185
197
|
break;
|
|
186
198
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type LocalAvailabilityResponse, type LocalRequestBody, type LocalRequestResponse, type LocalSessionResponse, type LocalVerifyBody, type LocalVerifyResponse } from './contract.js';
|
|
1
|
+
import { type LocalIdentityBody, type LocalAvailabilityResponse, type LocalRequestBody, type LocalRequestResponse, type LocalSessionResponse, type LocalVerifyBody, type LocalVerifyResponse } from './contract.js';
|
|
2
2
|
/**
|
|
3
3
|
* `GET local-session`. The account is chosen in the CLI and bound by the
|
|
4
4
|
* uploaded snapshot, so `accountId` is `null` until then; runs created before
|
|
@@ -67,6 +67,8 @@ declare abstract class LocalHttpClient {
|
|
|
67
67
|
* the lost answer was to a wrong code does the retry cost a second try.
|
|
68
68
|
*/
|
|
69
69
|
export declare class VerificationClient extends LocalHttpClient {
|
|
70
|
+
/** Only a signed, run-scoped email proof goes to Com; never the CLI login token. */
|
|
71
|
+
exchangeIdentity(body: LocalIdentityBody): Promise<VerifiedSession>;
|
|
70
72
|
private postJson;
|
|
71
73
|
/**
|
|
72
74
|
* `GET local-session/availability`, before the email is asked for: what
|
|
@@ -97,8 +99,9 @@ export declare class SubmitClient extends LocalHttpClient {
|
|
|
97
99
|
getSession(): Promise<LocalSession>;
|
|
98
100
|
/**
|
|
99
101
|
* `POST local-session/snapshot` with the exact file bytes. 503 and network
|
|
100
|
-
* errors are retried twice
|
|
101
|
-
*
|
|
102
|
+
* errors are retried twice with the same bytes, which the server treats
|
|
103
|
+
* idempotently. Respect Retry-After up to 60 s; otherwise stop with the
|
|
104
|
+
* server's retry guidance. Without it, wait 2 s, then 5 s.
|
|
102
105
|
*/
|
|
103
106
|
postSnapshot(bytes: Buffer, sha256: string): Promise<UploadResult>;
|
|
104
107
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NetworkError, readBoundedBody } from '../../lib/network.js';
|
|
2
|
-
import { CLI_VERSION_HEADER, LOCAL_AVAILABILITY_PATH, LOCAL_REQUEST_PATH, LOCAL_SESSION_PATH, LOCAL_SNAPSHOT_PATH, LOCAL_VERIFY_PATH, LocalAvailabilityResponseSchema, LocalErrorResponseSchema, LocalRequestBodySchema, LocalRequestResponseSchema, LocalSessionResponseSchema, LocalSnapshotResponseSchema, LocalVerifyBodySchema, LocalVerifyResponseSchema, PRODUCTION_ORIGIN, SESSION_CODE_REGEX, SNAPSHOT_SHA256_HEADER, compareSemver, } from './contract.js';
|
|
2
|
+
import { CLI_VERSION_HEADER, LOCAL_AVAILABILITY_PATH, LOCAL_REQUEST_PATH, LOCAL_IDENTITY_PATH, LocalIdentityBodySchema, LOCAL_SESSION_PATH, LOCAL_SNAPSHOT_PATH, LOCAL_VERIFY_PATH, LocalAvailabilityResponseSchema, LocalErrorResponseSchema, LocalRequestBodySchema, LocalRequestResponseSchema, LocalSessionResponseSchema, LocalSnapshotResponseSchema, LocalVerifyBodySchema, LocalVerifyResponseSchema, PRODUCTION_ORIGIN, SESSION_CODE_REGEX, SNAPSHOT_SHA256_HEADER, compareSemver, } from './contract.js';
|
|
3
3
|
import { LocalCheckupError, browserCheckupHint } from './errors.js';
|
|
4
4
|
import { formatDuration } from './output.js';
|
|
5
5
|
export const GET_TIMEOUT_MS = 30_000;
|
|
@@ -8,6 +8,7 @@ export const AVAILABILITY_TIMEOUT_MS = 10_000;
|
|
|
8
8
|
export const POST_TIMEOUT_MS = 120_000;
|
|
9
9
|
export const MAX_SUBMIT_RESPONSE_BYTES = 1024 * 1024;
|
|
10
10
|
export const POST_RETRY_DELAYS_MS = [2_000, 5_000];
|
|
11
|
+
const MAX_UPLOAD_RETRY_WAIT_MS = 60_000;
|
|
11
12
|
/** A verify that failed on the network or with a 503 is sent once more, after this pause. */
|
|
12
13
|
export const VERIFY_RETRY_DELAYS_MS = [2_000];
|
|
13
14
|
function isLoopbackHost(hostname) {
|
|
@@ -249,6 +250,14 @@ class LocalHttpClient {
|
|
|
249
250
|
* the lost answer was to a wrong code does the retry cost a second try.
|
|
250
251
|
*/
|
|
251
252
|
export class VerificationClient extends LocalHttpClient {
|
|
253
|
+
/** Only a signed, run-scoped email proof goes to Com; never the CLI login token. */
|
|
254
|
+
async exchangeIdentity(body) {
|
|
255
|
+
const checked = LocalIdentityBodySchema.safeParse(body);
|
|
256
|
+
if (!checked.success)
|
|
257
|
+
throw new LocalCheckupError('VERIFICATION_INVALID', 'One returned an invalid checkup identity proof.');
|
|
258
|
+
const { response, text } = await this.postJson(LOCAL_IDENTITY_PATH, checked.data);
|
|
259
|
+
return parseSuccess(LocalVerifyResponseSchema, response, text, 'identity').session;
|
|
260
|
+
}
|
|
252
261
|
async postJson(path, body) {
|
|
253
262
|
const result = await this.request(path, {
|
|
254
263
|
method: 'POST',
|
|
@@ -337,8 +346,9 @@ export class SubmitClient extends LocalHttpClient {
|
|
|
337
346
|
}
|
|
338
347
|
/**
|
|
339
348
|
* `POST local-session/snapshot` with the exact file bytes. 503 and network
|
|
340
|
-
* errors are retried twice
|
|
341
|
-
*
|
|
349
|
+
* errors are retried twice with the same bytes, which the server treats
|
|
350
|
+
* idempotently. Respect Retry-After up to 60 s; otherwise stop with the
|
|
351
|
+
* server's retry guidance. Without it, wait 2 s, then 5 s.
|
|
342
352
|
*/
|
|
343
353
|
async postSnapshot(bytes, sha256) {
|
|
344
354
|
for (let attempt = 0;; attempt++) {
|
|
@@ -373,7 +383,10 @@ export class SubmitClient extends LocalHttpClient {
|
|
|
373
383
|
}
|
|
374
384
|
const error = responseError(response, text, 'session', this.options.origin);
|
|
375
385
|
if (response.status === 503 && error.code !== 'VIGILBASE_CHALLENGED' && error.code !== 'LOCAL_MODE_DISABLED' && attempt < POST_RETRY_DELAYS_MS.length) {
|
|
376
|
-
|
|
386
|
+
const requestedMs = (error.retryAfterSec ?? 0) * 1_000;
|
|
387
|
+
if (!Number.isFinite(requestedMs) || requestedMs > MAX_UPLOAD_RETRY_WAIT_MS)
|
|
388
|
+
throw error;
|
|
389
|
+
await this.sleep(Math.max(POST_RETRY_DELAYS_MS[attempt], requestedMs));
|
|
377
390
|
continue;
|
|
378
391
|
}
|
|
379
392
|
throw error;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* ASCII-only console (the Linux console, legacy Windows consoles) gets no
|
|
16
16
|
* character outside ASCII.
|
|
17
17
|
*/
|
|
18
|
+
import { type CheckupReportView } from '../../lib/checkup-report.js';
|
|
18
19
|
import { type Writer } from '../../lib/format.js';
|
|
19
20
|
import { Tui, type SignalSource, type TtyInput, type TtyOutput } from '../../lib/tui.js';
|
|
20
21
|
import type { CollectProgress } from './collector.js';
|
|
@@ -49,6 +50,8 @@ export type UploadSummary = {
|
|
|
49
50
|
resultsUrl?: string;
|
|
50
51
|
opening: boolean;
|
|
51
52
|
requester: string;
|
|
53
|
+
/** The upload's locked free report, for the result card. */
|
|
54
|
+
report?: CheckupReportView;
|
|
52
55
|
};
|
|
53
56
|
export type CheckupTuiOptions = {
|
|
54
57
|
mode: 'text' | 'json';
|
|
@@ -151,10 +154,14 @@ export declare class CheckupTui {
|
|
|
151
154
|
}): Promise<boolean>;
|
|
152
155
|
/** The upload and the scoring, which happen in one request. */
|
|
153
156
|
uploading<T>(bytes: number, work: () => Promise<T>): Promise<T>;
|
|
157
|
+
/** Styles, symbols and width for the result card. */
|
|
158
|
+
private get palette();
|
|
154
159
|
/**
|
|
155
|
-
* `└ ✓ Done. 10 passed · 2 failed`, the
|
|
156
|
-
*
|
|
157
|
-
*
|
|
160
|
+
* `└ ✓ Done. 10 passed · 2 failed`, the result card (a bar of the free
|
|
161
|
+
* checks, the top findings and the premium teaser), the results link
|
|
162
|
+
* (underlined, an OSC 8 link where supported) and the email note.
|
|
163
|
+
* `plainLines` are the plain result lines, also written to stdout when
|
|
164
|
+
* stdout is not a terminal.
|
|
158
165
|
*/
|
|
159
166
|
uploaded(summary: UploadSummary, plainLines: readonly string[]): void;
|
|
160
167
|
/** Dim lines under the end of the timeline (the logout hint), then the command on a line of its own. */
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* ASCII-only console (the Linux console, legacy Windows consoles) gets no
|
|
16
16
|
* character outside ASCII.
|
|
17
17
|
*/
|
|
18
|
+
import { localResultCard } from '../../lib/checkup-report.js';
|
|
18
19
|
import { sanitizeTerminal } from '../../lib/format.js';
|
|
19
20
|
import { Tui, displayWidth, markRendered, terminalFeatures } from '../../lib/tui.js';
|
|
20
21
|
import { LocalCheckupError } from './errors.js';
|
|
@@ -331,10 +332,16 @@ export class CheckupTui {
|
|
|
331
332
|
this.cancelMessage = 'Cancelled.';
|
|
332
333
|
}
|
|
333
334
|
}
|
|
335
|
+
/** Styles, symbols and width for the result card. */
|
|
336
|
+
get palette() {
|
|
337
|
+
return { style: this.tui.style, symbols: this.tui.symbols, color: this.tui.features.color, columns: this.tui.columns };
|
|
338
|
+
}
|
|
334
339
|
/**
|
|
335
|
-
* `└ ✓ Done. 10 passed · 2 failed`, the
|
|
336
|
-
*
|
|
337
|
-
*
|
|
340
|
+
* `└ ✓ Done. 10 passed · 2 failed`, the result card (a bar of the free
|
|
341
|
+
* checks, the top findings and the premium teaser), the results link
|
|
342
|
+
* (underlined, an OSC 8 link where supported) and the email note.
|
|
343
|
+
* `plainLines` are the plain result lines, also written to stdout when
|
|
344
|
+
* stdout is not a terminal.
|
|
338
345
|
*/
|
|
339
346
|
uploaded(summary, plainLines) {
|
|
340
347
|
const { style, symbols } = this.tui;
|
|
@@ -346,6 +353,9 @@ export class CheckupTui {
|
|
|
346
353
|
].filter((part) => part !== null);
|
|
347
354
|
const done = summary.replayed ? 'Already uploaded (same file); nothing changed.' : 'Done.';
|
|
348
355
|
const lines = [`${style.green(symbols.end)} ${style.green(symbols.check)} ${done}${counts.length ? ` ${this.join(counts)}` : ''}`];
|
|
356
|
+
const card = summary.report ? localResultCard(this.palette, summary.report) : [];
|
|
357
|
+
if (card.length)
|
|
358
|
+
lines.push(...card, '');
|
|
349
359
|
const requester = sanitizeTerminal(summary.requester);
|
|
350
360
|
if (summary.resultsUrl !== undefined) {
|
|
351
361
|
const link = style.underline(this.tui.link(summary.resultsUrl));
|