@slicedearth/whoisleuth-cli 2.2.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 +12 -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 +60 -11
  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 +6 -4
  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 +171 -51
  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 +3 -2
  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
@@ -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 {
@@ -296,14 +296,89 @@ export function mergeWebsiteSnapshots(localRaw, incomingRaw) {
296
296
  const snapshots = normalizeWebsiteSnapshotStore([...byId.values()]).snapshots;
297
297
  return { snapshots, added, updated, skipped: Math.max(0, incoming.length - added - updated) };
298
298
  }
299
- function compareMap(field, before, after) {
299
+ const MAX_RECONCILED_WEBSITE_SOURCES = 16;
300
+ const UNAVAILABLE_WEBSITE_SOURCE_STATES = new Set([
301
+ 'blocked', 'error', 'not_found', 'rate_limited', 'unavailable', 'unsupported',
302
+ ]);
303
+ export function canonicalWebsiteSourceState(value) {
304
+ return value.trim().toLowerCase().replace(/[\s-]+/gu, '_');
305
+ }
306
+ function websiteSourceStateSeverity(value) {
307
+ if (UNAVAILABLE_WEBSITE_SOURCE_STATES.has(value))
308
+ return 2;
309
+ return ['complete', 'success'].includes(value) ? 0 : 1;
310
+ }
311
+ export function reconciledWebsiteSources(snapshot) {
312
+ const selected = new Map();
313
+ for (const sourceEntry of snapshot.sources.slice(0, MAX_RECONCILED_WEBSITE_SOURCES)) {
314
+ const sourceId = sourceEntry.source.trim().toLowerCase();
315
+ if (!sourceId)
316
+ continue;
317
+ const state = canonicalWebsiteSourceState(sourceEntry.state);
318
+ const severity = websiteSourceStateSeverity(state);
319
+ const current = selected.get(sourceId);
320
+ if (!current || severity > current.severity || (severity === current.severity && state.localeCompare(current.state) < 0)) {
321
+ selected.set(sourceId, { state, severity });
322
+ }
323
+ }
324
+ return [...selected.entries()]
325
+ .sort(([left], [right]) => left.localeCompare(right))
326
+ .map(([sourceName, value]) => ({ source: sourceName, state: value.state }));
327
+ }
328
+ export function websiteWithReconciledSources(snapshot) {
329
+ return { ...snapshot, sources: reconciledWebsiteSources(snapshot) };
330
+ }
331
+ export function reconciledWebsiteSourceState(snapshot, sourceId) {
332
+ const canonicalId = sourceId.trim().toLowerCase();
333
+ return reconciledWebsiteSources(snapshot)
334
+ .find((sourceEntry) => sourceEntry.source === canonicalId)?.state ?? null;
335
+ }
336
+ function completeWebsiteSourceState(value) {
337
+ const state = canonicalWebsiteSourceState(value);
338
+ return state === 'complete' || state === 'success';
339
+ }
340
+ export function websiteSnapshotFieldSource(field) {
341
+ if (field.startsWith('certificate.'))
342
+ return 'tls';
343
+ if (field.startsWith('source.'))
344
+ return field.slice('source.'.length);
345
+ if (field.startsWith('dependency.')) {
346
+ const recordType = field.slice('dependency.'.length).split(':')[0];
347
+ return recordType === 'HTTP' ? 'http' : 'dns';
348
+ }
349
+ if (['technology', 'posture', 'identity'].some((family) => field.startsWith(family)))
350
+ return 'http';
351
+ return '';
352
+ }
353
+ export function websiteSnapshotFieldComplete(snapshot, field) {
354
+ const sourceName = websiteSnapshotFieldSource(field);
355
+ const retainedSource = sourceName ? reconciledWebsiteSourceState(snapshot, sourceName) : null;
356
+ const retainedSourceComplete = !retainedSource || completeWebsiteSourceState(retainedSource);
357
+ if (field.startsWith('certificate.')) {
358
+ return Boolean(snapshot.certificate?.complete && !snapshot.certificate.truncated && retainedSourceComplete);
359
+ }
360
+ return snapshot.complete && !snapshot.truncated && retainedSourceComplete;
361
+ }
362
+ export function websiteSnapshotComparisonEvidenceComplete(snapshot) {
363
+ return websiteSnapshotFieldComplete(snapshot, 'technology')
364
+ && websiteSnapshotFieldComplete(snapshot, 'posture')
365
+ && websiteSnapshotFieldComplete(snapshot, 'identity')
366
+ && websiteSnapshotFieldComplete(snapshot, 'dependency.CNAME')
367
+ && (!snapshot.certificate || websiteSnapshotFieldComplete(snapshot, 'certificate.observation'));
368
+ }
369
+ function compareMap(field, before, after, options = {}) {
300
370
  const changes = [];
301
371
  for (const key of [...new Set([...before.keys(), ...after.keys()])].sort()) {
302
372
  const left = before.get(key) ?? null;
303
373
  const right = after.get(key) ?? null;
304
374
  if (left === right)
305
375
  continue;
306
- changes.push({ field: `${field}.${key}`, state: left === null ? 'added' : right === null ? 'removed' : 'changed', before: left, after: right });
376
+ const state = left === null
377
+ ? options.beforeComplete?.(key) === false ? 'incomparable' : 'added'
378
+ : right === null
379
+ ? options.afterComplete?.(key) === false ? 'incomparable' : 'removed'
380
+ : 'changed';
381
+ changes.push({ field: `${field}.${key}`, state, before: left, after: right });
307
382
  }
308
383
  return changes;
309
384
  }
@@ -313,6 +388,7 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
313
388
  if (!before || !after || before.domain !== after.domain) {
314
389
  return {
315
390
  compatible: false,
391
+ complete: false,
316
392
  changes: [{ field: 'snapshot', state: 'incomparable', before: before?.domain ?? null, after: after?.domain ?? null }],
317
393
  dependencyTransitions: [],
318
394
  };
@@ -328,20 +404,29 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
328
404
  ? 'legacy_unknown'
329
405
  : postureVersionBefore === postureVersionAfter ? 'comparable' : 'detector_changed';
330
406
  const changes = [
331
- ...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`]))) : [{
407
+ ...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), {
408
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'technology'),
409
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'technology'),
410
+ }) : [{
332
411
  field: 'technology.profileVersion',
333
412
  state: technologyComparability === 'detector_changed' ? 'changed' : 'incomparable',
334
413
  before: technologyVersionBefore === null ? null : String(technologyVersionBefore),
335
414
  after: technologyVersionAfter === null ? null : String(technologyVersionAfter),
336
415
  }]),
337
- ...(postureComparability === 'comparable' ? compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state]))) : [{
416
+ ...(postureComparability === 'comparable' ? compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state])), {
417
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'posture'),
418
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'posture'),
419
+ }) : [{
338
420
  field: 'posture.profileVersion',
339
421
  state: postureComparability === 'detector_changed' ? 'changed' : 'incomparable',
340
422
  before: postureVersionBefore === null ? null : String(postureVersionBefore),
341
423
  after: postureVersionAfter === null ? null : String(postureVersionAfter),
342
424
  }]),
343
425
  ...compareMap('source', new Map(before.sources.map((item) => [item.source, item.state])), new Map(after.sources.map((item) => [item.source, item.state]))),
344
- ...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`]))),
426
+ ...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), {
427
+ beforeComplete: (key) => websiteSnapshotFieldComplete(before, `dependency.${key}`),
428
+ afterComplete: (key) => websiteSnapshotFieldComplete(after, `dependency.${key}`),
429
+ }),
345
430
  ];
