@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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 +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- 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 +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- 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 +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -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 +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- 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-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- 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-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- 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 +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- 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 +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- 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 -19
- 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} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- 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/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -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/packages/evidence/artifact-integrity.mjs +65 -0
- 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} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- 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/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- 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} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -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} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- 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} +18 -12
- 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} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- 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 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- 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 -232
- 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 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /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,18 +1,15 @@
|
|
|
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
|
|
11
|
-
export const
|
|
12
|
-
export const
|
|
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;
|
|
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";
|
|
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.';
|
|
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.';
|
|
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.';
|
|
16
13
|
const CONTROL_RE = /[\x00-\x1f\x7f]/u;
|
|
17
14
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
18
15
|
const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
|
|
@@ -36,9 +33,7 @@ const SOURCE_STATES = new Set([
|
|
|
36
33
|
const TRUST_STATES = new Set(['official', 'partner', 'allowlisted']);
|
|
37
34
|
const COMPARISON_STATES = new Set(['error', 'not_found', 'partial', 'success', 'unavailable']);
|
|
38
35
|
function record(value) {
|
|
39
|
-
return value
|
|
40
|
-
? value
|
|
41
|
-
: null;
|
|
36
|
+
return ordinaryWorkspaceRecord(value, 'Bulk-session input');
|
|
42
37
|
}
|
|
43
38
|
function boundedText(value, maximum = MAX_BULK_SESSION_TEXT_LENGTH) {
|
|
44
39
|
return typeof value === 'string' && value.length <= maximum && !CONTROL_RE.test(value)
|
|
@@ -46,8 +41,75 @@ function boundedText(value, maximum = MAX_BULK_SESSION_TEXT_LENGTH) {
|
|
|
46
41
|
: '';
|
|
47
42
|
}
|
|
48
43
|
function timestamp(value, fallback = null) {
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
return normalizeExplicitIsoTimestamp(value) ?? fallback;
|
|
45
|
+
}
|
|
46
|
+
function profileContextLimitation(value, fallback) {
|
|
47
|
+
return boundedText(value, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH) || fallback;
|
|
48
|
+
}
|
|
49
|
+
export function unavailableBulkProfileContext(limitation = BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION) {
|
|
50
|
+
return {
|
|
51
|
+
sourceState: 'unavailable',
|
|
52
|
+
activeProfileId: null,
|
|
53
|
+
profileUpdatedAt: null,
|
|
54
|
+
limitation: profileContextLimitation(limitation, BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function bulkProfileContextProvenance(sourceState, profileRaw) {
|
|
58
|
+
if (sourceState !== 'ready')
|
|
59
|
+
return unavailableBulkProfileContext();
|
|
60
|
+
if (profileRaw === null || profileRaw === undefined) {
|
|
61
|
+
return { sourceState: 'ready', activeProfileId: null, profileUpdatedAt: null, limitation: '' };
|
|
62
|
+
}
|
|
63
|
+
const profile = record(profileRaw);
|
|
64
|
+
const activeProfileId = typeof profile?.id === 'string' && SAFE_ID_RE.test(profile.id) ? profile.id : null;
|
|
65
|
+
const profileUpdatedAt = timestamp(profile?.updatedAt);
|
|
66
|
+
return activeProfileId && profileUpdatedAt
|
|
67
|
+
? { sourceState: 'ready', activeProfileId, profileUpdatedAt, limitation: '' }
|
|
68
|
+
: unavailableBulkProfileContext('The active Brand Profile identity or revision was invalid when this row was evaluated. Profile-derived conclusions and the Risk assessment remain inconclusive.');
|
|
69
|
+
}
|
|
70
|
+
export function normalizeBulkProfileContext(value, fallbackLimitation = BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION) {
|
|
71
|
+
const item = record(value);
|
|
72
|
+
const sourceState = item?.sourceState;
|
|
73
|
+
if (sourceState === 'ready') {
|
|
74
|
+
if (item?.activeProfileId === null && item.profileUpdatedAt === null) {
|
|
75
|
+
return { sourceState: 'ready', activeProfileId: null, profileUpdatedAt: null, limitation: '' };
|
|
76
|
+
}
|
|
77
|
+
const activeProfileId = typeof item?.activeProfileId === 'string' && SAFE_ID_RE.test(item.activeProfileId)
|
|
78
|
+
? item.activeProfileId
|
|
79
|
+
: null;
|
|
80
|
+
const profileUpdatedAt = timestamp(item?.profileUpdatedAt);
|
|
81
|
+
if (activeProfileId && profileUpdatedAt) {
|
|
82
|
+
return { sourceState: 'ready', activeProfileId, profileUpdatedAt, limitation: '' };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (sourceState === 'mixed') {
|
|
86
|
+
return {
|
|
87
|
+
sourceState: 'mixed',
|
|
88
|
+
activeProfileId: null,
|
|
89
|
+
profileUpdatedAt: null,
|
|
90
|
+
limitation: profileContextLimitation(item?.limitation, 'Rows in this saved Bulk session were evaluated under more than one Brand Profile context. Review each row provenance before comparison.'),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return unavailableBulkProfileContext(profileContextLimitation(item?.limitation, fallbackLimitation));
|
|
94
|
+
}
|
|
95
|
+
function sameProfileContext(left, right) {
|
|
96
|
+
return left.sourceState === right.sourceState
|
|
97
|
+
&& left.activeProfileId === right.activeProfileId
|
|
98
|
+
&& left.profileUpdatedAt === right.profileUpdatedAt
|
|
99
|
+
&& left.limitation === right.limitation;
|
|
100
|
+
}
|
|
101
|
+
export function summarizeBulkProfileContexts(rows) {
|
|
102
|
+
const first = rows[0]?.profileContext;
|
|
103
|
+
if (!first)
|
|
104
|
+
return unavailableBulkProfileContext('This saved Bulk session contains no settled row with profile-context provenance.');
|
|
105
|
+
if (rows.every((row) => sameProfileContext(first, row.profileContext)))
|
|
106
|
+
return { ...first };
|
|
107
|
+
return {
|
|
108
|
+
sourceState: 'mixed',
|
|
109
|
+
activeProfileId: null,
|
|
110
|
+
profileUpdatedAt: null,
|
|
111
|
+
limitation: 'Rows in this saved Bulk session were evaluated under more than one Brand Profile context. Review each row provenance before comparison.',
|
|
112
|
+
};
|
|
51
113
|
}
|
|
52
114
|
function nullableBoolean(value) {
|
|
53
115
|
return typeof value === 'boolean' ? value : null;
|
|
@@ -215,7 +277,27 @@ export function normalizeBulkSessionResult(value) {
|
|
|
215
277
|
const scanDepth = boundedText(item?.scanDepth, 10);
|
|
216
278
|
if (!item || !domain || !RESULT_STATES.has(status) || !MODES.has(scanDepth))
|
|
217
279
|
return null;
|
|
280
|
+
const rawProfileContext = record(item.profileContext);
|
|
281
|
+
if (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState)))
|
|
282
|
+
return null;
|
|
283
|
+
const profileContext = normalizeBulkProfileContext(rawProfileContext);
|
|
284
|
+
if (rawProfileContext.sourceState === 'ready' && profileContext.sourceState !== 'ready')
|
|
285
|
+
return null;
|
|
286
|
+
const profileContextReady = profileContext.sourceState === 'ready';
|
|
218
287
|
const trusted = boundedText(item.trusted, 20);
|
|
288
|
+
const relationship = normalizeRelationship(item.relationship);
|
|
289
|
+
const readyWithoutActiveProfile = profileContextReady && profileContext.activeProfileId === null;
|
|
290
|
+
const impossibleNoProfileClaim = readyWithoutActiveProfile && (TRUST_STATES.has(trusted)
|
|
291
|
+
|| item.faviconMatch === true
|
|
292
|
+
|| item.faviconNearMatch === true
|
|
293
|
+
|| item.reusesOfficialAssets === true
|
|
294
|
+
|| item.idnReferenceMatch === true
|
|
295
|
+
|| item.pageBaselineMatch === true
|
|
296
|
+
|| item.hasActiveBrandProfile === true
|
|
297
|
+
|| relationship.officialAssetHosts.length > 0);
|
|
298
|
+
const profileClaimsUsable = profileContextReady && !impossibleNoProfileClaim;
|
|
299
|
+
if (!profileClaimsUsable || readyWithoutActiveProfile)
|
|
300
|
+
relationship.officialAssetHosts = [];
|
|
219
301
|
return {
|
|
220
302
|
domain,
|
|
221
303
|
status: status,
|
|
@@ -223,10 +305,10 @@ export function normalizeBulkSessionResult(value) {
|
|
|
223
305
|
confidence: boundedText(item.confidence, 40) || 'unknown',
|
|
224
306
|
registrar: boundedText(item.registrar, 300) || '—',
|
|
225
307
|
activity: boundedText(item.activity, 300) || '—',
|
|
226
|
-
risk: nullableScore(item.risk),
|
|
308
|
+
risk: profileClaimsUsable ? nullableScore(item.risk) : null,
|
|
227
309
|
opportunity: nullableScore(item.opportunity),
|
|
228
310
|
mutationTypes: boundedStrings(item.mutationTypes, 40),
|
|
229
|
-
trusted: TRUST_STATES.has(trusted) ? trusted : null,
|
|
311
|
+
trusted: profileClaimsUsable && !readyWithoutActiveProfile && TRUST_STATES.has(trusted) ? trusted : null,
|
|
230
312
|
error: boundedText(item.error),
|
|
231
313
|
scanDepth: scanDepth,
|
|
232
314
|
createdDate: boundedText(item.createdDate, 64) || null,
|
|
@@ -241,23 +323,24 @@ export function normalizeBulkSessionResult(value) {
|
|
|
241
323
|
pageTitle: boundedText(item.pageTitle, 300) || null,
|
|
242
324
|
faviconHash: nullableHash(item.faviconHash, HASH_64_RE),
|
|
243
325
|
faviconPHash: nullableHash(item.faviconPHash, PHASH_RE),
|
|
244
|
-
faviconMatch: item.faviconMatch
|
|
245
|
-
faviconNearMatch: item.faviconNearMatch
|
|
246
|
-
reusesOfficialAssets: item.reusesOfficialAssets
|
|
326
|
+
faviconMatch: profileClaimsUsable ? nullableBoolean(item.faviconMatch) : null,
|
|
327
|
+
faviconNearMatch: profileClaimsUsable ? nullableBoolean(item.faviconNearMatch) : null,
|
|
328
|
+
reusesOfficialAssets: profileClaimsUsable ? nullableBoolean(item.reusesOfficialAssets) : null,
|
|
247
329
|
hasPasswordField: item.hasPasswordField === true,
|
|
248
330
|
hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
|
|
249
331
|
phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
|
|
250
|
-
idnReferenceMatch: nullableBoolean(item.idnReferenceMatch),
|
|
251
|
-
pageBaselineMatch: nullableBoolean(item.pageBaselineMatch),
|
|
252
|
-
hasActiveBrandProfile: nullableBoolean(item.hasActiveBrandProfile),
|
|
253
|
-
riskModelVersion: nullableVersion(item.riskModelVersion),
|
|
332
|
+
idnReferenceMatch: profileClaimsUsable ? nullableBoolean(item.idnReferenceMatch) : null,
|
|
333
|
+
pageBaselineMatch: profileClaimsUsable ? nullableBoolean(item.pageBaselineMatch) : null,
|
|
334
|
+
hasActiveBrandProfile: profileClaimsUsable ? nullableBoolean(item.hasActiveBrandProfile) : null,
|
|
335
|
+
riskModelVersion: profileClaimsUsable ? nullableVersion(item.riskModelVersion) : null,
|
|
254
336
|
opportunityModelVersion: nullableVersion(item.opportunityModelVersion),
|
|
255
|
-
riskFactors: normalizeRiskFactors(item.riskFactors),
|
|
337
|
+
riskFactors: profileClaimsUsable ? normalizeRiskFactors(item.riskFactors) : [],
|
|
256
338
|
dns: normalizeDns(item.dns),
|
|
257
339
|
dnssec: boundedText(item.dnssec, 40) || null,
|
|
258
340
|
comparisonEvidence: normalizeBulkComparisonEvidence(item.comparisonEvidence),
|
|
259
|
-
relationship
|
|
341
|
+
relationship,
|
|
260
342
|
sourceCoverage: normalizeSourceCoverage(item.sourceCoverage),
|
|
343
|
+
profileContext,
|
|
261
344
|
};
|
|
262
345
|
}
|
|
263
346
|
export function normalizeBulkSession(value) {
|
|
@@ -282,18 +365,27 @@ export function normalizeBulkSession(value) {
|
|
|
282
365
|
const domains = domainList(item.domains);
|
|
283
366
|
if (!domains.length)
|
|
284
367
|
return null;
|
|
368
|
+
if (!Array.isArray(item.results))
|
|
369
|
+
return null;
|
|
285
370
|
const allowed = new Set(domains);
|
|
286
371
|
const results = [];
|
|
287
372
|
const seen = new Set();
|
|
288
373
|
for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
|
|
289
374
|
const result = normalizeBulkSessionResult(candidate);
|
|
290
|
-
if (!result || !allowed.has(result.domain) || seen.has(result.domain))
|
|
291
|
-
|
|
375
|
+
if (!result || !allowed.has(result.domain) || seen.has(result.domain)) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
292
378
|
seen.add(result.domain);
|
|
293
379
|
results.push(result);
|
|
294
|
-
if (results.length >= MAX_BULK_SESSION_ROWS)
|
|
295
|
-
break;
|
|
296
380
|
}
|
|
381
|
+
if ((state === 'complete') !== (results.length === domains.length))
|
|
382
|
+
return null;
|
|
383
|
+
const profileContext = summarizeBulkProfileContexts(results);
|
|
384
|
+
if (!record(item.profileContext))
|
|
385
|
+
return null;
|
|
386
|
+
const declaredProfileContext = normalizeBulkProfileContext(item.profileContext);
|
|
387
|
+
if (!sameProfileContext(declaredProfileContext, profileContext))
|
|
388
|
+
return null;
|
|
297
389
|
return {
|
|
298
390
|
id,
|
|
299
391
|
name,
|
|
@@ -305,6 +397,7 @@ export function normalizeBulkSession(value) {
|
|
|
305
397
|
startedAt,
|
|
306
398
|
updatedAt,
|
|
307
399
|
completedAt,
|
|
400
|
+
profileContext,
|
|
308
401
|
};
|
|
309
402
|
}
|
|
310
403
|
export function bulkSessionStoreVersion(raw) {
|
|
@@ -314,7 +407,13 @@ export function bulkSessionStoreVersion(raw) {
|
|
|
314
407
|
: null;
|
|
315
408
|
}
|
|
316
409
|
export function normalizeBulkSessionStore(raw) {
|
|
410
|
+
assertWorkspaceInputGraph(raw, 'Bulk-session store');
|
|
411
|
+
assertWorkspaceDeclaredVersion(raw, 'Bulk-session store');
|
|
317
412
|
const value = record(raw);
|
|
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
|
+
}
|
|
318
417
|
const candidates = Array.isArray(raw)
|
|
319
418
|
? raw
|
|
320
419
|
: value?.schema === BULK_SESSION_SCHEMA
|
|
@@ -389,12 +488,22 @@ export function deleteBulkSession(raw, idRaw) {
|
|
|
389
488
|
function sourceStateMap(value) {
|
|
390
489
|
return new Map(value.sourceCoverage.map((item) => [item.source, item.state]));
|
|
391
490
|
}
|
|
491
|
+
function comparableRisk(previous, current) {
|
|
492
|
+
return previous.profileContext.sourceState === 'ready'
|
|
493
|
+
&& current.profileContext.sourceState === 'ready'
|
|
494
|
+
&& sameProfileContext(previous.profileContext, current.profileContext)
|
|
495
|
+
&& previous.risk !== null
|
|
496
|
+
&& current.risk !== null
|
|
497
|
+
&& previous.riskModelVersion !== null
|
|
498
|
+
&& previous.riskModelVersion === current.riskModelVersion;
|
|
499
|
+
}
|
|
392
500
|
function resultChanges(previous, current) {
|
|
393
501
|
const changes = [];
|
|
394
502
|
if (previous.availability !== current.availability)
|
|
395
503
|
changes.push(`Registration: ${previous.availability} → ${current.availability}`);
|
|
396
|
-
if (previous.risk !== current.risk)
|
|
504
|
+
if (comparableRisk(previous, current) && previous.risk !== current.risk) {
|
|
397
505
|
changes.push(`Risk: ${previous.risk ?? 'unavailable'} → ${current.risk ?? 'unavailable'}`);
|
|
506
|
+
}
|
|
398
507
|
if (previous.registrar !== current.registrar)
|
|
399
508
|
changes.push(`Registrar: ${previous.registrar} → ${current.registrar}`);
|
|
400
509
|
if (previous.activity !== current.activity)
|
|
@@ -429,6 +538,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
429
538
|
const before = new Map(baseline.results.map((result) => [result.domain, result]));
|
|
430
539
|
const after = new Map(current.results.map((result) => [result.domain, result]));
|
|
431
540
|
const rows = [];
|
|
541
|
+
let riskIncomparable = false;
|
|
432
542
|
let added = 0;
|
|
433
543
|
let removed = 0;
|
|
434
544
|
let unchanged = 0;
|
|
@@ -444,6 +554,8 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
444
554
|
rows.push({ domain, changes: ['Absent from the later settled result set.'] });
|
|
445
555
|
}
|
|
446
556
|
else {
|
|
557
|
+
if (!comparableRisk(previous, next))
|
|
558
|
+
riskIncomparable = true;
|
|
447
559
|
const changes = resultChanges(previous, next);
|
|
448
560
|
if (changes.length)
|
|
449
561
|
rows.push({ domain, changes });
|
|
@@ -463,6 +575,9 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
463
575
|
'This compares compact saved observations, not historical provider data or raw source payloads.',
|
|
464
576
|
'A source-state change may reflect collection availability rather than a change to the domain.',
|
|
465
577
|
'A missing row means it was not completed in that saved session; it does not establish domain removal.',
|
|
578
|
+
...(riskIncomparable
|
|
579
|
+
? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance and the same versioned Risk model.']
|
|
580
|
+
: []),
|
|
466
581
|
],
|
|
467
582
|
};
|
|
468
583
|
}
|
|
@@ -480,6 +595,9 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
|
|
|
480
595
|
};
|
|
481
596
|
}
|
|
482
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');
|
|
483
601
|
const imported = record(importedRaw);
|
|
484
602
|
if (!imported || imported.schema !== BULK_SESSION_SCHEMA || !Array.isArray(imported.sessions)) {
|
|
485
603
|
throw new Error('This file is not a WHOISleuth Bulk session export.');
|
|
@@ -495,20 +613,42 @@ export function mergeBulkSessions(localRaw, importedRaw) {
|
|
|
495
613
|
let updated = 0;
|
|
496
614
|
let skipped = 0;
|
|
497
615
|
for (const candidate of imported.sessions.slice(0, MAX_BULK_SESSIONS * 4)) {
|
|
498
|
-
const
|
|
616
|
+
const normalized = normalizeBulkSession(candidate);
|
|
617
|
+
if (!normalized) {
|
|
618
|
+
skipped += 1;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const importedProfileContext = unavailableBulkProfileContext(BULK_PROFILE_CONTEXT_IMPORTED_LIMITATION);
|
|
622
|
+
const quarantinedResults = normalized.results.map((result) => ({
|
|
623
|
+
...result,
|
|
624
|
+
risk: null,
|
|
625
|
+
trusted: null,
|
|
626
|
+
faviconMatch: null,
|
|
627
|
+
faviconNearMatch: null,
|
|
628
|
+
reusesOfficialAssets: null,
|
|
629
|
+
idnReferenceMatch: null,
|
|
630
|
+
pageBaselineMatch: null,
|
|
631
|
+
hasActiveBrandProfile: null,
|
|
632
|
+
riskModelVersion: null,
|
|
633
|
+
riskFactors: [],
|
|
634
|
+
relationship: { ...result.relationship, officialAssetHosts: [] },
|
|
635
|
+
profileContext: importedProfileContext,
|
|
636
|
+
}));
|
|
637
|
+
const session = normalizeBulkSession({
|
|
638
|
+
...normalized,
|
|
639
|
+
results: quarantinedResults,
|
|
640
|
+
profileContext: summarizeBulkProfileContexts(quarantinedResults),
|
|
641
|
+
});
|
|
499
642
|
if (!session) {
|
|
500
643
|
skipped += 1;
|
|
501
644
|
continue;
|
|
502
645
|
}
|
|
503
646
|
const existing = sessions.find((item) => item.id === session.id);
|
|
504
|
-
if (
|
|
505
|
-
added += 1;
|
|
506
|
-
else if (existing.updatedAt !== session.updatedAt)
|
|
507
|
-
updated += 1;
|
|
508
|
-
else {
|
|
647
|
+
if (existing) {
|
|
509
648
|
skipped += 1;
|
|
510
649
|
continue;
|
|
511
650
|
}
|
|
651
|
+
added += 1;
|
|
512
652
|
sessions = upsertBulkSession(sessions, session).sessions;
|
|
513
653
|
}
|
|
514
654
|
const bounded = enforceBulkSessionStoreBudget({
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** Opportunity remains a retained data/export field, but is no longer a Bulk presentation sort. */
|
|
2
|
+
export function normalizeBulkPresentationSortKey(key) {
|
|
3
|
+
return key === 'opportunity' ? 'risk' : key;
|
|
4
|
+
}
|
|
1
5
|
const CONFIDENCE_RANK = {
|
|
2
6
|
low: 0,
|
|
3
7
|
medium: 1,
|
|
@@ -15,8 +19,11 @@ function compareText(left, right) {
|
|
|
15
19
|
const rightText = boundedText(right);
|
|
16
20
|
return leftText.localeCompare(rightText, 'en', { numeric: true, sensitivity: 'base' });
|
|
17
21
|
}
|
|
18
|
-
function sortValue(row, key) {
|
|
19
|
-
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') {
|
|
20
27
|
return { missing: !Number.isFinite(row[key]), value: Number(row[key] ?? 0) };
|
|
21
28
|
}
|
|
22
29
|
if (key === 'confidence') {
|
|
@@ -31,10 +38,10 @@ function sortValue(row, key) {
|
|
|
31
38
|
export function defaultBulkSortDirection(key) {
|
|
32
39
|
return key === 'risk' || key === 'opportunity' || key === 'confidence' ? -1 : 1;
|
|
33
40
|
}
|
|
34
|
-
export function sortBulkResults(rows, key, direction) {
|
|
41
|
+
export function sortBulkResults(rows, key, direction, riskValue = null) {
|
|
35
42
|
return [...rows].sort((left, right) => {
|
|
36
|
-
const leftValue = sortValue(left, key);
|
|
37
|
-
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);
|
|
38
45
|
// Missing values always remain last instead of jumping to the top when
|
|
39
46
|
// the requested direction changes.
|
|
40
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;
|
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
// Pure browser-local custom detection-rule model. Rules are deliberately a
|
|
2
2
|
// small structured language: field names and operators come from allowlists,
|
|
3
3
|
// so imported rules cannot execute code or reach outside bounded case evidence.
|
|
4
|
-
import { latestCaseEvidence } from "
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
export const MAX_RULE_CONDITIONS = 8;
|
|
9
|
-
export const MAX_RULE_INPUT_RECORDS = 250;
|
|
10
|
-
export const MAX_RULE_IMPORT_BYTES = 2 * 1024 * 1024;
|
|
11
|
-
export const MAX_RULE_STORE_BYTES = 256 * 1024;
|
|
12
|
-
export const MAX_RULE_NAME_LENGTH = 100;
|
|
13
|
-
export const MAX_RULE_TAG_LENGTH = 40;
|
|
14
|
-
export const MAX_CONDITION_VALUE_LENGTH = 200;
|
|
15
|
-
export const MAX_RULE_RISK_DELTA = 25;
|
|
16
|
-
export const MAX_CUSTOM_RISK_TOTAL = 50;
|
|
4
|
+
import { latestCaseEvidence } from "../cases/case-model.mjs";
|
|
5
|
+
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
6
|
+
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
|
+
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";
|
|
17
8
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
18
9
|
const TEXT_CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
19
10
|
export const RULE_FIELD_DEFINITIONS = Object.freeze([
|
|
@@ -41,9 +32,7 @@ export const RULE_FIELD_DEFINITIONS = Object.freeze([
|
|
|
41
32
|
]);
|
|
42
33
|
const FIELD_BY_VALUE = new Map(RULE_FIELD_DEFINITIONS.map((field) => [field.value, field]));
|
|
43
34
|
function record(value) {
|
|
44
|
-
return value
|
|
45
|
-
? value
|
|
46
|
-
: {};
|
|
35
|
+
return ordinaryWorkspaceRecord(value, 'Detection-rule input') ?? {};
|
|
47
36
|
}
|
|
48
37
|
export function operatorsForRuleField(field) {
|
|
49
38
|
const definition = typeof field === 'string' ? FIELD_BY_VALUE.get(field) : undefined;
|
|
@@ -145,6 +134,8 @@ export function detectionRuleStoreVersion(raw) {
|
|
|
145
134
|
return typeof item.version === 'number' && Number.isFinite(item.version) ? item.version : null;
|
|
146
135
|
}
|
|
147
136
|
export function normalizeDetectionRuleStore(raw) {
|
|
137
|
+
assertWorkspaceInputGraph(raw, 'Detection-rule store');
|
|
138
|
+
assertWorkspaceDeclaredVersion(raw, 'Detection-rule store');
|
|
148
139
|
const byId = new Map();
|
|
149
140
|
for (const item of ruleList(raw).slice(0, MAX_RULE_INPUT_RECORDS)) {
|
|
150
141
|
const rule = normalizeDetectionRule(item);
|
|
@@ -282,14 +273,21 @@ export function previewDetectionRule(records, existingRules, rawCandidate) {
|
|
|
282
273
|
});
|
|
283
274
|
}
|
|
284
275
|
export function mergeDetectionRules(localRaw, importedRaw) {
|
|
276
|
+
assertWorkspaceInputGraph(localRaw, 'Local detection-rule store');
|
|
277
|
+
assertWorkspaceInputGraph(importedRaw, 'Imported detection-rule document');
|
|
278
|
+
assertWorkspacePortableVersion(importedRaw, DETECTION_RULE_SCHEMA_VERSION, 'Imported detection-rule document');
|
|
285
279
|
const importedRecord = record(importedRaw);
|
|
286
|
-
if (
|
|
280
|
+
if (importedRecord.schema !== DETECTION_RULE_SCHEMA
|
|
281
|
+
|| !Array.isArray(importedRecord.rules)) {
|
|
287
282
|
throw new Error('This JSON file is not a WHOISleuth custom-rule export.');
|
|
288
283
|
}
|
|
289
284
|
const version = detectionRuleStoreVersion(importedRaw);
|
|
290
285
|
if (version !== null && version > DETECTION_RULE_SCHEMA_VERSION) {
|
|
291
286
|
throw new Error(`This custom-rule file uses newer schema ${version}. Update the app before importing it.`);
|
|
292
287
|
}
|
|
288
|
+
if (version !== DETECTION_RULE_SCHEMA_VERSION) {
|
|
289
|
+
throw new Error(`Expected custom-rule schema ${DETECTION_RULE_SCHEMA_VERSION}.`);
|
|
290
|
+
}
|
|
293
291
|
const local = normalizeDetectionRuleStore(localRaw).rules;
|
|
294
292
|
const byId = new Map(local.map((rule) => [rule.id, rule]));
|
|
295
293
|
const importedList = ruleList(importedRaw);
|