@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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 (122) hide show
  1. package/DISCLOSURE +1 -1
  2. package/cli/artifact-validation/case-response.mjs +51 -8
  3. package/cli/artifact-validation/lookup-evidence.mjs +15 -1
  4. package/cli/artifact-validation/signed-review.mjs +2 -51
  5. package/cli/artifact-verify.mjs +9 -2
  6. package/cli/bulk-command-runner.mjs +4 -6
  7. package/cli/case-command.mjs +79 -3
  8. package/cli/case-pack.mjs +7 -2
  9. package/cli/command-definition.mjs +2 -1
  10. package/cli/discovery-scan-command-runner.mjs +4 -6
  11. package/cli/domain-feed.mjs +61 -0
  12. package/cli/formatters/json.mjs +1 -13
  13. package/cli/indicator-set-command.mjs +1 -2
  14. package/cli/intake-command.mjs +43 -3
  15. package/cli/investigation-artifacts.mjs +2 -3
  16. package/cli/investigation-package-review.mjs +2 -0
  17. package/cli/local-mmdb-review.mjs +27 -4
  18. package/cli/lookup-command-runner.mjs +1 -0
  19. package/cli/offline-evidence-review.mjs +51 -5
  20. package/cli/review-arguments.mjs +3 -2
  21. package/cli/review-command-definitions.mjs +14 -8
  22. package/cli/review-command-runner.mjs +6 -3
  23. package/cli/risk-calibration.mjs +6 -4
  24. package/cli/selected-input-worker.mjs +12 -2
  25. package/cli/support-command-runner.mjs +3 -3
  26. package/cli/watchlist-review.mjs +43 -0
  27. package/cli/workflow-arguments.mjs +14 -0
  28. package/cli/workflow-command-definitions.mjs +26 -1
  29. package/cli/workflow-command-runner.mjs +32 -0
  30. package/docs/cli.md +99 -2
  31. package/docs/contextual-reviews.md +28 -5
  32. package/docs/infrastructure-observations.md +126 -0
  33. package/docs/offline-intake-context.md +213 -0
  34. package/lib/credential-form-attribution.mjs +136 -0
  35. package/lib/credential-surface-profile.mjs +6 -2
  36. package/lib/dns-delegation-health.mjs +23 -7
  37. package/lib/dns-intelligence.mjs +18 -4
  38. package/lib/domain-posture-analysis.mjs +53 -2
  39. package/lib/domain-posture-parsers.mjs +9 -0
  40. package/lib/domain-posture.mjs +29 -10
  41. package/lib/evidence-export-privacy.mjs +5 -2
  42. package/lib/evidence-report.mjs +1 -0
  43. package/lib/favicon.mjs +13 -4
  44. package/lib/html-signals.mjs +6 -0
  45. package/lib/lookup-child-profile-contract.mjs +28 -11
  46. package/lib/lookup-response-contract.mjs +1 -0
  47. package/lib/operation-budget.mjs +4 -0
  48. package/lib/service-binding-dns.mjs +19 -2
  49. package/lib/sslbl-certificates.generated.mjs +138 -137
  50. package/lib/static-html-analysis.mjs +32 -4
  51. package/lib/technology-evidence-role.mjs +1 -51
  52. package/lib/tls-intelligence.mjs +30 -5
  53. package/lib/website-enrichment.mjs +39 -23
  54. package/lib/whois-chain.mjs +12 -3
  55. package/lib/whois-common-formats.mjs +3 -1
  56. package/lib/whois-contacts.mjs +13 -2
  57. package/package.json +7 -5
  58. package/packages/analysis/risk-scoring.mjs +12 -9
  59. package/packages/cases/case-containment-handoff.mjs +195 -0
  60. package/packages/cases/case-evidence-links.mjs +16 -0
  61. package/packages/cases/case-evidence-model.mjs +14 -3
  62. package/packages/cases/case-object-coverage.mjs +33 -0
  63. package/packages/cases/case-packet-correction.mjs +106 -0
  64. package/packages/cases/case-recheck-model.mjs +35 -5
  65. package/packages/cases/case-record-operations.mjs +74 -9
  66. package/packages/cases/case-record-projection.mjs +15 -3
  67. package/packages/cases/case-record-version-input.mjs +23 -1
  68. package/packages/cases/case-report.mjs +30 -0
  69. package/packages/cases/case-requested-evidence.mjs +1 -1
  70. package/packages/cases/case-response-actions.mjs +57 -1
  71. package/packages/cases/case-response-model.mjs +7 -0
  72. package/packages/cases/case-response-object.mjs +119 -0
  73. package/packages/cases/case-response-outcomes.mjs +81 -14
  74. package/packages/cases/case-response-packet-render.mjs +22 -3
  75. package/packages/cases/case-response-packet.mjs +30 -2
  76. package/packages/cases/case-response-review-inputs.mjs +48 -7
  77. package/packages/cases/case-workflow-metadata.mjs +1 -9
  78. package/packages/contracts/analyst-interchange.mjs +7 -5
  79. package/packages/contracts/candidate-watch-review.mjs +54 -0
  80. package/packages/contracts/case-portability.mjs +260 -42
  81. package/packages/contracts/cli-command-semantics.mjs +3 -0
  82. package/packages/contracts/context-review.mjs +2 -1
  83. package/packages/contracts/external-observation-interchange.mjs +96 -28
  84. package/packages/contracts/lookup-evidence.mjs +3 -2
  85. package/packages/contracts/message-intake.mjs +7 -1
  86. package/packages/contracts/risk-calibration.mjs +44 -29
  87. package/packages/contracts/schema-lifecycle.mjs +3 -1
  88. package/packages/contracts/web-capture.mjs +1 -1
  89. package/packages/contracts/workspace-portability.mjs +86 -16
  90. package/packages/evidence/collection-quality.mjs +1 -1
  91. package/packages/evidence/credential-form-attribution.mjs +60 -0
  92. package/packages/evidence/image-regions.mjs +15 -0
  93. package/packages/evidence/record-identity.mjs +60 -16
  94. package/packages/evidence/technology-evidence-role.mjs +51 -0
  95. package/packages/interchange/external-findings-import.mjs +32 -17
  96. package/packages/interchange/json-output.mjs +14 -0
  97. package/packages/interchange/managed-indicator-export.mjs +3 -2
  98. package/packages/interchange/managed-indicator-set.mjs +13 -0
  99. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  100. package/packages/investigation/context-review.mjs +3 -3
  101. package/packages/investigation/document-intake.mjs +9 -3
  102. package/packages/investigation/domain-history-review.mjs +43 -11
  103. package/packages/investigation/domain-transition-review.mjs +2 -0
  104. package/packages/investigation/infrastructure-observation.mjs +234 -0
  105. package/packages/investigation/intake-context.mjs +284 -0
  106. package/packages/investigation/intake-indicators.mjs +116 -0
  107. package/packages/investigation/intake-phones.mjs +155 -0
  108. package/packages/investigation/intake-report.mjs +2 -1
  109. package/packages/investigation/investigation-manifest.mjs +33 -7
  110. package/packages/investigation/link-intake.mjs +38 -8
  111. package/packages/investigation/message-intake.mjs +55 -15
  112. package/packages/monitoring/domain-feed.mjs +321 -0
  113. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  114. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  115. package/packages/workspace/brand-profile-model.mjs +46 -2
  116. package/packages/workspace/bulk-session-model.mjs +4 -0
  117. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  118. package/packages/workspace/detection-rule-model.mjs +10 -2
  119. package/packages/workspace/shortlist-model.mjs +10 -2
  120. package/packages/workspace/watchlist-history.mjs +31 -4
  121. package/packages/workspace/watchlist-store.mjs +136 -7
  122. package/third-party-notices.txt +29 -29