346
431
  const dependencyTransitions = [];
347
432
  const beforeDependencies = new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, item]));
@@ -365,10 +450,10 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
365
450
  detail: 'An earlier active dependency is unresolved in the later retained observation. Resolver or collection failure remains possible.',
366
451
  });
367
452
  }
368
- else if (!left && right) {
453
+ else if (!left && right && websiteSnapshotFieldComplete(before, `dependency.${key}`)) {
369
454
  dependencyTransitions.push({ target: right.target, recordType: right.recordType, state: 'added', detail: 'This dependency first appears in the later retained snapshot.' });
370
455
  }
371
- else if (left && !right && after.complete) {
456
+ else if (left && !right && websiteSnapshotFieldComplete(after, `dependency.${key}`)) {
372
457
  dependencyTransitions.push({ target: left.target, recordType: left.recordType, state: 'removed', detail: 'This dependency is not represented in the later complete snapshot. It may have been intentionally removed or replaced.' });
373
458
  }
374
459
  if (dependencyTransitions.length >= 20)
@@ -381,7 +466,16 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
381
466
  continue;
382
467
  changes.push({ field: `identity.${key}`, state: left === null || right === null ? 'unavailable' : 'changed', before: left, after: right });
383
468
  }
384
- changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item]))), ...compareMap('identityValues.trackingIdentifiers', new Map(before.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), new Map(after.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value]))), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item]))));
469
+ changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item])), {
470
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.resourceHosts'),
471
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.resourceHosts'),
472
+ }), ...compareMap('identityValues.trackingIdentifiers', new Map(before.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), new Map(after.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), {
473
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.trackingIdentifiers'),
474
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.trackingIdentifiers'),
475
+ }), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item])), {
476
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.formActionOrigins'),
477
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.formActionOrigins'),
478
+ }));
385
479
  if (before.certificate || after.certificate) {
386
480
  if (!before.certificate || !after.certificate) {
387
481
  changes.push({
@@ -425,6 +519,10 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
425
519
  }
426
520
  return {
427
521
  compatible: true,
522
+ complete: websiteSnapshotComparisonEvidenceComplete(before)
523
+ && websiteSnapshotComparisonEvidenceComplete(after)
524
+ && technologyComparability === 'comparable'
525
+ && postureComparability === 'comparable',
428
526
  profileComparability: {
429
527
  technology: technologyComparability,
430
528
  securityPosture: postureComparability,
@@ -528,6 +528,7 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
528
528
  skipped: result.skipped ?? 0,
529
529
  pruned: result.pruned ?? 0,
530
530
  brandProfileReferencesOmitted: result.brandProfileReferencesOmitted ?? 0,
531
+ authoredHistoryOmitted: result.authoredHistoryOmitted ?? 0,
531
532
  selected,
532
533
  normalizedSettings: result.settings || null,
533
534
  });
@@ -567,6 +568,7 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
567
568
  skipped: dependentSection.recordCount,
568
569
  pruned: 0,
569
570
  brandProfileReferencesOmitted: 0,
571
+ authoredHistoryOmitted: 0,
570
572
  selected: false,
571
573
  normalizedSettings: dependentId === 'settings' ? null : dependentSection.normalizedSettings ?? null,
572
574
  };