@slicedearth/whoisleuth-cli 1.32.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/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
3
|
+
import { CliUsageError } from "./arguments.mjs";
|
|
4
|
+
import { RISK_MUTATION_TYPES, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
|
|
5
|
+
export const RISK_CALIBRATION_DATASET_SCHEMA = 'whoisleuth.risk-calibration-dataset';
|
|
6
|
+
export const RISK_CALIBRATION_DATASET_VERSION = 1;
|
|
7
|
+
export const RISK_CALIBRATION_REPORT_SCHEMA = 'whoisleuth.cli.risk-calibration';
|
|
8
|
+
export const RISK_CALIBRATION_REPORT_VERSION = 1;
|
|
9
|
+
export const MAX_RISK_CALIBRATION_INPUT_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
export const MAX_RISK_CALIBRATION_RECORDS = 500;
|
|
11
|
+
export const MAX_RISK_CALIBRATION_STRING_LENGTH = 256;
|
|
12
|
+
export const RISK_CALIBRATION_THRESHOLDS = Object.freeze([...new Set([40, 50, 60, RISK_REVIEW_THRESHOLD, 80, 90])].sort((a, b) => a - b));
|
|
13
|
+
const MAX_MUTATIONS = 30;
|
|
14
|
+
const MAX_PROVIDERS = 10;
|
|
15
|
+
const MAX_FINDINGS_PER_PROVIDER = 100;
|
|
16
|
+
const MAX_TIMESTAMP_LENGTH = 64;
|
|
17
|
+
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
18
|
+
const DISPOSITIONS = new Set([
|
|
19
|
+
'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action',
|
|
20
|
+
]);
|
|
21
|
+
const POSITIVE_DISPOSITIONS = new Set(['confirmed_abuse']);
|
|
22
|
+
const NEGATIVE_DISPOSITIONS = new Set(['false_positive', 'expected']);
|
|
23
|
+
const AVAILABILITY_STATES = new Set(['registered', 'for_sale', 'expiring', 'available', 'unknown', 'error']);
|
|
24
|
+
const ACTIVITY_STATES = new Set(['active', 'parked', 'unreachable', 'no_site']);
|
|
25
|
+
const BOOLEAN_FIELDS = [
|
|
26
|
+
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField',
|
|
27
|
+
'hasMx', 'hasSpf', 'hasDmarc', 'privacyProtected',
|
|
28
|
+
];
|
|
29
|
+
const MUTATION_TYPES = new Set(RISK_MUTATION_TYPES);
|
|
30
|
+
function object(value, field) {
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
32
|
+
throw new CliUsageError(`${field} must be an object.`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function boundedString(value, field, maximum = MAX_RISK_CALIBRATION_STRING_LENGTH) {
|
|
37
|
+
if (typeof value !== 'string' || !value.trim())
|
|
38
|
+
throw new CliUsageError(`${field} must be non-empty text.`);
|
|
39
|
+
const trimmed = value.trim();
|
|
40
|
+
if (trimmed.length > maximum || CONTROL_RE.test(value))
|
|
41
|
+
throw new CliUsageError(`${field} exceeds its text limit or contains control characters.`);
|
|
42
|
+
return trimmed;
|
|
43
|
+
}
|
|
44
|
+
function optionalTimestamp(value, field) {
|
|
45
|
+
if (value === null || value === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
return boundedString(value, field, MAX_TIMESTAMP_LENGTH);
|
|
48
|
+
}
|
|
49
|
+
function projectThreatIntelligence(value, field) {
|
|
50
|
+
if (value === null || value === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
const envelope = object(value, field);
|
|
53
|
+
if (!Array.isArray(envelope.providers))
|
|
54
|
+
throw new CliUsageError(`${field}.providers must be an array.`);
|
|
55
|
+
if (envelope.providers.length > MAX_PROVIDERS)
|
|
56
|
+
throw new CliUsageError(`${field}.providers exceeds the ${MAX_PROVIDERS}-provider limit.`);
|
|
57
|
+
return {
|
|
58
|
+
providers: envelope.providers.map((item, providerIndex) => {
|
|
59
|
+
const prefix = `${field}.providers[${providerIndex}]`;
|
|
60
|
+
const provider = object(item, prefix);
|
|
61
|
+
const identity = object(provider.provider, `${prefix}.provider`);
|
|
62
|
+
const observation = provider.observation === null || provider.observation === undefined
|
|
63
|
+
? null
|
|
64
|
+
: object(provider.observation, `${prefix}.observation`);
|
|
65
|
+
if (!Array.isArray(provider.findings))
|
|
66
|
+
throw new CliUsageError(`${prefix}.findings must be an array.`);
|
|
67
|
+
if (provider.findings.length > MAX_FINDINGS_PER_PROVIDER) {
|
|
68
|
+
throw new CliUsageError(`${prefix}.findings exceeds the ${MAX_FINDINGS_PER_PROVIDER}-finding limit.`);
|
|
69
|
+
}
|
|
70
|
+
const observedAt = observation
|
|
71
|
+
? optionalTimestamp(observation.observedAt, `${prefix}.observation.observedAt`)
|
|
72
|
+
: undefined;
|
|
73
|
+
return {
|
|
74
|
+
provider: { id: boundedString(identity.id, `${prefix}.provider.id`, 64) },
|
|
75
|
+
state: boundedString(provider.state, `${prefix}.state`, 32),
|
|
76
|
+
...(observation ? { observation: observedAt ? { observedAt } : {} } : {}),
|
|
77
|
+
findings: provider.findings.map((findingValue, findingIndex) => {
|
|
78
|
+
const findingPrefix = `${prefix}.findings[${findingIndex}]`;
|
|
79
|
+
const finding = object(findingValue, findingPrefix);
|
|
80
|
+
const firstObservedAt = optionalTimestamp(finding.firstObservedAt, `${findingPrefix}.firstObservedAt`);
|
|
81
|
+
const lastObservedAt = optionalTimestamp(finding.lastObservedAt, `${findingPrefix}.lastObservedAt`);
|
|
82
|
+
return {
|
|
83
|
+
category: boundedString(finding.category, `${findingPrefix}.category`, 64),
|
|
84
|
+
...(firstObservedAt ? { firstObservedAt } : {}),
|
|
85
|
+
...(lastObservedAt ? { lastObservedAt } : {}),
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function projectEvidence(value, field) {
|
|
93
|
+
const source = object(value, field);
|
|
94
|
+
const availability = boundedString(source.availability ?? source.state, `${field}.availability`, 32);
|
|
95
|
+
if (!AVAILABILITY_STATES.has(availability))
|
|
96
|
+
throw new CliUsageError(`${field}.availability is unsupported.`);
|
|
97
|
+
const result = { availability };
|
|
98
|
+
for (const name of BOOLEAN_FIELDS) {
|
|
99
|
+
const candidate = source[name];
|
|
100
|
+
if (candidate === null || candidate === undefined)
|
|
101
|
+
continue;
|
|
102
|
+
if (typeof candidate !== 'boolean')
|
|
103
|
+
throw new CliUsageError(`${field}.${name} must be true or false when present.`);
|
|
104
|
+
result[name] = candidate;
|
|
105
|
+
}
|
|
106
|
+
if (source.activityStatus !== null && source.activityStatus !== undefined) {
|
|
107
|
+
const activity = boundedString(source.activityStatus, `${field}.activityStatus`, 32);
|
|
108
|
+
if (!ACTIVITY_STATES.has(activity))
|
|
109
|
+
throw new CliUsageError(`${field}.activityStatus is unsupported.`);
|
|
110
|
+
result.activityStatus = activity;
|
|
111
|
+
}
|
|
112
|
+
if (source.phishingLanguageMatch !== null && source.phishingLanguageMatch !== undefined) {
|
|
113
|
+
result.phishingLanguageMatch = boundedString(source.phishingLanguageMatch, `${field}.phishingLanguageMatch`);
|
|
114
|
+
}
|
|
115
|
+
if (source.domainAgeDays !== null && source.domainAgeDays !== undefined) {
|
|
116
|
+
if (typeof source.domainAgeDays !== 'number' || !Number.isFinite(source.domainAgeDays)
|
|
117
|
+
|| source.domainAgeDays < 0 || source.domainAgeDays > 100_000) {
|
|
118
|
+
throw new CliUsageError(`${field}.domainAgeDays must be a finite number from 0 to 100000.`);
|
|
119
|
+
}
|
|
120
|
+
result.domainAgeDays = source.domainAgeDays;
|
|
121
|
+
}
|
|
122
|
+
if (source.mutationTypes !== null && source.mutationTypes !== undefined) {
|
|
123
|
+
if (!Array.isArray(source.mutationTypes))
|
|
124
|
+
throw new CliUsageError(`${field}.mutationTypes must be an array.`);
|
|
125
|
+
if (source.mutationTypes.length > MAX_MUTATIONS)
|
|
126
|
+
throw new CliUsageError(`${field}.mutationTypes exceeds the ${MAX_MUTATIONS}-item limit.`);
|
|
127
|
+
result.mutationTypes = [...new Set(source.mutationTypes.map((item, index) => {
|
|
128
|
+
const mutation = boundedString(item, `${field}.mutationTypes[${index}]`, 64);
|
|
129
|
+
if (!MUTATION_TYPES.has(mutation))
|
|
130
|
+
throw new CliUsageError(`${field}.mutationTypes[${index}] is unsupported.`);
|
|
131
|
+
return mutation;
|
|
132
|
+
}))];
|
|
133
|
+
}
|
|
134
|
+
const threatIntelligence = projectThreatIntelligence(source.threatIntelligence, `${field}.threatIntelligence`);
|
|
135
|
+
if (threatIntelligence)
|
|
136
|
+
result.threatIntelligence = threatIntelligence;
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
export async function readRiskCalibrationInputBounded(stream, limit = MAX_RISK_CALIBRATION_INPUT_BYTES) {
|
|
140
|
+
if (!stream || stream.isTTY)
|
|
141
|
+
return '';
|
|
142
|
+
const chunks = [];
|
|
143
|
+
let total = 0;
|
|
144
|
+
for await (const chunk of stream) {
|
|
145
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
146
|
+
total += buffer.length;
|
|
147
|
+
if (total > limit)
|
|
148
|
+
throw new CliUsageError(`Risk calibration input is limited to ${limit} bytes.`);
|
|
149
|
+
chunks.push(buffer);
|
|
150
|
+
}
|
|
151
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
152
|
+
}
|
|
153
|
+
export function parseRiskCalibrationDataset(text) {
|
|
154
|
+
if (typeof text !== 'string')
|
|
155
|
+
throw new CliUsageError('Risk calibration input must be a JSON document.');
|
|
156
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RISK_CALIBRATION_INPUT_BYTES) {
|
|
157
|
+
throw new CliUsageError(`Risk calibration input is limited to ${MAX_RISK_CALIBRATION_INPUT_BYTES} bytes.`);
|
|
158
|
+
}
|
|
159
|
+
let parsed;
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
throw new CliUsageError('Risk calibration input must be valid JSON.');
|
|
165
|
+
}
|
|
166
|
+
const document = object(parsed, 'Risk calibration input');
|
|
167
|
+
if (document.schema !== RISK_CALIBRATION_DATASET_SCHEMA || document.version !== RISK_CALIBRATION_DATASET_VERSION) {
|
|
168
|
+
throw new CliUsageError(`Risk calibration input must use ${RISK_CALIBRATION_DATASET_SCHEMA} version ${RISK_CALIBRATION_DATASET_VERSION}.`);
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(document.records) || !document.records.length) {
|
|
171
|
+
throw new CliUsageError('Risk calibration input must contain a non-empty records array.');
|
|
172
|
+
}
|
|
173
|
+
if (document.records.length > MAX_RISK_CALIBRATION_RECORDS) {
|
|
174
|
+
throw new CliUsageError(`Risk calibration input exceeds the ${MAX_RISK_CALIBRATION_RECORDS}-record limit.`);
|
|
175
|
+
}
|
|
176
|
+
const ids = new Set();
|
|
177
|
+
const records = document.records.map((value, index) => {
|
|
178
|
+
const prefix = `records[${index}]`;
|
|
179
|
+
const record = object(value, prefix);
|
|
180
|
+
const id = boundedString(record.id, `${prefix}.id`, 128);
|
|
181
|
+
if (ids.has(id))
|
|
182
|
+
throw new CliUsageError(`${prefix}.id must be unique.`);
|
|
183
|
+
ids.add(id);
|
|
184
|
+
const domain = boundedString(record.domain, `${prefix}.domain`, 253).toLowerCase().replace(/\.$/, '');
|
|
185
|
+
if (!isValidAsciiDomainName(domain, { requireDot: true }))
|
|
186
|
+
throw new CliUsageError(`${prefix}.domain must be a valid ASCII DNS hostname, not an IP address.`);
|
|
187
|
+
const analystDisposition = boundedString(record.analystDisposition, `${prefix}.analystDisposition`, 32);
|
|
188
|
+
if (!DISPOSITIONS.has(analystDisposition))
|
|
189
|
+
throw new CliUsageError(`${prefix}.analystDisposition is unsupported.`);
|
|
190
|
+
return {
|
|
191
|
+
id,
|
|
192
|
+
domain,
|
|
193
|
+
analystDisposition: analystDisposition,
|
|
194
|
+
evidence: projectEvidence(record.evidence, `${prefix}.evidence`),
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
schema: RISK_CALIBRATION_DATASET_SCHEMA,
|
|
199
|
+
version: RISK_CALIBRATION_DATASET_VERSION,
|
|
200
|
+
records,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function ratio(numerator, denominator) {
|
|
204
|
+
return denominator ? Number((numerator / denominator).toFixed(4)) : null;
|
|
205
|
+
}
|
|
206
|
+
function metricsForThreshold(records, threshold) {
|
|
207
|
+
let truePositive = 0;
|
|
208
|
+
let falsePositive = 0;
|
|
209
|
+
let trueNegative = 0;
|
|
210
|
+
let falseNegative = 0;
|
|
211
|
+
for (const record of records) {
|
|
212
|
+
if (!record.includedInMetrics || record.score === null)
|
|
213
|
+
continue;
|
|
214
|
+
const flagged = record.score >= threshold;
|
|
215
|
+
if (record.metricClass === 'positive')
|
|
216
|
+
flagged ? truePositive += 1 : falseNegative += 1;
|
|
217
|
+
else
|
|
218
|
+
flagged ? falsePositive += 1 : trueNegative += 1;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
threshold,
|
|
222
|
+
truePositive,
|
|
223
|
+
falsePositive,
|
|
224
|
+
trueNegative,
|
|
225
|
+
falseNegative,
|
|
226
|
+
precision: ratio(truePositive, truePositive + falsePositive),
|
|
227
|
+
recall: ratio(truePositive, truePositive + falseNegative),
|
|
228
|
+
specificity: ratio(trueNegative, trueNegative + falsePositive),
|
|
229
|
+
falsePositiveRate: ratio(falsePositive, falsePositive + trueNegative),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function metricClass(disposition) {
|
|
233
|
+
if (POSITIVE_DISPOSITIONS.has(disposition))
|
|
234
|
+
return 'positive';
|
|
235
|
+
if (NEGATIVE_DISPOSITIONS.has(disposition))
|
|
236
|
+
return 'negative';
|
|
237
|
+
return 'excluded';
|
|
238
|
+
}
|
|
239
|
+
function scoreBand(score) {
|
|
240
|
+
if (score === null)
|
|
241
|
+
return 'not_scored';
|
|
242
|
+
if (score >= 70)
|
|
243
|
+
return '70_100';
|
|
244
|
+
if (score >= 40)
|
|
245
|
+
return '40_69';
|
|
246
|
+
return '0_39';
|
|
247
|
+
}
|
|
248
|
+
export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
249
|
+
const records = dataset.records.map((record) => {
|
|
250
|
+
const explained = explainRiskScore(record.evidence);
|
|
251
|
+
const classification = metricClass(record.analystDisposition);
|
|
252
|
+
const includedInMetrics = classification !== 'excluded' && explained !== null;
|
|
253
|
+
return {
|
|
254
|
+
id: record.id,
|
|
255
|
+
domain: record.domain,
|
|
256
|
+
analystDisposition: record.analystDisposition,
|
|
257
|
+
metricClass: classification,
|
|
258
|
+
includedInMetrics,
|
|
259
|
+
exclusionReason: includedInMetrics
|
|
260
|
+
? null
|
|
261
|
+
: explained === null
|
|
262
|
+
? 'not_scored'
|
|
263
|
+
: 'contextual_disposition',
|
|
264
|
+
modelVersion: explained?.modelVersion ?? options.modelVersion,
|
|
265
|
+
score: explained?.score ?? null,
|
|
266
|
+
band: scoreBand(explained?.score ?? null),
|
|
267
|
+
factors: explained?.factors ?? [],
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
const bands = { not_scored: 0, '0_39': 0, '40_69': 0, '70_100': 0 };
|
|
271
|
+
for (const record of records)
|
|
272
|
+
bands[record.band] += 1;
|
|
273
|
+
const positive = records.filter((record) => record.metricClass === 'positive' && record.score !== null).length;
|
|
274
|
+
const negative = records.filter((record) => record.metricClass === 'negative' && record.score !== null).length;
|
|
275
|
+
const excluded = records.length - positive - negative;
|
|
276
|
+
return {
|
|
277
|
+
schema: RISK_CALIBRATION_REPORT_SCHEMA,
|
|
278
|
+
version: RISK_CALIBRATION_REPORT_VERSION,
|
|
279
|
+
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
280
|
+
dataset: { schema: dataset.schema, version: dataset.version, recordCount: records.length },
|
|
281
|
+
riskModelVersion: options.modelVersion,
|
|
282
|
+
currentReviewThreshold: options.reviewThreshold,
|
|
283
|
+
summary: { total: records.length, positive, negative, excluded, scoreBands: bands },
|
|
284
|
+
thresholds: RISK_CALIBRATION_THRESHOLDS.map((threshold) => metricsForThreshold(records, threshold)),
|
|
285
|
+
records,
|
|
286
|
+
interpretation: {
|
|
287
|
+
authority: 'analyst_context_only',
|
|
288
|
+
statement: 'This offline replay compares heuristic Risk scores with analyst dispositions. It does not prove maliciousness or safety.',
|
|
289
|
+
automaticTuning: false,
|
|
290
|
+
networkRequests: false,
|
|
291
|
+
persisted: false,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|