@slicedearth/whoisleuth-cli 1.47.4 → 2.0.1
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/README.md +74 -177
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +2 -1
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +204 -174
- package/cli/artifact-structure.mjs +837 -163
- package/cli/artifact-verify.mjs +90 -61
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +33 -20
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +180 -576
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +910 -92
- package/cli/completion.mjs +504 -226
- package/cli/config-profile.mjs +13 -3
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +9 -9
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/domain-control-monitor.mjs +203 -30
- package/cli/domain-control-observations.mjs +15 -21
- package/cli/errors.mjs +8 -3
- package/cli/evidence-signing.mjs +10 -20
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +583 -22
- package/cli/input.mjs +7 -2
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +19 -19
- package/cli/investigation-manifest.mjs +1 -1
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +9 -1
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +94 -31
- package/cli/lookup-diff.mjs +2 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/offline-evidence-review.mjs +18 -7
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +11 -6
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +9 -51
- package/cli/registry-doctor.mjs +67 -28
- package/cli/retained-artifact-diff.mjs +7 -8
- package/cli/risk-calibration.mjs +85 -38
- package/cli/runner.mjs +189 -75
- package/cli/saved-lookup.mjs +424 -57
- package/cli/sharing-review.mjs +6 -1
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1708
- package/docs/cli.md +86 -254
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
- package/frontend/src/lib/analysis/domain-control-records.js +3 -88
- package/lib/availability.mjs +58 -40
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +78 -4
- package/lib/bounded-json.mjs +51 -13
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +35 -17
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +37 -21
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +2 -3
- package/lib/dnssec-evidence-validation.mjs +13 -7
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +1 -1
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -59
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +518 -36
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-intelligence.mjs +331 -2
- package/lib/interchange-fidelity-registry.mjs +28 -26
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup.mjs +25 -2
- package/lib/observed-network-context.mjs +1 -1
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/portable-generator.mjs +8 -0
- package/lib/project-metadata.mjs +1 -1
- package/lib/provider-policy-admission.mjs +3 -1
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +306 -75
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +35 -33
- package/lib/risk-scoring.mjs +4 -3
- package/lib/safe-fetch.mjs +62 -40
- package/lib/security-txt.mjs +1 -1
- package/lib/smtp-transport-review.mjs +8 -5
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +3 -5
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +4 -4
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +13 -5
- package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
- package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
- package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
- package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
- package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
- package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
- package/third-party-notices.txt +14 -14
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -305
- package/frontend/src/lib/analysis/case-record-model.js +0 -9
- package/frontend/src/lib/analysis/case-response-model.js +0 -728
- package/frontend/src/lib/analysis/case-response-packet.js +0 -583
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -255
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
- package/frontend/src/lib/analysis/visualization-models.js +0 -338
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
- package/lib/bounded-json-response.mjs +0 -170
- package/lib/lookup-response-contract.mjs +0 -1034
- package/lib/observation.mjs +0 -115
- /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -1,31 +1,14 @@
|
|
|
1
1
|
// Pure Brand Profile normalization and storage model. The browser wrapper owns
|
|
2
2
|
// persistence and downloads; this module owns schema migration, semantic field
|
|
3
3
|
// bounds, import merging, and exact serialized-byte accounting.
|
|
4
|
-
import { normalizeDomain } from "
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export
|
|
12
|
-
export const MAX_PROFILE_VALUES = 200;
|
|
13
|
-
export const MAX_PROFILE_VALUE_INPUTS = MAX_PROFILE_VALUES * 4;
|
|
14
|
-
// Profiles share the origin's browser-storage quota with cases, campaigns,
|
|
15
|
-
// watchlists, and CT history. Fail at a predictable one-megabyte boundary.
|
|
16
|
-
export const MAX_PROFILE_STORE_BYTES = 1024 * 1024;
|
|
17
|
-
export const MAX_PROFILE_NAME_LENGTH = 100;
|
|
18
|
-
export const MAX_PROFILE_TEXT_LENGTH = 200;
|
|
19
|
-
export const MAX_PROFILE_DOMAIN_LENGTH = 253;
|
|
20
|
-
export const MAX_PROFILE_TLD_LENGTH = 63;
|
|
21
|
-
export const MAX_DKIM_SELECTOR_LENGTH = 253;
|
|
22
|
-
export const MAX_DKIM_SELECTORS = 10;
|
|
23
|
-
export const MAX_PROTECTION_ATTESTATIONS = 6;
|
|
24
|
-
export const MAX_DESIRED_POSTURE_BASELINES = 20;
|
|
25
|
-
export const MAX_DESIRED_POSTURE_RECORDS = 32;
|
|
26
|
-
export const MAX_DESIRED_POSTURE_SUPPRESSIONS = 12;
|
|
27
|
-
export const MAX_DESIRED_POSTURE_OBSERVATIONS = 12;
|
|
28
|
-
export const MAX_DESIRED_POSTURE_CHANGE_WINDOWS = 8;
|
|
4
|
+
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
5
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
6
|
+
import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
|
|
7
|
+
import { normalizePageBaseline } from "./page-baseline.mjs";
|
|
8
|
+
import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
|
|
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";
|
|
29
12
|
const SHA256_RE = /^[a-f0-9]{64}$/i;
|
|
30
13
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
31
14
|
const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
@@ -46,7 +29,7 @@ const PROTECTION_ATTESTATION_STATES = new Set([
|
|
|
46
29
|
'unavailable',
|
|
47
30
|
'not_applicable',
|
|
48
31
|
]);
|
|
49
|
-
const
|
|
32
|
+
export const DESIRED_POSTURE_SUPPRESSION_FIELDS = Object.freeze([
|
|
50
33
|
'nameservers',
|
|
51
34
|
'ds',
|
|
52
35
|
'mx',
|
|
@@ -57,11 +40,10 @@ const DESIRED_POSTURE_FIELDS = new Set([
|
|
|
57
40
|
'registrar_lock',
|
|
58
41
|
'renewal_review',
|
|
59
42
|
]);
|
|
43
|
+
const DESIRED_POSTURE_FIELDS = new Set(DESIRED_POSTURE_SUPPRESSION_FIELDS);
|
|
60
44
|
const POSTURE_CHECK_STATUSES = new Set(['danger', 'info', 'pass', 'warning']);
|
|
61
45
|
function record(value) {
|
|
62
|
-
return value
|
|
63
|
-
? value
|
|
64
|
-
: {};
|
|
46
|
+
return ordinaryWorkspaceRecord(value, 'Brand Profile input') ?? {};
|
|
65
47
|
}
|
|
66
48
|
function boundedText(value, maximum = MAX_PROFILE_TEXT_LENGTH) {
|
|
67
49
|
if (typeof value !== 'string' || CONTROL_RE.test(value))
|
|
@@ -72,12 +54,13 @@ export function normalizeBrandProfileId(value) {
|
|
|
72
54
|
return normalizeOpaqueReferenceId(value);
|
|
73
55
|
}
|
|
74
56
|
function timestamp(value, fallback) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
57
|
+
return normalizeExplicitIsoTimestamp(value) ?? fallback;
|
|
58
|
+
}
|
|
59
|
+
function calendarTimestamp(value, fallback) {
|
|
60
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(value)) {
|
|
61
|
+
return normalizeExplicitIsoTimestamp(`${value}T00:00:00Z`) ?? fallback;
|
|
79
62
|
}
|
|
80
|
-
return fallback;
|
|
63
|
+
return timestamp(value, fallback);
|
|
81
64
|
}
|
|
82
65
|
function normalizeTld(value) {
|
|
83
66
|
if (typeof value !== 'string' || CONTROL_RE.test(value))
|
|
@@ -157,7 +140,7 @@ export function normalizeProtectionAttestations(value) {
|
|
|
157
140
|
control: candidate.control,
|
|
158
141
|
state: candidate.state,
|
|
159
142
|
assertedAt,
|
|
160
|
-
expiresAt:
|
|
143
|
+
expiresAt: calendarTimestamp(candidate.expiresAt, null),
|
|
161
144
|
note: boundedText(candidate.note),
|
|
162
145
|
});
|
|
163
146
|
if (output.length >= MAX_PROTECTION_ATTESTATIONS)
|
|
@@ -218,10 +201,25 @@ function normalizeDesiredPostureObservationHistory(value, previous) {
|
|
|
218
201
|
.sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt))
|
|
219
202
|
.slice(-MAX_DESIRED_POSTURE_OBSERVATIONS);
|
|
220
203
|
}
|
|
221
|
-
function
|
|
204
|
+
function deterministicChangeWindowId(seed) {
|
|
205
|
+
const states = [0x811c9dc5, 0x9e3779b9, 0x85ebca6b, 0xc2b2ae35];
|
|
206
|
+
const factors = [0x01000193, 0x85ebca6b, 0xc2b2ae35, 0x27d4eb2f];
|
|
207
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
208
|
+
const code = seed.charCodeAt(index);
|
|
209
|
+
for (let stateIndex = 0; stateIndex < states.length; stateIndex += 1) {
|
|
210
|
+
const state = states[stateIndex] ?? 0;
|
|
211
|
+
const mixed = state ^ (code + Math.imul(index + 1, stateIndex + 17));
|
|
212
|
+
states[stateIndex] = Math.imul(mixed, factors[stateIndex] ?? 0x01000193);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return `cw-${states.map((state) => (state >>> 0).toString(16).padStart(8, '0')).join('')}`;
|
|
216
|
+
}
|
|
217
|
+
function normalizeDesiredPostureChangeWindows(value, identityScope = '') {
|
|
222
218
|
if (!Array.isArray(value))
|
|
223
219
|
return [];
|
|
224
220
|
const output = [];
|
|
221
|
+
const seenIds = new Set();
|
|
222
|
+
const duplicateMaterial = new Map();
|
|
225
223
|
for (const item of value.slice(0, MAX_DESIRED_POSTURE_CHANGE_WINDOWS * 4)) {
|
|
226
224
|
const candidate = record(item);
|
|
227
225
|
const startsAt = timestamp(candidate.startsAt, null);
|
|
@@ -229,7 +227,17 @@ function normalizeDesiredPostureChangeWindows(value) {
|
|
|
229
227
|
const summary = boundedText(candidate.summary, 300);
|
|
230
228
|
if (!startsAt || !endsAt || Date.parse(endsAt) <= Date.parse(startsAt) || !summary)
|
|
231
229
|
continue;
|
|
232
|
-
|
|
230
|
+
const material = `${startsAt}\u001f${endsAt}\u001f${summary}`;
|
|
231
|
+
const occurrence = duplicateMaterial.get(material) ?? 0;
|
|
232
|
+
duplicateMaterial.set(material, occurrence + 1);
|
|
233
|
+
let id = normalizeOpaqueReferenceId(candidate.id);
|
|
234
|
+
let salt = 0;
|
|
235
|
+
while (!id || seenIds.has(id)) {
|
|
236
|
+
id = deterministicChangeWindowId(`${identityScope}\u001f${material}\u001f${occurrence}\u001f${salt}`);
|
|
237
|
+
salt += 1;
|
|
238
|
+
}
|
|
239
|
+
seenIds.add(id);
|
|
240
|
+
output.push({ id, startsAt, endsAt, summary });
|
|
233
241
|
if (output.length >= MAX_DESIRED_POSTURE_CHANGE_WINDOWS)
|
|
234
242
|
break;
|
|
235
243
|
}
|
|
@@ -251,7 +259,7 @@ function normalizeTlsSanPatterns(value) {
|
|
|
251
259
|
}
|
|
252
260
|
return [...output].sort();
|
|
253
261
|
}
|
|
254
|
-
export function normalizeDesiredPostureBaselines(value, officialDomains, fallbackNow = new Date().toISOString()) {
|
|
262
|
+
export function normalizeDesiredPostureBaselines(value, officialDomains, fallbackNow = new Date().toISOString(), identityScope = '') {
|
|
255
263
|
if (!Array.isArray(value))
|
|
256
264
|
return [];
|
|
257
265
|
const allowedDomains = new Set(officialDomains);
|
|
@@ -276,7 +284,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
|
|
|
276
284
|
suppressions.push({
|
|
277
285
|
field,
|
|
278
286
|
reason,
|
|
279
|
-
expiresAt:
|
|
287
|
+
expiresAt: calendarTimestamp(suppression.expiresAt, null),
|
|
280
288
|
});
|
|
281
289
|
if (suppressions.length >= MAX_DESIRED_POSTURE_SUPPRESSIONS)
|
|
282
290
|
break;
|
|
@@ -299,7 +307,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
|
|
|
299
307
|
registrarLock: ['required', 'not_required'].includes(String(candidate.registrarLock))
|
|
300
308
|
? candidate.registrarLock
|
|
301
309
|
: 'unconfigured',
|
|
302
|
-
renewalReviewAt:
|
|
310
|
+
renewalReviewAt: calendarTimestamp(candidate.renewalReviewAt, null),
|
|
303
311
|
zoneIntent: ['active_service', 'defensive_registration', 'no_service', 'parked', 'redirect_only'].includes(String(candidate.zoneIntent))
|
|
304
312
|
? candidate.zoneIntent
|
|
305
313
|
: 'unconfigured',
|
|
@@ -307,7 +315,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
|
|
|
307
315
|
? candidate.lifecycle
|
|
308
316
|
: 'active',
|
|
309
317
|
recoveryDependency: boundedText(candidate.recoveryDependency, 200),
|
|
310
|
-
approvedChangeWindows: normalizeDesiredPostureChangeWindows(candidate.approvedChangeWindows),
|
|
318
|
+
approvedChangeWindows: normalizeDesiredPostureChangeWindows(candidate.approvedChangeWindows, `${identityScope}\u001f${domain}`),
|
|
311
319
|
suppressions,
|
|
312
320
|
note: boundedText(candidate.note, MAX_PROFILE_TEXT_LENGTH),
|
|
313
321
|
previousObservation: observationHistory.at(-1) ?? previousObservation,
|
|
@@ -332,9 +340,12 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
332
340
|
const now = timestamp(options.nowIso, new Date().toISOString());
|
|
333
341
|
const officialDomains = normalizeProfileDomains(value.officialDomains);
|
|
334
342
|
const dkimSelectors = normalizeDkimSelectors(value.dkimSelectors);
|
|
335
|
-
const
|
|
343
|
+
const suppliedBaseline = Object.prototype.hasOwnProperty.call(value, 'pageBaseline');
|
|
344
|
+
const candidateBaseline = suppliedBaseline
|
|
336
345
|
? normalizePageBaseline(value.pageBaseline)
|
|
337
346
|
: normalizePageBaseline(existing?.pageBaseline);
|
|
347
|
+
const baselineDomainMismatch = suppliedBaseline && candidateBaseline !== null
|
|
348
|
+
&& !officialDomains.includes(candidateBaseline.domain);
|
|
338
349
|
const pageBaseline = candidateBaseline && officialDomains.includes(candidateBaseline.domain)
|
|
339
350
|
? candidateBaseline
|
|
340
351
|
: null;
|
|
@@ -359,11 +370,11 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
359
370
|
.filter((selector) => !dkimSelectors.includes(selector)),
|
|
360
371
|
mailProtectionProfile: normalizeMailProtectionProfile(value.mailProtectionProfile),
|
|
361
372
|
protectionAttestations: normalizeProtectionAttestations(value.protectionAttestations),
|
|
362
|
-
desiredPostureBaselines: normalizeDesiredPostureBaselines(value.desiredPostureBaselines, officialDomains, now),
|
|
373
|
+
desiredPostureBaselines: normalizeDesiredPostureBaselines(value.desiredPostureBaselines, officialDomains, now, profileId),
|
|
363
374
|
trademarkOwner: boundedText(value.trademarkOwner),
|
|
364
375
|
trademarkRegistration: boundedText(value.trademarkRegistration),
|
|
365
|
-
officialFaviconHash: normalizeFaviconHash(value.officialFaviconHash),
|
|
366
|
-
officialFaviconPHash: normalizeFaviconPHash(value.officialFaviconPHash),
|
|
376
|
+
officialFaviconHash: baselineDomainMismatch ? '' : normalizeFaviconHash(value.officialFaviconHash),
|
|
377
|
+
officialFaviconPHash: baselineDomainMismatch ? '' : normalizeFaviconPHash(value.officialFaviconPHash),
|
|
367
378
|
pageBaseline,
|
|
368
379
|
createdAt,
|
|
369
380
|
updatedAt: options.touch === true ? now : timestamp(value.updatedAt, createdAt),
|
|
@@ -377,15 +388,24 @@ function profileList(raw) {
|
|
|
377
388
|
}
|
|
378
389
|
export function brandProfileStoreVersion(raw) {
|
|
379
390
|
if (Array.isArray(raw))
|
|
380
|
-
return
|
|
391
|
+
return BRAND_PROFILE_SCHEMA_VERSION;
|
|
381
392
|
const value = record(raw);
|
|
382
393
|
return typeof value.version === 'number' && Number.isFinite(value.version) && value.version > 0 ? value.version : null;
|
|
383
394
|
}
|
|
384
395
|
/** Normalize an internal profile collection or current stored envelope. */
|
|
385
396
|
export function normalizeBrandProfileStore(raw) {
|
|
397
|
+
assertWorkspaceInputGraph(raw, 'Brand Profile store');
|
|
398
|
+
assertWorkspaceDeclaredVersion(raw, 'Brand Profile store');
|
|
399
|
+
const sourceVersion = brandProfileStoreVersion(raw);
|
|
400
|
+
if (!Array.isArray(raw) && sourceVersion !== null
|
|
401
|
+
&& !SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS.includes(sourceVersion)) {
|
|
402
|
+
throw new Error(`Brand Profile schema ${sourceVersion} is unsupported; no data was changed.`);
|
|
403
|
+
}
|
|
386
404
|
const byId = new Map();
|
|
387
405
|
for (const item of profileList(raw).slice(0, MAX_PROFILES * 4)) {
|
|
388
|
-
const profile = normalizeBrandProfile(item
|
|
406
|
+
const profile = normalizeBrandProfile(item, {
|
|
407
|
+
nowIso: new Date(0).toISOString(),
|
|
408
|
+
});
|
|
389
409
|
if (!profile)
|
|
390
410
|
continue;
|
|
391
411
|
const previous = byId.get(profile.id);
|
|
@@ -410,6 +430,9 @@ export function assertBrandProfileStoreBudget(profiles) {
|
|
|
410
430
|
return store;
|
|
411
431
|
}
|
|
412
432
|
export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
|
|
433
|
+
assertWorkspaceInputGraph(localRaw, 'Local Brand Profile store');
|
|
434
|
+
assertWorkspaceInputGraph(importedRaw, 'Imported Brand Profile document');
|
|
435
|
+
assertWorkspacePortableVersion(importedRaw, BRAND_PROFILE_SCHEMA_VERSION, 'Imported Brand Profile document');
|
|
413
436
|
const imported = record(importedRaw);
|
|
414
437
|
if (imported.schema !== BRAND_PROFILE_SCHEMA) {
|
|
415
438
|
throw new Error('This JSON file is not a WHOISleuth Brand Profile export.');
|
|
@@ -457,7 +480,7 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
|
|
|
457
480
|
const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
|
|
458
481
|
const profile = normalizeBrandProfile(item, {
|
|
459
482
|
existing,
|
|
460
|
-
touch:
|
|
483
|
+
touch: Boolean(existing),
|
|
461
484
|
nowIso: options.nowIso,
|
|
462
485
|
makeId: options.makeId,
|
|
463
486
|
});
|
package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs}
RENAMED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { normalizeDomain } from "
|
|
2
|
-
import { normalizeBulkPresentationSortKey } from "./bulk-sort.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
export const MAX_BULK_REVIEW_STORE_BYTES = 512 * 1024;
|
|
8
|
-
export const MAX_BULK_REVIEW_NAME_LENGTH = 80;
|
|
1
|
+
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
2
|
+
import { normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
|
|
3
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
|
+
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
5
|
+
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
|
+
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";
|
|
9
7
|
export const BULK_REVIEW_STATES = ['unreviewed', 'reviewing', 'reviewed', 'deferred'];
|
|
10
8
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
11
9
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
@@ -20,9 +18,7 @@ const REVIEW_STATES = new Set(BULK_REVIEW_STATES);
|
|
|
20
18
|
const CASE_DISPOSITIONS = new Set(['', 'untracked', 'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action']);
|
|
21
19
|
const SIGNAL_FILTERS = new Set(['favicon', 'password', 'phishing', 'asset_reuse', 'idn']);
|
|
22
20
|
function record(value) {
|
|
23
|
-
return value
|
|
24
|
-
? value
|
|
25
|
-
: {};
|
|
21
|
+
return ordinaryWorkspaceRecord(value, 'Bulk-review input') ?? {};
|
|
26
22
|
}
|
|
27
23
|
function text(value, maximum) {
|
|
28
24
|
if (typeof value !== 'string' || CONTROL_RE.test(value))
|
|
@@ -30,9 +26,7 @@ function text(value, maximum) {
|
|
|
30
26
|
return value.trim().slice(0, maximum);
|
|
31
27
|
}
|
|
32
28
|
function timestamp(value, fallback) {
|
|
33
|
-
|
|
34
|
-
const parsed = Date.parse(normalized);
|
|
35
|
-
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
|
|
29
|
+
return normalizeExplicitIsoTimestamp(value) ?? fallback;
|
|
36
30
|
}
|
|
37
31
|
function safeId(value) {
|
|
38
32
|
return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : '';
|
|
@@ -111,6 +105,8 @@ function sourceLists(raw) {
|
|
|
111
105
|
};
|
|
112
106
|
}
|
|
113
107
|
export function normalizeBulkReviewStore(raw) {
|
|
108
|
+
assertWorkspaceInputGraph(raw, 'Bulk-review store');
|
|
109
|
+
assertWorkspaceDeclaredVersion(raw, 'Bulk-review store');
|
|
114
110
|
const fallback = new Date(0).toISOString();
|
|
115
111
|
const source = sourceLists(raw);
|
|
116
112
|
const presets = new Map();
|
|
@@ -208,6 +204,9 @@ export function buildBulkReviewExport(raw) {
|
|
|
208
204
|
return enforceBulkReviewBudget(raw);
|
|
209
205
|
}
|
|
210
206
|
export function mergeBulkReviewStores(localRaw, importedRaw) {
|
|
207
|
+
assertWorkspaceInputGraph(localRaw, 'Local Bulk-review store');
|
|
208
|
+
assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-review document');
|
|
209
|
+
assertWorkspacePortableVersion(importedRaw, BULK_REVIEW_SCHEMA_VERSION, 'Imported Bulk-review document');
|
|
211
210
|
const importedRecord = record(importedRaw);
|
|
212
211
|
if (importedRecord.schema !== BULK_REVIEW_SCHEMA) {
|
|
213
212
|
throw new Error('This file is not a WHOISleuth Bulk review export.');
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
// Pure, bounded persistence model for analyst-saved Bulk investigations.
|
|
2
2
|
// Sessions retain compact derived rows and source states, never raw registry,
|
|
3
3
|
// WHOIS, HTTP, TLS, page, or provider payloads.
|
|
4
|
-
import { normalizeDomain } from "
|
|
5
|
-
import { normalizeCaaCritical } from "./dns-record-normalization.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
export const MAX_BULK_SESSION_ROWS = 2_000;
|
|
11
|
-
export const MAX_BULK_SESSION_STORE_BYTES = 4 * 1024 * 1024;
|
|
12
|
-
export const MAX_BULK_SESSION_NAME_LENGTH = 100;
|
|
13
|
-
export const MAX_BULK_SESSION_TEXT_LENGTH = 500;
|
|
14
|
-
export const MAX_BULK_SESSION_ARRAY_VALUES = 100;
|
|
15
|
-
export const MAX_BULK_SESSION_SOURCES = 12;
|
|
16
|
-
export const MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH = 300;
|
|
4
|
+
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
5
|
+
import { normalizeCaaCritical } from "./dns-record-normalization.mjs";
|
|
6
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
7
|
+
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
8
|
+
import { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ARRAY_VALUES, MAX_BULK_SESSION_NAME_LENGTH, MAX_BULK_SESSION_ROWS, MAX_BULK_SESSION_SOURCES, MAX_BULK_SESSION_STORE_BYTES, MAX_BULK_SESSION_TEXT_LENGTH, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
9
|
+
export { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ARRAY_VALUES, MAX_BULK_SESSION_NAME_LENGTH, MAX_BULK_SESSION_ROWS, MAX_BULK_SESSION_SOURCES, MAX_BULK_SESSION_STORE_BYTES, MAX_BULK_SESSION_TEXT_LENGTH, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
17
10
|
export const BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION = 'Brand Profile context was unavailable when this row was evaluated. Trust, profile matches, and the profile-dependent Risk assessment remain inconclusive.';
|
|
18
|
-
export const BULK_PROFILE_CONTEXT_LEGACY_LIMITATION = 'This legacy Bulk row did not retain profile-context provenance. Trust, profile matches, and the potentially profile-influenced Risk assessment are withheld until the domain is rescanned.';
|
|
19
11
|
export const BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION = 'This saved Bulk row was evaluated with a different or unreadable Brand Profile context. Profile-derived conclusions and the Risk assessment are withheld until the domain is rescanned.';
|
|
20
12
|
export const BULK_PROFILE_CONTEXT_IMPORTED_LIMITATION = 'This row came from a portable import whose claimed Brand Profile identity and revision cannot authenticate profile-derived conclusions. Trust, profile matches, and the potentially profile-influenced Risk assessment are withheld until the domain is rescanned locally.';
|
|
21
13
|
const CONTROL_RE = /[\x00-\x1f\x7f]/u;
|
|
@@ -41,9 +33,7 @@ const SOURCE_STATES = new Set([
|
|
|
41
33
|
const TRUST_STATES = new Set(['official', 'partner', 'allowlisted']);
|
|
42
34
|
const COMPARISON_STATES = new Set(['error', 'not_found', 'partial', 'success', 'unavailable']);
|
|
43
35
|
function record(value) {
|
|
44
|
-
return value
|
|
45
|
-
? value
|
|
46
|
-
: null;
|
|
36
|
+
return ordinaryWorkspaceRecord(value, 'Bulk-session input');
|
|
47
37
|
}
|
|
48
38
|
function boundedText(value, maximum = MAX_BULK_SESSION_TEXT_LENGTH) {
|
|
49
39
|
return typeof value === 'string' && value.length <= maximum && !CONTROL_RE.test(value)
|
|
@@ -51,8 +41,7 @@ function boundedText(value, maximum = MAX_BULK_SESSION_TEXT_LENGTH) {
|
|
|
51
41
|
: '';
|
|
52
42
|
}
|
|
53
43
|
function timestamp(value, fallback = null) {
|
|
54
|
-
|
|
55
|
-
return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : fallback;
|
|
44
|
+
return normalizeExplicitIsoTimestamp(value) ?? fallback;
|
|
56
45
|
}
|
|
57
46
|
function profileContextLimitation(value, fallback) {
|
|
58
47
|
return boundedText(value, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH) || fallback;
|
|
@@ -281,7 +270,7 @@ export function normalizeBulkComparisonEvidence(value) {
|
|
|
281
270
|
},
|
|
282
271
|
};
|
|
283
272
|
}
|
|
284
|
-
export function normalizeBulkSessionResult(value
|
|
273
|
+
export function normalizeBulkSessionResult(value) {
|
|
285
274
|
const item = record(value);
|
|
286
275
|
const domain = normalizeDomain(item?.domain);
|
|
287
276
|
const status = boundedText(item?.status, 20);
|
|
@@ -289,13 +278,10 @@ export function normalizeBulkSessionResult(value, sourceVersion = BULK_SESSION_S
|
|
|
289
278
|
if (!item || !domain || !RESULT_STATES.has(status) || !MODES.has(scanDepth))
|
|
290
279
|
return null;
|
|
291
280
|
const rawProfileContext = record(item.profileContext);
|
|
292
|
-
if (
|
|
293
|
-
&& (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState))))
|
|
281
|
+
if (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState)))
|
|
294
282
|
return null;
|
|
295
|
-
const profileContext =
|
|
296
|
-
|
|
297
|
-
: unavailableBulkProfileContext(BULK_PROFILE_CONTEXT_LEGACY_LIMITATION);
|
|
298
|
-
if (sourceVersion >= 4 && rawProfileContext?.sourceState === 'ready' && profileContext.sourceState !== 'ready')
|
|
283
|
+
const profileContext = normalizeBulkProfileContext(rawProfileContext);
|
|
284
|
+
if (rawProfileContext.sourceState === 'ready' && profileContext.sourceState !== 'ready')
|
|
299
285
|
return null;
|
|
300
286
|
const profileContextReady = profileContext.sourceState === 'ready';
|
|
301
287
|
const trusted = boundedText(item.trusted, 20);
|
|
@@ -357,7 +343,7 @@ export function normalizeBulkSessionResult(value, sourceVersion = BULK_SESSION_S
|
|
|
357
343
|
profileContext,
|
|
358
344
|
};
|
|
359
345
|
}
|
|
360
|
-
export function normalizeBulkSession(value
|
|
346
|
+
export function normalizeBulkSession(value) {
|
|
361
347
|
const item = record(value);
|
|
362
348
|
const id = boundedText(item?.id, 128);
|
|
363
349
|
const name = boundedText(item?.name, MAX_BULK_SESSION_NAME_LENGTH);
|
|
@@ -379,33 +365,27 @@ export function normalizeBulkSession(value, sourceVersion = BULK_SESSION_SCHEMA_
|
|
|
379
365
|
const domains = domainList(item.domains);
|
|
380
366
|
if (!domains.length)
|
|
381
367
|
return null;
|
|
382
|
-
if (
|
|
368
|
+
if (!Array.isArray(item.results))
|
|
383
369
|
return null;
|
|
384
370
|
const allowed = new Set(domains);
|
|
385
371
|
const results = [];
|
|
386
372
|
const seen = new Set();
|
|
387
373
|
for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
|
|
388
|
-
const result = normalizeBulkSessionResult(candidate
|
|
374
|
+
const result = normalizeBulkSessionResult(candidate);
|
|
389
375
|
if (!result || !allowed.has(result.domain) || seen.has(result.domain)) {
|
|
390
|
-
|
|
391
|
-
return null;
|
|
392
|
-
continue;
|
|
376
|
+
return null;
|
|
393
377
|
}
|
|
394
378
|
seen.add(result.domain);
|
|
395
379
|
results.push(result);
|
|
396
|
-
if (sourceVersion < 4 && results.length >= MAX_BULK_SESSION_ROWS)
|
|
397
|
-
break;
|
|
398
380
|
}
|
|
399
|
-
if (
|
|
381
|
+
if ((state === 'complete') !== (results.length === domains.length))
|
|
400
382
|
return null;
|
|
401
383
|
const profileContext = summarizeBulkProfileContexts(results);
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
return null;
|
|
408
|
-
}
|
|
384
|
+
if (!record(item.profileContext))
|
|
385
|
+
return null;
|
|
386
|
+
const declaredProfileContext = normalizeBulkProfileContext(item.profileContext);
|
|
387
|
+
if (!sameProfileContext(declaredProfileContext, profileContext))
|
|
388
|
+
return null;
|
|
409
389
|
return {
|
|
410
390
|
id,
|
|
411
391
|
name,
|
|
@@ -427,11 +407,13 @@ export function bulkSessionStoreVersion(raw) {
|
|
|
427
407
|
: null;
|
|
428
408
|
}
|
|
429
409
|
export function normalizeBulkSessionStore(raw) {
|
|
410
|
+
assertWorkspaceInputGraph(raw, 'Bulk-session store');
|
|
411
|
+
assertWorkspaceDeclaredVersion(raw, 'Bulk-session store');
|
|
430
412
|
const value = record(raw);
|
|
431
|
-
|
|
432
|
-
&& SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS.includes(Number(value.version))
|
|
433
|
-
|
|
434
|
-
|
|
413
|
+
if (value?.schema === BULK_SESSION_SCHEMA
|
|
414
|
+
&& !SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS.includes(Number(value.version))) {
|
|
415
|
+
throw new Error(`Bulk-session store schema ${String(value.version)} is unsupported; no data was changed.`);
|
|
416
|
+
}
|
|
435
417
|
const candidates = Array.isArray(raw)
|
|
436
418
|
? raw
|
|
437
419
|
: value?.schema === BULK_SESSION_SCHEMA
|
|
@@ -441,17 +423,7 @@ export function normalizeBulkSessionStore(raw) {
|
|
|
441
423
|
: [];
|
|
442
424
|
const byId = new Map();
|
|
443
425
|
for (const candidate of candidates.slice(0, MAX_BULK_SESSIONS * 4)) {
|
|
444
|
-
const
|
|
445
|
-
const resultRecords = Array.isArray(candidateRecord?.results)
|
|
446
|
-
? candidateRecord.results.map(record).filter((item) => Boolean(item))
|
|
447
|
-
: [];
|
|
448
|
-
const whollyUnversionedLegacy = Array.isArray(raw)
|
|
449
|
-
&& !Object.hasOwn(candidateRecord ?? {}, 'profileContext')
|
|
450
|
-
&& resultRecords.every((item) => !Object.hasOwn(item, 'profileContext'));
|
|
451
|
-
const candidateVersion = Array.isArray(raw)
|
|
452
|
-
? whollyUnversionedLegacy ? 3 : BULK_SESSION_SCHEMA_VERSION
|
|
453
|
-
: sourceVersion ?? BULK_SESSION_SCHEMA_VERSION;
|
|
454
|
-
const session = normalizeBulkSession(candidate, candidateVersion);
|
|
426
|
+
const session = normalizeBulkSession(candidate);
|
|
455
427
|
if (!session)
|
|
456
428
|
continue;
|
|
457
429
|
const existing = byId.get(session.id);
|
|
@@ -623,6 +595,9 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
|
|
|
623
595
|
};
|
|
624
596
|
}
|
|
625
597
|
export function mergeBulkSessions(localRaw, importedRaw) {
|
|
598
|
+
assertWorkspaceInputGraph(localRaw, 'Local Bulk-session store');
|
|
599
|
+
assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-session document');
|
|
600
|
+
assertWorkspacePortableVersion(importedRaw, BULK_SESSION_SCHEMA_VERSION, 'Imported Bulk-session document');
|
|
626
601
|
const imported = record(importedRaw);
|
|
627
602
|
if (!imported || imported.schema !== BULK_SESSION_SCHEMA || !Array.isArray(imported.sessions)) {
|
|
628
603
|
throw new Error('This file is not a WHOISleuth Bulk session export.');
|
|
@@ -637,9 +612,8 @@ export function mergeBulkSessions(localRaw, importedRaw) {
|
|
|
637
612
|
let added = 0;
|
|
638
613
|
let updated = 0;
|
|
639
614
|
let skipped = 0;
|
|
640
|
-
const importedVersion = Number(imported.version);
|
|
641
615
|
for (const candidate of imported.sessions.slice(0, MAX_BULK_SESSIONS * 4)) {
|
|
642
|
-
const normalized = normalizeBulkSession(candidate
|
|
616
|
+
const normalized = normalizeBulkSession(candidate);
|
|
643
617
|
if (!normalized) {
|
|
644
618
|
skipped += 1;
|
|
645
619
|
continue;
|
|
@@ -19,8 +19,11 @@ function compareText(left, right) {
|
|
|
19
19
|
const rightText = boundedText(right);
|
|
20
20
|
return leftText.localeCompare(rightText, 'en', { numeric: true, sensitivity: 'base' });
|
|
21
21
|
}
|
|
22
|
-
function sortValue(row, key) {
|
|
23
|
-
if (key === 'risk'
|
|
22
|
+
function sortValue(row, key, comparableRisk) {
|
|
23
|
+
if (key === 'risk') {
|
|
24
|
+
return { missing: !Number.isFinite(comparableRisk), value: Number(comparableRisk ?? 0) };
|
|
25
|
+
}
|
|
26
|
+
if (key === 'opportunity') {
|
|
24
27
|
return { missing: !Number.isFinite(row[key]), value: Number(row[key] ?? 0) };
|
|
25
28
|
}
|
|
26
29
|
if (key === 'confidence') {
|
|
@@ -35,10 +38,10 @@ function sortValue(row, key) {
|
|
|
35
38
|
export function defaultBulkSortDirection(key) {
|
|
36
39
|
return key === 'risk' || key === 'opportunity' || key === 'confidence' ? -1 : 1;
|
|
37
40
|
}
|
|
38
|
-
export function sortBulkResults(rows, key, direction) {
|
|
41
|
+
export function sortBulkResults(rows, key, direction, riskValue = null) {
|
|
39
42
|
return [...rows].sort((left, right) => {
|
|
40
|
-
const leftValue = sortValue(left, key);
|
|
41
|
-
const rightValue = sortValue(right, key);
|
|
43
|
+
const leftValue = sortValue(left, key, key === 'risk' && riskValue ? riskValue(left) : left.risk);
|
|
44
|
+
const rightValue = sortValue(right, key, key === 'risk' && riskValue ? riskValue(right) : right.risk);
|
|
42
45
|
// Missing values always remain last instead of jumping to the top when
|
|
43
46
|
// the requested direction changes.
|
|
44
47
|
if (leftValue.missing !== rightValue.missing)
|
package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs}
RENAMED
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
// Pure, framework-neutral browser-local campaign model. Campaigns deliberately
|
|
2
2
|
// reference normalized case domains rather than copying case evidence, notes,
|
|
3
3
|
// or mutable case ids. The browser wrapper owns persistence and downloads.
|
|
4
|
-
import { normalizeDomain } from "
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
export const MAX_CAMPAIGN_NAME_LENGTH = 100;
|
|
10
|
-
export const MAX_CAMPAIGN_DESCRIPTION_LENGTH = 1000;
|
|
11
|
-
export const MAX_CAMPAIGN_IMPORT_BYTES = 2 * 1024 * 1024;
|
|
12
|
-
export const MAX_CAMPAIGN_INPUT_RECORDS = 500;
|
|
13
|
-
// Cases and watchlists share the same per-origin browser quota. Keep this
|
|
14
|
-
// collection smaller than the case store and fail before a browser quota error.
|
|
15
|
-
export const MAX_CAMPAIGN_STORE_BYTES = 512 * 1024;
|
|
4
|
+
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
5
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
6
|
+
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
7
|
+
import { CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, MAX_CAMPAIGNS, MAX_CAMPAIGN_DESCRIPTION_LENGTH, MAX_CAMPAIGN_DOMAINS, MAX_CAMPAIGN_INPUT_RECORDS, MAX_CAMPAIGN_NAME_LENGTH, MAX_CAMPAIGN_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
|
|
8
|
+
export { CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, MAX_CAMPAIGNS, MAX_CAMPAIGN_DESCRIPTION_LENGTH, MAX_CAMPAIGN_DOMAINS, MAX_CAMPAIGN_IMPORT_BYTES, MAX_CAMPAIGN_INPUT_RECORDS, MAX_CAMPAIGN_NAME_LENGTH, MAX_CAMPAIGN_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
|
|
16
9
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
17
10
|
function plainRecord(value) {
|
|
18
|
-
return value
|
|
19
|
-
? value
|
|
20
|
-
: null;
|
|
11
|
+
return ordinaryWorkspaceRecord(value, 'Campaign input');
|
|
21
12
|
}
|
|
22
13
|
function hashString(value) {
|
|
23
14
|
let hash = 2166136261 >>> 0;
|
|
@@ -36,10 +27,7 @@ function makeId() {
|
|
|
36
27
|
return `campaign-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
37
28
|
}
|
|
38
29
|
function isoOrNull(value) {
|
|
39
|
-
|
|
40
|
-
return null;
|
|
41
|
-
const parsed = Date.parse(value);
|
|
42
|
-
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
30
|
+
return normalizeExplicitIsoTimestamp(value);
|
|
43
31
|
}
|
|
44
32
|
function normalizeName(value) {
|
|
45
33
|
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, MAX_CAMPAIGN_NAME_LENGTH).trim();
|
|
@@ -97,10 +85,12 @@ export function campaignStoreVersion(raw) {
|
|
|
97
85
|
}
|
|
98
86
|
/** Recover a deterministic, bounded store from parsed browser data. */
|
|
99
87
|
export function normalizeCampaignStore(raw) {
|
|
100
|
-
|
|
88
|
+
assertWorkspaceInputGraph(raw, 'Campaign store');
|
|
89
|
+
assertWorkspaceDeclaredVersion(raw, 'Campaign store');
|
|
90
|
+
const fallback = new Date(0).toISOString();
|
|
101
91
|
const byId = new Map();
|
|
102
92
|
for (const item of asCampaignList(raw).slice(0, MAX_CAMPAIGN_INPUT_RECORDS)) {
|
|
103
|
-
const campaign = normalizeCampaign(item,
|
|
93
|
+
const campaign = normalizeCampaign(item, fallback);
|
|
104
94
|
if (!campaign)
|
|
105
95
|
continue;
|
|
106
96
|
const existing = byId.get(campaign.id);
|
|
@@ -207,14 +197,22 @@ function mergeCampaign(local, imported) {
|
|
|
207
197
|
}
|
|
208
198
|
/** Non-destructively merge a portable export into local campaigns by id. */
|
|
209
199
|
export function mergeCampaigns(localRaw, importedRaw) {
|
|
200
|
+
assertWorkspaceInputGraph(localRaw, 'Local campaign store');
|
|
201
|
+
assertWorkspaceInputGraph(importedRaw, 'Imported campaign document');
|
|
202
|
+
assertWorkspacePortableVersion(importedRaw, CAMPAIGN_SCHEMA_VERSION, 'Imported campaign document');
|
|
210
203
|
const importedEnvelope = plainRecord(importedRaw);
|
|
211
|
-
if (importedEnvelope
|
|
204
|
+
if (!importedEnvelope
|
|
205
|
+
|| importedEnvelope.schema !== CAMPAIGN_SCHEMA
|
|
206
|
+
|| !Array.isArray(importedEnvelope.campaigns)) {
|
|
212
207
|
throw new Error('This JSON file is not a WHOISleuth campaign export.');
|
|
213
208
|
}
|
|
214
209
|
const version = campaignStoreVersion(importedRaw);
|
|
215
210
|
if (version !== null && version > CAMPAIGN_SCHEMA_VERSION) {
|
|
216
211
|
throw new Error(`This campaign file uses newer schema ${version}. Update the app before importing it.`);
|
|
217
212
|
}
|
|
213
|
+
if (version !== CAMPAIGN_SCHEMA_VERSION) {
|
|
214
|
+
throw new Error(`Expected campaign schema ${CAMPAIGN_SCHEMA_VERSION}.`);
|
|
215
|
+
}
|
|
218
216
|
const local = normalizeCampaignStore(localRaw).campaigns;
|
|
219
217
|
const byId = new Map(local.map((campaign) => [campaign.id, campaign]));
|
|
220
218
|
let added = 0;
|