@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
5
5
|
import { normalizeCaaCritical } from "./dns-record-normalization.mjs";
|
|
6
6
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
7
|
+
import { RELATIONSHIP_EVIDENCE_VERSION } from "../contracts/offline-comparison.mjs";
|
|
8
|
+
import { normalizeRelationshipSourceProjection } from "../comparison/relationship-provenance.mjs";
|
|
7
9
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
8
10
|
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
11
|
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";
|
|
@@ -175,7 +177,7 @@ function normalizeSourceCoverage(value) {
|
|
|
175
177
|
if (!SOURCE_RE.test(source) || !SOURCE_STATES.has(state) || seen.has(source))
|
|
176
178
|
continue;
|
|
177
179
|
seen.add(source);
|
|
178
|
-
output.push({ source, state: state });
|
|
180
|
+
output.push({ source, state: state, observedAt: normalizeExplicitIsoTimestamp(item?.observedAt) });
|
|
179
181
|
if (output.length >= MAX_BULK_SESSION_SOURCES)
|
|
180
182
|
break;
|
|
181
183
|
}
|
|
@@ -198,8 +200,11 @@ function nullableHash(value, expression) {
|
|
|
198
200
|
}
|
|
199
201
|
function normalizeRelationship(value) {
|
|
200
202
|
const item = record(value);
|
|
203
|
+
if (item?.version !== undefined && item.version !== 2 && item.version !== RELATIONSHIP_EVIDENCE_VERSION) {
|
|
204
|
+
throw new TypeError('This Bulk relationship projection uses an unsupported version. No source evidence was interpreted.');
|
|
205
|
+
}
|
|
201
206
|
return {
|
|
202
|
-
version:
|
|
207
|
+
version: RELATIONSHIP_EVIDENCE_VERSION,
|
|
203
208
|
nameservers: boundedStrings(item?.nameservers, 20),
|
|
204
209
|
ipAddresses: boundedStrings(item?.ipAddresses, 50),
|
|
205
210
|
trackingIdentifiers: boundedTrackingIdentifiers(item?.trackingIdentifiers),
|
|
@@ -207,6 +212,7 @@ function normalizeRelationship(value) {
|
|
|
207
212
|
faviconHash: nullableHash(item?.faviconHash, HASH_64_RE),
|
|
208
213
|
faviconPHash: nullableHash(item?.faviconPHash, PHASH_RE),
|
|
209
214
|
certificateFingerprint: nullableHash(item?.certificateFingerprint, HASH_64_RE),
|
|
215
|
+
sourceEvidence: item?.version === RELATIONSHIP_EVIDENCE_VERSION ? normalizeRelationshipSourceProjection(item.sourceEvidence) : {},
|
|
210
216
|
truncated: item?.truncated === true,
|
|
211
217
|
};
|
|
212
218
|
}
|
|
@@ -270,14 +276,14 @@ export function normalizeBulkComparisonEvidence(value) {
|
|
|
270
276
|
},
|
|
271
277
|
};
|
|
272
278
|
}
|
|
273
|
-
export function normalizeBulkSessionResult(value) {
|
|
279
|
+
export function normalizeBulkSessionResult(value, inheritedProfileContext) {
|
|
274
280
|
const item = record(value);
|
|
275
281
|
const domain = normalizeDomain(item?.domain);
|
|
276
282
|
const status = boundedText(item?.status, 20);
|
|
277
283
|
const scanDepth = boundedText(item?.scanDepth, 10);
|
|
278
284
|
if (!item || !domain || !RESULT_STATES.has(status) || !MODES.has(scanDepth))
|
|
279
285
|
return null;
|
|
280
|
-
const rawProfileContext = record(item.profileContext);
|
|
286
|
+
const rawProfileContext = record(Object.hasOwn(item, 'profileContext') ? item.profileContext : inheritedProfileContext);
|
|
281
287
|
if (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState)))
|
|
282
288
|
return null;
|
|
283
289
|
const profileContext = normalizeBulkProfileContext(rawProfileContext);
|
|
@@ -311,6 +317,7 @@ export function normalizeBulkSessionResult(value) {
|
|
|
311
317
|
trusted: profileClaimsUsable && !readyWithoutActiveProfile && TRUST_STATES.has(trusted) ? trusted : null,
|
|
312
318
|
error: boundedText(item.error),
|
|
313
319
|
scanDepth: scanDepth,
|
|
320
|
+
observedAt: normalizeExplicitIsoTimestamp(item.observedAt),
|
|
314
321
|
createdDate: boundedText(item.createdDate, 64) || null,
|
|
315
322
|
expiryDate: boundedText(item.expiryDate, 64) || null,
|
|
316
323
|
privacyProtected: nullableBoolean(item.privacyProtected),
|
|
@@ -343,7 +350,7 @@ export function normalizeBulkSessionResult(value) {
|
|
|
343
350
|
profileContext,
|
|
344
351
|
};
|
|
345
352
|
}
|
|
346
|
-
export function normalizeBulkSession(value) {
|
|
353
|
+
export function normalizeBulkSession(value, sourceStoreVersion) {
|
|
347
354
|
const item = record(value);
|
|
348
355
|
const id = boundedText(item?.id, 128);
|
|
349
356
|
const name = boundedText(item?.name, MAX_BULK_SESSION_NAME_LENGTH);
|
|
@@ -367,23 +374,43 @@ export function normalizeBulkSession(value) {
|
|
|
367
374
|
return null;
|
|
368
375
|
if (!Array.isArray(item.results))
|
|
369
376
|
return null;
|
|
377
|
+
const rawProfileContext = record(item.profileContext);
|
|
378
|
+
if (!rawProfileContext)
|
|
379
|
+
return null;
|
|
380
|
+
const declaredProfileContext = normalizeBulkProfileContext(rawProfileContext);
|
|
381
|
+
// Only an explicitly versioned current document may omit identical row
|
|
382
|
+
// context. Bare runtime rows and public schema 4 must carry their own context;
|
|
383
|
+
// an explicit invalid/null row value never falls back to the session.
|
|
384
|
+
const inheritedProfileContext = sourceStoreVersion === 5
|
|
385
|
+
&& declaredProfileContext.sourceState !== 'mixed'
|
|
386
|
+
&& rawProfileContext.sourceState === declaredProfileContext.sourceState
|
|
387
|
+
? declaredProfileContext
|
|
388
|
+
: undefined;
|
|
370
389
|
const allowed = new Set(domains);
|
|
371
390
|
const results = [];
|
|
372
391
|
const seen = new Set();
|
|
373
392
|
for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
|
|
374
|
-
const
|
|
393
|
+
const publicLegacyRow = sourceStoreVersion === 4;
|
|
394
|
+
if (publicLegacyRow && record(record(candidate)?.relationship)?.version === RELATIONSHIP_EVIDENCE_VERSION) {
|
|
395
|
+
throw new TypeError('Bulk schema 4 cannot contain newer relationship source evidence; no evidence was interpreted.');
|
|
396
|
+
}
|
|
397
|
+
const result = normalizeBulkSessionResult(candidate, inheritedProfileContext);
|
|
375
398
|
if (!result || !allowed.has(result.domain) || seen.has(result.domain)) {
|
|
376
399
|
return null;
|
|
377
400
|
}
|
|
401
|
+
// Public schema 4 never recorded a row observation time or qualified
|
|
402
|
+
// relationship sources. Ignore added fields rather than backdating them.
|
|
403
|
+
if (publicLegacyRow) {
|
|
404
|
+
result.observedAt = null;
|
|
405
|
+
result.relationship.sourceEvidence = {};
|
|
406
|
+
result.sourceCoverage = result.sourceCoverage.map((source) => ({ ...source, observedAt: null }));
|
|
407
|
+
}
|
|
378
408
|
seen.add(result.domain);
|
|
379
409
|
results.push(result);
|
|
380
410
|
}
|
|
381
411
|
if ((state === 'complete') !== (results.length === domains.length))
|
|
382
412
|
return null;
|
|
383
413
|
const profileContext = summarizeBulkProfileContexts(results);
|
|
384
|
-
if (!record(item.profileContext))
|
|
385
|
-
return null;
|
|
386
|
-
const declaredProfileContext = normalizeBulkProfileContext(item.profileContext);
|
|
387
414
|
if (!sameProfileContext(declaredProfileContext, profileContext))
|
|
388
415
|
return null;
|
|
389
416
|
return {
|
|
@@ -407,7 +434,9 @@ export function bulkSessionStoreVersion(raw) {
|
|
|
407
434
|
: null;
|
|
408
435
|
}
|
|
409
436
|
export function normalizeBulkSessionStore(raw) {
|
|
410
|
-
|
|
437
|
+
// Preparation may combine an existing store and one incoming store before
|
|
438
|
+
// whole-session eviction. The persisted document still has its 4 MiB cap.
|
|
439
|
+
assertWorkspaceInputGraph(raw, 'Bulk-session store', { maximumBytes: MAX_BULK_SESSION_STORE_BYTES * 2 });
|
|
411
440
|
assertWorkspaceDeclaredVersion(raw, 'Bulk-session store');
|
|
412
441
|
const value = record(raw);
|
|
413
442
|
if (value?.schema === BULK_SESSION_SCHEMA
|
|
@@ -423,7 +452,7 @@ export function normalizeBulkSessionStore(raw) {
|
|
|
423
452
|
: [];
|
|
424
453
|
const byId = new Map();
|
|
425
454
|
for (const candidate of candidates.slice(0, MAX_BULK_SESSIONS * 4)) {
|
|
426
|
-
const session = normalizeBulkSession(candidate);
|
|
455
|
+
const session = normalizeBulkSession(candidate, bulkSessionStoreVersion(raw) ?? undefined);
|
|
427
456
|
if (!session)
|
|
428
457
|
continue;
|
|
429
458
|
const existing = byId.get(session.id);
|
|
@@ -438,8 +467,21 @@ export function normalizeBulkSessionStore(raw) {
|
|
|
438
467
|
.slice(0, MAX_BULK_SESSIONS),
|
|
439
468
|
};
|
|
440
469
|
}
|
|
470
|
+
/** Current wire representation of an already-normalized session. */
|
|
471
|
+
export function bulkSessionStorageValue(session) {
|
|
472
|
+
return {
|
|
473
|
+
...session,
|
|
474
|
+
results: session.results.map(({ profileContext, ...row }) => sameProfileContext(profileContext, session.profileContext)
|
|
475
|
+
? row
|
|
476
|
+
: { ...row, profileContext }),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/** Current wire bytes for sessions already admitted by the collection owner. */
|
|
480
|
+
export function serializeNormalizedBulkSessions(sessions) {
|
|
481
|
+
return JSON.stringify({ schema: BULK_SESSION_SCHEMA, version: BULK_SESSION_SCHEMA_VERSION, sessions: sessions.map(bulkSessionStorageValue) });
|
|
482
|
+
}
|
|
441
483
|
export function serializeBulkSessionStore(raw) {
|
|
442
|
-
return
|
|
484
|
+
return serializeNormalizedBulkSessions(normalizeBulkSessionStore(raw).sessions);
|
|
443
485
|
}
|
|
444
486
|
function byteLength(value) {
|
|
445
487
|
return new TextEncoder().encode(value).byteLength;
|
|
@@ -447,11 +489,16 @@ function byteLength(value) {
|
|
|
447
489
|
export function enforceBulkSessionStoreBudget(raw) {
|
|
448
490
|
const store = normalizeBulkSessionStore(raw);
|
|
449
491
|
let pruned = 0;
|
|
450
|
-
|
|
492
|
+
const sessionBytes = store.sessions.map((session) => byteLength(JSON.stringify(bulkSessionStorageValue(session))));
|
|
493
|
+
let bytes = byteLength(JSON.stringify({ ...store, sessions: [] }))
|
|
494
|
+
+ sessionBytes.reduce((total, size) => total + size, 0)
|
|
495
|
+
+ Math.max(0, sessionBytes.length - 1);
|
|
496
|
+
while (bytes > MAX_BULK_SESSION_STORE_BYTES && store.sessions.length > 1) {
|
|
497
|
+
bytes -= sessionBytes.pop() + 1;
|
|
451
498
|
store.sessions.pop();
|
|
452
499
|
pruned += 1;
|
|
453
500
|
}
|
|
454
|
-
if (
|
|
501
|
+
if (bytes > MAX_BULK_SESSION_STORE_BYTES) {
|
|
455
502
|
throw new Error('This Bulk session exceeds the 4 MiB browser-local session limit.');
|
|
456
503
|
}
|
|
457
504
|
return { store, pruned };
|
|
@@ -474,9 +521,29 @@ export function upsertBulkSession(raw, sessionRaw) {
|
|
|
474
521
|
sessions: bounded.store.sessions,
|
|
475
522
|
session,
|
|
476
523
|
added: index < 0,
|
|
477
|
-
pruned: bounded.
|
|
524
|
+
pruned: Math.max(0, next.length - bounded.store.sessions.length),
|
|
478
525
|
};
|
|
479
526
|
}
|
|
527
|
+
/** Ephemeral retention preview, never part of the stored or exported format. */
|
|
528
|
+
export function prepareBulkSessionSave(raw, sessionRaw) {
|
|
529
|
+
const current = normalizeBulkSessionStore(raw).sessions;
|
|
530
|
+
const result = upsertBulkSession(current, sessionRaw);
|
|
531
|
+
if (!result.sessions.some((session) => session.id === result.session.id)) {
|
|
532
|
+
throw new Error('This Bulk session cannot fit alongside the saved sessions. Export or remove saved sessions before trying again. Nothing was changed.');
|
|
533
|
+
}
|
|
534
|
+
const retained = new Set(result.sessions.map((session) => session.id));
|
|
535
|
+
const removed = current.filter((session) => !retained.has(session.id));
|
|
536
|
+
return {
|
|
537
|
+
...result,
|
|
538
|
+
current,
|
|
539
|
+
removed,
|
|
540
|
+
removedBytes: removed.reduce((total, session) => total + byteLength(JSON.stringify(bulkSessionStorageValue(session))), 0),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/** Confirmation applies to the exact records displayed, not just their IDs. */
|
|
544
|
+
export function bulkSessionSavePreviewIsCurrent(raw, preview) {
|
|
545
|
+
return serializeBulkSessionStore(raw) === serializeNormalizedBulkSessions(preview.current);
|
|
546
|
+
}
|
|
480
547
|
export function deleteBulkSession(raw, idRaw) {
|
|
481
548
|
const id = boundedText(idRaw, 128);
|
|
482
549
|
return normalizeBulkSessionStore({
|
|
@@ -488,10 +555,11 @@ export function deleteBulkSession(raw, idRaw) {
|
|
|
488
555
|
function sourceStateMap(value) {
|
|
489
556
|
return new Map(value.sourceCoverage.map((item) => [item.source, item.state]));
|
|
490
557
|
}
|
|
491
|
-
function
|
|
558
|
+
export function areBulkRiskScoresComparable(previous, current) {
|
|
492
559
|
return previous.profileContext.sourceState === 'ready'
|
|
493
560
|
&& current.profileContext.sourceState === 'ready'
|
|
494
561
|
&& sameProfileContext(previous.profileContext, current.profileContext)
|
|
562
|
+
&& previous.scanDepth === current.scanDepth
|
|
495
563
|
&& previous.risk !== null
|
|
496
564
|
&& current.risk !== null
|
|
497
565
|
&& previous.riskModelVersion !== null
|
|
@@ -501,7 +569,7 @@ function resultChanges(previous, current) {
|
|
|
501
569
|
const changes = [];
|
|
502
570
|
if (previous.availability !== current.availability)
|
|
503
571
|
changes.push(`Registration: ${previous.availability} → ${current.availability}`);
|
|
504
|
-
if (
|
|
572
|
+
if (areBulkRiskScoresComparable(previous, current) && previous.risk !== current.risk) {
|
|
505
573
|
changes.push(`Risk: ${previous.risk ?? 'unavailable'} → ${current.risk ?? 'unavailable'}`);
|
|
506
574
|
}
|
|
507
575
|
if (previous.registrar !== current.registrar)
|
|
@@ -554,7 +622,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
554
622
|
rows.push({ domain, changes: ['Absent from the later settled result set.'] });
|
|
555
623
|
}
|
|
556
624
|
else {
|
|
557
|
-
if (!
|
|
625
|
+
if (!areBulkRiskScoresComparable(previous, next))
|
|
558
626
|
riskIncomparable = true;
|
|
559
627
|
const changes = resultChanges(previous, next);
|
|
560
628
|
if (changes.length)
|
|
@@ -576,7 +644,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
576
644
|
'A source-state change may reflect collection availability rather than a change to the domain.',
|
|
577
645
|
'A missing row means it was not completed in that saved session; it does not establish domain removal.',
|
|
578
646
|
...(riskIncomparable
|
|
579
|
-
? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance
|
|
647
|
+
? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, and versioned Risk model.']
|
|
580
648
|
: []),
|
|
581
649
|
],
|
|
582
650
|
};
|
|
@@ -587,7 +655,7 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
|
|
|
587
655
|
schema: BULK_SESSION_SCHEMA,
|
|
588
656
|
version: BULK_SESSION_SCHEMA_VERSION,
|
|
589
657
|
generatedAt: timestamp(generatedAt, new Date().toISOString()),
|
|
590
|
-
sessions: store.sessions,
|
|
658
|
+
sessions: store.sessions.map(bulkSessionStorageValue),
|
|
591
659
|
limitations: [
|
|
592
660
|
'Compact Bulk results and source states only; raw source payloads and contact records are excluded.',
|
|
593
661
|
'Import is non-destructive and does not resume network collection automatically.',
|
|
@@ -595,8 +663,8 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
|
|
|
595
663
|
};
|
|
596
664
|
}
|
|
597
665
|
export function mergeBulkSessions(localRaw, importedRaw) {
|
|
598
|
-
assertWorkspaceInputGraph(localRaw, 'Local Bulk-session store');
|
|
599
|
-
assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-session document');
|
|
666
|
+
assertWorkspaceInputGraph(localRaw, 'Local Bulk-session store', { maximumBytes: MAX_BULK_SESSION_STORE_BYTES * 2 });
|
|
667
|
+
assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-session document', { maximumBytes: MAX_BULK_SESSION_STORE_BYTES * 2 });
|
|
600
668
|
assertWorkspacePortableVersion(importedRaw, BULK_SESSION_SCHEMA_VERSION, 'Imported Bulk-session document');
|
|
601
669
|
const imported = record(importedRaw);
|
|
602
670
|
if (!imported || imported.schema !== BULK_SESSION_SCHEMA || !Array.isArray(imported.sessions)) {
|
|
@@ -613,7 +681,7 @@ export function mergeBulkSessions(localRaw, importedRaw) {
|
|
|
613
681
|
let updated = 0;
|
|
614
682
|
let skipped = 0;
|
|
615
683
|
for (const candidate of imported.sessions.slice(0, MAX_BULK_SESSIONS * 4)) {
|
|
616
|
-
const normalized = normalizeBulkSession(candidate);
|
|
684
|
+
const normalized = normalizeBulkSession(candidate, Number(imported.version));
|
|
617
685
|
if (!normalized) {
|
|
618
686
|
skipped += 1;
|
|
619
687
|
continue;
|
|
@@ -648,8 +716,16 @@ export function mergeBulkSessions(localRaw, importedRaw) {
|
|
|
648
716
|
skipped += 1;
|
|
649
717
|
continue;
|
|
650
718
|
}
|
|
719
|
+
// An import is additive: count and byte pressure must not silently remove
|
|
720
|
+
// a local session (or an earlier admitted import). The preview reports
|
|
721
|
+
// the skipped candidate and the analyst can export/remove data explicitly.
|
|
722
|
+
const merged = upsertBulkSession(sessions, session);
|
|
723
|
+
if (merged.pruned > 0 || !merged.sessions.some((item) => item.id === session.id)) {
|
|
724
|
+
skipped += 1;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
651
727
|
added += 1;
|
|
652
|
-
sessions =
|
|
728
|
+
sessions = merged.sessions;
|
|
653
729
|
}
|
|
654
730
|
const bounded = enforceBulkSessionStoreBudget({
|
|
655
731
|
schema: BULK_SESSION_SCHEMA,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const BULK_SORT_KEYS = ['domain', 'availability', 'confidence', 'risk', 'opportunity', 'activity', 'registrar', 'mutation'];
|
|
1
2
|
/** Opportunity remains a retained data/export field, but is no longer a Bulk presentation sort. */
|
|
2
3
|
export function normalizeBulkPresentationSortKey(key) {
|
|
3
4
|
return key === 'opportunity' ? 'risk' : key;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { CASE_VIEWS_SCHEMA, CASE_VIEWS_VERSION, CASE_VIEW_SORTS, MAX_CASE_VIEWS, MAX_CASE_VIEWS_BYTES, MAX_CASE_VIEW_NAME_LENGTH, MAX_CASE_VIEW_SEARCH_LENGTH, } from "../contracts/case-views-contract.mjs";
|
|
2
|
+
import { isValidDisposition, isValidStatus } from "../cases/case-record-decisions.mjs";
|
|
3
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
|
+
import { assertWorkspaceInputGraph, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const control = /[\u0000-\u001f\u007f]/u;
|
|
7
|
+
function object(raw, keys, label) {
|
|
8
|
+
const value = ordinaryWorkspaceRecord(raw, label);
|
|
9
|
+
if (!value || Object.keys(value).length !== keys.length || Object.keys(value).some(key => !keys.includes(key)))
|
|
10
|
+
throw new Error(`${label} has missing or unsupported fields.`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function text(raw, maximum, label) {
|
|
14
|
+
if (typeof raw !== 'string' || raw.length > maximum || control.test(raw))
|
|
15
|
+
throw new Error(`${label} is invalid or exceeds ${maximum} characters.`);
|
|
16
|
+
return raw;
|
|
17
|
+
}
|
|
18
|
+
function time(raw) {
|
|
19
|
+
const value = normalizeExplicitIsoTimestamp(raw);
|
|
20
|
+
if (!value)
|
|
21
|
+
throw new Error('A saved Case view has an invalid timestamp.');
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function normalizeCaseViewFilters(raw) {
|
|
25
|
+
const value = object(raw, ['status', 'disposition', 'search', 'sort'], 'Case filters');
|
|
26
|
+
if (value.status !== '' && !isValidStatus(value.status))
|
|
27
|
+
throw new Error('Unsupported Case status filter.');
|
|
28
|
+
if (value.disposition !== '' && !isValidDisposition(value.disposition))
|
|
29
|
+
throw new Error('Unsupported Case disposition filter.');
|
|
30
|
+
const sort = CASE_VIEW_SORTS.find(option => option.value === value.sort)?.value;
|
|
31
|
+
if (!sort)
|
|
32
|
+
throw new Error('Unsupported Case sort order.');
|
|
33
|
+
return { status: value.status, disposition: value.disposition, search: text(value.search, MAX_CASE_VIEW_SEARCH_LENGTH, 'Case search'), sort };
|
|
34
|
+
}
|
|
35
|
+
export function emptyCaseViewsStore() {
|
|
36
|
+
return { schema: CASE_VIEWS_SCHEMA, version: CASE_VIEWS_VERSION, views: [] };
|
|
37
|
+
}
|
|
38
|
+
export function caseViewsStoreVersion(raw) {
|
|
39
|
+
const value = ordinaryWorkspaceRecord(raw, 'Case views');
|
|
40
|
+
return value?.schema === CASE_VIEWS_SCHEMA && Number.isSafeInteger(value.version) ? value.version : null;
|
|
41
|
+
}
|
|
42
|
+
/** Strict, lossless admission: invalid views and capacity excess never prune saved work. */
|
|
43
|
+
export function normalizeCaseViewsStore(raw) {
|
|
44
|
+
if (raw === null || raw === undefined)
|
|
45
|
+
return emptyCaseViewsStore();
|
|
46
|
+
assertWorkspaceInputGraph(raw, 'Saved Case views', { maximumBytes: MAX_CASE_VIEWS_BYTES });
|
|
47
|
+
const store = object(raw, ['schema', 'version', 'views'], 'Case views');
|
|
48
|
+
if (store.schema !== CASE_VIEWS_SCHEMA || store.version !== CASE_VIEWS_VERSION)
|
|
49
|
+
throw new Error('Unsupported saved Case view format. No views were changed.');
|
|
50
|
+
if (!Array.isArray(store.views) || store.views.length > MAX_CASE_VIEWS)
|
|
51
|
+
throw new Error(`Keep at most ${MAX_CASE_VIEWS} saved Case views. Delete unwanted views explicitly before adding more.`);
|
|
52
|
+
const ids = new Set();
|
|
53
|
+
const views = [];
|
|
54
|
+
let bytes = encoder.encode(JSON.stringify(emptyCaseViewsStore())).byteLength;
|
|
55
|
+
for (const candidate of store.views) {
|
|
56
|
+
const value = object(candidate, ['id', 'name', 'filters', 'createdAt', 'updatedAt'], 'Saved Case view');
|
|
57
|
+
if (typeof value.id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value.id) || ids.has(value.id))
|
|
58
|
+
throw new Error('Invalid or duplicate saved Case view identity.');
|
|
59
|
+
ids.add(value.id);
|
|
60
|
+
const name = text(value.name, MAX_CASE_VIEW_NAME_LENGTH, 'Case view name').trim();
|
|
61
|
+
if (!name)
|
|
62
|
+
throw new Error('Name this Case view.');
|
|
63
|
+
const createdAt = time(value.createdAt);
|
|
64
|
+
const updatedAt = time(value.updatedAt);
|
|
65
|
+
if (Date.parse(updatedAt) < Date.parse(createdAt))
|
|
66
|
+
throw new Error('A saved Case view update precedes its creation.');
|
|
67
|
+
const view = { id: value.id, name, filters: normalizeCaseViewFilters(value.filters), createdAt, updatedAt };
|
|
68
|
+
bytes += encoder.encode(JSON.stringify(view)).byteLength + (views.length ? 1 : 0);
|
|
69
|
+
if (bytes > MAX_CASE_VIEWS_BYTES)
|
|
70
|
+
throw new Error('Saved Case views exceed their storage limit. Existing views were not removed.');
|
|
71
|
+
views.push(view);
|
|
72
|
+
}
|
|
73
|
+
return { ...emptyCaseViewsStore(), views };
|
|
74
|
+
}
|
|
75
|
+
export function serializeCaseViewsStore(raw) {
|
|
76
|
+
return JSON.stringify(normalizeCaseViewsStore(raw));
|
|
77
|
+
}
|
|
78
|
+
export function serialiseCaseViewsJson(raw) {
|
|
79
|
+
return `${JSON.stringify(normalizeCaseViewsStore(raw), null, 2)}\n`;
|
|
80
|
+
}
|
|
81
|
+
export function mergeCaseViews(local, incoming) {
|
|
82
|
+
const current = normalizeCaseViewsStore(local);
|
|
83
|
+
const imported = normalizeCaseViewsStore(incoming);
|
|
84
|
+
const views = new Map(current.views.map(view => [view.id, view]));
|
|
85
|
+
let added = 0, updated = 0, skipped = 0;
|
|
86
|
+
for (const view of imported.views) {
|
|
87
|
+
const existing = views.get(view.id);
|
|
88
|
+
if (!existing) {
|
|
89
|
+
views.set(view.id, view);
|
|
90
|
+
added += 1;
|
|
91
|
+
}
|
|
92
|
+
else if (JSON.stringify(existing) === JSON.stringify(view))
|
|
93
|
+
continue;
|
|
94
|
+
else if (Date.parse(view.updatedAt) > Date.parse(existing.updatedAt) && view.createdAt === existing.createdAt) {
|
|
95
|
+
views.set(view.id, view);
|
|
96
|
+
updated += 1;
|
|
97
|
+
}
|
|
98
|
+
else
|
|
99
|
+
skipped += 1;
|
|
100
|
+
}
|
|
101
|
+
return { store: normalizeCaseViewsStore({ ...current, views: [...views.values()] }), added, updated, skipped };
|
|
102
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// small structured language: field names and operators come from allowlists,
|
|
3
3
|
// so imported rules cannot execute code or reach outside bounded case evidence.
|
|
4
4
|
import { latestCaseEvidence } from "../cases/case-model.mjs";
|
|
5
|
+
import { CASE_DISPOSITIONS, CASE_STATUSES } from "../cases/case-record-contracts.mjs";
|
|
5
6
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
6
7
|
import { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
|
|
7
8
|
export { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_IMPORT_BYTES, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
|
|
@@ -26,8 +27,8 @@ export const RULE_FIELD_DEFINITIONS = Object.freeze([
|
|
|
26
27
|
{ value: 'mutationTypes', label: 'Mutation type', kind: 'list' },
|
|
27
28
|
{ value: 'nameservers', label: 'Nameserver', kind: 'list' },
|
|
28
29
|
{ value: 'httpSecurityHeaders', label: 'HTTP security header', kind: 'list' },
|
|
29
|
-
{ value: 'status', label: 'Case status', kind: 'enum', values:
|
|
30
|
-
{ value: 'disposition', label: 'Case disposition', kind: 'enum', values:
|
|
30
|
+
{ value: 'status', label: 'Case status', kind: 'enum', values: CASE_STATUSES.map((item) => item.value) },
|
|
31
|
+
{ value: 'disposition', label: 'Case disposition', kind: 'enum', values: CASE_DISPOSITIONS.map((item) => item.value) },
|
|
31
32
|
{ value: 'tags', label: 'Case tag', kind: 'list' },
|
|
32
33
|
]);
|
|
33
34
|
const FIELD_BY_VALUE = new Map(RULE_FIELD_DEFINITIONS.map((field) => [field.value, field]));
|
|
@@ -103,13 +104,14 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
|
|
|
103
104
|
return null;
|
|
104
105
|
const conditions = [];
|
|
105
106
|
const rawConditions = Array.isArray(item.conditions) ? item.conditions : [];
|
|
106
|
-
|
|
107
|
-
const condition = normalizeRuleCondition(item);
|
|
108
|
-
if (condition)
|
|
109
|
-
conditions.push(condition);
|
|
110
|
-
}
|
|
111
|
-
if (!conditions.length)
|
|
107
|
+
if (!rawConditions.length || rawConditions.length > MAX_RULE_CONDITIONS)
|
|
112
108
|
return null;
|
|
109
|
+
for (const rawCondition of rawConditions) {
|
|
110
|
+
const condition = normalizeRuleCondition(rawCondition);
|
|
111
|
+
if (!condition)
|
|
112
|
+
return null;
|
|
113
|
+
conditions.push(condition);
|
|
114
|
+
}
|
|
113
115
|
const riskDelta = normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA);
|
|
114
116
|
return {
|
|
115
117
|
id: safeId(item.id) || (generateId ? makeId() : null),
|
|
@@ -121,6 +123,30 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
|
|
|
121
123
|
tag: normalizedText(item.tag, MAX_RULE_TAG_LENGTH).toLowerCase(),
|
|
122
124
|
};
|
|
123
125
|
}
|
|
126
|
+
function validateDetectionRule(raw, { generateId = false } = {}) {
|
|
127
|
+
const item = record(raw);
|
|
128
|
+
if (!Array.isArray(item.conditions) || !item.conditions.length) {
|
|
129
|
+
throw new Error('A custom rule needs at least one condition.');
|
|
130
|
+
}
|
|
131
|
+
if (item.conditions.length > MAX_RULE_CONDITIONS) {
|
|
132
|
+
throw new Error(`Custom rules are limited to ${MAX_RULE_CONDITIONS} conditions.`);
|
|
133
|
+
}
|
|
134
|
+
for (const [index, condition] of item.conditions.entries()) {
|
|
135
|
+
if (!normalizeRuleCondition(condition)) {
|
|
136
|
+
throw new Error(`Condition ${index + 1} is invalid or outside the allowed bounds.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (item.match !== undefined && item.match !== 'all' && item.match !== 'any') {
|
|
140
|
+
throw new Error('Choose whether all or any conditions must match.');
|
|
141
|
+
}
|
|
142
|
+
if (item.riskDelta !== undefined && normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA) === null) {
|
|
143
|
+
throw new Error(`Custom contribution must be a whole number from 0 to ${MAX_RULE_RISK_DELTA}.`);
|
|
144
|
+
}
|
|
145
|
+
const normalized = normalizeDetectionRule(item, { generateId });
|
|
146
|
+
if (!normalized)
|
|
147
|
+
throw new Error('Enter a rule name and valid conditions.');
|
|
148
|
+
return normalized;
|
|
149
|
+
}
|
|
124
150
|
function ruleList(raw) {
|
|
125
151
|
if (Array.isArray(raw))
|
|
126
152
|
return raw;
|
|
@@ -133,23 +159,32 @@ export function detectionRuleStoreVersion(raw) {
|
|
|
133
159
|
const item = record(raw);
|
|
134
160
|
return typeof item.version === 'number' && Number.isFinite(item.version) ? item.version : null;
|
|
135
161
|
}
|
|
136
|
-
export function
|
|
162
|
+
export function recoverDetectionRuleStore(raw) {
|
|
137
163
|
assertWorkspaceInputGraph(raw, 'Detection-rule store');
|
|
138
164
|
assertWorkspaceDeclaredVersion(raw, 'Detection-rule store');
|
|
139
165
|
const byId = new Map();
|
|
140
|
-
|
|
166
|
+
const source = ruleList(raw);
|
|
167
|
+
let rejected = Math.max(0, source.length - MAX_RULE_INPUT_RECORDS);
|
|
168
|
+
for (const item of source.slice(0, MAX_RULE_INPUT_RECORDS)) {
|
|
141
169
|
const rule = normalizeDetectionRule(item);
|
|
142
|
-
if (!rule?.id || byId.has(rule.id))
|
|
170
|
+
if (!rule?.id || byId.has(rule.id)) {
|
|
171
|
+
rejected += 1;
|
|
143
172
|
continue;
|
|
173
|
+
}
|
|
174
|
+
if (byId.size >= MAX_DETECTION_RULES) {
|
|
175
|
+
rejected += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
144
178
|
byId.set(rule.id, { ...rule, id: rule.id });
|
|
145
|
-
if (byId.size >= MAX_DETECTION_RULES)
|
|
146
|
-
break;
|
|
147
179
|
}
|
|
148
|
-
return { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] };
|
|
180
|
+
return { store: { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] }, rejected };
|
|
181
|
+
}
|
|
182
|
+
export function normalizeDetectionRuleStore(raw) {
|
|
183
|
+
return recoverDetectionRuleStore(raw).store;
|
|
149
184
|
}
|
|
150
185
|
export function createDetectionRule(rules, input) {
|
|
151
|
-
const normalized =
|
|
152
|
-
if (!normalized
|
|
186
|
+
const normalized = validateDetectionRule(input, { generateId: true });
|
|
187
|
+
if (!normalized.id)
|
|
153
188
|
throw new Error('Enter a rule name and one valid condition.');
|
|
154
189
|
const existing = normalizeDetectionRuleStore(rules).rules;
|
|
155
190
|
if (existing.length >= MAX_DETECTION_RULES)
|
|
@@ -162,8 +197,8 @@ export function updateDetectionRule(rules, id, patch) {
|
|
|
162
197
|
const current = normalizedRules.find((rule) => rule.id === id);
|
|
163
198
|
if (!current)
|
|
164
199
|
throw new Error('That custom rule no longer exists.');
|
|
165
|
-
const updated =
|
|
166
|
-
if (!updated
|
|
200
|
+
const updated = validateDetectionRule({ ...current, ...record(patch), id });
|
|
201
|
+
if (!updated.id)
|
|
167
202
|
throw new Error('A custom rule needs a name and at least one valid condition.');
|
|
168
203
|
return normalizeDetectionRuleStore(normalizedRules.map((rule) => rule.id === id ? updated : rule)).rules;
|
|
169
204
|
}
|
|
@@ -245,8 +280,14 @@ function ruleSignature(rule) {
|
|
|
245
280
|
});
|
|
246
281
|
}
|
|
247
282
|
export function previewDetectionRule(records, existingRules, rawCandidate) {
|
|
248
|
-
|
|
249
|
-
|
|
283
|
+
let normalized;
|
|
284
|
+
try {
|
|
285
|
+
normalized = validateDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (!normalized.id)
|
|
250
291
|
return null;
|
|
251
292
|
const candidate = { ...normalized, id: normalized.id };
|
|
252
293
|
const sourceRecords = Array.isArray(records) ? records.slice(0, 500) : [];
|
|
@@ -295,8 +336,15 @@ export function mergeDetectionRules(localRaw, importedRaw) {
|
|
|
295
336
|
let updated = 0;
|
|
296
337
|
let skipped = Math.max(0, importedList.length - MAX_RULE_INPUT_RECORDS);
|
|
297
338
|
for (const item of importedList.slice(0, MAX_RULE_INPUT_RECORDS)) {
|
|
298
|
-
|
|
299
|
-
|
|
339
|
+
let rule;
|
|
340
|
+
try {
|
|
341
|
+
rule = validateDetectionRule(item);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
skipped++;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (!rule.id) {
|
|
300
348
|
skipped++;
|
|
301
349
|
continue;
|
|
302
350
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MAX_WORKSPACE_INPUT_ARRAY_LENGTH, MAX_WORKSPACE_INPUT_GRAPH_DEPTH, MAX_WORKSPACE_INPUT_GRAPH_NODES, MAX_WORKSPACE_INPUT_OBJECT_KEYS, MAX_WORKSPACE_INPUT_STRING_CODE_UNITS, } from "../contracts/workspace-portability.mjs";
|
|
2
|
+
import { boundedJsonLimitsForBytes } from "../../lib/bounded-json.mjs";
|
|
2
3
|
function inputError(label, detail) {
|
|
3
4
|
return new TypeError(`${label} must be bounded ordinary JSON data; ${detail}.`);
|
|
4
5
|
}
|
|
@@ -27,9 +28,7 @@ function ownDataDescriptors(value, label) {
|
|
|
27
28
|
return [key, descriptor];
|
|
28
29
|
});
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
32
|
-
return null;
|
|
31
|
+
function ordinaryRecordDescriptors(value, label) {
|
|
33
32
|
let prototype;
|
|
34
33
|
try {
|
|
35
34
|
prototype = Object.getPrototypeOf(value);
|
|
@@ -47,19 +46,33 @@ export function ordinaryWorkspaceRecord(value, label = 'Workspace input') {
|
|
|
47
46
|
if (descriptors.some(([, descriptor]) => !descriptor.enumerable)) {
|
|
48
47
|
throw inputError(label, 'non-enumerable object fields are not supported');
|
|
49
48
|
}
|
|
49
|
+
return descriptors;
|
|
50
|
+
}
|
|
51
|
+
export function ordinaryWorkspaceRecord(value, label = 'Workspace input') {
|
|
52
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
53
|
+
return null;
|
|
54
|
+
ordinaryRecordDescriptors(value, label);
|
|
50
55
|
return value;
|
|
51
56
|
}
|
|
52
|
-
export function assertWorkspaceInputGraph(value, label = 'Workspace input') {
|
|
57
|
+
export function assertWorkspaceInputGraph(value, label = 'Workspace input', options = {}) {
|
|
58
|
+
const limits = options.maximumBytes === undefined ? null : boundedJsonLimitsForBytes(options.maximumBytes);
|
|
59
|
+
const maximumStringCodeUnits = limits?.maximumStringCodeUnits ?? MAX_WORKSPACE_INPUT_STRING_CODE_UNITS;
|
|
53
60
|
const pending = [{ value, depth: 0, label }];
|
|
54
61
|
const seen = new WeakSet();
|
|
55
62
|
let nodes = 0;
|
|
63
|
+
let values = 0;
|
|
64
|
+
let keys = 0;
|
|
56
65
|
let stringCodeUnits = 0;
|
|
57
66
|
while (pending.length) {
|
|
58
67
|
const current = pending.pop();
|
|
59
68
|
const candidate = current.value;
|
|
69
|
+
values += 1;
|
|
70
|
+
if (limits && values > limits.maximumValues) {
|
|
71
|
+
throw inputError(label, 'the graph exceeds the byte-budgeted value ceiling');
|
|
72
|
+
}
|
|
60
73
|
if (typeof candidate === 'string') {
|
|
61
74
|
stringCodeUnits += candidate.length;
|
|
62
|
-
if (stringCodeUnits >
|
|
75
|
+
if (stringCodeUnits > maximumStringCodeUnits) {
|
|
63
76
|
throw inputError(label, 'aggregate text exceeds the string ceiling');
|
|
64
77
|
}
|
|
65
78
|
continue;
|
|
@@ -76,7 +89,7 @@ export function assertWorkspaceInputGraph(value, label = 'Workspace input') {
|
|
|
76
89
|
throw inputError(label, 'the nesting depth exceeds the graph ceiling');
|
|
77
90
|
}
|
|
78
91
|
nodes += 1;
|
|
79
|
-
if (nodes > MAX_WORKSPACE_INPUT_GRAPH_NODES) {
|
|
92
|
+
if (!limits && nodes > MAX_WORKSPACE_INPUT_GRAPH_NODES) {
|
|
80
93
|
throw inputError(label, 'the graph exceeds the node ceiling');
|
|
81
94
|
}
|
|
82
95
|
if (seen.has(candidate))
|
|
@@ -118,8 +131,16 @@ export function assertWorkspaceInputGraph(value, label = 'Workspace input') {
|
|
|
118
131
|
}
|
|
119
132
|
continue;
|
|
120
133
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
134
|
+
const descriptors = ordinaryRecordDescriptors(candidate, current.label);
|
|
135
|
+
if (limits) {
|
|
136
|
+
keys += descriptors.length;
|
|
137
|
+
if (keys > limits.maximumKeys)
|
|
138
|
+
throw inputError(label, 'the graph exceeds the byte-budgeted key ceiling');
|
|
139
|
+
stringCodeUnits += descriptors.reduce((total, [key]) => total + key.length, 0);
|
|
140
|
+
if (stringCodeUnits > maximumStringCodeUnits) {
|
|
141
|
+
throw inputError(label, 'aggregate text exceeds the string ceiling');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
123
144
|
for (const [key, descriptor] of descriptors) {
|
|
124
145
|
pending.push({
|
|
125
146
|
value: descriptor.value,
|