@slicedearth/whoisleuth-cli 2.5.1 → 2.7.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.
- package/DISCLOSURE +9 -6
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -814
- package/cli/artifact-validation/lookup-evidence.mjs +30 -3
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +41 -3
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +92 -16
- package/cli/case-pack.mjs +14 -3
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +7 -6
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/doctor.mjs +37 -3
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +9 -14
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +15 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +21 -13
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +23 -17
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/saved-lookup.mjs +22 -7
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +9 -7
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +27 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +200 -26
- package/docs/contextual-reviews.md +38 -5
- package/docs/infrastructure-observations.md +135 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dmarc-discovery.mjs +4 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-inheritance-review.mjs +14 -1
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-export.mjs +10 -4
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/html-signals.mjs +6 -0
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +10 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/operation-budget.mjs +4 -0
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +142 -140
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-authority.mjs +56 -1
- package/lib/whois-chain.mjs +14 -4
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +9 -7
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +44 -0
- package/packages/cases/case-evidence-model.mjs +51 -7
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +119 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +87 -9
- package/packages/cases/case-record-projection.mjs +43 -5
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +35 -4
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +32 -17
- package/packages/cases/case-response-review-inputs.mjs +66 -8
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +1 -17
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +292 -43
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +13 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/registry-insights.mjs +6 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +40 -21
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +50 -12
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +241 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +12 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +30 -30
|
@@ -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
|
-
|
|
659
|
-
|
|
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);
|
|
@@ -35,6 +35,14 @@ const SOURCE_STATES = new Set([
|
|
|
35
35
|
]);
|
|
36
36
|
const TRUST_STATES = new Set(['official', 'partner', 'allowlisted']);
|
|
37
37
|
const COMPARISON_STATES = new Set(['error', 'not_found', 'partial', 'success', 'unavailable']);
|
|
38
|
+
/** Resume an admitted session without implicitly retrying any recorded row. */
|
|
39
|
+
export function planBulkSessionResume(session) {
|
|
40
|
+
const settled = new Set(session.results.map(row => row.domain));
|
|
41
|
+
return {
|
|
42
|
+
retainedRows: session.results.length,
|
|
43
|
+
pendingDomains: session.domains.filter(domain => !settled.has(domain)),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
38
46
|
function record(value) {
|
|
39
47
|
return ordinaryWorkspaceRecord(value, 'Bulk-session input');
|
|
40
48
|
}
|
|
@@ -338,6 +346,7 @@ export function normalizeBulkSessionResult(value, inheritedProfileContext) {
|
|
|
338
346
|
reusesOfficialAssets: profileClaimsUsable ? nullableBoolean(item.reusesOfficialAssets) : null,
|
|
339
347
|
hasPasswordField: item.hasPasswordField === true,
|
|
340
348
|
hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
|
|
349
|
+
hasExternalPasswordForm: nullableBoolean(item.hasExternalPasswordForm),
|
|
341
350
|
phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
|
|
342
351
|
idnReferenceMatch: profileClaimsUsable ? nullableBoolean(item.idnReferenceMatch) : null,
|
|
343
352
|
pageBaselineMatch: profileClaimsUsable ? nullableBoolean(item.pageBaselineMatch) : null,
|
|
@@ -393,6 +402,9 @@ export function normalizeBulkSession(value, sourceStoreVersion) {
|
|
|
393
402
|
const results = [];
|
|
394
403
|
const seen = new Set();
|
|
395
404
|
for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
|
|
405
|
+
if (sourceStoreVersion !== undefined && sourceStoreVersion < 7 && Object.hasOwn(record(candidate) ?? {}, 'hasExternalPasswordForm')) {
|
|
406
|
+
throw new TypeError('Form attribution requires Bulk schema 7 or later; historical evidence was not reinterpreted.');
|
|
407
|
+
}
|
|
396
408
|
if (sourceStoreVersion !== undefined && sourceStoreVersion < 6 && record(candidate)?.webCollectionQuality !== undefined) {
|
|
397
409
|
throw new TypeError('Web collection quality requires Bulk schema 6 or later; historical evidence was not reinterpreted.');
|
|
398
410
|
}
|
|
@@ -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
|
|
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
|
-
...
|
|
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))
|