@vigilbase/cli 0.2.5 → 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.
@@ -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
- * Any change to a field read or a projection rule is a new schema version
26
- * (`/2`), and the server then accepts both for at least one CLI minor release.
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: 100,
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.object({ type: z.enum(['A', 'AAAA']), name: hostname.optional(),
320
- content: z.string().min(1).max(64).regex(IP_CONTENT_RE), proxied: z.literal(true).optional() }).strict();
321
- export const RulesetHeadData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional() }).strict();
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: z.string().refine((v) => MANAGED_RULESET_IDS.includes(v)).optional(),
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): only `(A|AAAA) && content` records.
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`, which is printed only for non-proxied records (3292-3297); proxied
1120
- * names are only counted. `content` stays raw here and is pseudonymised by
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, content: record.content };
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
- else if (record.name)
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"; `kind` and `version` are dropped. `name` is read only by
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 (MANAGED_RULESET_IDS.includes(executeId))
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 invalidContent = records.some((record) => !pseudonymFormatOk(record.content));
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(records.map((record) => record.content));
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, r) => ({ ...record, content: pseudonyms.data[r] })) }
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 declare class VerifiedEmail {
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
- ips.add(record.content);
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 (after 2 s, then 5 s) with the same bytes, which
101
- * the server treats idempotently. Challenges are never retried.
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 (after 2 s, then 5 s) with the same bytes, which
341
- * the server treats idempotently. Challenges are never retried.
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
- await this.sleep(POST_RETRY_DELAYS_MS[attempt]);
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 results link (underlined, an OSC 8
156
- * link where supported) and the email note. `plainLines` are the plain
157
- * result lines, also written to stdout when stdout is not a terminal.
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 results link (underlined, an OSC 8
336
- * link where supported) and the email note. `plainLines` are the plain
337
- * result lines, also written to stdout when stdout is not a terminal.
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vigilbase/cli",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.",
5
5
  "keywords": [
6
6
  "vigilbase",