@slicedearth/whoisleuth-cli 1.36.1 → 1.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +236 -5
- package/cli/artifact-verify.mjs +56 -14
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +18 -3
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-plan.mjs +2 -2
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/offline-evidence-review.mjs +20 -2
- package/cli/registry-doctor.mjs +125 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +236 -17
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1110 -0
- package/docs/cli.md +224 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +1 -1
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +1 -1
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
package/cli/lookup-timeline.mjs
CHANGED
|
@@ -85,7 +85,7 @@ function buildCliLookupTimeline(inputs, generatedAt = new Date().toISOString())
|
|
|
85
85
|
rawRegistryPayloadsCopied: 0,
|
|
86
86
|
},
|
|
87
87
|
limitations: [
|
|
88
|
-
'The timeline compares only bounded
|
|
88
|
+
'The timeline compares only bounded normalised fields already present in the selected saved Lookup documents and makes no network request.',
|
|
89
89
|
'Input filenames and raw registry payloads are not copied into the timeline output.',
|
|
90
90
|
'Missing, unavailable, and not-recorded evidence remain separate from an observed difference.',
|
|
91
91
|
'A difference can reflect a domain change or changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
|
package/cli/mail-review.mjs
CHANGED
|
@@ -213,7 +213,7 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
213
213
|
'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
|
|
214
214
|
'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
|
|
215
215
|
'Optional TLSA evidence is compared offline. A DANE match requires separately validated DNSSEC evidence from the same observation.',
|
|
216
|
-
'SMTP delivery, mailbox existence, catch-all
|
|
216
|
+
'SMTP delivery, mailbox existence, catch-all behaviour, banner collection, and message acceptance were not tested.',
|
|
217
217
|
],
|
|
218
218
|
};
|
|
219
219
|
}
|
|
@@ -6,6 +6,7 @@ import { normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-sea
|
|
|
6
6
|
import { reviewRpkiRoute } from "../lib/rpki-evidence.mjs";
|
|
7
7
|
import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
|
|
8
8
|
import { CliUsageError } from "./errors.mjs";
|
|
9
|
+
import { LOCAL_MMDB_QUERY_SCHEMA, reviewLocalMmdb } from "./local-mmdb-review.mjs";
|
|
9
10
|
const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
|
|
10
11
|
const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
|
|
11
12
|
const MAX_OFFLINE_EVIDENCE_INPUT_BYTES = 16 * 1024 * 1024;
|
|
@@ -88,7 +89,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
|
|
|
88
89
|
result = planEncryptedDnsQuery(normalizeEncryptedDnsAdapter(input.adapter), { name: query.name, type: query.type });
|
|
89
90
|
}
|
|
90
91
|
else {
|
|
91
|
-
throw new CliUsageError('Offline evidence review does not
|
|
92
|
+
throw new CliUsageError('Offline evidence review does not recognise this input schema.');
|
|
92
93
|
}
|
|
93
94
|
return Object.freeze({
|
|
94
95
|
schema: OFFLINE_EVIDENCE_REVIEW_SCHEMA,
|
|
@@ -101,6 +102,23 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
|
|
|
101
102
|
]),
|
|
102
103
|
});
|
|
103
104
|
}
|
|
105
|
+
async function buildOfflineEvidenceReviewWithLocalResources(value, generatedAt = new Date().toISOString(), options = {}) {
|
|
106
|
+
const input = parseInput(value);
|
|
107
|
+
if (input.schema !== LOCAL_MMDB_QUERY_SCHEMA)
|
|
108
|
+
return buildOfflineEvidenceReview(value, generatedAt);
|
|
109
|
+
if (!options.mmdbPath)
|
|
110
|
+
throw new CliUsageError('Local MMDB review requires --mmdb <database-file>.');
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
schema: OFFLINE_EVIDENCE_REVIEW_SCHEMA,
|
|
113
|
+
version: OFFLINE_EVIDENCE_REVIEW_VERSION,
|
|
114
|
+
generatedAt,
|
|
115
|
+
kind: 'geoip',
|
|
116
|
+
result: await reviewLocalMmdb(input, options.mmdbPath),
|
|
117
|
+
limitations: Object.freeze([
|
|
118
|
+
'The review is local and uses only the supplied query metadata and analyst-supplied database. It does not transmit the address or refresh the database.',
|
|
119
|
+
]),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
104
122
|
function formatOfflineEvidenceReview(document) {
|
|
105
123
|
const result = record(document.result);
|
|
106
124
|
const state = typeof result.state === 'string' ? result.state : 'reviewed';
|
|
@@ -138,4 +156,4 @@ function formatOfflineEvidenceReview(document) {
|
|
|
138
156
|
}
|
|
139
157
|
return `${lines.join('\n')}\n`;
|
|
140
158
|
}
|
|
141
|
-
export { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, buildOfflineEvidenceReview, formatOfflineEvidenceReview, };
|
|
159
|
+
export { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { registryCapabilityFor, REGISTRY_CAPABILITIES_VERSION } from "../lib/registry-capabilities.mjs";
|
|
2
|
+
import { CliUsageError } from "./errors.mjs";
|
|
3
|
+
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
4
|
+
const REGISTRY_DOCTOR_SCHEMA = 'whoisleuth.cli.registry-doctor';
|
|
5
|
+
const REGISTRY_DOCTOR_VERSION = 1;
|
|
6
|
+
function record(value) {
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
8
|
+
}
|
|
9
|
+
function status(value) {
|
|
10
|
+
return typeof value === 'string'
|
|
11
|
+
? value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().toLowerCase().slice(0, 40) || 'unavailable'
|
|
12
|
+
: 'unavailable';
|
|
13
|
+
}
|
|
14
|
+
function expectedWhois(profile) {
|
|
15
|
+
if (profile === 'iana-referral')
|
|
16
|
+
return 'allowed';
|
|
17
|
+
if (profile === 'registry-policy-restricted' || profile === 'source-ip-authorization-required')
|
|
18
|
+
return 'permission_required';
|
|
19
|
+
return 'unsupported';
|
|
20
|
+
}
|
|
21
|
+
function sourceAlignment(expected, observed) {
|
|
22
|
+
const successful = ['complete', 'partial', 'success'].includes(observed);
|
|
23
|
+
const constrained = ['disabled', 'skipped', 'unsupported'].includes(observed);
|
|
24
|
+
if (expected === 'allowed')
|
|
25
|
+
return successful ? 'observed' : 'investigate';
|
|
26
|
+
if (successful)
|
|
27
|
+
return 'unexpected_observation';
|
|
28
|
+
return constrained ? 'expected_constraint' : 'investigate';
|
|
29
|
+
}
|
|
30
|
+
function normalizedObjectIdentifier(parsed) {
|
|
31
|
+
const candidate = parsed.handle ?? parsed.objectIdentifier ?? parsed.registryObjectId ?? parsed.id;
|
|
32
|
+
return typeof candidate === 'string' && candidate.trim() ? 'observed' : 'not_observed';
|
|
33
|
+
}
|
|
34
|
+
function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString()) {
|
|
35
|
+
const document = parseSavedLookupDocument(raw, { label: 'Registry doctor input' });
|
|
36
|
+
const capability = registryCapabilityFor(document.registrableDomain);
|
|
37
|
+
if (!capability)
|
|
38
|
+
throw new CliUsageError('Registry doctor could not resolve a capability profile for this domain.');
|
|
39
|
+
const diagnostics = record(document.diagnostics);
|
|
40
|
+
const rdap = record(document.rdap);
|
|
41
|
+
const whois = record(document.whois);
|
|
42
|
+
const rdapParsed = record(rdap.parsed);
|
|
43
|
+
const whoisParsed = record(whois.parsed);
|
|
44
|
+
const sourceInputs = [
|
|
45
|
+
{
|
|
46
|
+
source: 'rdap',
|
|
47
|
+
expected: capability.rdapAccessProfile === 'iana-bootstrap' ? 'allowed' : 'unsupported',
|
|
48
|
+
observed: status(record(diagnostics.rdap).status),
|
|
49
|
+
parsed: rdapParsed,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
source: 'whois',
|
|
53
|
+
expected: expectedWhois(capability.whoisAccessProfile),
|
|
54
|
+
observed: status(record(diagnostics.whois).status),
|
|
55
|
+
parsed: whoisParsed,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
const sources = sourceInputs.map((source) => ({
|
|
59
|
+
source: source.source,
|
|
60
|
+
expected: source.expected,
|
|
61
|
+
observed: source.observed,
|
|
62
|
+
alignment: sourceAlignment(source.expected, source.observed),
|
|
63
|
+
normalizedFields: Object.keys(source.parsed).length,
|
|
64
|
+
objectIdentifier: normalizedObjectIdentifier(source.parsed),
|
|
65
|
+
limitation: capability.limitation.slice(0, 500),
|
|
66
|
+
}));
|
|
67
|
+
const recommendations = [
|
|
68
|
+
...sources.filter((source) => source.alignment === 'investigate').map((source) => (source.expected === 'allowed'
|
|
69
|
+
? `Review ${source.source.toUpperCase()} source health, transport attempts, and fixture coverage; the catalogue expected collection to be available.`
|
|
70
|
+
: `Review the ${source.source.toUpperCase()} result and capability profile; the observed state did not match the declared access constraint.`)),
|
|
71
|
+
...sources.filter((source) => source.alignment === 'unexpected_observation').map((source) => (`Review why ${source.source.toUpperCase()} produced normalized evidence despite the declared ${source.expected.replaceAll('_', ' ')} access profile.`)),
|
|
72
|
+
...sources.filter((source) => source.alignment === 'observed' && source.objectIdentifier === 'not_observed').map((source) => (`${source.source.toUpperCase()} succeeded without a retained registry object identifier; this can be a valid publication omission and should remain unknown rather than an error.`)),
|
|
73
|
+
].slice(0, 12);
|
|
74
|
+
return {
|
|
75
|
+
schema: REGISTRY_DOCTOR_SCHEMA,
|
|
76
|
+
version: REGISTRY_DOCTOR_VERSION,
|
|
77
|
+
generatedAt,
|
|
78
|
+
domain: document.registrableDomain,
|
|
79
|
+
suffix: capability.suffixes[0] || document.registrableDomain.split('.').at(-1) || '',
|
|
80
|
+
catalogueVersion: REGISTRY_CAPABILITIES_VERSION,
|
|
81
|
+
profile: {
|
|
82
|
+
id: capability.id,
|
|
83
|
+
explicitSuffixProfile: capability.explicitSuffixProfile,
|
|
84
|
+
coverageState: capability.coverageState,
|
|
85
|
+
},
|
|
86
|
+
sources,
|
|
87
|
+
summary: {
|
|
88
|
+
investigate: sources.filter((source) => source.alignment === 'investigate' || source.alignment === 'unexpected_observation').length,
|
|
89
|
+
expectedConstraints: sources.filter((source) => source.alignment === 'expected_constraint').length,
|
|
90
|
+
observed: sources.filter((source) => source.alignment === 'observed').length,
|
|
91
|
+
},
|
|
92
|
+
recommendations,
|
|
93
|
+
limitations: [
|
|
94
|
+
'This diagnostic compares one saved observation with the local reviewed capability catalogue and makes no registry, registrar, RDAP, or WHOIS request.',
|
|
95
|
+
'A catalogue alignment does not prove live reachability, parser completeness, current registry policy, registration, availability, ownership, safety, or maliciousness.',
|
|
96
|
+
'A missing registry object identifier can be a publication characteristic. It remains not observed and is not converted into a collection failure.',
|
|
97
|
+
'Use fixture-based maintenance checks before changing a registry parser or access profile; automated tests must not contact live registries.',
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function formatRegistryDoctorReport(report) {
|
|
102
|
+
const output = [
|
|
103
|
+
'Registry compatibility diagnostic',
|
|
104
|
+
`Domain ${report.domain}`,
|
|
105
|
+
`Profile ${report.profile.id}${report.profile.explicitSuffixProfile ? ' (explicit)' : ' (default)'}`,
|
|
106
|
+
`Review items ${report.summary.investigate}`,
|
|
107
|
+
'',
|
|
108
|
+
];
|
|
109
|
+
for (const source of report.sources) {
|
|
110
|
+
output.push(`${source.source.toUpperCase()} [${source.alignment.replaceAll('_', ' ')}]`);
|
|
111
|
+
output.push(` Expected: ${source.expected.replaceAll('_', ' ')}`);
|
|
112
|
+
output.push(` Observed: ${source.observed.replaceAll('_', ' ')}`);
|
|
113
|
+
output.push(` Object identifier: ${source.objectIdentifier.replaceAll('_', ' ')}`);
|
|
114
|
+
}
|
|
115
|
+
if (report.recommendations.length) {
|
|
116
|
+
output.push('', 'Recommendations:');
|
|
117
|
+
for (const item of report.recommendations)
|
|
118
|
+
output.push(` - ${item}`);
|
|
119
|
+
}
|
|
120
|
+
output.push('', 'Limitations:');
|
|
121
|
+
for (const limitation of report.limitations)
|
|
122
|
+
output.push(` - ${limitation}`);
|
|
123
|
+
return `${output.join('\n')}\n`;
|
|
124
|
+
}
|
|
125
|
+
export { REGISTRY_DOCTOR_SCHEMA, REGISTRY_DOCTOR_VERSION, buildRegistryDoctorReport, formatRegistryDoctorReport, };
|
package/cli/risk-calibration.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
3
|
+
import { ANALYST_REVIEW_REASON_VALUES, analystInteroperabilityTags } from "../lib/analyst-taxonomy.mjs";
|
|
3
4
|
import { CliUsageError } from "./arguments.mjs";
|
|
4
5
|
import { RISK_MUTATION_TYPES, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
|
|
5
6
|
export const RISK_CALIBRATION_DATASET_SCHEMA = 'whoisleuth.risk-calibration-dataset';
|
|
6
|
-
export const RISK_CALIBRATION_DATASET_VERSION =
|
|
7
|
+
export const RISK_CALIBRATION_DATASET_VERSION = 2;
|
|
8
|
+
export const SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS = Object.freeze([1, RISK_CALIBRATION_DATASET_VERSION]);
|
|
7
9
|
export const RISK_CALIBRATION_REPORT_SCHEMA = 'whoisleuth.cli.risk-calibration';
|
|
8
|
-
export const RISK_CALIBRATION_REPORT_VERSION =
|
|
10
|
+
export const RISK_CALIBRATION_REPORT_VERSION = 2;
|
|
9
11
|
export const MAX_RISK_CALIBRATION_INPUT_BYTES = 2 * 1024 * 1024;
|
|
10
12
|
export const MAX_RISK_CALIBRATION_RECORDS = 500;
|
|
11
13
|
export const MAX_RISK_CALIBRATION_STRING_LENGTH = 256;
|
|
@@ -24,8 +26,10 @@ const AVAILABILITY_STATES = new Set(['registered', 'for_sale', 'expiring', 'avai
|
|
|
24
26
|
const ACTIVITY_STATES = new Set(['active', 'parked', 'unreachable', 'no_site']);
|
|
25
27
|
const BOOLEAN_FIELDS = [
|
|
26
28
|
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField',
|
|
27
|
-
'hasMx', 'hasSpf', 'hasDmarc', 'privacyProtected',
|
|
29
|
+
'hasMx', 'hasSpf', 'hasDmarc', 'privacyProtected', 'hasExternalFormAction',
|
|
30
|
+
'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
|
|
28
31
|
];
|
|
32
|
+
const REVIEW_REASON_CODES = ANALYST_REVIEW_REASON_VALUES;
|
|
29
33
|
const MUTATION_TYPES = new Set(RISK_MUTATION_TYPES);
|
|
30
34
|
function object(value, field) {
|
|
31
35
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -109,6 +113,17 @@ function projectEvidence(value, field) {
|
|
|
109
113
|
throw new CliUsageError(`${field}.activityStatus is unsupported.`);
|
|
110
114
|
result.activityStatus = activity;
|
|
111
115
|
}
|
|
116
|
+
if (source.scanDepth !== null && source.scanDepth !== undefined) {
|
|
117
|
+
const depth = boundedString(source.scanDepth, `${field}.scanDepth`, 16);
|
|
118
|
+
if (depth !== 'fast' && depth !== 'deep')
|
|
119
|
+
throw new CliUsageError(`${field}.scanDepth must be fast or deep.`);
|
|
120
|
+
Object.assign(result, { scanDepth: depth });
|
|
121
|
+
}
|
|
122
|
+
if (source.observedAt !== null && source.observedAt !== undefined) {
|
|
123
|
+
const observedAt = optionalTimestamp(source.observedAt, `${field}.observedAt`);
|
|
124
|
+
if (observedAt)
|
|
125
|
+
Object.assign(result, { observedAt });
|
|
126
|
+
}
|
|
112
127
|
if (source.phishingLanguageMatch !== null && source.phishingLanguageMatch !== undefined) {
|
|
113
128
|
result.phishingLanguageMatch = boundedString(source.phishingLanguageMatch, `${field}.phishingLanguageMatch`);
|
|
114
129
|
}
|
|
@@ -164,8 +179,13 @@ export function parseRiskCalibrationDataset(text) {
|
|
|
164
179
|
throw new CliUsageError('Risk calibration input must be valid JSON.');
|
|
165
180
|
}
|
|
166
181
|
const document = object(parsed, 'Risk calibration input');
|
|
167
|
-
|
|
168
|
-
|
|
182
|
+
const documentVersion = typeof document.version === 'number' && Number.isInteger(document.version)
|
|
183
|
+
? document.version
|
|
184
|
+
: null;
|
|
185
|
+
if (document.schema !== RISK_CALIBRATION_DATASET_SCHEMA
|
|
186
|
+
|| documentVersion === null
|
|
187
|
+
|| !SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.includes(documentVersion)) {
|
|
188
|
+
throw new CliUsageError(`Risk calibration input must use ${RISK_CALIBRATION_DATASET_SCHEMA} version ${SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.join(' or ')}.`);
|
|
169
189
|
}
|
|
170
190
|
if (!Array.isArray(document.records) || !document.records.length) {
|
|
171
191
|
throw new CliUsageError('Risk calibration input must contain a non-empty records array.');
|
|
@@ -187,22 +207,39 @@ export function parseRiskCalibrationDataset(text) {
|
|
|
187
207
|
const analystDisposition = boundedString(record.analystDisposition, `${prefix}.analystDisposition`, 32);
|
|
188
208
|
if (!DISPOSITIONS.has(analystDisposition))
|
|
189
209
|
throw new CliUsageError(`${prefix}.analystDisposition is unsupported.`);
|
|
210
|
+
let reviewReasonCode;
|
|
211
|
+
if (record.reviewReasonCode !== null && record.reviewReasonCode !== undefined) {
|
|
212
|
+
reviewReasonCode = boundedString(record.reviewReasonCode, `${prefix}.reviewReasonCode`, 64);
|
|
213
|
+
if (!REVIEW_REASON_CODES.has(reviewReasonCode))
|
|
214
|
+
throw new CliUsageError(`${prefix}.reviewReasonCode is unsupported.`);
|
|
215
|
+
}
|
|
190
216
|
return {
|
|
191
217
|
id,
|
|
192
218
|
domain,
|
|
193
219
|
analystDisposition: analystDisposition,
|
|
220
|
+
...(reviewReasonCode ? { reviewReasonCode } : {}),
|
|
194
221
|
evidence: projectEvidence(record.evidence, `${prefix}.evidence`),
|
|
195
222
|
};
|
|
196
223
|
});
|
|
197
224
|
return {
|
|
198
225
|
schema: RISK_CALIBRATION_DATASET_SCHEMA,
|
|
199
|
-
version:
|
|
226
|
+
version: documentVersion,
|
|
200
227
|
records,
|
|
201
228
|
};
|
|
202
229
|
}
|
|
203
230
|
function ratio(numerator, denominator) {
|
|
204
231
|
return denominator ? Number((numerator / denominator).toFixed(4)) : null;
|
|
205
232
|
}
|
|
233
|
+
function wilson95(successes, total) {
|
|
234
|
+
if (!total)
|
|
235
|
+
return null;
|
|
236
|
+
const z = 1.96;
|
|
237
|
+
const proportion = successes / total;
|
|
238
|
+
const denominator = 1 + (z * z) / total;
|
|
239
|
+
const center = (proportion + (z * z) / (2 * total)) / denominator;
|
|
240
|
+
const margin = (z * Math.sqrt((proportion * (1 - proportion) + (z * z) / (4 * total)) / total)) / denominator;
|
|
241
|
+
return { lower: Number(Math.max(0, center - margin).toFixed(4)), upper: Number(Math.min(1, center + margin).toFixed(4)) };
|
|
242
|
+
}
|
|
206
243
|
function metricsForThreshold(records, threshold) {
|
|
207
244
|
let truePositive = 0;
|
|
208
245
|
let falsePositive = 0;
|
|
@@ -217,16 +254,30 @@ function metricsForThreshold(records, threshold) {
|
|
|
217
254
|
else
|
|
218
255
|
flagged ? falsePositive += 1 : trueNegative += 1;
|
|
219
256
|
}
|
|
257
|
+
const precision = ratio(truePositive, truePositive + falsePositive);
|
|
258
|
+
const recall = ratio(truePositive, truePositive + falseNegative);
|
|
259
|
+
const specificity = ratio(trueNegative, trueNegative + falsePositive);
|
|
220
260
|
return {
|
|
221
261
|
threshold,
|
|
222
262
|
truePositive,
|
|
223
263
|
falsePositive,
|
|
224
264
|
trueNegative,
|
|
225
265
|
falseNegative,
|
|
226
|
-
precision
|
|
227
|
-
recall
|
|
228
|
-
specificity
|
|
266
|
+
precision,
|
|
267
|
+
recall,
|
|
268
|
+
specificity,
|
|
229
269
|
falsePositiveRate: ratio(falsePositive, falsePositive + trueNegative),
|
|
270
|
+
f1: precision === null || recall === null || precision + recall === 0
|
|
271
|
+
? null
|
|
272
|
+
: Number(((2 * precision * recall) / (precision + recall)).toFixed(4)),
|
|
273
|
+
balancedAccuracy: recall === null || specificity === null
|
|
274
|
+
? null
|
|
275
|
+
: Number(((recall + specificity) / 2).toFixed(4)),
|
|
276
|
+
confidence95: {
|
|
277
|
+
precision: wilson95(truePositive, truePositive + falsePositive),
|
|
278
|
+
recall: wilson95(truePositive, truePositive + falseNegative),
|
|
279
|
+
specificity: wilson95(trueNegative, trueNegative + falsePositive),
|
|
280
|
+
},
|
|
230
281
|
};
|
|
231
282
|
}
|
|
232
283
|
function metricClass(disposition) {
|
|
@@ -254,6 +305,8 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
|
254
305
|
id: record.id,
|
|
255
306
|
domain: record.domain,
|
|
256
307
|
analystDisposition: record.analystDisposition,
|
|
308
|
+
reviewReasonCode: record.reviewReasonCode ?? null,
|
|
309
|
+
interoperabilityTags: analystInteroperabilityTags(record.analystDisposition, record.reviewReasonCode),
|
|
257
310
|
metricClass: classification,
|
|
258
311
|
includedInMetrics,
|
|
259
312
|
exclusionReason: includedInMetrics
|
|
@@ -273,6 +326,52 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
|
273
326
|
const positive = records.filter((record) => record.metricClass === 'positive' && record.score !== null).length;
|
|
274
327
|
const negative = records.filter((record) => record.metricClass === 'negative' && record.score !== null).length;
|
|
275
328
|
const excluded = records.length - positive - negative;
|
|
329
|
+
const strata = [];
|
|
330
|
+
const appendStrata = (dimension, values) => {
|
|
331
|
+
for (const [value, members] of [...values].sort(([left], [right]) => left.localeCompare(right))) {
|
|
332
|
+
const sampleCount = members.filter((member) => member.includedInMetrics).length;
|
|
333
|
+
strata.push({
|
|
334
|
+
dimension,
|
|
335
|
+
value,
|
|
336
|
+
sampleCount,
|
|
337
|
+
insufficientSample: sampleCount < 20,
|
|
338
|
+
metrics: metricsForThreshold(members, options.reviewThreshold),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
const byDepth = new Map();
|
|
343
|
+
const byReason = new Map();
|
|
344
|
+
for (let index = 0; index < dataset.records.length; index += 1) {
|
|
345
|
+
const source = dataset.records[index];
|
|
346
|
+
const scored = records[index];
|
|
347
|
+
if (!source || !scored)
|
|
348
|
+
continue;
|
|
349
|
+
const depth = source.evidence.scanDepth === 'deep' || source.evidence.scanDepth === 'fast' ? source.evidence.scanDepth : 'unknown';
|
|
350
|
+
byDepth.set(depth, [...(byDepth.get(depth) ?? []), scored]);
|
|
351
|
+
const reason = source.reviewReasonCode ?? 'not_recorded';
|
|
352
|
+
byReason.set(reason, [...(byReason.get(reason) ?? []), scored]);
|
|
353
|
+
}
|
|
354
|
+
appendStrata('scan_depth', byDepth);
|
|
355
|
+
appendStrata('review_reason', byReason);
|
|
356
|
+
let scoresChanged = 0;
|
|
357
|
+
let bandsChanged = 0;
|
|
358
|
+
let thresholdClassificationsChanged = 0;
|
|
359
|
+
if (options.explainPreviousRiskScore) {
|
|
360
|
+
for (let index = 0; index < dataset.records.length; index += 1) {
|
|
361
|
+
const source = dataset.records[index];
|
|
362
|
+
const current = records[index];
|
|
363
|
+
if (!source || !current)
|
|
364
|
+
continue;
|
|
365
|
+
const previous = options.explainPreviousRiskScore(source.evidence);
|
|
366
|
+
if (previous?.score !== current.score)
|
|
367
|
+
scoresChanged += 1;
|
|
368
|
+
if (scoreBand(previous?.score ?? null) !== current.band)
|
|
369
|
+
bandsChanged += 1;
|
|
370
|
+
if (((previous?.score ?? -1) >= options.reviewThreshold) !== ((current.score ?? -1) >= options.reviewThreshold)) {
|
|
371
|
+
thresholdClassificationsChanged += 1;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
276
375
|
return {
|
|
277
376
|
schema: RISK_CALIBRATION_REPORT_SCHEMA,
|
|
278
377
|
version: RISK_CALIBRATION_REPORT_VERSION,
|
|
@@ -282,6 +381,15 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
|
|
|
282
381
|
currentReviewThreshold: options.reviewThreshold,
|
|
283
382
|
summary: { total: records.length, positive, negative, excluded, scoreBands: bands },
|
|
284
383
|
thresholds: RISK_CALIBRATION_THRESHOLDS.map((threshold) => metricsForThreshold(records, threshold)),
|
|
384
|
+
strata,
|
|
385
|
+
modelComparison: {
|
|
386
|
+
available: Boolean(options.explainPreviousRiskScore),
|
|
387
|
+
previousModelVersion: options.explainPreviousRiskScore ? options.previousModelVersion ?? null : null,
|
|
388
|
+
currentModelVersion: options.modelVersion,
|
|
389
|
+
scoresChanged,
|
|
390
|
+
bandsChanged,
|
|
391
|
+
thresholdClassificationsChanged,
|
|
392
|
+
},
|
|
285
393
|
records,
|
|
286
394
|
interpretation: {
|
|
287
395
|
authority: 'analyst_context_only',
|