@@ -10,6 +10,8 @@ import { MAX_IDENTITY_DIGEST_BYTES, sha256IdentityHex } from "../evidence/record
10
10
  import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECKS, MAX_POSTURE_CHECK_RECORDS, MAX_POSTURE_RECORD_LENGTH, normalizeDomainPostureSourceContext, normalizeDomainPostureProfileContext } from "../evidence/domain-posture-context.mjs";
11
11
  import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
12
12
  import { normalizePageBaseline } from "./page-baseline.mjs";
13
+ import { normalizeCandidateObservations, normalizeCandidateExceptions } from "./brand-candidate-workflow.mjs";
14
+ import { normalizeBrandKeywordCampaigns, mergeBrandKeywordCampaigns } from "./brand-keyword-campaign.mjs";
13
15
  import { isInformativePerceptualHash as isInformativeFaviconHash } from "../analysis/perceptual-hash-comparison.mjs";
14
16
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
15
17
  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";
@@ -583,6 +585,9 @@ export function normalizeBrandProfile(raw, options = {}) {
583
585
  approvedPartnerDomains: normalizeProfileDomains(value.approvedPartnerDomains),
584
586
  allowlistedDomains: normalizeProfileDomains(value.allowlistedDomains),
585
587
  allowlistedRegistrars: normalizeProfileTextValues(value.allowlistedRegistrars),
588
+ candidateObservations: normalizeCandidateObservations(value.candidateObservations),
589
+ candidateExceptions: normalizeCandidateExceptions(value.candidateExceptions),
590
+ keywordCampaigns: existing ? mergeBrandKeywordCampaigns(existing.keywordCampaigns, value.keywordCampaigns) : normalizeBrandKeywordCampaigns(value.keywordCampaigns),
586
591
  dkimSelectors,
587
592
  retiredDkimSelectors: normalizeDkimSelectors(value.retiredDkimSelectors)
588
593
  .filter((selector) => !dkimSelectors.includes(selector)),
@@ -602,6 +607,9 @@ export function normalizeBrandProfile(raw, options = {}) {
602
607
  const BRAND_PROFILE_FIELD_PATCH_KEYS = Object.freeze([
603
608
  'allowlistedDomains',
604
609
  'allowlistedRegistrars',
610
+ 'candidateObservations',
611
+ 'candidateExceptions',
612
+ 'keywordCampaigns',
605
613
  'desiredPostureBaselines',
606
614
  'protectionAttestations',
607
615
  ]);
@@ -638,11 +646,24 @@ export function brandProfileStoreVersion(raw) {
638
646
  const value = record(raw);
639
647
  return typeof value.version === 'number' && Number.isFinite(value.version) && value.version > 0 ? value.version : null;
640
648
  }
649
+ function assertHistoricalCandidateFields(raw, version) {
650
+ if (Array.isArray(raw) || version === null || version >= 11)
651
+ return;
652
+ for (const input of profileList(raw).slice(0, MAX_PROFILES * 4)) {
653
+ const profile = record(input);
654
+ if (version < 10 && (Object.hasOwn(profile, 'candidateObservations') || Object.hasOwn(profile, 'candidateExceptions'))) {
655
+ throw new TypeError('Candidate observations and scoped exceptions require Brand Profile schema 10; historical records were not reinterpreted.');
656
+ }
657
+ if (Object.hasOwn(profile, 'keywordCampaigns'))
658
+ throw new TypeError('Keyword campaigns require Brand Profile schema 11; historical records were not reinterpreted.');
659
+ }
660
+ }
641
661
  /** Normalize an internal profile collection or current stored envelope. */
642
662
  export function normalizeBrandProfileStore(raw) {
643
663
  assertWorkspaceInputGraph(raw, 'Brand Profile store', { maximumBytes: MAX_PROFILE_STORE_BYTES });
644
664
  assertWorkspaceDeclaredVersion(raw, 'Brand Profile store');
645
665
  const sourceVersion = brandProfileStoreVersion(raw);
666
+ assertHistoricalCandidateFields(raw, sourceVersion);
646
667
  if (!Array.isArray(raw) && sourceVersion !== null
647
668
  && !SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS.includes(sourceVersion)) {
648
669
  throw new Error(`Brand Profile schema ${sourceVersion} is unsupported; no data was changed.`);
@@ -655,8 +676,8 @@ export function normalizeBrandProfileStore(raw) {
655
676
  if (!profile)
656
677
  continue;
657
678
  const previous = byId.get(profile.id);
658
- if (!previous || profile.updatedAt > previous.updatedAt)
659
- byId.set(profile.id, profile);
679
+ const keywordCampaigns = mergeBrandKeywordCampaigns(previous?.keywordCampaigns, profile.keywordCampaigns);
680
+ byId.set(profile.id, { ...(previous && profile.updatedAt <= previous.updatedAt ? previous : profile), keywordCampaigns });
660
681
  if (byId.size >= MAX_PROFILES)
661
682
  break;
662
683
  }
@@ -691,6 +712,7 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
691
712
  throw new Error('Expected a current WHOISleuth Brand Profile export.');
692
713
  }
693
714
  const importedVersion = brandProfileStoreVersion(importedRaw);
715
+ assertHistoricalCandidateFields(importedRaw, importedVersion);
694
716
  if (importedVersion !== null && importedVersion > BRAND_PROFILE_SCHEMA_VERSION) {
695
717
  throw new Error(`This Brand Profile file uses newer schema ${importedVersion}. Update the app before importing it.`);
696
718
  }
@@ -727,8 +749,17 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
727
749
  if (rawId && rawName)
728
750
  retainIdName(rawId, rawName);
729
751
  const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
752
+ if (existing && rawId !== existing.id && ((Array.isArray(value.candidateObservations) && value.candidateObservations.length) || (Array.isArray(value.candidateExceptions) && value.candidateExceptions.length) || (Array.isArray(value.keywordCampaigns) && value.keywordCampaigns.length))) {
753
+ throw new TypeError('Candidate metadata belongs to another Brand identifier with this name. Rename or review its exact Brand identity before importing; no decisions were reassigned.');
754
+ }
755
+ const keywordCampaigns = mergeBrandKeywordCampaigns(existing?.keywordCampaigns, value.keywordCampaigns);
730
756
  const incomingUpdatedAt = timestamp(value.updatedAt, null);
731
757
  if (existing && (!incomingUpdatedAt || incomingUpdatedAt <= existing.updatedAt)) {
758
+ if (JSON.stringify(keywordCampaigns) !== JSON.stringify(existing.keywordCampaigns ?? [])) {
759
+ byName.set(existing.name.toLowerCase(), { ...existing, keywordCampaigns });
760
+ updated++;
761
+ continue;
762
+ }
732
763
  skipped++;
733
764
  continue;
734
765
  }
@@ -742,6 +773,19 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
742
773
  skipped++;
743
774
  continue;
744
775
  }
776
+ if (existing) {
777
+ profile.keywordCampaigns = keywordCampaigns;
778
+ profile.candidateObservations = normalizeCandidateObservations([...existing.candidateObservations, ...profile.candidateObservations]);
779
+ const exceptions = new Map(existing.candidateExceptions.map(exception => [exception.id, exception]));
780
+ for (const incoming of profile.candidateExceptions) {
781
+ const old = exceptions.get(incoming.id);
782
+ if (old && (old.domain !== incoming.domain || old.ruleKey !== incoming.ruleKey || old.purpose !== incoming.purpose))
783
+ throw new Error('An imported exception identifier changes its exact scope or purpose; no profiles were imported.');
784
+ if (!old || incoming.revision > old.revision)
785
+ exceptions.set(incoming.id, incoming);
786
+ }
787
+ profile.candidateExceptions = normalizeCandidateExceptions([...exceptions.values()]);
788
+ }
745
789
  retainIdName(profile.id, profile.name);
746
790
  if (existing) {
747
791
  byName.set(profile.name.toLowerCase(), profile);
@@ -338,6 +338,7 @@ export function normalizeBulkSessionResult(value, inheritedProfileContext) {
338
338
  reusesOfficialAssets: profileClaimsUsable ? nullableBoolean(item.reusesOfficialAssets) : null,
339
339
  hasPasswordField: item.hasPasswordField === true,
340
340
  hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
341
+ hasExternalPasswordForm: nullableBoolean(item.hasExternalPasswordForm),
341
342
  phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
342
343
  idnReferenceMatch: profileClaimsUsable ? nullableBoolean(item.idnReferenceMatch) : null,
343
344
  pageBaselineMatch: profileClaimsUsable ? nullableBoolean(item.pageBaselineMatch) : null,
@@ -393,6 +394,9 @@ export function normalizeBulkSession(value, sourceStoreVersion) {
393
394
  const results = [];
394
395
  const seen = new Set();
395
396
  for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
397
+ if (sourceStoreVersion !== undefined && sourceStoreVersion < 7 && Object.hasOwn(record(candidate) ?? {}, 'hasExternalPasswordForm')) {
398
+ throw new TypeError('Form attribution requires Bulk schema 7 or later; historical evidence was not reinterpreted.');
399
+ }
396
400
  if (sourceStoreVersion !== undefined && sourceStoreVersion < 6 && record(candidate)?.webCollectionQuality !== undefined) {
397
401
  throw new TypeError('Web collection quality requires Bulk schema 6 or later; historical evidence was not reinterpreted.');
398
402
  }
@@ -0,0 +1,195 @@
1
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { assertWorkspaceInputGraph } from "./hostile-input.mjs";
4
+ import { MAX_WATCHLISTS, normalizeWatchlistName, normalizeWatchlistStore, assertWatchlistStoreBudget, } from "./watchlist-store.mjs";
5
+ import { MAX_WATCHLIST_DOMAINS } from "../contracts/workspace-portability.mjs";
6
+ import { assertWatchlistEditable } from "./watchlist-history.mjs";
7
+ import { MAX_WATCH_CONTEXTS, WATCH_PRIORITIES, mergeWatchDomainMetadata, normalizeCandidateObservation, candidateMaterialFingerprint, } from "./brand-candidate-workflow.mjs";
8
+ function admitInput(input) {
9
+ assertWorkspaceInputGraph(input, 'Candidate watch handoff');
10
+ if (!normalizeWatchlistName(input.name))
11
+ throw new TypeError('Choose a safe watchlist name containing 1–100 characters.');
12
+ if (!Array.isArray(input.candidates) || input.candidates.length > MAX_WATCHLIST_DOMAINS)
13
+ throw new RangeError(`Select at most ${MAX_WATCHLIST_DOMAINS} candidate domains.`);
14
+ if (input.brandProfileId !== null &&
15
+ (typeof input.brandProfileId !== 'string' ||
16
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(input.brandProfileId)))
17
+ throw new TypeError('The Brand context identifier is invalid.');
18
+ if (!WATCH_PRIORITIES.some((option) => option.value === input.priority))
19
+ throw new TypeError('Choose an admitted review priority.');
20
+ if (typeof input.reason !== 'string' ||
21
+ /[\u0000-\u001f\u007f]/u.test(input.reason) ||
22
+ input.reason.trim().length > 300 ||
23
+ !input.reason.trim())
24
+ throw new TypeError('Enter a watch reason containing 1–300 characters and no control characters.');
25
+ if (input.reviewDueAt != null && !normalizeExplicitIsoTimestamp(input.reviewDueAt))
26
+ throw new TypeError('Use a valid explicit review time. A review time is not a scan schedule.');
27
+ if (input.replaceExistingContext !== undefined &&
28
+ typeof input.replaceExistingContext !== 'boolean')
29
+ throw new TypeError('Existing-context replacement must be selected explicitly.');
30
+ return {
31
+ ...input,
32
+ name: normalizeWatchlistName(input.name),
33
+ reason: input.reason.trim(),
34
+ reviewDueAt: normalizeExplicitIsoTimestamp(input.reviewDueAt),
35
+ };
36
+ }
37
+ /** Pure exact-domain preview. It grants no collection authority and makes no requests. */
38
+ export function planCandidateWatchHandoff(localRaw, rawInput) {
39
+ const input = admitInput(rawInput), local = normalizeWatchlistStore(localRaw).watchlists;
40
+ const name = Object.keys(local).find((name) => name.toLowerCase() === input.name.toLowerCase()) ??
41
+ input.name;
42
+ const entry = local[name], existing = new Map(entry?.domainMetadata.map((record) => [record.domain, record]) ?? []);
43
+ assertWatchlistEditable(entry);
44
+ const seen = new Set(), rows = [];
45
+ let admitted = existing.size;
46
+ for (const raw of input.candidates) {
47
+ const candidate = normalizeCandidateObservation(raw), domain = candidate?.domain ?? normalizeDomain(raw?.domain) ?? '';
48
+ let reason = '';
49
+ if (!candidate || !domain)
50
+ reason = 'Invalid candidate domain or provenance.';
51
+ else if (seen.has(domain))
52
+ reason = 'Repeated selection; the first exact domain is retained.';
53
+ else if (!entry && Object.keys(local).length >= MAX_WATCHLISTS)
54
+ reason = 'Watchlist count capacity is exhausted.';
55
+ else if (!existing.has(domain) && admitted >= MAX_WATCHLIST_DOMAINS)
56
+ reason = 'Domain capacity is exhausted.';
57
+ else if (existing.get(domain)?.contexts.length === MAX_WATCH_CONTEXTS &&
58
+ !existing
59
+ .get(domain)
60
+ ?.contexts.some((context) => context.brandProfileId === input.brandProfileId))
61
+ reason = 'This domain has reached its separate-context capacity.';
62
+ seen.add(domain);
63
+ const previousContext = existing
64
+ .get(domain)
65
+ ?.contexts.find((context) => context.brandProfileId === input.brandProfileId) ?? null;
66
+ const materialFingerprint = candidate
67
+ ? candidateMaterialFingerprint(candidate, input.brandProfileId ?? '')
68
+ : null;
69
+ if (reason)
70
+ rows.push({ domain, state: 'rejected', reason, previousContext, materialFingerprint });
71
+ else {
72
+ const context = existing
73
+ .get(domain)
74
+ ?.contexts.find((context) => context.brandProfileId === input.brandProfileId);
75
+ rows.push({
76
+ domain,
77
+ state: existing.has(domain) ? 'existing' : 'add',
78
+ previousContext,
79
+ materialFingerprint,
80
+ reason: context
81
+ ? input.replaceExistingContext
82
+ ? `Explicitly replace this context (${context.priority}) with ${input.priority}; preserve evidence and other Brand contexts.`
83
+ : `Preserve existing deliberate ${context.priority} priority and reason; merge provenance only.`
84
+ : existing.has(domain)
85
+ ? 'Already tracked; add this separate Brand context, preserving other contexts.'
86
+ : 'Add metadata only; no scan or baseline is invented.',
87
+ });
88
+ if (!existing.has(domain))
89
+ admitted++;
90
+ }
91
+ }
92
+ return {
93
+ destination: name,
94
+ brandProfileId: input.brandProfileId,
95
+ priority: input.priority,
96
+ reason: input.reason,
97
+ reviewDueAt: input.reviewDueAt ?? null,
98
+ replaceExistingContext: input.replaceExistingContext ?? false,
99
+ rows,
100
+ additionalRequests: 0,
101
+ collectionAuthorised: false,
102
+ };
103
+ }
104
+ export function applyCandidateWatchHandoff(localRaw, rawInput, nowRaw) {
105
+ const input = admitInput(rawInput), now = normalizeExplicitIsoTimestamp(nowRaw);
106
+ if (!now)
107
+ throw new TypeError('The watch metadata change time must use an explicit timezone.');
108
+ if (input.reviewDueAt && input.reviewDueAt <= now)
109
+ throw new TypeError('The review time must be later than this change time.');
110
+ const local = normalizeWatchlistStore(localRaw).watchlists, plan = planCandidateWatchHandoff(local, input);
111
+ const candidates = new Map();
112
+ for (const raw of input.candidates) {
113
+ const candidate = normalizeCandidateObservation(raw);
114
+ if (candidate && !candidates.has(candidate.domain))
115
+ candidates.set(candidate.domain, candidate);
116
+ }
117
+ const context = {
118
+ brandProfileId: input.brandProfileId,
119
+ priority: input.priority,
120
+ reason: input.reason,
121
+ changedAt: now,
122
+ reviewDueAt: input.reviewDueAt ?? null,
123
+ };
124
+ const entry = local[plan.destination] ?? {
125
+ updatedAt: null,
126
+ results: [],
127
+ baseline: [],
128
+ history: [],
129
+ domainMetadata: [],
130
+ };
131
+ if (input.replaceExistingContext &&
132
+ plan.rows.some((row) => row.state !== 'rejected' &&
133
+ row.previousContext?.changedAt &&
134
+ now <= row.previousContext.changedAt))
135
+ throw new Error('The change clock does not follow the retained context. Review the clock before replacing a manual priority.');
136
+ const metadata = plan.rows
137
+ .filter((row) => row.state !== 'rejected')
138
+ .map((row) => ({
139
+ domain: row.domain,
140
+ contexts: !input.replaceExistingContext &&
141
+ entry.domainMetadata.some((record) => record.domain === row.domain &&
142
+ record.contexts.some((old) => old.brandProfileId === input.brandProfileId))
143
+ ? []
144
+ : [context],
145
+ candidate: candidates.get(row.domain) ?? null,
146
+ }));
147
+ if (!metadata.length)
148
+ return { watchlists: local, plan };
149
+ Object.defineProperty(local, plan.destination, {
150
+ value: { ...entry, domainMetadata: mergeWatchDomainMetadata(entry.domainMetadata, metadata) },
151
+ enumerable: true,
152
+ configurable: true,
153
+ writable: true,
154
+ });
155
+ return { watchlists: assertWatchlistStoreBudget(local).watchlists, plan };
156
+ }
157
+ /** Field-scoped optimistic edits preserve unrelated domains and contexts. */
158
+ export function setWatchDomainContext(localRaw, name, domainRaw, input, expected) {
159
+ return setWatchDomainContexts(localRaw, name, [{ domain: domainRaw, input, expected }]);
160
+ }
161
+ export function setWatchDomainContexts(localRaw, name, edits) {
162
+ if (!Array.isArray(edits) || edits.length > MAX_WATCHLIST_DOMAINS)
163
+ throw new RangeError('The reviewed metadata edit exceeds its domain bound.');
164
+ const local = normalizeWatchlistStore(localRaw).watchlists, entry = local[name];
165
+ if (!entry)
166
+ throw new Error('The selected watchlist was deleted; nothing was overwritten.');
167
+ assertWatchlistEditable(entry);
168
+ const metadata = new Map(entry.domainMetadata.map((record) => [record.domain, record])), seen = new Set();
169
+ for (const edit of edits) {
170
+ const domain = normalizeDomain(edit.domain), record = domain ? metadata.get(domain) : null;
171
+ const key = `${domain}\0${edit.input.brandProfileId ?? ''}`, old = record?.contexts.find((context) => context.brandProfileId === edit.input.brandProfileId) ??
172
+ null;
173
+ if (!domain ||
174
+ !record ||
175
+ seen.has(key) ||
176
+ JSON.stringify(old) !== JSON.stringify(edit.expected))
177
+ throw new Error('A reviewed domain context changed, was repeated or was deleted. Reopen the selection; nothing was overwritten.');
178
+ const input = admitInput({ name, candidates: [], ...edit.input });
179
+ const changedAt = normalizeExplicitIsoTimestamp(edit.input.changedAt);
180
+ if (!changedAt ||
181
+ (old?.changedAt && changedAt <= old.changedAt) ||
182
+ (input.reviewDueAt && input.reviewDueAt <= changedAt))
183
+ throw new TypeError('Use a current change time and a later optional review time.');
184
+ seen.add(key);
185
+ metadata.set(domain, {
186
+ ...record,
187
+ contexts: [
188
+ ...record.contexts.filter((context) => context.brandProfileId !== edit.input.brandProfileId),
189
+ { ...edit.input, reason: input.reason, changedAt, reviewDueAt: input.reviewDueAt ?? null },
190
+ ],
191
+ });
192
+ }
193
+ local[name] = { ...entry, domainMetadata: [...metadata.values()] };
194
+ return assertWatchlistStoreBudget(local).watchlists;
195
+ }
@@ -4,7 +4,7 @@
4
4
  import { latestCaseEvidence } from "../cases/case-evidence-model.mjs";
5
5
  import { CASE_DISPOSITIONS, CASE_STATUSES } from "../cases/case-record-contracts.mjs";
6
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
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
+ import { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS, 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";
8
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";
9
9
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
10
10
  const TEXT_CONTROL_RE = /[\x00-\x1f\x7f]/;
@@ -24,6 +24,7 @@ export const RULE_FIELD_DEFINITIONS = Object.freeze([
24
24
  { value: 'reusesOfficialAssets', label: 'Official assets reused', kind: 'boolean' },
25
25
  { value: 'phishingLanguageMatch', label: 'Phishing-language signal', kind: 'text' },
26
26
  { value: 'hasExternalFormAction', label: 'External form action observed', kind: 'boolean' },
27
+ { value: 'hasExternalPasswordForm', label: 'Password form declares external destination', kind: 'boolean' },
27
28
  { value: 'mutationTypes', label: 'Mutation type', kind: 'list' },
28
29
  { value: 'nameservers', label: 'Nameserver', kind: 'list' },
29
30
  { value: 'httpSecurityHeaders', label: 'HTTP security header', kind: 'list' },
@@ -164,8 +165,12 @@ export function recoverDetectionRuleStore(raw) {
164
165
  assertWorkspaceDeclaredVersion(raw, 'Detection-rule store');
165
166
  const byId = new Map();
166
167
  const source = ruleList(raw);
168
+ const sourceVersion = detectionRuleStoreVersion(raw);
167
169
  let rejected = Math.max(0, source.length - MAX_RULE_INPUT_RECORDS);
168
170
  for (const item of source.slice(0, MAX_RULE_INPUT_RECORDS)) {
171
+ if (sourceVersion === 1 && Array.isArray(record(item).conditions) && record(item).conditions.some(condition => record(condition).field === 'hasExternalPasswordForm')) {
172
+ throw new Error('Form-attribution conditions require custom-rule schema 2; historical rules were not reinterpreted.');
173
+ }
169
174
  const rule = normalizeDetectionRule(item);
170
175
  if (!rule?.id || byId.has(rule.id)) {
171
176
  rejected += 1;
@@ -326,12 +331,15 @@ export function mergeDetectionRules(localRaw, importedRaw) {
326
331
  if (version !== null && version > DETECTION_RULE_SCHEMA_VERSION) {
327
332
  throw new Error(`This custom-rule file uses newer schema ${version}. Update the app before importing it.`);
328
333
  }
329
- if (version !== DETECTION_RULE_SCHEMA_VERSION) {
334
+ if (version === null || !DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS.includes(version)) {
330
335
  throw new Error(`Expected custom-rule schema ${DETECTION_RULE_SCHEMA_VERSION}.`);
331
336
  }
332
337
  const local = normalizeDetectionRuleStore(localRaw).rules;
333
338
  const byId = new Map(local.map((rule) => [rule.id, rule]));
334
339
  const importedList = ruleList(importedRaw);
340
+ if (version === 1 && importedList.some(item => Array.isArray(record(item).conditions) && record(item).conditions.some(condition => record(condition).field === 'hasExternalPasswordForm'))) {
341
+ throw new Error('Form-attribution conditions require custom-rule schema 2; historical rules were not reinterpreted.');
342
+ }
335
343
  let added = 0;
336
344
  let updated = 0;
337
345
  let skipped = Math.max(0, importedList.length - MAX_RULE_INPUT_RECORDS);
@@ -69,9 +69,11 @@ export function normalizeShortlistRecord(raw, options = {}) {
69
69
  const compact = compactWatchlistResults([value])[0];
70
70
  if (!compact)
71
71
  return null;
72
+ // Shortlist's published schema does not retain the newer local watch evidence.
73
+ const { hasExternalPasswordForm: _localWatchEvidence, ...shortlistCompact } = compact;
72
74
  const riskScore = score(value.riskScore);
73
75
  return {
74
- ...compact,
76
+ ...shortlistCompact,
75
77
  availability: typeof compact.availability === 'string' ? compact.availability : 'unknown',
76
78
  riskModelVersion: riskScore === null ? null : normalizeRiskModelVersion(value.riskModelVersion),
77
79
  riskScore,
@@ -120,12 +122,13 @@ export function serializeShortlistStore(records) {
120
122
  return JSON.stringify(assertShortlistStoreBudget(records));
121
123
  }
122
124
  /** Add, refresh, or remove an explicit bounded selection in one transaction. */
123
- export function setShortlistSelection(localRaw, selectedRaw, selected, nowIso = new Date().toISOString()) {
125
+ export function setShortlistSelection(localRaw, selectedRaw, selected, nowIso = new Date().toISOString(), existing = 'refresh') {
124
126
  const local = normalizeShortlistStore(localRaw).entries;
125
127
  const byDomain = new Map(local.map((record) => [record.domain, record]));
126
128
  const candidates = Array.isArray(selectedRaw) ? selectedRaw.slice(0, MAX_SHORTLIST_INPUTS) : [];
127
129
  let added = 0;
128
130
  let updated = 0;
131
+ let retained = 0;
129
132
  let removed = 0;
130
133
  let skipped = Math.max(0, (Array.isArray(selectedRaw) ? selectedRaw.length : 0) - MAX_SHORTLIST_INPUTS);
131
134
  const seen = new Set();
@@ -137,6 +140,10 @@ export function setShortlistSelection(localRaw, selectedRaw, selected, nowIso =
137
140
  }
138
141
  seen.add(record.domain);
139
142
  if (selected) {
143
+ if (existing === 'retain' && byDomain.has(record.domain)) {
144
+ retained += 1;
145
+ continue;
146
+ }
140
147
  if (!byDomain.has(record.domain) && byDomain.size >= MAX_SHORTLIST_ENTRIES) {
141
148
  skipped += 1;
142
149
  continue;
@@ -155,6 +162,7 @@ export function setShortlistSelection(localRaw, selectedRaw, selected, nowIso =
155
162
  entries: assertShortlistStoreBudget([...byDomain.values()]).entries,
156
163
  added,
157
164
  updated,
165
+ retained,
158
166
  removed,
159
167
  skipped,
160
168
  };
@@ -7,8 +7,9 @@ import { HTTP_SECURITY_HEADER_TOKENS, normalizeHttpSummary } from "../cases/http
7
7
  import { normalizeDomain } from "../evidence/domain-name.mjs";
8
8
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
9
9
  import { registryDateIso } from "../evidence/registry-dates.mjs";
10
+ import { normalizeWatchDomainMetadata } from "./brand-candidate-workflow.mjs";
10
11
  import { mergeWebCollectionQuality, normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
11
- import { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
12
+ import { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, WATCHLIST_RECOVERY_METADATA, } from "../contracts/workspace-portability.mjs";
12
13
  export { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
13
14
  const MAX_TEXT_LENGTH = 300;
14
15
  const MAX_TITLE_LENGTH = 200;
@@ -37,6 +38,7 @@ const DEEP_FIELDS = new Set([
37
38
  'faviconMatch',
38
39
  'faviconNearMatch',
39
40
  'hasPasswordField',
41
+ 'hasExternalPasswordForm',
40
42
  'phishingLanguageMatch',
41
43
  'reusesOfficialAssets',
42
44
  'riskModelVersion',
@@ -68,6 +70,7 @@ const FIELD_LABELS = {
68
70
  faviconMatch: 'Official favicon match',
69
71
  faviconNearMatch: 'Official favicon near-match',
70
72
  hasPasswordField: 'Password form',
73
+ hasExternalPasswordForm: 'External password-form destination',
71
74
  phishingLanguageMatch: 'Phishing language',
72
75
  reusesOfficialAssets: 'Official asset reuse',
73
76
  riskScore: 'Risk score',
@@ -94,6 +97,7 @@ const HISTORY_CATEGORY_FIELDS = {
94
97
  'faviconMatch',
95
98
  'faviconNearMatch',
96
99
  'hasPasswordField',
100
+ 'hasExternalPasswordForm',
97
101
  'phishingLanguageMatch',
98
102
  'reusesOfficialAssets',
99
103
  ]),
@@ -237,6 +241,7 @@ function compactRecord(value) {
237
241
  faviconMatch: typeof record.faviconMatch === 'boolean' ? record.faviconMatch : null,
238
242
  faviconNearMatch: typeof record.faviconNearMatch === 'boolean' ? record.faviconNearMatch : null,
239
243
  hasPasswordField: typeof record.hasPasswordField === 'boolean' ? record.hasPasswordField : null,
244
+ hasExternalPasswordForm: typeof record.hasExternalPasswordForm === 'boolean' ? record.hasExternalPasswordForm : null,
240
245
  phishingLanguageMatch: boundedText(record.phishingLanguageMatch, MAX_TITLE_LENGTH),
241
246
  reusesOfficialAssets: typeof record.reusesOfficialAssets === 'boolean' ? record.reusesOfficialAssets : null,
242
247
  riskModelVersion,
@@ -301,7 +306,7 @@ function classifyChange(field, before, after) {
301
306
  }
302
307
  return { kind: 'availability_changed', tone: 'warn' };
303
308
  }
304
- if (['faviconMatch', 'faviconNearMatch', 'hasPasswordField', 'reusesOfficialAssets'].includes(field) && before === false && after === true) {
309
+ if (['faviconMatch', 'faviconNearMatch', 'hasPasswordField', 'hasExternalPasswordForm', 'reusesOfficialAssets'].includes(field) && before === false && after === true) {
305
310
  return { kind: 'risk_signal_added', tone: 'danger' };
306
311
  }
307
312
  if (field === 'phishingLanguageMatch' && !before && after)
@@ -469,8 +474,18 @@ function initialHistoryEvent(entry, baseline) {
469
474
  changes: [],
470
475
  };
471
476
  }
477
+ /** Active membership excludes historical baselines and includes unobserved candidates. */
478
+ export function watchlistActiveDomains(entry) {
479
+ if (entry.membershipRecovery)
480
+ return [];
481
+ return normalizeWatchDomainMetadata(entry.domainMetadata, entry.results.map(record => record.domain)).map(record => record.domain);
482
+ }
483
+ export function assertWatchlistEditable(entry) {
484
+ if (entry?.membershipRecovery)
485
+ throw new Error('This older watchlist is paused for membership recovery. Export its preserved records and create a separate watchlist with at most 2,000 selected domains.');
486
+ }
472
487
  /** @param {object} entry */
473
- export function normalizeWatchlistEntry(entry) {
488
+ export function normalizeWatchlistEntry(entry, options = {}) {
474
489
  const input = plainRecord(entry) || {};
475
490
  const rawResults = Array.isArray(input.results) ? input.results : [];
476
491
  const results = compactWatchlistResults(rawResults);
@@ -502,13 +517,21 @@ export function normalizeWatchlistEntry(entry) {
502
517
  };
503
518
  }).slice(-MAX_WATCHLIST_HISTORY_EVENTS)
504
519
  : [];
520
+ const metadata = normalizeWatchDomainMetadata(input.domainMetadata);
521
+ const membership = new Set([...metadata.map(record => record.domain), ...results.map(record => record.domain)]);
522
+ const recovery = membership.size > MAX_WATCHLIST_DOMAINS
523
+ && (options.recoverLegacyMembership || input.membershipRecovery === 'legacy_overflow');
524
+ if (input.membershipRecovery !== undefined && (!recovery || input.membershipRecovery !== 'legacy_overflow'))
525
+ throw new TypeError('Watchlist membership recovery state is invalid.');
505
526
  const normalized = {
506
527
  updatedAt: normalizeExplicitIsoTimestamp(input.updatedAt),
507
528
  results,
508
529
  baseline,
509
530
  history,
531
+ domainMetadata: recovery ? metadata : normalizeWatchDomainMetadata(metadata, results.map(record => record.domain)),
532
+ ...(recovery ? WATCHLIST_RECOVERY_METADATA : {}),
510
533
  };
511
- if (normalized.history.length === 0)
534
+ if (normalized.history.length === 0 && (results.length || baseline.length))
512
535
  normalized.history.push(initialHistoryEvent(normalized, baseline));
513
536
  return normalized;
514
537
  }
@@ -528,6 +551,7 @@ export function appendWatchlistScan(existingEntry, results, options = {}) {
528
551
  ? options.mode
529
552
  : 'saved';
530
553
  const previous = existingEntry ? normalizeWatchlistEntry(existingEntry) : null;
554
+ assertWatchlistEditable(previous);
531
555
  const current = compactWatchlistResults(results);
532
556
  const changes = previous
533
557
  ? diffWatchlistBaseline(previous.baseline, current, options.ignoredDomains || new Set())
@@ -550,6 +574,7 @@ export function appendWatchlistScan(existingEntry, results, options = {}) {
550
574
  results: current,
551
575
  baseline: mergeWatchlistBaseline(previous?.baseline || [], current),
552
576
  history,
577
+ domainMetadata: normalizeWatchDomainMetadata(previous?.domainMetadata, current.map(record => record.domain)),
553
578
  },
554
579
  changes,
555
580
  };
@@ -594,6 +619,8 @@ export function watchlistHistoryDomains(entry) {
594
619
  if (record?.domain)
595
620
  current.add(record.domain);
596
621
  }
622
+ for (const metadata of normalized.domainMetadata)
623
+ current.add(metadata.domain);
597
624
  for (const event of normalized.history) {
598
625
  for (const change of event.changes) {
599
626
  if (change.domain && !current.has(change.domain))