@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
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
|
|
4
|
+
import { BULK_SESSION_SCHEMA, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ROWS, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, mergeBulkSessions, normalizeBulkSession, } from "../packages/workspace/bulk-session-model.mjs";
|
|
5
|
+
import { buildBulkComparisonCandidates } from "../packages/comparison/comparison-ledger-bulk.mjs";
|
|
6
|
+
import { MAX_COMPARISON_LEDGER_DETAIL_ROWS, boundedComparisonLedgerLimitations, comparisonLedgerCollector, comparisonLedgerValuePresent, freshComparisonLedgerCounters, makeComparisonLedgerCandidate, normaliseComparisonLedgerRowWithCounters, stableComparisonLedgerId, stableComparisonLedgerJson, } from "../packages/comparison/comparison-ledger-contract.mjs";
|
|
7
|
+
import { DOMAIN_PORTFOLIO_INPUT_SCHEMA, DOMAIN_PORTFOLIO_REVIEW_SCHEMA, DOMAIN_PORTFOLIO_REVIEW_VERSION, reviewDomainPortfolio, } from "../lib/domain-portfolio-review.mjs";
|
|
8
|
+
import { parseBoundedJsonObject } from "./bounded-json.mjs";
|
|
9
|
+
import { CliUsageError } from "./errors.mjs";
|
|
10
|
+
import { buildCliLookupDiff, formatCliLookupDiff } from "./lookup-diff.mjs";
|
|
11
|
+
import { SAVED_LOOKUP_SCHEMA } from "./saved-lookup.mjs";
|
|
12
|
+
import { CLI_COMPARISON_LEDGER_SCHEMA, CLI_COMPARISON_LEDGER_VERSION, MAX_RETAINED_ARTIFACT_DIFF_BYTES, } from "../packages/contracts/offline-comparison.mjs";
|
|
13
|
+
export { CLI_COMPARISON_LEDGER_SCHEMA, CLI_COMPARISON_LEDGER_VERSION, MAX_RETAINED_ARTIFACT_DIFF_BYTES };
|
|
14
|
+
const BULK_ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'sessions', 'limitations']);
|
|
15
|
+
const PORTFOLIO_ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'portfolioLabel', 'assets', 'simulations', 'renewalQueue', 'recoveryCycles', 'unknownCounts', 'limitations']);
|
|
16
|
+
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
17
|
+
function record(value) {
|
|
18
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
19
|
+
}
|
|
20
|
+
function exactKeys(value, allowed, label) {
|
|
21
|
+
if (Object.keys(value).some((key) => !allowed.has(key)) || [...allowed].some((key) => !Object.hasOwn(value, key))) {
|
|
22
|
+
throw new CliUsageError(`${label} does not match its exact retained export contract.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function timestamp(value, label) {
|
|
26
|
+
if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
27
|
+
throw new CliUsageError(`${label} is missing or invalid.`);
|
|
28
|
+
}
|
|
29
|
+
const parsed = Date.parse(value);
|
|
30
|
+
if (!Number.isFinite(parsed))
|
|
31
|
+
throw new CliUsageError(`${label} is missing or invalid.`);
|
|
32
|
+
return new Date(parsed).toISOString();
|
|
33
|
+
}
|
|
34
|
+
function digest(value) {
|
|
35
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
36
|
+
}
|
|
37
|
+
function parseDocument(raw, label) {
|
|
38
|
+
try {
|
|
39
|
+
return parseBoundedJsonObject(raw, { label, maximumBytes: MAX_RETAINED_ARTIFACT_DIFF_BYTES });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new CliUsageError(error instanceof Error ? error.message : `${label} is invalid.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function assertLimitations(value, label) {
|
|
46
|
+
if (!Array.isArray(value) || value.length > 8 || value.some((item) => (typeof item !== 'string' || item.length > 600 || /[\u0000-\u001f\u007f]/u.test(item))))
|
|
47
|
+
throw new CliUsageError(`${label} limitations are invalid.`);
|
|
48
|
+
}
|
|
49
|
+
function parseBulkExport(root) {
|
|
50
|
+
exactKeys(root, BULK_ROOT_KEYS, 'Bulk session export');
|
|
51
|
+
const version = Number(root.version);
|
|
52
|
+
if (root.schema !== BULK_SESSION_SCHEMA || !SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS.includes(version)) {
|
|
53
|
+
throw new CliUsageError(`Bulk session exports must use a supported ${BULK_SESSION_SCHEMA} version.`);
|
|
54
|
+
}
|
|
55
|
+
const generatedAt = timestamp(root.generatedAt, 'Bulk session export generatedAt');
|
|
56
|
+
assertLimitations(root.limitations, 'Bulk session export');
|
|
57
|
+
if (!Array.isArray(root.sessions) || root.sessions.length < 1 || root.sessions.length > MAX_BULK_SESSIONS) {
|
|
58
|
+
throw new CliUsageError(`Bulk session exports used by diff must contain from 1 to ${MAX_BULK_SESSIONS} sessions.`);
|
|
59
|
+
}
|
|
60
|
+
const ids = new Set();
|
|
61
|
+
for (const [index, candidate] of root.sessions.entries()) {
|
|
62
|
+
const rawSession = record(candidate);
|
|
63
|
+
const rawResults = Array.isArray(rawSession?.results) ? rawSession.results : null;
|
|
64
|
+
const rawDomains = Array.isArray(rawSession?.domains) ? rawSession.domains : null;
|
|
65
|
+
const session = normalizeBulkSession(candidate);
|
|
66
|
+
if (!session || !rawResults || !rawDomains
|
|
67
|
+
|| rawResults.length > MAX_BULK_SESSION_ROWS
|
|
68
|
+
|| rawResults.length !== session.results.length
|
|
69
|
+
|| rawDomains.length !== session.domains.length
|
|
70
|
+
|| ids.has(session.id)) {
|
|
71
|
+
throw new CliUsageError(`Bulk session export row ${index + 1} is malformed, duplicated, or would be silently omitted.`);
|
|
72
|
+
}
|
|
73
|
+
ids.add(session.id);
|
|
74
|
+
}
|
|
75
|
+
const imported = mergeBulkSessions([], root);
|
|
76
|
+
if (imported.skipped || imported.pruned || imported.sessions.length !== root.sessions.length) {
|
|
77
|
+
throw new CliUsageError('Bulk session export cannot be compared without omitting or pruning retained sessions.');
|
|
78
|
+
}
|
|
79
|
+
return Object.freeze({ version, generatedAt, sessions: Object.freeze(imported.sessions) });
|
|
80
|
+
}
|
|
81
|
+
function selectSession(document, requested, side) {
|
|
82
|
+
if (requested !== null && requested !== undefined && !SAFE_SESSION_ID_RE.test(requested)) {
|
|
83
|
+
throw new CliUsageError(`The ${side} saved-session ID is invalid.`);
|
|
84
|
+
}
|
|
85
|
+
if (!requested && document.sessions.length !== 1) {
|
|
86
|
+
throw new CliUsageError(`The ${side} Bulk export contains multiple sessions; select one with --${side}-session.`);
|
|
87
|
+
}
|
|
88
|
+
const selected = requested
|
|
89
|
+
? document.sessions.find((session) => session.id === requested)
|
|
90
|
+
: document.sessions[0];
|
|
91
|
+
if (!selected)
|
|
92
|
+
throw new CliUsageError(`The selected ${side} saved session was not found.`);
|
|
93
|
+
return selected;
|
|
94
|
+
}
|
|
95
|
+
function withoutOwnerHref(index) {
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
...index,
|
|
98
|
+
items: Object.freeze(index.items.map((item) => Object.freeze({ ...item, ownerHref: '' }))),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function withoutDetailOwnerHref(details) {
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
...details,
|
|
104
|
+
selectedItems: Object.freeze(details.selectedItems.map((item) => Object.freeze({ ...item, ownerHref: '' }))),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function buildBulkLedger(leftRaw, rightRaw, leftRoot, rightRoot, options, generatedAt) {
|
|
108
|
+
const leftDocument = parseBulkExport(leftRoot);
|
|
109
|
+
const rightDocument = parseBulkExport(rightRoot);
|
|
110
|
+
const left = selectSession(leftDocument, options.leftSessionId, 'left');
|
|
111
|
+
const right = selectSession(rightDocument, options.rightSessionId, 'right');
|
|
112
|
+
if (left.updatedAt > right.updatedAt)
|
|
113
|
+
throw new CliUsageError('Bulk diff requires the left saved session to be no later than the right saved session.');
|
|
114
|
+
const leftTransientId = stableComparisonLedgerId('cli-bulk-left', [left.id, left.updatedAt, digest(leftRaw)]);
|
|
115
|
+
const rightTransientId = stableComparisonLedgerId('cli-bulk-right', [right.id, right.updatedAt, digest(rightRaw)]);
|
|
116
|
+
const transientLeft = { ...left, id: leftTransientId };
|
|
117
|
+
const transientRight = { ...right, id: rightTransientId };
|
|
118
|
+
const counters = freshComparisonLedgerCounters();
|
|
119
|
+
const candidates = buildBulkComparisonCandidates([transientLeft, transientRight], [{ earlierSessionId: leftTransientId, laterSessionId: rightTransientId }], counters);
|
|
120
|
+
const candidate = candidates[0];
|
|
121
|
+
if (!candidate || candidates.length !== 1)
|
|
122
|
+
throw new CliUsageError('The selected Bulk sessions are not a compatible chronological pair.');
|
|
123
|
+
const projected = projectSingleCandidate(candidate, counters);
|
|
124
|
+
return Object.freeze({
|
|
125
|
+
schema: CLI_COMPARISON_LEDGER_SCHEMA,
|
|
126
|
+
version: CLI_COMPARISON_LEDGER_VERSION,
|
|
127
|
+
generatedAt: timestamp(generatedAt, 'Comparison ledger generatedAt'),
|
|
128
|
+
artifactFamily: 'bulk_sessions',
|
|
129
|
+
left: Object.freeze({ schema: BULK_SESSION_SCHEMA, version: leftDocument.version, id: left.id, label: left.name, retainedAt: left.updatedAt }),
|
|
130
|
+
right: Object.freeze({ schema: BULK_SESSION_SCHEMA, version: rightDocument.version, id: right.id, label: right.name, retainedAt: right.updatedAt }),
|
|
131
|
+
index: withoutOwnerHref(projected.index),
|
|
132
|
+
details: withoutDetailOwnerHref(projected.details),
|
|
133
|
+
limitations: Object.freeze([
|
|
134
|
+
'The ledger compares one explicit chronological pair of compact retained Bulk sessions without making a request or reading source file paths.',
|
|
135
|
+
'Portable session claims are treated as unauthenticated imports: profile-derived trust, matches, and Risk remain quarantined while independent retained source fields can still be reviewed.',
|
|
136
|
+
'Missing later rows are not domain removals, releases, resolution changes, or availability findings.',
|
|
137
|
+
]),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function parsePortfolioReview(root) {
|
|
141
|
+
exactKeys(root, PORTFOLIO_ROOT_KEYS, 'Domain portfolio review');
|
|
142
|
+
if (root.schema !== DOMAIN_PORTFOLIO_REVIEW_SCHEMA || root.version !== DOMAIN_PORTFOLIO_REVIEW_VERSION) {
|
|
143
|
+
throw new CliUsageError(`Domain portfolio reviews must use ${DOMAIN_PORTFOLIO_REVIEW_SCHEMA} version ${DOMAIN_PORTFOLIO_REVIEW_VERSION}.`);
|
|
144
|
+
}
|
|
145
|
+
const rebuilt = reviewDomainPortfolio({
|
|
146
|
+
schema: DOMAIN_PORTFOLIO_INPUT_SCHEMA,
|
|
147
|
+
version: 1,
|
|
148
|
+
portfolioLabel: root.portfolioLabel,
|
|
149
|
+
assets: root.assets,
|
|
150
|
+
}, timestamp(root.generatedAt, 'Domain portfolio review generatedAt'));
|
|
151
|
+
if (canonicalArtifactJsonV2(rebuilt) !== canonicalArtifactJsonV2(root)) {
|
|
152
|
+
throw new CliUsageError('Domain portfolio review derived fields do not match its bounded source asset projection.');
|
|
153
|
+
}
|
|
154
|
+
return rebuilt;
|
|
155
|
+
}
|
|
156
|
+
const PORTFOLIO_FIELDS = Object.freeze([
|
|
157
|
+
['Criticality', 'portfolio', (item) => item.criticality],
|
|
158
|
+
['Registrar', 'registration', (item) => item.registrar],
|
|
159
|
+
['Registrar account label', 'registration', (item) => item.registrarAccount],
|
|
160
|
+
['Expiry', 'registration', (item) => item.expiresAt],
|
|
161
|
+
['Auto-renew', 'registration', (item) => item.autoRenew],
|
|
162
|
+
['DNS providers', 'dns', (item) => item.dnsProviders],
|
|
163
|
+
['Mail providers', 'mail', (item) => item.mailProviders],
|
|
164
|
+
['Certificate providers', 'certificate', (item) => item.certificateProviders],
|
|
165
|
+
['Recovery domains', 'recovery', (item) => item.recoveryDomains],
|
|
166
|
+
]);
|
|
167
|
+
function portfolioSide(report, value, observedAt = null) {
|
|
168
|
+
return {
|
|
169
|
+
source: `Retained domain portfolio · ${report.portfolioLabel}`,
|
|
170
|
+
sourceState: 'analyst_supplied',
|
|
171
|
+
value,
|
|
172
|
+
observedAt,
|
|
173
|
+
retainedAt: report.generatedAt,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function portfolioRows(ownerId, earlier, later) {
|
|
177
|
+
const output = comparisonLedgerCollector();
|
|
178
|
+
let incomplete = false;
|
|
179
|
+
const before = new Map(earlier.assets.map((asset) => [asset.domain, asset]));
|
|
180
|
+
const after = new Map(later.assets.map((asset) => [asset.domain, asset]));
|
|
181
|
+
for (const domain of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
182
|
+
const left = before.get(domain);
|
|
183
|
+
const right = after.get(domain);
|
|
184
|
+
if (!left && right) {
|
|
185
|
+
output.add({ comparisonId: ownerId, ownerId, entityId: domain, mode: 'membership', state: 'added', field: 'Retained portfolio membership', family: 'membership', earlier: portfolioSide(earlier, null), later: portfolioSide(later, 'Included', right.reviewedAt), completeness: 'complete', limitations: ['Membership applies only to the two analyst-supplied retained portfolios; it is not evidence of registration, ownership, control, or activity.'] });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (left && !right) {
|
|
189
|
+
incomplete = true;
|
|
190
|
+
output.add({ comparisonId: ownerId, ownerId, entityId: domain, mode: 'membership', state: 'not_compared', field: 'Retained portfolio membership', family: 'membership', earlier: portfolioSide(earlier, 'Included', left.reviewedAt), later: portfolioSide(later, null), completeness: 'partial', limitations: ['The later retained portfolio omits this asset. That omission is not evidence of registration removal, release, loss of control, or inactivity.'] });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!left || !right)
|
|
194
|
+
continue;
|
|
195
|
+
const leftRecord = left;
|
|
196
|
+
const rightRecord = right;
|
|
197
|
+
for (const [field, family, read] of PORTFOLIO_FIELDS) {
|
|
198
|
+
const beforeValue = read(leftRecord);
|
|
199
|
+
const afterValue = read(rightRecord);
|
|
200
|
+
if (stableComparisonLedgerJson(beforeValue) === stableComparisonLedgerJson(afterValue))
|
|
201
|
+
continue;
|
|
202
|
+
const afterPresent = comparisonLedgerValuePresent(afterValue);
|
|
203
|
+
const beforePresent = comparisonLedgerValuePresent(beforeValue);
|
|
204
|
+
if (beforePresent && !afterPresent)
|
|
205
|
+
incomplete = true;
|
|
206
|
+
output.add({
|
|
207
|
+
comparisonId: ownerId,
|
|
208
|
+
ownerId,
|
|
209
|
+
entityId: domain,
|
|
210
|
+
mode: 'temporal',
|
|
211
|
+
state: !beforePresent && afterPresent ? 'added' : beforePresent && !afterPresent ? 'incomplete' : 'different',
|
|
212
|
+
field,
|
|
213
|
+
family,
|
|
214
|
+
earlier: portfolioSide(earlier, beforeValue, left.reviewedAt),
|
|
215
|
+
later: portfolioSide(later, afterValue, right.reviewedAt),
|
|
216
|
+
completeness: beforePresent && !afterPresent ? 'partial' : 'complete',
|
|
217
|
+
limitations: ['Values are bounded analyst-supplied portfolio assertions, not current provider observations or proof of ownership, configuration, or responsibility.'],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (left.reviewedAt !== right.reviewedAt) {
|
|
221
|
+
output.add({ comparisonId: ownerId, ownerId, entityId: domain, mode: 'temporal', state: 'collection_changed', field: 'Analyst review time', family: 'collection', earlier: portfolioSide(earlier, left.reviewedAt, left.reviewedAt), later: portfolioSide(later, right.reviewedAt, right.reviewedAt), completeness: 'complete', limitations: ['Review-time movement records analyst retention context and is not a change to the domain or provider.'] });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!output.totalRows) {
|
|
225
|
+
output.add({ comparisonId: ownerId, ownerId, entityId: 'portfolio-summary', mode: 'temporal', state: 'equivalent', field: 'Bounded retained portfolio assertions', family: 'summary', earlier: portfolioSide(earlier, 'No bounded material difference'), later: portfolioSide(later, 'No bounded material difference'), completeness: 'complete', limitations: ['Equivalence applies only to the exact bounded analyst-supplied fields retained in both portfolio review documents.'] });
|
|
226
|
+
}
|
|
227
|
+
return Object.freeze({
|
|
228
|
+
projection: Object.freeze({ rows: output.rows, totalRows: output.totalRows, sourceOmittedRows: 0 }),
|
|
229
|
+
incomplete,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function projectSingleCandidate(candidate, counters) {
|
|
233
|
+
const indexOmissions = Object.freeze({
|
|
234
|
+
inputRecords: counters.inputRecords,
|
|
235
|
+
inputScanTruncations: counters.inputScanTruncations,
|
|
236
|
+
invalidRecords: counters.invalidRecords,
|
|
237
|
+
duplicateRecords: counters.duplicateRecords,
|
|
238
|
+
indexItems: 0,
|
|
239
|
+
limitations: counters.limitations,
|
|
240
|
+
truncatedStrings: counters.truncatedStrings,
|
|
241
|
+
});
|
|
242
|
+
const index = Object.freeze({ items: Object.freeze([candidate.item]), counts: Object.freeze({ candidates: 1, retained: 1 }), omissions: indexOmissions, truncated: candidate.item.truncated || Object.values(indexOmissions).some((count) => count > 0) });
|
|
243
|
+
const projection = candidate.buildDetails();
|
|
244
|
+
const rows = [];
|
|
245
|
+
const ids = new Set();
|
|
246
|
+
let invalidRows = 0;
|
|
247
|
+
let duplicateRows = 0;
|
|
248
|
+
for (const rawRow of projection.rows) {
|
|
249
|
+
const row = normaliseComparisonLedgerRowWithCounters(rawRow, counters);
|
|
250
|
+
if (!row) {
|
|
251
|
+
invalidRows += 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (ids.has(row.id)) {
|
|
255
|
+
duplicateRows += 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
ids.add(row.id);
|
|
259
|
+
if (rows.length < MAX_COMPARISON_LEDGER_DETAIL_ROWS)
|
|
260
|
+
rows.push(row);
|
|
261
|
+
}
|
|
262
|
+
rows.sort((left, right) => left.entityId.localeCompare(right.entityId) || left.family.localeCompare(right.family) || left.field.localeCompare(right.field) || left.id.localeCompare(right.id));
|
|
263
|
+
const totalRows = Math.max(0, projection.totalRows - duplicateRows);
|
|
264
|
+
const limitations = boundedComparisonLedgerLimitations(candidate.item.limitations, counters);
|
|
265
|
+
const omissions = Object.freeze({
|
|
266
|
+
inputRecords: counters.inputRecords,
|
|
267
|
+
inputScanTruncations: counters.inputScanTruncations,
|
|
268
|
+
invalidRecords: counters.invalidRecords + invalidRows,
|
|
269
|
+
duplicateRecords: counters.duplicateRecords,
|
|
270
|
+
indexItems: 0,
|
|
271
|
+
entityRequests: 0,
|
|
272
|
+
duplicateEntityRequests: 0,
|
|
273
|
+
invalidEntityRequests: 0,
|
|
274
|
+
missingEntities: 0,
|
|
275
|
+
duplicateDetailRows: duplicateRows,
|
|
276
|
+
detailRows: Math.max(0, totalRows - rows.length),
|
|
277
|
+
sourceRows: projection.sourceOmittedRows,
|
|
278
|
+
limitations: counters.limitations,
|
|
279
|
+
truncatedStrings: counters.truncatedStrings,
|
|
280
|
+
});
|
|
281
|
+
const details = Object.freeze({ selectedItems: Object.freeze([candidate.item]), rows: Object.freeze(rows), totalRows, limitations: limitations.values, omissions, truncated: candidate.item.truncated || Object.values(omissions).some((count) => count > 0) });
|
|
282
|
+
return Object.freeze({ index, details });
|
|
283
|
+
}
|
|
284
|
+
function buildPortfolioLedger(leftRaw, rightRaw, leftRoot, rightRoot, generatedAt) {
|
|
285
|
+
const left = parsePortfolioReview(leftRoot);
|
|
286
|
+
const right = parsePortfolioReview(rightRoot);
|
|
287
|
+
if (left.generatedAt > right.generatedAt)
|
|
288
|
+
throw new CliUsageError('Domain portfolio diff requires the left review to be no later than the right review.');
|
|
289
|
+
const counters = freshComparisonLedgerCounters();
|
|
290
|
+
const ownerId = stableComparisonLedgerId('retained-portfolio-pair', [digest(leftRaw), digest(rightRaw)]);
|
|
291
|
+
const portfolioProjection = portfolioRows(ownerId, left, right);
|
|
292
|
+
const { projection } = portfolioProjection;
|
|
293
|
+
const completeness = portfolioProjection.incomplete ? 'partial' : 'complete';
|
|
294
|
+
const projectionTruncated = projection.totalRows > projection.rows.length;
|
|
295
|
+
const candidate = makeComparisonLedgerCandidate({
|
|
296
|
+
idParts: [digest(leftRaw), digest(rightRaw)],
|
|
297
|
+
ownerType: 'retained_artifact_pair',
|
|
298
|
+
ownerId,
|
|
299
|
+
entityId: 'domain-portfolio-pair',
|
|
300
|
+
label: `${left.portfolioLabel} → ${right.portfolioLabel} · retained portfolio pair`,
|
|
301
|
+
mode: 'temporal',
|
|
302
|
+
earlier: portfolioSide(left, null),
|
|
303
|
+
later: portfolioSide(right, null),
|
|
304
|
+
completeness,
|
|
305
|
+
truncated: projectionTruncated,
|
|
306
|
+
limitations: [
|
|
307
|
+
'This offline ledger compares bounded retained analyst-supplied portfolio assertions and makes no provider request.',
|
|
308
|
+
...(completeness === 'partial' ? ['At least one later omission is not represented as a real-world removal or resolved dependency.'] : []),
|
|
309
|
+
],
|
|
310
|
+
ownerHref: '',
|
|
311
|
+
buildDetails: () => projection,
|
|
312
|
+
}, counters);
|
|
313
|
+
if (!candidate)
|
|
314
|
+
throw new CliUsageError('The retained domain portfolio pair could not be projected safely.');
|
|
315
|
+
const projected = projectSingleCandidate(candidate, counters);
|
|
316
|
+
return Object.freeze({
|
|
317
|
+
schema: CLI_COMPARISON_LEDGER_SCHEMA,
|
|
318
|
+
version: CLI_COMPARISON_LEDGER_VERSION,
|
|
319
|
+
generatedAt: timestamp(generatedAt, 'Comparison ledger generatedAt'),
|
|
320
|
+
artifactFamily: 'domain_portfolio',
|
|
321
|
+
left: Object.freeze({ schema: DOMAIN_PORTFOLIO_REVIEW_SCHEMA, version: DOMAIN_PORTFOLIO_REVIEW_VERSION, id: digest(leftRaw), label: left.portfolioLabel, retainedAt: left.generatedAt }),
|
|
322
|
+
right: Object.freeze({ schema: DOMAIN_PORTFOLIO_REVIEW_SCHEMA, version: DOMAIN_PORTFOLIO_REVIEW_VERSION, id: digest(rightRaw), label: right.portfolioLabel, retainedAt: right.generatedAt }),
|
|
323
|
+
...projected,
|
|
324
|
+
limitations: Object.freeze([
|
|
325
|
+
'Only the two explicitly selected retained review documents are compared; source file paths are not retained.',
|
|
326
|
+
'Portfolio values are analyst-supplied context. They do not prove live configuration, registration, ownership, control, availability, or provider responsibility.',
|
|
327
|
+
]),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
export function buildCliRetainedArtifactDiff(leftRaw, rightRaw, options = {}, generatedAt = new Date().toISOString()) {
|
|
331
|
+
if (Buffer.byteLength(leftRaw, 'utf8') + Buffer.byteLength(rightRaw, 'utf8') > MAX_RETAINED_ARTIFACT_DIFF_BYTES * 2) {
|
|
332
|
+
throw new CliUsageError(`Retained diff input is limited to ${MAX_RETAINED_ARTIFACT_DIFF_BYTES * 2} bytes in total.`);
|
|
333
|
+
}
|
|
334
|
+
const left = parseDocument(leftRaw, 'Left retained diff input');
|
|
335
|
+
const right = parseDocument(rightRaw, 'Right retained diff input');
|
|
336
|
+
if (left.schema !== right.schema)
|
|
337
|
+
throw new CliUsageError('diff requires two retained documents from the same supported artifact family.');
|
|
338
|
+
if (left.schema === SAVED_LOOKUP_SCHEMA) {
|
|
339
|
+
if (options.leftSessionId || options.rightSessionId)
|
|
340
|
+
throw new CliUsageError('Saved-session selectors apply only to Bulk session exports.');
|
|
341
|
+
return buildCliLookupDiff(leftRaw, rightRaw, generatedAt);
|
|
342
|
+
}
|
|
343
|
+
if (left.schema === BULK_SESSION_SCHEMA)
|
|
344
|
+
return buildBulkLedger(leftRaw, rightRaw, left, right, options, generatedAt);
|
|
345
|
+
if (left.schema === DOMAIN_PORTFOLIO_REVIEW_SCHEMA) {
|
|
346
|
+
if (options.leftSessionId || options.rightSessionId)
|
|
347
|
+
throw new CliUsageError('Saved-session selectors apply only to Bulk session exports.');
|
|
348
|
+
return buildPortfolioLedger(leftRaw, rightRaw, left, right, generatedAt);
|
|
349
|
+
}
|
|
350
|
+
throw new CliUsageError('diff supports saved Lookups, Bulk session exports, or retained domain portfolio reviews.');
|
|
351
|
+
}
|
|
352
|
+
export function formatCliRetainedArtifactDiff(document) {
|
|
353
|
+
if (document.schema !== CLI_COMPARISON_LEDGER_SCHEMA)
|
|
354
|
+
return formatCliLookupDiff(document);
|
|
355
|
+
const lines = [
|
|
356
|
+
'Retained artifact comparison ledger',
|
|
357
|
+
`Family ${document.artifactFamily.replaceAll('_', ' ')}`,
|
|
358
|
+
`Left ${document.left.label} · ${document.left.id}`,
|
|
359
|
+
`Right ${document.right.label} · ${document.right.id}`,
|
|
360
|
+
`Candidates ${document.index.counts.retained}`,
|
|
361
|
+
`Exact rows ${document.details.rows.length} of ${document.details.totalRows}`,
|
|
362
|
+
'',
|
|
363
|
+
];
|
|
364
|
+
for (const row of document.details.rows) {
|
|
365
|
+
lines.push(`${row.entityId} · ${row.field} [${row.state.replaceAll('_', ' ')}]`);
|
|
366
|
+
lines.push(` Earlier: ${row.earlier.value ?? 'not retained'} · ${row.earlier.sourceState}`);
|
|
367
|
+
lines.push(` Later: ${row.later.value ?? 'not retained'} · ${row.later.sourceState}`);
|
|
368
|
+
}
|
|
369
|
+
if (document.details.omissions.detailRows)
|
|
370
|
+
lines.push(`Omitted exact rows: ${document.details.omissions.detailRows}`);
|
|
371
|
+
lines.push('', 'Limitations:');
|
|
372
|
+
for (const limitation of document.limitations)
|
|
373
|
+
lines.push(` - ${limitation}`);
|
|
374
|
+
return `${lines.join('\n')}\n`;
|
|
375
|
+
}
|
package/cli/risk-calibration.mjs
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { decodeBoundedUtf8 } from "../lib/bounded-file.mjs";
|
|
2
3
|
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
4
|
+
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
5
|
+
import { canonicalRegistrableDomain } from "../lib/registrable-domain.mjs";
|
|
3
6
|
import { ANALYST_REVIEW_REASON_VALUES, analystInteroperabilityTags } from "../lib/analyst-taxonomy.mjs";
|
|
7
|
+
import { buildRiskCalibrationSummaryReport, parseRiskCalibrationSummaryReport, RISK_CALIBRATION_SUMMARY_INTERPRETATION, RISK_CALIBRATION_SUMMARY_THRESHOLDS, } from "../lib/risk-calibration-summary.mjs";
|
|
8
|
+
import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, serializeRiskCalibrationSnapshot, snapshotRiskCalibrationReportForSerialization, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
4
9
|
import { CliUsageError } from "./arguments.mjs";
|
|
5
|
-
import { RISK_MUTATION_TYPES
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
export const
|
|
9
|
-
export const RISK_CALIBRATION_REPORT_SCHEMA = 'whoisleuth.cli.risk-calibration';
|
|
10
|
-
export const RISK_CALIBRATION_REPORT_VERSION = 2;
|
|
11
|
-
export const MAX_RISK_CALIBRATION_INPUT_BYTES = 2 * 1024 * 1024;
|
|
12
|
-
export const MAX_RISK_CALIBRATION_RECORDS = 500;
|
|
13
|
-
export const MAX_RISK_CALIBRATION_STRING_LENGTH = 256;
|
|
14
|
-
export const RISK_CALIBRATION_THRESHOLDS = Object.freeze([...new Set([40, 50, 60, RISK_REVIEW_THRESHOLD, 80, 90])].sort((a, b) => a - b));
|
|
15
|
-
const MAX_MUTATIONS = 30;
|
|
16
|
-
const MAX_PROVIDERS = 10;
|
|
17
|
-
const MAX_FINDINGS_PER_PROVIDER = 100;
|
|
18
|
-
const MAX_TIMESTAMP_LENGTH = 64;
|
|
10
|
+
import { RISK_MUTATION_TYPES } from "../lib/risk-scoring.mjs";
|
|
11
|
+
import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_SCHEMA, } from "../lib/threat-intelligence-types.mjs";
|
|
12
|
+
export { MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_STRING_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
13
|
+
export const RISK_CALIBRATION_THRESHOLDS = RISK_CALIBRATION_SUMMARY_THRESHOLDS;
|
|
19
14
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
20
15
|
const DISPOSITIONS = new Set([
|
|
21
16
|
'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action',
|
|
@@ -48,7 +43,7 @@ function boundedString(value, field, maximum = MAX_RISK_CALIBRATION_STRING_LENGT
|
|
|
48
43
|
function optionalTimestamp(value, field) {
|
|
49
44
|
if (value === null || value === undefined)
|
|
50
45
|
return undefined;
|
|
51
|
-
return boundedString(value, field,
|
|
46
|
+
return boundedString(value, field, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH);
|
|
52
47
|
}
|
|
53
48
|
function projectThreatIntelligence(value, field) {
|
|
54
49
|
if (value === null || value === undefined)
|
|
@@ -56,8 +51,8 @@ function projectThreatIntelligence(value, field) {
|
|
|
56
51
|
const envelope = object(value, field);
|
|
57
52
|
if (!Array.isArray(envelope.providers))
|
|
58
53
|
throw new CliUsageError(`${field}.providers must be an array.`);
|
|
59
|
-
if (envelope.providers.length >
|
|
60
|
-
throw new CliUsageError(`${field}.providers exceeds the ${
|
|
54
|
+
if (envelope.providers.length > MAX_RISK_CALIBRATION_PROVIDERS)
|
|
55
|
+
throw new CliUsageError(`${field}.providers exceeds the ${MAX_RISK_CALIBRATION_PROVIDERS}-provider limit.`);
|
|
61
56
|
return {
|
|
62
57
|
providers: envelope.providers.map((item, providerIndex) => {
|
|
63
58
|
const prefix = `${field}.providers[${providerIndex}]`;
|
|
@@ -68,15 +63,15 @@ function projectThreatIntelligence(value, field) {
|
|
|
68
63
|
: object(provider.observation, `${prefix}.observation`);
|
|
69
64
|
if (!Array.isArray(provider.findings))
|
|
70
65
|
throw new CliUsageError(`${prefix}.findings must be an array.`);
|
|
71
|
-
if (provider.findings.length >
|
|
72
|
-
throw new CliUsageError(`${prefix}.findings exceeds the ${
|
|
66
|
+
if (provider.findings.length > MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER) {
|
|
67
|
+
throw new CliUsageError(`${prefix}.findings exceeds the ${MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER}-finding limit.`);
|
|
73
68
|
}
|
|
74
69
|
const observedAt = observation
|
|
75
70
|
? optionalTimestamp(observation.observedAt, `${prefix}.observation.observedAt`)
|
|
76
71
|
: undefined;
|
|
77
72
|
return {
|
|
78
|
-
provider: { id: boundedString(identity.id, `${prefix}.provider.id`,
|
|
79
|
-
state: boundedString(provider.state, `${prefix}.state`,
|
|
73
|
+
provider: { id: boundedString(identity.id, `${prefix}.provider.id`, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH) },
|
|
74
|
+
state: boundedString(provider.state, `${prefix}.state`, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH),
|
|
80
75
|
...(observation ? { observation: observedAt ? { observedAt } : {} } : {}),
|
|
81
76
|
findings: provider.findings.map((findingValue, findingIndex) => {
|
|
82
77
|
const findingPrefix = `${prefix}.findings[${findingIndex}]`;
|
|
@@ -84,7 +79,7 @@ function projectThreatIntelligence(value, field) {
|
|
|
84
79
|
const firstObservedAt = optionalTimestamp(finding.firstObservedAt, `${findingPrefix}.firstObservedAt`);
|
|
85
80
|
const lastObservedAt = optionalTimestamp(finding.lastObservedAt, `${findingPrefix}.lastObservedAt`);
|
|
86
81
|
return {
|
|
87
|
-
category: boundedString(finding.category, `${findingPrefix}.category`,
|
|
82
|
+
category: boundedString(finding.category, `${findingPrefix}.category`, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH),
|
|
88
83
|
...(firstObservedAt ? { firstObservedAt } : {}),
|
|
89
84
|
...(lastObservedAt ? { lastObservedAt } : {}),
|
|
90
85
|
};
|
|
@@ -95,10 +90,12 @@ function projectThreatIntelligence(value, field) {
|
|
|
95
90
|
}
|
|
96
91
|
function projectEvidence(value, field) {
|
|
97
92
|
const source = object(value, field);
|
|
98
|
-
const availability = boundedString(source.availability ?? source.state, `${field}.availability`,
|
|
93
|
+
const availability = boundedString(source.availability ?? source.state, `${field}.availability`, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH);
|
|
99
94
|
if (!AVAILABILITY_STATES.has(availability))
|
|
100
95
|
throw new CliUsageError(`${field}.availability is unsupported.`);
|
|
101
|
-
const result = {
|
|
96
|
+
const result = {
|
|
97
|
+
availability: availability,
|
|
98
|
+
};
|
|
102
99
|
for (const name of BOOLEAN_FIELDS) {
|
|
103
100
|
const candidate = source[name];
|
|
104
101
|
if (candidate === null || candidate === undefined)
|
|
@@ -108,13 +105,13 @@ function projectEvidence(value, field) {
|
|
|
108
105
|
result[name] = candidate;
|
|
109
106
|
}
|
|
110
107
|
if (source.activityStatus !== null && source.activityStatus !== undefined) {
|
|
111
|
-
const activity = boundedString(source.activityStatus, `${field}.activityStatus`,
|
|
108
|
+
const activity = boundedString(source.activityStatus, `${field}.activityStatus`, MAX_RISK_CALIBRATION_ACTIVITY_LENGTH);
|
|
112
109
|
if (!ACTIVITY_STATES.has(activity))
|
|
113
110
|
throw new CliUsageError(`${field}.activityStatus is unsupported.`);
|
|
114
111
|
result.activityStatus = activity;
|
|
115
112
|
}
|
|
116
113
|
if (source.scanDepth !== null && source.scanDepth !== undefined) {
|
|
117
|
-
const depth = boundedString(source.scanDepth, `${field}.scanDepth`,
|
|
114
|
+
const depth = boundedString(source.scanDepth, `${field}.scanDepth`, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH);
|
|
118
115
|
if (depth !== 'fast' && depth !== 'deep')
|
|
119
116
|
throw new CliUsageError(`${field}.scanDepth must be fast or deep.`);
|
|
120
117
|
Object.assign(result, { scanDepth: depth });
|
|
@@ -129,7 +126,7 @@ function projectEvidence(value, field) {
|
|
|
129
126
|
}
|
|
130
127
|
if (source.domainAgeDays !== null && source.domainAgeDays !== undefined) {
|
|
131
128
|
if (typeof source.domainAgeDays !== 'number' || !Number.isFinite(source.domainAgeDays)
|
|
132
|
-
|| source.domainAgeDays < 0 || source.domainAgeDays >
|
|
129
|
+
|| source.domainAgeDays < 0 || source.domainAgeDays > MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS) {
|
|
133
130
|
throw new CliUsageError(`${field}.domainAgeDays must be a finite number from 0 to 100000.`);
|
|
134
131
|
}
|
|
135
132
|
result.domainAgeDays = source.domainAgeDays;
|
|
@@ -137,10 +134,10 @@ function projectEvidence(value, field) {
|
|
|
137
134
|
if (source.mutationTypes !== null && source.mutationTypes !== undefined) {
|
|
138
135
|
if (!Array.isArray(source.mutationTypes))
|
|
139
136
|
throw new CliUsageError(`${field}.mutationTypes must be an array.`);
|
|
140
|
-
if (source.mutationTypes.length >
|
|
141
|
-
throw new CliUsageError(`${field}.mutationTypes exceeds the ${
|
|
137
|
+
if (source.mutationTypes.length > MAX_RISK_CALIBRATION_MUTATIONS)
|
|
138
|
+
throw new CliUsageError(`${field}.mutationTypes exceeds the ${MAX_RISK_CALIBRATION_MUTATIONS}-item limit.`);
|
|
142
139
|
result.mutationTypes = [...new Set(source.mutationTypes.map((item, index) => {
|
|
143
|
-
const mutation = boundedString(item, `${field}.mutationTypes[${index}]`,
|
|
140
|
+
const mutation = boundedString(item, `${field}.mutationTypes[${index}]`, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH);
|
|
144
141
|
if (!MUTATION_TYPES.has(mutation))
|
|
145
142
|
throw new CliUsageError(`${field}.mutationTypes[${index}] is unsupported.`);
|
|
146
143
|
return mutation;
|
|
@@ -163,7 +160,12 @@ export async function readRiskCalibrationInputBounded(stream, limit = MAX_RISK_C
|
|
|
163
160
|
throw new CliUsageError(`Risk calibration input is limited to ${limit} bytes.`);
|
|
164
161
|
chunks.push(buffer);
|
|
165
162
|
}
|
|
166
|
-
|
|
163
|
+
try {
|
|
164
|
+
return decodeBoundedUtf8(Buffer.concat(chunks), 'Risk calibration input');
|
|
165
|
+
}
|
|
166
|
+
catch (cause) {
|
|
167
|
+
throw new CliUsageError(cause instanceof Error ? cause.message : 'Risk calibration input must contain valid UTF-8 text.');
|
|
168
|
+
}
|
|
167
169
|
}
|
|
168
170
|
export function parseRiskCalibrationDataset(text) {
|
|
169
171
|
if (typeof text !== 'string')
|
|
@@ -171,12 +173,19 @@ export function parseRiskCalibrationDataset(text) {
|
|
|
171
173
|
if (Buffer.byteLength(text, 'utf8') > MAX_RISK_CALIBRATION_INPUT_BYTES) {
|
|
172
174
|
throw new CliUsageError(`Risk calibration input is limited to ${MAX_RISK_CALIBRATION_INPUT_BYTES} bytes.`);
|
|
173
175
|
}
|
|
176
|
+
const normalized = text.replace(/^\uFEFF/u, '');
|
|
174
177
|
let parsed;
|
|
175
178
|
try {
|
|
176
|
-
|
|
179
|
+
scanBoundedJson(normalized, {
|
|
180
|
+
maximumDepth: MAX_RISK_CALIBRATION_JSON_DEPTH,
|
|
181
|
+
maximumKeys: MAX_RISK_CALIBRATION_JSON_KEYS,
|
|
182
|
+
maximumValues: MAX_RISK_CALIBRATION_JSON_VALUES,
|
|
183
|
+
maximumContainerItems: MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS,
|
|
184
|
+
});
|
|
185
|
+
parsed = JSON.parse(normalized);
|
|
177
186
|
}
|
|
178
187
|
catch {
|
|
179
|
-
throw new CliUsageError('Risk calibration input must be valid JSON.');
|
|
188
|
+
throw new CliUsageError('Risk calibration input must be valid bounded JSON without duplicate keys.');
|
|
180
189
|
}
|
|
181
190
|
const document = object(parsed, 'Risk calibration input');
|
|
182
191
|
const documentVersion = typeof document.version === 'number' && Number.isInteger(document.version)
|
|
@@ -197,19 +206,19 @@ export function parseRiskCalibrationDataset(text) {
|
|
|
197
206
|
const records = document.records.map((value, index) => {
|
|
198
207
|
const prefix = `records[${index}]`;
|
|
199
208
|
const record = object(value, prefix);
|
|
200
|
-
const id = boundedString(record.id, `${prefix}.id`,
|
|
209
|
+
const id = boundedString(record.id, `${prefix}.id`, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH);
|
|
201
210
|
if (ids.has(id))
|
|
202
211
|
throw new CliUsageError(`${prefix}.id must be unique.`);
|
|
203
212
|
ids.add(id);
|
|
204
|
-
const domain = boundedString(record.domain, `${prefix}.domain`,
|
|
213
|
+
const domain = boundedString(record.domain, `${prefix}.domain`, MAX_RISK_CALIBRATION_DOMAIN_LENGTH).toLowerCase().replace(/\.$/, '');
|
|
205
214
|
if (!isValidAsciiDomainName(domain, { requireDot: true }))
|
|
206
215
|
throw new CliUsageError(`${prefix}.domain must be a valid ASCII DNS hostname, not an IP address.`);
|
|
207
|
-
const analystDisposition = boundedString(record.analystDisposition, `${prefix}.analystDisposition`,
|
|
216
|
+
const analystDisposition = boundedString(record.analystDisposition, `${prefix}.analystDisposition`, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH);
|
|
208
217
|
if (!DISPOSITIONS.has(analystDisposition))
|
|
209
218
|
throw new CliUsageError(`${prefix}.analystDisposition is unsupported.`);
|
|
210
219
|
let reviewReasonCode;
|
|
211
220
|
if (record.reviewReasonCode !== null && record.reviewReasonCode !== undefined) {
|
|
212
|
-
reviewReasonCode = boundedString(record.reviewReasonCode, `${prefix}.reviewReasonCode`,
|
|
221
|
+
reviewReasonCode = boundedString(record.reviewReasonCode, `${prefix}.reviewReasonCode`, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH);
|
|
213
222
|
if (!REVIEW_REASON_CODES.has(reviewReasonCode))
|
|
214
223
|
throw new CliUsageError(`${prefix}.reviewReasonCode is unsupported.`);
|
|
215
224
|
}
|
|
@@ -296,9 +305,32 @@ function scoreBand(score) {
|
|
|
296
305
|
return '40_69';
|
|
297
306
|
return '0_39';
|
|
298
307
|
}
|
|
308
|
+
function scoringEvidence(record) {
|
|
309
|
+
const threatIntelligence = record.evidence.threatIntelligence;
|
|
310
|
+
const scoringDomain = canonicalRegistrableDomain(record.domain);
|
|
311
|
+
return {
|
|
312
|
+
...record.evidence,
|
|
313
|
+
domain: scoringDomain ?? record.domain,
|
|
314
|
+
...(threatIntelligence && scoringDomain ? {
|
|
315
|
+
threatIntelligence: {
|
|
316
|
+
version: THREAT_INTELLIGENCE_ENVELOPE_VERSION,
|
|
317
|
+
providers: threatIntelligence.providers.map((provider) => ({
|
|
318
|
+
...provider,
|
|
319
|
+
schema: THREAT_INTELLIGENCE_SCHEMA,
|
|
320
|
+
version: THREAT_INTELLIGENCE_CONTRACT_VERSION,
|
|
321
|
+
target: {
|
|
322
|
+
type: 'domain',
|
|
323
|
+
value: scoringDomain,
|
|
324
|
+
exposure: 'registrable_domain',
|
|
325
|
+
},
|
|
326
|
+
})),
|
|
327
|
+
},
|
|
328
|
+
} : {}),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
299
331
|
export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
300
332
|
const records = dataset.records.map((record) => {
|
|
301
|
-
const explained = explainRiskScore(record
|
|
333
|
+
const explained = explainRiskScore(scoringEvidence(record));
|
|
302
334
|
const classification = metricClass(record.analystDisposition);
|
|
303
335
|
const includedInMetrics = classification !== 'excluded' && explained !== null;
|
|
304
336
|
return {
|
|
@@ -362,7 +394,7 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
|
362
394
|
const current = records[index];
|
|
363
395
|
if (!source || !current)
|
|
364
396
|
continue;
|
|
365
|
-
const previous = options.explainPreviousRiskScore(source
|
|
397
|
+
const previous = options.explainPreviousRiskScore(scoringEvidence(source));
|
|
366
398
|
if (previous?.score !== current.score)
|
|
367
399
|
scoresChanged += 1;
|
|
368
400
|
if (scoreBand(previous?.score ?? null) !== current.band)
|
|
@@ -375,6 +407,7 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
|
375
407
|
return {
|
|
376
408
|
schema: RISK_CALIBRATION_REPORT_SCHEMA,
|
|
377
409
|
version: RISK_CALIBRATION_REPORT_VERSION,
|
|
410
|
+
mode: 'detailed',
|
|
378
411
|
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
379
412
|
dataset: { schema: dataset.schema, version: dataset.version, recordCount: records.length },
|
|
380
413
|
riskModelVersion: options.modelVersion,
|
|
@@ -393,10 +426,26 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
|
393
426
|
records,
|
|
394
427
|
interpretation: {
|
|
395
428
|
authority: 'analyst_context_only',
|
|
396
|
-
statement:
|
|
429
|
+
statement: RISK_CALIBRATION_SUMMARY_INTERPRETATION,
|
|
397
430
|
automaticTuning: false,
|
|
398
431
|
networkRequests: false,
|
|
399
432
|
persisted: false,
|
|
400
433
|
},
|
|
401
434
|
};
|
|
402
435
|
}
|
|
436
|
+
export function serializeRiskCalibrationReport(report) {
|
|
437
|
+
try {
|
|
438
|
+
const snapshot = snapshotRiskCalibrationReportForSerialization(report);
|
|
439
|
+
const serialised = serializeRiskCalibrationSnapshot(snapshot);
|
|
440
|
+
if (snapshot.mode === 'summary')
|
|
441
|
+
parseRiskCalibrationSummaryReport(serialised);
|
|
442
|
+
else
|
|
443
|
+
buildRiskCalibrationSummaryReport(snapshot.document);
|
|
444
|
+
return serialised;
|
|
445
|
+
}
|
|
446
|
+
catch (cause) {
|
|
447
|
+
throw new CliUsageError(cause instanceof Error
|
|
448
|
+
? cause.message
|
|
449
|
+
: 'Risk calibration output must use the current detailed or summary report contract.');
|
|
450
|
+
}
|
|
451
|
+
}
|