@slicedearth/whoisleuth-cli 2.1.0 → 2.3.0

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.
Files changed (77) hide show
  1. package/README.md +2 -1
  2. package/cli/arguments.mjs +389 -1745
  3. package/cli/artifact-validation/case-response.mjs +55 -30
  4. package/cli/artifact-validation/signed-review.mjs +30 -12
  5. package/cli/bulk-checkpoint.mjs +34 -3
  6. package/cli/case-pack.mjs +39 -92
  7. package/cli/command-argument-grammar.mjs +211 -0
  8. package/cli/command-reference.mjs +200 -213
  9. package/cli/completion.mjs +5 -1
  10. package/cli/discovery-observation-snapshot.mjs +16 -3
  11. package/cli/discovery-scan-command-runner.mjs +19 -0
  12. package/cli/discovery-scan.mjs +6 -0
  13. package/cli/domain-control-observations.mjs +1 -1
  14. package/cli/inline-command-families.mjs +1 -0
  15. package/cli/input.mjs +116 -0
  16. package/cli/interchange-report.mjs +14 -0
  17. package/cli/lookalike-calibration.mjs +7 -2
  18. package/cli/lookup-brief.mjs +4 -2
  19. package/cli/lookup-browser-view.mjs +1 -1
  20. package/cli/mail-header-review.mjs +467 -0
  21. package/cli/review-command-runner.mjs +25 -0
  22. package/cli/risk-calibration.mjs +19 -17
  23. package/cli/runner.mjs +7 -1
  24. package/docs/cli-reference.md +8 -1
  25. package/docs/cli.md +24 -1
  26. package/lib/bounded-json.mjs +9 -6
  27. package/lib/dns-convergence-review.mjs +1 -0
  28. package/lib/dns-intelligence.mjs +37 -3
  29. package/lib/dnssec-chain-validation.mjs +6 -0
  30. package/lib/domain-change-packet.mjs +42 -14
  31. package/lib/domain-control-flight-recorder.mjs +4 -1
  32. package/lib/domain-control-manifest.mjs +16 -16
  33. package/lib/domain-posture-parsers.mjs +114 -3
  34. package/lib/domain-posture.mjs +34 -3
  35. package/lib/evidence-export.mjs +63 -13
  36. package/lib/evidence-report.mjs +2 -2
  37. package/lib/html-signals.mjs +39 -33
  38. package/lib/local-geoip-evidence.mjs +19 -3
  39. package/lib/lookup-response.mjs +5 -4
  40. package/lib/lookup-source-progress.mjs +27 -4
  41. package/lib/nameserver-preflight-review.mjs +1 -0
  42. package/lib/rdap-capabilities.mjs +1 -1
  43. package/lib/registry-insights.mjs +35 -22
  44. package/lib/risk-calibration-summary.mjs +1 -3
  45. package/lib/security-txt.mjs +33 -1
  46. package/lib/static-html-analysis.mjs +69 -13
  47. package/lib/website-technology.mjs +79 -11
  48. package/lib/zone-intent-review.mjs +16 -6
  49. package/package.json +1 -1
  50. package/packages/cases/case-migration-model.mjs +71 -22
  51. package/packages/cases/case-record-contracts.mjs +5 -20
  52. package/packages/cases/case-record-core.mjs +10 -21
  53. package/packages/cases/case-record-decisions.mjs +72 -0
  54. package/packages/cases/case-record-model.mjs +1 -0
  55. package/packages/cases/case-record-operations.mjs +240 -3
  56. package/packages/cases/case-record-projection.mjs +218 -0
  57. package/packages/cases/case-report.mjs +11 -6
  58. package/packages/cases/case-response-model.mjs +43 -6
  59. package/packages/cases/case-response-packet.mjs +78 -27
  60. package/packages/cases/case-storage-model.mjs +6 -2
  61. package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
  62. package/packages/comparison/page-similarity.mjs +3 -1
  63. package/packages/contracts/case-portability.mjs +183 -53
  64. package/packages/contracts/cli-command-semantics.mjs +93 -0
  65. package/packages/contracts/risk-calibration.mjs +15 -10
  66. package/packages/contracts/workspace-portability.mjs +44 -7
  67. package/packages/evidence/domain-control-runtime.mjs +46 -6
  68. package/packages/interchange/external-findings-import.mjs +115 -46
  69. package/packages/investigation/lookup-asset-graph.mjs +35 -27
  70. package/packages/workspace/brand-profile-model.mjs +118 -2
  71. package/packages/workspace/bulk-review-model.mjs +13 -7
  72. package/packages/workspace/bulk-session-model.mjs +5 -4
  73. package/packages/workspace/detection-rule-model.mjs +70 -22
  74. package/packages/workspace/relationship-observation-model.mjs +4 -1
  75. package/packages/workspace/website-snapshot-model.mjs +106 -8
  76. package/packages/workspace/workspace-archive.mjs +2 -0
  77. package/cli/evidence-command-arguments.mjs +0 -156
@@ -130,13 +130,16 @@ function hostname(value) {
130
130
  return null;
131
131
  return candidate;
132
132
  }
133
- function urlHost(value) {
133
+ function webOrigin(value) {
134
134
  const candidate = text(value, 2_048);
135
135
  if (!candidate)
136
136
  return null;
137
137
  try {
138
- return new URL(/^[a-z][a-z\d+.-]*:\/\//iu.test(candidate) ? candidate : `https://${candidate}`)
139
- .hostname.toLowerCase().replace(/\.$/u, '');
138
+ const parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//iu.test(candidate) ? candidate : `https://${candidate}`);
139
+ const parsedHostname = hostname(parsed.hostname);
140
+ return parsedHostname && parsed.origin !== 'null'
141
+ ? Object.freeze({ origin: parsed.origin, hostname: parsedHostname })
142
+ : null;
140
143
  }
141
144
  catch {
142
145
  return null;
@@ -233,12 +236,12 @@ export function buildLookupAssetGraph(input) {
233
236
  ...textList(input.profileDomains?.partner, 32),
234
237
  ...textList(input.profileDomains?.allowlisted, 32),
235
238
  ].map((value) => value.toLowerCase().replace(/\.$/u, '')));
236
- const trustBoundary = (candidate, sourceHost = target) => {
239
+ const trustBoundary = (candidate, sourceHost = target, candidateOrigin = null, sourceOrigin = null) => {
237
240
  if (!candidate)
238
241
  return 'unresolved';
239
- if (candidate === sourceHost)
242
+ if (candidateOrigin && sourceOrigin && candidateOrigin === sourceOrigin)
240
243
  return 'same_origin';
241
- if (candidate === target || candidate.endsWith(`.${target}`))
244
+ if (candidate === sourceHost || candidate === target || candidate.endsWith(`.${target}`))
242
245
  return 'same_registrable_domain';
243
246
  if (reviewedProfileDomains.has(candidate))
244
247
  return 'reviewed_profile';
@@ -414,7 +417,8 @@ export function buildLookupAssetGraph(input) {
414
417
  }
415
418
  }
416
419
  const httpEvidence = record(input.httpEvidence);
417
- const finalHost = urlHost(httpEvidence.finalUrl);
420
+ const finalOrigin = webOrigin(httpEvidence.finalUrl);
421
+ const finalHost = finalOrigin?.hostname ?? null;
418
422
  const finalHostId = finalHost
419
423
  ? connect('hostname', finalHost, {
420
424
  kind: 'redirects-to',
@@ -429,9 +433,9 @@ export function buildLookupAssetGraph(input) {
429
433
  }, 'Observed final website origin')
430
434
  : null;
431
435
  const identitySource = finalHostId || targetId;
432
- const canonicalHost = urlHost(record(input.pageCanonical).url);
433
- if (canonicalHost) {
434
- connect('origin', canonicalHost, {
436
+ const canonicalOrigin = webOrigin(record(input.pageCanonical).url);
437
+ if (canonicalOrigin) {
438
+ connect('origin', canonicalOrigin.origin, {
435
439
  kind: 'declares-canonical',
436
440
  label: 'declares canonical origin',
437
441
  sourceLabel: 'HTML canonical metadata',
@@ -440,12 +444,12 @@ export function buildLookupAssetGraph(input) {
440
444
  limitations: limitations(record(input.pageIdentity).limitations),
441
445
  lenses: ['identity'],
442
446
  href: '#evidence-page',
443
- boundary: trustBoundary(canonicalHost, finalHost || target),
447
+ boundary: trustBoundary(canonicalOrigin.hostname, finalHost || target, canonicalOrigin.origin, finalOrigin?.origin ?? null),
444
448
  }, 'Publisher-declared canonical origin', identitySource);
445
449
  }
446
- const openGraphHost = urlHost(record(input.pageOpenGraphUrl).url);
447
- if (openGraphHost) {
448
- connect('origin', openGraphHost, {
450
+ const openGraphOrigin = webOrigin(record(input.pageOpenGraphUrl).url);
451
+ if (openGraphOrigin) {
452
+ connect('origin', openGraphOrigin.origin, {
449
453
  kind: 'declares-open-graph',
450
454
  label: 'declares Open Graph origin',
451
455
  sourceLabel: 'Open Graph metadata',
@@ -454,13 +458,13 @@ export function buildLookupAssetGraph(input) {
454
458
  limitations: limitations(record(input.pageIdentity).limitations),
455
459
  lenses: ['identity'],
456
460
  href: '#evidence-page',
457
- boundary: trustBoundary(openGraphHost, finalHost || target),
461
+ boundary: trustBoundary(openGraphOrigin.hostname, finalHost || target, openGraphOrigin.origin, finalOrigin?.origin ?? null),
458
462
  }, 'Publisher-declared Open Graph origin', identitySource);
459
463
  }
460
464
  for (const origin of textList(record(input.pageForms).externalActionOrigins)) {
461
- const host = urlHost(origin);
462
- if (host) {
463
- connect('origin', host, {
465
+ const destination = webOrigin(origin);
466
+ if (destination) {
467
+ connect('origin', destination.origin, {
464
468
  kind: 'form-destination',
465
469
  label: 'form may submit to',
466
470
  sourceLabel: 'Static HTML form',
@@ -469,14 +473,14 @@ export function buildLookupAssetGraph(input) {
469
473
  limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
470
474
  lenses: ['identity'],
471
475
  href: '#evidence-page',
472
- boundary: trustBoundary(host, finalHost || target),
476
+ boundary: trustBoundary(destination.hostname, finalHost || target, destination.origin, finalOrigin?.origin ?? null),
473
477
  }, 'External form-action origin', identitySource);
474
478
  }
475
479
  }
476
480
  for (const origin of textList(record(input.pageResources).externalOrigins)) {
477
- const host = urlHost(origin);
478
- if (host) {
479
- connect('hostname', host, {
481
+ const resource = webOrigin(origin);
482
+ if (resource) {
483
+ connect('origin', resource.origin, {
480
484
  kind: 'loads-from',
481
485
  label: 'references resources from',
482
486
  sourceLabel: 'Static HTML resources',
@@ -485,7 +489,7 @@ export function buildLookupAssetGraph(input) {
485
489
  limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
486
490
  lenses: ['identity'],
487
491
  href: '#evidence-page',
488
- boundary: trustBoundary(host, finalHost || target),
492
+ boundary: trustBoundary(resource.hostname, finalHost || target, resource.origin, finalOrigin?.origin ?? null),
489
493
  }, 'External resource origin', identitySource);
490
494
  }
491
495
  }
@@ -507,7 +511,7 @@ export function buildLookupAssetGraph(input) {
507
511
  const structuredIdentity = record(input.structuredDataIdentity);
508
512
  for (const entity of records(structuredIdentity.entities, 12)) {
509
513
  const entityLabel = text(entity.name, 180);
510
- const declaredOrigin = urlHost(entity.declaredOrigin);
514
+ const declaredOrigin = webOrigin(entity.declaredOrigin);
511
515
  const entityId = entityLabel
512
516
  ? connect('identity', entityLabel, {
513
517
  kind: 'declares-publisher',
@@ -524,7 +528,7 @@ export function buildLookupAssetGraph(input) {
524
528
  }, textList(entity.types, 5).join(', ') || 'Publisher-declared entity', identitySource)
525
529
  : identitySource;
526
530
  if (declaredOrigin) {
527
- connect('origin', declaredOrigin, {
531
+ connect('origin', declaredOrigin.origin, {
528
532
  kind: 'declares-origin',
529
533
  label: 'declares origin',
530
534
  sourceLabel: 'Structured identity metadata',
@@ -533,7 +537,7 @@ export function buildLookupAssetGraph(input) {
533
537
  limitations: limitations(structuredIdentity.limitations),
534
538
  lenses: ['identity'],
535
539
  href: '#evidence-structured-identity',
536
- boundary: trustBoundary(declaredOrigin, finalHost || target),
540
+ boundary: trustBoundary(declaredOrigin.hostname, finalHost || target, declaredOrigin.origin, finalOrigin?.origin ?? null),
537
541
  }, 'Publisher-declared structured-data origin', entityId || identitySource);
538
542
  }
539
543
  for (const sameAsHost of textList(entity.sameAsHosts, 12)) {
@@ -666,7 +670,11 @@ export function buildLookupAssetGraph(input) {
666
670
  }
667
671
  }
668
672
  const issuer = record(input.tlsIssuer);
669
- const issuerLabel = text(issuer.organization || issuer.commonName || issuer.CN, 180);
673
+ const issuerOrganizations = textList(issuer.organizations, 8);
674
+ const issuerCommonNames = textList(issuer.commonNames, 8);
675
+ const issuerLabel = issuerOrganizations[0]
676
+ || issuerCommonNames[0]
677
+ || text(issuer.organization || issuer.commonName || issuer.CN, 180);
670
678
  if (issuerLabel) {
671
679
  const issuerId = addNode('issuer', issuerLabel, 'Certificate issuer');
672
680
  if (issuerId) {
@@ -7,8 +7,8 @@ import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
7
7
  import { normalizePageBaseline } from "./page-baseline.mjs";
8
8
  import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
9
9
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
10
- import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
11
- export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
10
+ import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
11
+ export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
12
12
  const SHA256_RE = /^[a-f0-9]{64}$/i;
13
13
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
14
14
  const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -42,6 +42,17 @@ export const DESIRED_POSTURE_SUPPRESSION_FIELDS = Object.freeze([
42
42
  ]);
43
43
  const DESIRED_POSTURE_FIELDS = new Set(DESIRED_POSTURE_SUPPRESSION_FIELDS);
44
44
  const POSTURE_CHECK_STATUSES = new Set(['danger', 'info', 'pass', 'warning']);
45
+ export const OFFICIAL_CHANNEL_PLATFORMS = [
46
+ 'facebook',
47
+ 'instagram',
48
+ 'linkedin',
49
+ 'telegram',
50
+ 'tiktok',
51
+ 'x',
52
+ 'youtube',
53
+ 'other',
54
+ ];
55
+ export const RIGHTS_REFERENCE_KINDS = ['trademark', 'copyright', 'design', 'other'];
45
56
  function record(value) {
46
57
  return ordinaryWorkspaceRecord(value, 'Brand Profile input') ?? {};
47
58
  }
@@ -333,6 +344,82 @@ function normalizeFaviconHash(value) {
333
344
  function normalizeFaviconPHash(value) {
334
345
  return typeof value === 'string' && isInformativeFaviconHash(value) ? value.toLowerCase() : '';
335
346
  }
347
+ function normalizePublicUrl(value) {
348
+ if (typeof value !== 'string' || CONTROL_RE.test(value) || value.length > MAX_PROFILE_URL_LENGTH * 2)
349
+ return '';
350
+ try {
351
+ const parsed = new URL(value.trim());
352
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
353
+ return '';
354
+ const normalized = parsed.toString();
355
+ return normalized.length <= MAX_PROFILE_URL_LENGTH ? normalized : '';
356
+ }
357
+ catch {
358
+ return '';
359
+ }
360
+ }
361
+ function normalizeOfficialChannels(value) {
362
+ if (!Array.isArray(value))
363
+ return [];
364
+ const output = new Map();
365
+ for (const candidate of value.slice(0, MAX_OFFICIAL_CHANNELS * 4)) {
366
+ const item = record(candidate);
367
+ const platform = typeof item.platform === 'string'
368
+ && OFFICIAL_CHANNEL_PLATFORMS.includes(item.platform)
369
+ ? item.platform
370
+ : null;
371
+ const url = normalizePublicUrl(item.url);
372
+ if (!platform || !url)
373
+ continue;
374
+ const key = url.toLowerCase();
375
+ if (output.has(key))
376
+ continue;
377
+ output.set(key, {
378
+ platform,
379
+ url,
380
+ handle: boundedText(item.handle),
381
+ role: boundedText(item.role),
382
+ reviewedAt: timestamp(item.reviewedAt, null),
383
+ });
384
+ if (output.size >= MAX_OFFICIAL_CHANNELS)
385
+ break;
386
+ }
387
+ return [...output.values()];
388
+ }
389
+ function normalizeRightsReferences(value) {
390
+ if (!Array.isArray(value))
391
+ return [];
392
+ const output = new Map();
393
+ for (const candidate of value.slice(0, MAX_RIGHTS_REFERENCES * 4)) {
394
+ const item = record(candidate);
395
+ const kind = typeof item.kind === 'string' && RIGHTS_REFERENCE_KINDS.includes(item.kind)
396
+ ? item.kind
397
+ : null;
398
+ const owner = boundedText(item.owner);
399
+ const identifier = boundedText(item.identifier);
400
+ if (!kind || (!owner && !identifier))
401
+ continue;
402
+ const jurisdiction = boundedText(item.jurisdiction);
403
+ const sourceUrl = item.sourceUrl == null || item.sourceUrl === '' ? '' : normalizePublicUrl(item.sourceUrl);
404
+ if (item.sourceUrl && !sourceUrl)
405
+ continue;
406
+ const key = `${kind}\u0000${owner.toLowerCase()}\u0000${identifier.toLowerCase()}\u0000${jurisdiction.toLowerCase()}`;
407
+ if (output.has(key))
408
+ continue;
409
+ output.set(key, {
410
+ kind,
411
+ owner,
412
+ identifier,
413
+ jurisdiction,
414
+ sourceUrl,
415
+ reviewedAt: timestamp(item.reviewedAt, null),
416
+ note: boundedText(item.note),
417
+ });
418
+ if (output.size >= MAX_RIGHTS_REFERENCES)
419
+ break;
420
+ }
421
+ return [...output.values()];
422
+ }
336
423
  /** Normalize one profile while retaining only known, bounded fields. */
337
424
  export function normalizeBrandProfile(raw, options = {}) {
338
425
  const value = record(raw);
@@ -360,6 +447,7 @@ export function normalizeBrandProfile(raw, options = {}) {
360
447
  id: profileId,
361
448
  name,
362
449
  officialDomains,
450
+ officialChannels: normalizeOfficialChannels(value.officialChannels),
363
451
  productNames: normalizeProfileTextValues(value.productNames),
364
452
  tlds: normalizeProfileTlds(value.tlds),
365
453
  approvedPartnerDomains: normalizeProfileDomains(value.approvedPartnerDomains),
@@ -373,6 +461,7 @@ export function normalizeBrandProfile(raw, options = {}) {
373
461
  desiredPostureBaselines: normalizeDesiredPostureBaselines(value.desiredPostureBaselines, officialDomains, now, profileId),
374
462
  trademarkOwner: boundedText(value.trademarkOwner),
375
463
  trademarkRegistration: boundedText(value.trademarkRegistration),
464
+ rightsReferences: normalizeRightsReferences(value.rightsReferences),
376
465
  officialFaviconHash: baselineDomainMismatch ? '' : normalizeFaviconHash(value.officialFaviconHash),
377
466
  officialFaviconPHash: baselineDomainMismatch ? '' : normalizeFaviconPHash(value.officialFaviconPHash),
378
467
  pageBaseline,
@@ -380,6 +469,33 @@ export function normalizeBrandProfile(raw, options = {}) {
380
469
  updatedAt: options.touch === true ? now : timestamp(value.updatedAt, createdAt),
381
470
  };
382
471
  }
472
+ const BRAND_PROFILE_FIELD_PATCH_KEYS = Object.freeze([
473
+ 'allowlistedDomains',
474
+ 'allowlistedRegistrars',
475
+ 'desiredPostureBaselines',
476
+ 'protectionAttestations',
477
+ ]);
478
+ /**
479
+ * Applies one field-owned edit to the transaction-current profile. Identity,
480
+ * timestamps, and every field outside the explicit patch remain owned by that
481
+ * current record rather than by the caller's potentially stale snapshot.
482
+ */
483
+ export function applyBrandProfileFieldPatch(existing, patch, options = {}) {
484
+ const merged = { ...existing };
485
+ const supplied = patch;
486
+ for (const key of BRAND_PROFILE_FIELD_PATCH_KEYS) {
487
+ if (Object.hasOwn(supplied, key))
488
+ merged[key] = supplied[key];
489
+ }
490
+ const normalized = normalizeBrandProfile(merged, {
491
+ existing,
492
+ nowIso: options.nowIso,
493
+ touch: true,
494
+ });
495
+ if (!normalized)
496
+ throw new Error('The Brand Profile field update is invalid.');
497
+ return normalized;
498
+ }
383
499
  function profileList(raw) {
384
500
  if (Array.isArray(raw))
385
501
  return raw;
@@ -1,21 +1,27 @@
1
1
  import { normalizeDomain } from "../cases/case-model.mjs";
2
+ import { CASE_DISPOSITIONS as CASE_DISPOSITION_OPTIONS } from "../cases/case-record-contracts.mjs";
2
3
  import { normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
3
4
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
5
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
5
6
  import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
6
7
  export { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
7
8
  export const BULK_REVIEW_STATES = ['unreviewed', 'reviewing', 'reviewed', 'deferred'];
9
+ export const BULK_SOURCE_FILTERS = ['', 'complete', 'limited', 'unrecorded'];
10
+ export const BULK_LIFECYCLE_FILTERS = ['', 'registered', 'for_sale', 'expiring', 'available', 'unknown', 'error'];
11
+ export const BULK_AGE_FILTERS = ['', 'new_30', 'new_365', 'older_365', 'unknown'];
12
+ export const BULK_MAIL_FILTERS = ['', 'mail', 'no_mail', 'authenticated', 'auth_gap', 'unknown'];
13
+ export const BULK_GROUP_OPTIONS = ['', 'mutation', 'tld', 'registrar', 'nameserver'];
8
14
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
9
15
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
10
16
  const PRIMARY_FILTERS = new Set(['all', 'available', 'registered', 'high_risk', 'trusted', 'profile_unevaluated', 'errors']);
11
- const SOURCE_FILTERS = new Set(['', 'complete', 'limited', 'failed']);
12
- const LIFECYCLE_FILTERS = new Set(['', 'new', 'expiring', 'aged', 'unknown']);
13
- const AGE_FILTERS = new Set(['', '7d', '30d', '90d', '365d', 'older', 'unknown']);
14
- const MAIL_FILTERS = new Set(['', 'mx', 'no_mx', 'spf_missing', 'dmarc_missing', 'unknown']);
15
- const GROUPS = new Set(['', 'registrar', 'nameserver', 'source_state', 'lifecycle', 'mail']);
16
- const SORT_KEYS = new Set(['domain', 'availability', 'risk', 'opportunity', 'activity', 'registrar', 'mutation']);
17
+ const SOURCE_FILTERS = new Set(BULK_SOURCE_FILTERS);
18
+ const LIFECYCLE_FILTERS = new Set(BULK_LIFECYCLE_FILTERS);
19
+ const AGE_FILTERS = new Set(BULK_AGE_FILTERS);
20
+ const MAIL_FILTERS = new Set(BULK_MAIL_FILTERS);
21
+ const GROUPS = new Set(BULK_GROUP_OPTIONS);
22
+ const SORT_KEYS = new Set(['domain', 'availability', 'confidence', 'risk', 'opportunity', 'activity', 'registrar', 'mutation']);
17
23
  const REVIEW_STATES = new Set(BULK_REVIEW_STATES);
18
- const CASE_DISPOSITIONS = new Set(['', 'untracked', 'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action']);
24
+ const CASE_DISPOSITIONS = new Set(['', 'untracked', ...CASE_DISPOSITION_OPTIONS.map((item) => item.value)]);
19
25
  const SIGNAL_FILTERS = new Set(['favicon', 'password', 'phishing', 'asset_reuse', 'idn']);
20
26
  function record(value) {
21
27
  return ordinaryWorkspaceRecord(value, 'Bulk-review input') ?? {};
@@ -488,10 +488,11 @@ export function deleteBulkSession(raw, idRaw) {
488
488
  function sourceStateMap(value) {
489
489
  return new Map(value.sourceCoverage.map((item) => [item.source, item.state]));
490
490
  }
491
- function comparableRisk(previous, current) {
491
+ export function areBulkRiskScoresComparable(previous, current) {
492
492
  return previous.profileContext.sourceState === 'ready'
493
493
  && current.profileContext.sourceState === 'ready'
494
494
  && sameProfileContext(previous.profileContext, current.profileContext)
495
+ && previous.scanDepth === current.scanDepth
495
496
  && previous.risk !== null
496
497
  && current.risk !== null
497
498
  && previous.riskModelVersion !== null
@@ -501,7 +502,7 @@ function resultChanges(previous, current) {
501
502
  const changes = [];
502
503
  if (previous.availability !== current.availability)
503
504
  changes.push(`Registration: ${previous.availability} → ${current.availability}`);
504
- if (comparableRisk(previous, current) && previous.risk !== current.risk) {
505
+ if (areBulkRiskScoresComparable(previous, current) && previous.risk !== current.risk) {
505
506
  changes.push(`Risk: ${previous.risk ?? 'unavailable'} → ${current.risk ?? 'unavailable'}`);
506
507
  }
507
508
  if (previous.registrar !== current.registrar)
@@ -554,7 +555,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
554
555
  rows.push({ domain, changes: ['Absent from the later settled result set.'] });
555
556
  }
556
557
  else {
557
- if (!comparableRisk(previous, next))
558
+ if (!areBulkRiskScoresComparable(previous, next))
558
559
  riskIncomparable = true;
559
560
  const changes = resultChanges(previous, next);
560
561
  if (changes.length)
@@ -576,7 +577,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
576
577
  'A source-state change may reflect collection availability rather than a change to the domain.',
577
578
  'A missing row means it was not completed in that saved session; it does not establish domain removal.',
578
579
  ...(riskIncomparable
579
- ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance and the same versioned Risk model.']
580
+ ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, and versioned Risk model.']
580
581
  : []),
581
582
  ],
582
583
  };
@@ -2,6 +2,7 @@
2
2
  // small structured language: field names and operators come from allowlists,
3
3
  // so imported rules cannot execute code or reach outside bounded case evidence.
4
4
  import { latestCaseEvidence } from "../cases/case-model.mjs";
5
+ import { CASE_DISPOSITIONS, CASE_STATUSES } from "../cases/case-record-contracts.mjs";
5
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
6
7
  import { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
7
8
  export { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_IMPORT_BYTES, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
@@ -26,8 +27,8 @@ export const RULE_FIELD_DEFINITIONS = Object.freeze([
26
27
  { value: 'mutationTypes', label: 'Mutation type', kind: 'list' },
27
28
  { value: 'nameservers', label: 'Nameserver', kind: 'list' },
28
29
  { value: 'httpSecurityHeaders', label: 'HTTP security header', kind: 'list' },
29
- { value: 'status', label: 'Case status', kind: 'enum', values: ['new', 'investigating', 'monitoring', 'escalated', 'closed'] },
30
- { value: 'disposition', label: 'Case disposition', kind: 'enum', values: ['unreviewed', 'benign', 'suspicious', 'confirmed_abuse', 'false_positive'] },
30
+ { value: 'status', label: 'Case status', kind: 'enum', values: CASE_STATUSES.map((item) => item.value) },
31
+ { value: 'disposition', label: 'Case disposition', kind: 'enum', values: CASE_DISPOSITIONS.map((item) => item.value) },
31
32
  { value: 'tags', label: 'Case tag', kind: 'list' },
32
33
  ]);
33
34
  const FIELD_BY_VALUE = new Map(RULE_FIELD_DEFINITIONS.map((field) => [field.value, field]));
@@ -103,13 +104,14 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
103
104
  return null;
104
105
  const conditions = [];
105
106
  const rawConditions = Array.isArray(item.conditions) ? item.conditions : [];
106
- for (const item of rawConditions.slice(0, MAX_RULE_CONDITIONS)) {
107
- const condition = normalizeRuleCondition(item);
108
- if (condition)
109
- conditions.push(condition);
110
- }
111
- if (!conditions.length)
107
+ if (!rawConditions.length || rawConditions.length > MAX_RULE_CONDITIONS)
112
108
  return null;
109
+ for (const rawCondition of rawConditions) {
110
+ const condition = normalizeRuleCondition(rawCondition);
111
+ if (!condition)
112
+ return null;
113
+ conditions.push(condition);
114
+ }
113
115
  const riskDelta = normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA);
114
116
  return {
115
117
  id: safeId(item.id) || (generateId ? makeId() : null),
@@ -121,6 +123,30 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
121
123
  tag: normalizedText(item.tag, MAX_RULE_TAG_LENGTH).toLowerCase(),
122
124
  };
123
125
  }
126
+ function validateDetectionRule(raw, { generateId = false } = {}) {
127
+ const item = record(raw);
128
+ if (!Array.isArray(item.conditions) || !item.conditions.length) {
129
+ throw new Error('A custom rule needs at least one condition.');
130
+ }
131
+ if (item.conditions.length > MAX_RULE_CONDITIONS) {
132
+ throw new Error(`Custom rules are limited to ${MAX_RULE_CONDITIONS} conditions.`);
133
+ }
134
+ for (const [index, condition] of item.conditions.entries()) {
135
+ if (!normalizeRuleCondition(condition)) {
136
+ throw new Error(`Condition ${index + 1} is invalid or outside the allowed bounds.`);
137
+ }
138
+ }
139
+ if (item.match !== undefined && item.match !== 'all' && item.match !== 'any') {
140
+ throw new Error('Choose whether all or any conditions must match.');
141
+ }
142
+ if (item.riskDelta !== undefined && normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA) === null) {
143
+ throw new Error(`Custom contribution must be a whole number from 0 to ${MAX_RULE_RISK_DELTA}.`);
144
+ }
145
+ const normalized = normalizeDetectionRule(item, { generateId });
146
+ if (!normalized)
147
+ throw new Error('Enter a rule name and valid conditions.');
148
+ return normalized;
149
+ }
124
150
  function ruleList(raw) {
125
151
  if (Array.isArray(raw))
126
152
  return raw;
@@ -133,23 +159,32 @@ export function detectionRuleStoreVersion(raw) {
133
159
  const item = record(raw);
134
160
  return typeof item.version === 'number' && Number.isFinite(item.version) ? item.version : null;
135
161
  }
136
- export function normalizeDetectionRuleStore(raw) {
162
+ export function recoverDetectionRuleStore(raw) {
137
163
  assertWorkspaceInputGraph(raw, 'Detection-rule store');
138
164
  assertWorkspaceDeclaredVersion(raw, 'Detection-rule store');
139
165
  const byId = new Map();
140
- for (const item of ruleList(raw).slice(0, MAX_RULE_INPUT_RECORDS)) {
166
+ const source = ruleList(raw);
167
+ let rejected = Math.max(0, source.length - MAX_RULE_INPUT_RECORDS);
168
+ for (const item of source.slice(0, MAX_RULE_INPUT_RECORDS)) {
141
169
  const rule = normalizeDetectionRule(item);
142
- if (!rule?.id || byId.has(rule.id))
170
+ if (!rule?.id || byId.has(rule.id)) {
171
+ rejected += 1;
143
172
  continue;
173
+ }
174
+ if (byId.size >= MAX_DETECTION_RULES) {
175
+ rejected += 1;
176
+ continue;
177
+ }
144
178
  byId.set(rule.id, { ...rule, id: rule.id });
145
- if (byId.size >= MAX_DETECTION_RULES)
146
- break;
147
179
  }
148
- return { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] };
180
+ return { store: { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] }, rejected };
181
+ }
182
+ export function normalizeDetectionRuleStore(raw) {
183
+ return recoverDetectionRuleStore(raw).store;
149
184
  }
150
185
  export function createDetectionRule(rules, input) {
151
- const normalized = normalizeDetectionRule(input, { generateId: true });
152
- if (!normalized?.id)
186
+ const normalized = validateDetectionRule(input, { generateId: true });
187
+ if (!normalized.id)
153
188
  throw new Error('Enter a rule name and one valid condition.');
154
189
  const existing = normalizeDetectionRuleStore(rules).rules;
155
190
  if (existing.length >= MAX_DETECTION_RULES)
@@ -162,8 +197,8 @@ export function updateDetectionRule(rules, id, patch) {
162
197
  const current = normalizedRules.find((rule) => rule.id === id);
163
198
  if (!current)
164
199
  throw new Error('That custom rule no longer exists.');
165
- const updated = normalizeDetectionRule({ ...current, ...record(patch), id });
166
- if (!updated?.id)
200
+ const updated = validateDetectionRule({ ...current, ...record(patch), id });
201
+ if (!updated.id)
167
202
  throw new Error('A custom rule needs a name and at least one valid condition.');
168
203
  return normalizeDetectionRuleStore(normalizedRules.map((rule) => rule.id === id ? updated : rule)).rules;
169
204
  }
@@ -245,8 +280,14 @@ function ruleSignature(rule) {
245
280
  });
246
281
  }
247
282
  export function previewDetectionRule(records, existingRules, rawCandidate) {
248
- const normalized = normalizeDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
249
- if (!normalized?.id)
283
+ let normalized;
284
+ try {
285
+ normalized = validateDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
286
+ }
287
+ catch {
288
+ return null;
289
+ }
290
+ if (!normalized.id)
250
291
  return null;
251
292
  const candidate = { ...normalized, id: normalized.id };
252
293
  const sourceRecords = Array.isArray(records) ? records.slice(0, 500) : [];
@@ -295,8 +336,15 @@ export function mergeDetectionRules(localRaw, importedRaw) {
295
336
  let updated = 0;
296
337
  let skipped = Math.max(0, importedList.length - MAX_RULE_INPUT_RECORDS);
297
338
  for (const item of importedList.slice(0, MAX_RULE_INPUT_RECORDS)) {
298
- const rule = normalizeDetectionRule(item);
299
- if (!rule?.id) {
339
+ let rule;
340
+ try {
341
+ rule = validateDetectionRule(item);
342
+ }
343
+ catch {
344
+ skipped++;
345
+ continue;
346
+ }
347
+ if (!rule.id) {
300
348
  skipped++;
301
349
  continue;
302
350
  }
@@ -283,8 +283,11 @@ export function createRelationshipObservation(raw, options = {}) {
283
283
  throw new Error('That relationship does not contain a supported bounded value and at least one valid domain.');
284
284
  }
285
285
  const now = new Date().toISOString();
286
- const observedAt = timestamp(options.observedAt) || now;
286
+ const observedAt = Object.hasOwn(options, 'observedAt') ? timestamp(options.observedAt) : now;
287
287
  const retainedAt = timestamp(options.retainedAt) || now;
288
+ if (!observedAt) {
289
+ throw new Error('A relationship observation needs the contributing scan observation time; an unknown time is not replaced with the retention time.');
290
+ }
288
291
  const canonical = canonicalIdentity(type, normalizedValue, domains);
289
292
  const metadata = TYPE_METADATA[type];
290
293
  return {