@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4
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 +59 -6
- package/bin/whoisleuth.mjs +5 -3
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +2 -3
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +16 -8
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-scan-command-runner.mjs +4 -5
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/terminal.mjs +3 -0
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +147 -185
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +444 -70
- package/docs/cli.md +24 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +42 -12
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
- package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +26 -3
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive.js +104 -14
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +13 -0
- package/lib/bounded-json-response.mjs +17 -4
- package/lib/bounded-json.mjs +249 -0
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +59 -10
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +3 -11
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const MAX_COLLECTION_SCAN = 256;
|
|
2
|
+
const MAX_DEPTH = 8;
|
|
3
|
+
const MAX_SCALAR_LENGTH = 2_000;
|
|
4
|
+
const MAX_TRAVERSAL_NODES = 1_024;
|
|
5
|
+
const MAX_SERIALISED_LENGTH = 8_192;
|
|
6
|
+
function record(value) {
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
function boundedJsonString(value, state) {
|
|
12
|
+
if (value.length > MAX_SCALAR_LENGTH)
|
|
13
|
+
state.truncated = true;
|
|
14
|
+
return JSON.stringify(value.slice(0, MAX_SCALAR_LENGTH));
|
|
15
|
+
}
|
|
16
|
+
function stableJson(value, depth, state) {
|
|
17
|
+
if (depth > MAX_DEPTH) {
|
|
18
|
+
state.truncated = true;
|
|
19
|
+
return '"[bounded depth]"';
|
|
20
|
+
}
|
|
21
|
+
if (state.nodes >= MAX_TRAVERSAL_NODES) {
|
|
22
|
+
state.truncated = true;
|
|
23
|
+
return '"[bounded traversal]"';
|
|
24
|
+
}
|
|
25
|
+
state.nodes += 1;
|
|
26
|
+
if (value === null)
|
|
27
|
+
return 'null';
|
|
28
|
+
if (typeof value === 'string')
|
|
29
|
+
return boundedJsonString(value, state);
|
|
30
|
+
if (typeof value === 'number')
|
|
31
|
+
return Number.isFinite(value) ? String(value) : 'null';
|
|
32
|
+
if (typeof value === 'boolean')
|
|
33
|
+
return value ? 'true' : 'false';
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
const values = [];
|
|
36
|
+
const limit = Math.min(value.length, MAX_COLLECTION_SCAN);
|
|
37
|
+
for (let index = 0; index < limit && state.nodes < MAX_TRAVERSAL_NODES; index += 1) {
|
|
38
|
+
values.push(stableJson(value[index], depth + 1, state));
|
|
39
|
+
}
|
|
40
|
+
if (values.length < value.length)
|
|
41
|
+
state.truncated = true;
|
|
42
|
+
return `[${values.join(',')}]`;
|
|
43
|
+
}
|
|
44
|
+
const item = record(value);
|
|
45
|
+
if (!item)
|
|
46
|
+
return 'null';
|
|
47
|
+
const keys = [];
|
|
48
|
+
for (const key in item) {
|
|
49
|
+
if (!Object.hasOwn(item, key))
|
|
50
|
+
continue;
|
|
51
|
+
if (keys.length >= MAX_COLLECTION_SCAN) {
|
|
52
|
+
state.truncated = true;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
keys.push(key);
|
|
56
|
+
}
|
|
57
|
+
keys.sort();
|
|
58
|
+
const values = [];
|
|
59
|
+
for (const key of keys) {
|
|
60
|
+
if (state.nodes >= MAX_TRAVERSAL_NODES) {
|
|
61
|
+
state.truncated = true;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
values.push(`${boundedJsonString(key, state)}:${stableJson(item[key], depth + 1, state)}`);
|
|
65
|
+
}
|
|
66
|
+
if (values.length < keys.length)
|
|
67
|
+
state.truncated = true;
|
|
68
|
+
return `{${values.join(',')}}`;
|
|
69
|
+
}
|
|
70
|
+
export function projectComparisonLedgerJson(value) {
|
|
71
|
+
const state = { truncated: false, nodes: 0 };
|
|
72
|
+
const serialised = stableJson(value, 0, state);
|
|
73
|
+
if (serialised.length > MAX_SERIALISED_LENGTH)
|
|
74
|
+
state.truncated = true;
|
|
75
|
+
return Object.freeze({ value: serialised.slice(0, MAX_SERIALISED_LENGTH), truncated: state.truncated });
|
|
76
|
+
}
|
|
77
|
+
export function stableComparisonLedgerJson(value) {
|
|
78
|
+
return projectComparisonLedgerJson(value).value;
|
|
79
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/gu;
|
|
2
|
+
const MAX_ID_PARTS = 32;
|
|
3
|
+
const MAX_ID_PART_LENGTH = 500;
|
|
4
|
+
function stableHash(value) {
|
|
5
|
+
let first = 0x811c9dc5;
|
|
6
|
+
let second = 0;
|
|
7
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
8
|
+
const code = value.charCodeAt(index);
|
|
9
|
+
first ^= code;
|
|
10
|
+
first = Math.imul(first, 0x01000193);
|
|
11
|
+
second = (second + code) | 0;
|
|
12
|
+
second = (second + (second << 10)) | 0;
|
|
13
|
+
second ^= second >>> 6;
|
|
14
|
+
}
|
|
15
|
+
second = (second + (second << 3)) | 0;
|
|
16
|
+
second ^= second >>> 11;
|
|
17
|
+
second = (second + (second << 15)) | 0;
|
|
18
|
+
return `${(first >>> 0).toString(36).padStart(7, '0')}${(second >>> 0).toString(36).padStart(7, '0')}`;
|
|
19
|
+
}
|
|
20
|
+
export function stableComparisonLedgerId(prefix, parts) {
|
|
21
|
+
const encoded = parts.slice(0, MAX_ID_PARTS).map((part) => {
|
|
22
|
+
let value = '';
|
|
23
|
+
try {
|
|
24
|
+
value = part === null || part === undefined || ['string', 'number', 'boolean', 'bigint'].includes(typeof part)
|
|
25
|
+
? String(part ?? '')
|
|
26
|
+
: Object.prototype.toString.call(part);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
value = '[unreadable]';
|
|
30
|
+
}
|
|
31
|
+
const bounded = value.slice(0, MAX_ID_PART_LENGTH);
|
|
32
|
+
const typed = `${typeof part}:${value.length}:${bounded}`;
|
|
33
|
+
return `${typed.length}:${typed}`;
|
|
34
|
+
}).join('');
|
|
35
|
+
const material = `${parts.length}:${encoded}`;
|
|
36
|
+
return `${prefix.slice(0, 64)}-${stableHash(material)}`;
|
|
37
|
+
}
|
|
38
|
+
export function safeComparisonLedgerHref(value) {
|
|
39
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
|
|
40
|
+
return '';
|
|
41
|
+
const href = String(value).replace(CONTROL_RE, ' ').replace(/\s+/gu, ' ').trim().slice(0, 500).trim();
|
|
42
|
+
if (!href.startsWith('/') || href.startsWith('//') || href.includes('\\'))
|
|
43
|
+
return '';
|
|
44
|
+
try {
|
|
45
|
+
const base = new URL('https://comparison-ledger.invalid/');
|
|
46
|
+
return new URL(href, base).origin === base.origin ? href : '';
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, SORTED_JSON_V1, SORTED_JSON_V2, } from "./artifact-integrity.js";
|
|
2
|
+
import { normalizeDomain } from "./case-record-core.js";
|
|
3
|
+
import { canonicalDomainControlRecordList } from "./domain-control-records.js";
|
|
4
|
+
export const DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
|
|
5
|
+
export const DOMAIN_CONTROL_PASSPORT_SCHEMA = 'whoisleuth.domain-control-manifest';
|
|
6
|
+
export const DOMAIN_CONTROL_PASSPORT_VERSION = 1;
|
|
7
|
+
export const DOMAIN_CONTROL_MANIFEST_VERSION = 2;
|
|
8
|
+
export const MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES = 100;
|
|
9
|
+
export const DOMAIN_CONTROL_PASSPORT_LIMITATIONS = Object.freeze([
|
|
10
|
+
'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
|
|
11
|
+
'Empty desired fields are unconfigured rather than claims that a record should be absent.',
|
|
12
|
+
]);
|
|
13
|
+
const INPUT_KEYS = new Set(['schema', 'version', 'expiresAt', 'entries']);
|
|
14
|
+
const MANIFEST_KEYS = new Set(['schema', 'version', 'generatedAt', 'expiresAt', 'entries', 'limitations', 'integrity']);
|
|
15
|
+
const ENTRY_KEYS = new Set(['domain', 'nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock', 'renewalReviewAt', 'note']);
|
|
16
|
+
const INTEGRITY_KEYS = new Set(['algorithm', 'canonicalization', 'digestSha256']);
|
|
17
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
18
|
+
function record(value) {
|
|
19
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
20
|
+
? value
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
function exactKeys(value, allowed, label) {
|
|
24
|
+
const unknown = Object.keys(value).find((key) => !allowed.has(key));
|
|
25
|
+
if (unknown)
|
|
26
|
+
throw new TypeError(`${label} contains unknown field: ${unknown}.`);
|
|
27
|
+
}
|
|
28
|
+
function text(value, maximum = 300) {
|
|
29
|
+
if (typeof value !== 'string' || value.length > maximum * 4 || CONTROL_RE.test(value))
|
|
30
|
+
return null;
|
|
31
|
+
return value.replace(/\s+/gu, ' ').trim().slice(0, maximum) || null;
|
|
32
|
+
}
|
|
33
|
+
function timestamp(value) {
|
|
34
|
+
const candidate = text(value, 64);
|
|
35
|
+
if (!candidate)
|
|
36
|
+
return null;
|
|
37
|
+
const parsed = Date.parse(candidate);
|
|
38
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
39
|
+
}
|
|
40
|
+
function hostnames(value) {
|
|
41
|
+
if (!Array.isArray(value))
|
|
42
|
+
return [];
|
|
43
|
+
return [...new Set(value.slice(0, 128).flatMap((item) => {
|
|
44
|
+
const normalized = normalizeDomain(item);
|
|
45
|
+
return normalized ? [normalized] : [];
|
|
46
|
+
}))].sort().slice(0, 32);
|
|
47
|
+
}
|
|
48
|
+
function digest(value) {
|
|
49
|
+
const candidate = text(value, 64)?.toLowerCase() ?? '';
|
|
50
|
+
return /^[a-f0-9]{64}$/u.test(candidate) ? candidate : null;
|
|
51
|
+
}
|
|
52
|
+
function normalizeEntry(value) {
|
|
53
|
+
const source = record(value);
|
|
54
|
+
if (!source)
|
|
55
|
+
return null;
|
|
56
|
+
exactKeys(source, ENTRY_KEYS, 'Domain control manifest entry');
|
|
57
|
+
const domain = normalizeDomain(source.domain);
|
|
58
|
+
if (!domain)
|
|
59
|
+
return null;
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
domain,
|
|
62
|
+
nameservers: Object.freeze(hostnames(source.nameservers)),
|
|
63
|
+
ds: Object.freeze(canonicalDomainControlRecordList(source.ds, 'ds')),
|
|
64
|
+
mx: Object.freeze(canonicalDomainControlRecordList(source.mx, 'mx')),
|
|
65
|
+
caa: Object.freeze(canonicalDomainControlRecordList(source.caa, 'caa')),
|
|
66
|
+
tlsIssuer: text(source.tlsIssuer, 300)?.toLowerCase() ?? null,
|
|
67
|
+
tlsSpkiSha256: digest(source.tlsSpkiSha256),
|
|
68
|
+
registrarLock: source.registrarLock === 'required' || source.registrarLock === 'not_required'
|
|
69
|
+
? source.registrarLock
|
|
70
|
+
: null,
|
|
71
|
+
renewalReviewAt: timestamp(source.renewalReviewAt),
|
|
72
|
+
note: text(source.note, 500),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function buildUnsignedDomainControlPassport(input, generatedAtValue) {
|
|
76
|
+
const source = record(input);
|
|
77
|
+
if (!source || source.schema !== DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA || source.version !== DOMAIN_CONTROL_PASSPORT_VERSION) {
|
|
78
|
+
throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA} version ${DOMAIN_CONTROL_PASSPORT_VERSION}.`);
|
|
79
|
+
}
|
|
80
|
+
exactKeys(source, INPUT_KEYS, 'Domain control manifest input');
|
|
81
|
+
const generatedAt = timestamp(generatedAtValue);
|
|
82
|
+
const expiresAt = timestamp(source.expiresAt);
|
|
83
|
+
if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
|
|
84
|
+
throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES) {
|
|
87
|
+
throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES} entries.`);
|
|
88
|
+
}
|
|
89
|
+
const entries = source.entries.map(normalizeEntry);
|
|
90
|
+
if (entries.some((entry) => entry === null))
|
|
91
|
+
throw new TypeError('Domain control manifest contains an invalid entry.');
|
|
92
|
+
const normalizedEntries = entries;
|
|
93
|
+
if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
|
|
94
|
+
throw new TypeError('Domain control manifest entries must use unique domains.');
|
|
95
|
+
}
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
schema: DOMAIN_CONTROL_PASSPORT_SCHEMA,
|
|
98
|
+
version: DOMAIN_CONTROL_MANIFEST_VERSION,
|
|
99
|
+
generatedAt,
|
|
100
|
+
expiresAt,
|
|
101
|
+
entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
|
|
102
|
+
limitations: DOMAIN_CONTROL_PASSPORT_LIMITATIONS,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export function normalizeDomainControlPassportDocument(value) {
|
|
106
|
+
const source = record(value);
|
|
107
|
+
const integrity = record(source?.integrity);
|
|
108
|
+
if (!source
|
|
109
|
+
|| source.schema !== DOMAIN_CONTROL_PASSPORT_SCHEMA
|
|
110
|
+
|| !integrity
|
|
111
|
+
|| integrity.algorithm !== 'SHA-256'
|
|
112
|
+
|| typeof integrity.digestSha256 !== 'string'
|
|
113
|
+
|| !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
114
|
+
throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
|
|
115
|
+
}
|
|
116
|
+
exactKeys(source, MANIFEST_KEYS, 'Domain control manifest');
|
|
117
|
+
exactKeys(integrity, INTEGRITY_KEYS, 'Domain control manifest integrity');
|
|
118
|
+
const canonicalization = resolveArtifactCanonicalization(source.version, integrity.canonicalization, [
|
|
119
|
+
{ version: DOMAIN_CONTROL_PASSPORT_VERSION, canonicalization: SORTED_JSON_V1, explicit: true },
|
|
120
|
+
{ version: DOMAIN_CONTROL_MANIFEST_VERSION, canonicalization: SORTED_JSON_V2, explicit: true },
|
|
121
|
+
], 'Domain control manifest');
|
|
122
|
+
if (!Array.isArray(source.limitations)
|
|
123
|
+
|| source.limitations.length !== DOMAIN_CONTROL_PASSPORT_LIMITATIONS.length
|
|
124
|
+
|| source.limitations.some((item, index) => item !== DOMAIN_CONTROL_PASSPORT_LIMITATIONS[index])) {
|
|
125
|
+
throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
|
|
126
|
+
}
|
|
127
|
+
const baseUnsigned = buildUnsignedDomainControlPassport({
|
|
128
|
+
schema: DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA,
|
|
129
|
+
version: DOMAIN_CONTROL_PASSPORT_VERSION,
|
|
130
|
+
expiresAt: source.expiresAt,
|
|
131
|
+
entries: source.entries,
|
|
132
|
+
}, source.generatedAt);
|
|
133
|
+
const unsigned = Object.freeze({ ...baseUnsigned, version: source.version });
|
|
134
|
+
const { integrity: _integrity, ...suppliedUnsigned } = source;
|
|
135
|
+
const canonicalUnsigned = canonicalArtifactJsonFor(unsigned, canonicalization);
|
|
136
|
+
if (canonicalArtifactJsonFor(suppliedUnsigned, canonicalization) !== canonicalUnsigned) {
|
|
137
|
+
throw new TypeError('Domain control manifest must use its canonical normalised content.');
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
manifest: source,
|
|
141
|
+
unsigned,
|
|
142
|
+
canonicalUnsigned,
|
|
143
|
+
canonicalization,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { normalizeDomain } from "./case-record-core.js";
|
|
2
|
+
export const MAX_CANONICAL_DOMAIN_CONTROL_RECORDS = 32;
|
|
3
|
+
function record(value) {
|
|
4
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
5
|
+
? value
|
|
6
|
+
: {};
|
|
7
|
+
}
|
|
8
|
+
function text(value, maximum = 500) {
|
|
9
|
+
if (typeof value !== 'string' || value.length > maximum * 4 || /[\u0000-\u001f\u007f]/u.test(value))
|
|
10
|
+
return '';
|
|
11
|
+
return value.replace(/\s+/gu, ' ').trim().slice(0, maximum).trim();
|
|
12
|
+
}
|
|
13
|
+
function integer(value, maximum) {
|
|
14
|
+
const candidate = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/u.test(value.trim()) ? Number(value) : NaN;
|
|
15
|
+
return Number.isSafeInteger(candidate) && candidate >= 0 && candidate <= maximum ? candidate : null;
|
|
16
|
+
}
|
|
17
|
+
function exchange(value) {
|
|
18
|
+
const candidate = text(value, 253).toLowerCase();
|
|
19
|
+
if (candidate === '.')
|
|
20
|
+
return '.';
|
|
21
|
+
return normalizeDomain(candidate) ?? '';
|
|
22
|
+
}
|
|
23
|
+
export function canonicalMxRecord(value) {
|
|
24
|
+
const item = record(value);
|
|
25
|
+
if (Object.keys(item).length) {
|
|
26
|
+
const host = exchange(item.exchange ?? item.host ?? item.value);
|
|
27
|
+
const priority = integer(item.priority ?? item.preference, 65_535);
|
|
28
|
+
return host && priority !== null ? `${priority} ${host}` : '';
|
|
29
|
+
}
|
|
30
|
+
const candidate = text(value, 500).toLowerCase();
|
|
31
|
+
const match = /^(\d{1,5})\s+(.+)$/u.exec(candidate);
|
|
32
|
+
if (match) {
|
|
33
|
+
const priority = integer(match[1], 65_535);
|
|
34
|
+
const host = exchange(match[2]);
|
|
35
|
+
return priority !== null && host ? `${priority} ${host}` : '';
|
|
36
|
+
}
|
|
37
|
+
return exchange(candidate);
|
|
38
|
+
}
|
|
39
|
+
function caaValue(value) {
|
|
40
|
+
return text(value, 500).replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2').trim().toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
export function canonicalCaaRecord(value) {
|
|
43
|
+
const item = record(value);
|
|
44
|
+
if (Object.keys(item).length) {
|
|
45
|
+
const flags = integer(item.critical ?? item.flags ?? 0, 255);
|
|
46
|
+
const tag = text(item.tag, 32).toLowerCase();
|
|
47
|
+
const payload = caaValue(item.value);
|
|
48
|
+
return flags !== null && /^(?:issue|issuewild|iodef)$/u.test(tag) && payload
|
|
49
|
+
? `${flags} ${tag} ${payload}`
|
|
50
|
+
: '';
|
|
51
|
+
}
|
|
52
|
+
const candidate = text(value, 600);
|
|
53
|
+
const match = /^(\d{1,3})\s+(issue|issuewild|iodef)\s+(.+)$/iu.exec(candidate);
|
|
54
|
+
if (!match)
|
|
55
|
+
return '';
|
|
56
|
+
const flags = integer(match[1], 255);
|
|
57
|
+
const tag = match[2]?.toLowerCase() ?? '';
|
|
58
|
+
const payload = caaValue(match[3]);
|
|
59
|
+
return flags !== null && payload ? `${flags} ${tag} ${payload}` : '';
|
|
60
|
+
}
|
|
61
|
+
export function canonicalDsRecord(value) {
|
|
62
|
+
const item = record(value);
|
|
63
|
+
if (Object.keys(item).length) {
|
|
64
|
+
const keyTag = integer(item.keyTag ?? item.key_tag, 65_535);
|
|
65
|
+
const algorithm = integer(item.algorithm, 255);
|
|
66
|
+
const digestType = integer(item.digestType ?? item.digest_type, 255);
|
|
67
|
+
const digest = text(item.digest, 1024).replace(/\s+/gu, '').toLowerCase();
|
|
68
|
+
return keyTag !== null && algorithm !== null && digestType !== null && /^[a-f0-9]{2,1024}$/u.test(digest)
|
|
69
|
+
? `${keyTag} ${algorithm} ${digestType} ${digest}`
|
|
70
|
+
: '';
|
|
71
|
+
}
|
|
72
|
+
const candidate = text(value, 1200);
|
|
73
|
+
const match = /^(\d{1,5})\s+(\d{1,3})\s+(\d{1,3})\s+([a-f0-9\s]+)$/iu.exec(candidate);
|
|
74
|
+
if (!match)
|
|
75
|
+
return '';
|
|
76
|
+
return canonicalDsRecord({ keyTag: match[1], algorithm: match[2], digestType: match[3], digest: match[4] });
|
|
77
|
+
}
|
|
78
|
+
export function canonicalDomainControlRecordList(value, kind) {
|
|
79
|
+
if (!Array.isArray(value))
|
|
80
|
+
return [];
|
|
81
|
+
const normalizer = kind === 'mx' ? canonicalMxRecord : kind === 'caa' ? canonicalCaaRecord : canonicalDsRecord;
|
|
82
|
+
return [...new Set(value
|
|
83
|
+
.slice(0, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS * 4)
|
|
84
|
+
.map(normalizer)
|
|
85
|
+
.filter(Boolean))]
|
|
86
|
+
.sort()
|
|
87
|
+
.slice(0, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS);
|
|
88
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { normalizeDomain, openOrCreateCase, updateCase, } from "./case-model.js";
|
|
2
2
|
export const EXTERNAL_FINDINGS_SCHEMA = 'whoisleuth.external-findings';
|
|
3
|
-
export const EXTERNAL_FINDINGS_VERSION =
|
|
3
|
+
export const EXTERNAL_FINDINGS_VERSION = 4;
|
|
4
4
|
export const MAX_EXTERNAL_FINDINGS_IMPORT_BYTES = 384 * 1024;
|
|
5
5
|
export const MAX_EXTERNAL_FINDINGS = 100;
|
|
6
6
|
export const MAX_EXTERNAL_FINDINGS_PER_DOMAIN = 20;
|
|
@@ -38,12 +38,18 @@ const STRUCTURED_OBSERVATION_KEYS = new Set([
|
|
|
38
38
|
'value',
|
|
39
39
|
'issuer',
|
|
40
40
|
'notAfter',
|
|
41
|
+
'eventId',
|
|
42
|
+
'logId',
|
|
43
|
+
'certificateSha256',
|
|
44
|
+
'dnsNameCount',
|
|
45
|
+
'namesComplete',
|
|
41
46
|
]);
|
|
42
47
|
const STRUCTURED_OBSERVATION_SCHEMAS = new Set([
|
|
43
48
|
'whoisleuth.domain-observation-rows',
|
|
44
49
|
'whoisleuth.dns-observation-rows',
|
|
45
50
|
'whoisleuth.certificate-observation-rows',
|
|
46
51
|
]);
|
|
52
|
+
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
47
53
|
function record(value) {
|
|
48
54
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
49
55
|
? value
|
|
@@ -86,7 +92,7 @@ function limitations(value, index) {
|
|
|
86
92
|
unique.add(requiredText(item, 240, `Finding ${index + 1} limitation`));
|
|
87
93
|
return [...unique];
|
|
88
94
|
}
|
|
89
|
-
function structuredObservation(value, index) {
|
|
95
|
+
function structuredObservation(value, index, documentVersion) {
|
|
90
96
|
if (value === undefined || value === null)
|
|
91
97
|
return null;
|
|
92
98
|
const item = record(value);
|
|
@@ -104,9 +110,44 @@ function structuredObservation(value, index) {
|
|
|
104
110
|
const issuer = optionalText(item.issuer, 160, `Finding ${index + 1} certificate issuer`);
|
|
105
111
|
const notAfter = iso(item.notAfter, `Finding ${index + 1} certificate expiry`, true);
|
|
106
112
|
const certificateSchema = item.sourceSchema === 'whoisleuth.certificate-observation-rows';
|
|
113
|
+
if (certificateSchema && (field === 'certificateSha256' || field === 'fingerprintSha256') && !SHA256_RE.test(observationValue.toLowerCase())) {
|
|
114
|
+
throw new Error(`Finding ${index + 1} certificate observation value must be a SHA-256 hexadecimal digest.`);
|
|
115
|
+
}
|
|
107
116
|
if (!certificateSchema && (issuer !== null || notAfter !== null)) {
|
|
108
117
|
throw new Error(`Finding ${index + 1} non-certificate observation cannot declare certificate metadata.`);
|
|
109
118
|
}
|
|
119
|
+
const declaresEventMetadata = ['eventId', 'logId', 'certificateSha256', 'dnsNameCount', 'namesComplete']
|
|
120
|
+
.some((key) => item[key] !== undefined && item[key] !== null);
|
|
121
|
+
let eventId = null;
|
|
122
|
+
let logId = null;
|
|
123
|
+
let certificateSha256 = null;
|
|
124
|
+
let dnsNameCount = null;
|
|
125
|
+
let namesComplete = null;
|
|
126
|
+
if (declaresEventMetadata) {
|
|
127
|
+
if (!certificateSchema || documentVersion < 4) {
|
|
128
|
+
throw new Error(`Finding ${index + 1} certificate-event metadata requires external-findings schema version 4.`);
|
|
129
|
+
}
|
|
130
|
+
eventId = requiredText(item.eventId, 64, `Finding ${index + 1} certificate event id`);
|
|
131
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/u.test(eventId)) {
|
|
132
|
+
throw new Error(`Finding ${index + 1} certificate event id is invalid.`);
|
|
133
|
+
}
|
|
134
|
+
logId = requiredText(item.logId, 200, `Finding ${index + 1} certificate log id`);
|
|
135
|
+
certificateSha256 = requiredText(item.certificateSha256, 64, `Finding ${index + 1} certificate digest`).toLowerCase();
|
|
136
|
+
if (!SHA256_RE.test(certificateSha256)) {
|
|
137
|
+
throw new Error(`Finding ${index + 1} certificate event digest must be a SHA-256 hexadecimal digest.`);
|
|
138
|
+
}
|
|
139
|
+
if (certificateSha256 !== observationValue.toLowerCase()) {
|
|
140
|
+
throw new Error(`Finding ${index + 1} certificate event digest must match the structured observation value.`);
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isSafeInteger(item.dnsNameCount) || Number(item.dnsNameCount) < 1 || Number(item.dnsNameCount) > 100) {
|
|
143
|
+
throw new Error(`Finding ${index + 1} certificate DNS name count is invalid.`);
|
|
144
|
+
}
|
|
145
|
+
dnsNameCount = Number(item.dnsNameCount);
|
|
146
|
+
if (typeof item.namesComplete !== 'boolean') {
|
|
147
|
+
throw new Error(`Finding ${index + 1} certificate name completeness is invalid.`);
|
|
148
|
+
}
|
|
149
|
+
namesComplete = item.namesComplete;
|
|
150
|
+
}
|
|
110
151
|
return {
|
|
111
152
|
sourceSchema: item.sourceSchema,
|
|
112
153
|
sourceVersion: 1,
|
|
@@ -114,6 +155,11 @@ function structuredObservation(value, index) {
|
|
|
114
155
|
value: observationValue,
|
|
115
156
|
issuer,
|
|
116
157
|
notAfter,
|
|
158
|
+
eventId,
|
|
159
|
+
logId,
|
|
160
|
+
certificateSha256,
|
|
161
|
+
dnsNameCount,
|
|
162
|
+
namesComplete,
|
|
117
163
|
};
|
|
118
164
|
}
|
|
119
165
|
function findingKey(finding, sourceName) {
|
|
@@ -125,6 +171,7 @@ function findingKey(finding, sourceName) {
|
|
|
125
171
|
finding.observedAt,
|
|
126
172
|
finding.completeness,
|
|
127
173
|
sourceName,
|
|
174
|
+
finding.structuredObservation?.eventId ?? '',
|
|
128
175
|
].join('\u0000');
|
|
129
176
|
}
|
|
130
177
|
export function parseExternalFindingsDocument(value) {
|
|
@@ -133,8 +180,8 @@ export function parseExternalFindingsDocument(value) {
|
|
|
133
180
|
throw new Error('External findings must use the documented object shape without additional top-level fields.');
|
|
134
181
|
}
|
|
135
182
|
if (root.schema !== EXTERNAL_FINDINGS_SCHEMA
|
|
136
|
-
|| ![1, 2, EXTERNAL_FINDINGS_VERSION].includes(Number(root.schemaVersion))) {
|
|
137
|
-
throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 1, 2, or ${EXTERNAL_FINDINGS_VERSION}.`);
|
|
183
|
+
|| ![1, 2, 3, EXTERNAL_FINDINGS_VERSION].includes(Number(root.schemaVersion))) {
|
|
184
|
+
throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 1, 2, 3, or ${EXTERNAL_FINDINGS_VERSION}.`);
|
|
138
185
|
}
|
|
139
186
|
const sourceValue = record(root.source);
|
|
140
187
|
if (!sourceValue || !hasOnlyKeys(sourceValue, SOURCE_KEYS)) {
|
|
@@ -181,7 +228,7 @@ export function parseExternalFindingsDocument(value) {
|
|
|
181
228
|
completeness: item.completeness,
|
|
182
229
|
limitations: limitations(item.limitations, index),
|
|
183
230
|
reference: optionalText(item.reference, 500, `Finding ${index + 1} reference`),
|
|
184
|
-
structuredObservation: structuredObservation(item.structuredObservation, index),
|
|
231
|
+
structuredObservation: structuredObservation(item.structuredObservation, index, Number(root.schemaVersion)),
|
|
185
232
|
};
|
|
186
233
|
const key = findingKey(finding, source.name);
|
|
187
234
|
if (seen.has(key))
|
|
@@ -229,12 +276,28 @@ function existingPinKey(recordValue, finding, sourceName) {
|
|
|
229
276
|
&& (!observation || (pin.field === observation.field
|
|
230
277
|
&& pin.sourceSchema?.collection === 'external_observations'
|
|
231
278
|
&& pin.sourceSchema.schema === observation.sourceSchema
|
|
232
|
-
&& pin.sourceSchema.version === observation.sourceVersion
|
|
279
|
+
&& pin.sourceSchema.version === observation.sourceVersion
|
|
280
|
+
&& (observation.eventId === null || pin.certificateObservation?.eventId === observation.eventId))))) ? findingKey(finding, sourceName) : '';
|
|
233
281
|
}
|
|
234
282
|
function structuredPinFields(finding) {
|
|
235
283
|
const observation = finding.structuredObservation;
|
|
236
284
|
if (!observation)
|
|
237
285
|
return {};
|
|
286
|
+
const certificateObservation = observation.eventId
|
|
287
|
+
&& observation.logId
|
|
288
|
+
&& observation.certificateSha256
|
|
289
|
+
&& observation.dnsNameCount !== null
|
|
290
|
+
&& observation.namesComplete !== null
|
|
291
|
+
? {
|
|
292
|
+
eventId: observation.eventId,
|
|
293
|
+
logId: observation.logId,
|
|
294
|
+
certificateSha256: observation.certificateSha256,
|
|
295
|
+
issuer: observation.issuer,
|
|
296
|
+
notAfter: observation.notAfter,
|
|
297
|
+
dnsNameCount: observation.dnsNameCount,
|
|
298
|
+
namesComplete: observation.namesComplete,
|
|
299
|
+
}
|
|
300
|
+
: null;
|
|
238
301
|
return {
|
|
239
302
|
field: observation.field,
|
|
240
303
|
category: finding.category,
|
|
@@ -243,6 +306,7 @@ function structuredPinFields(finding) {
|
|
|
243
306
|
schema: observation.sourceSchema,
|
|
244
307
|
version: observation.sourceVersion,
|
|
245
308
|
},
|
|
309
|
+
certificateObservation,
|
|
246
310
|
};
|
|
247
311
|
}
|
|
248
312
|
export function mergeExternalFindingsIntoCases(current, document, now = new Date().toISOString()) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { canonicalArtifactJson, sha256ArtifactDigest } from "./artifact-integrity.js";
|
|
1
|
+
import { canonicalArtifactJson, canonicalArtifactJsonV2, sha256ArtifactDigest, sha256ArtifactDigestV2, SORTED_JSON_V2, } from "./artifact-integrity.js";
|
|
2
2
|
export const INVESTIGATION_CAPSULE_SCHEMA = 'whoisleuth.investigation-capsule';
|
|
3
|
-
export const INVESTIGATION_CAPSULE_VERSION =
|
|
3
|
+
export const INVESTIGATION_CAPSULE_VERSION = 2;
|
|
4
|
+
export const LEGACY_INVESTIGATION_CAPSULE_VERSION = 1;
|
|
4
5
|
function safeVersion(value) {
|
|
5
6
|
return typeof value === 'string' && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(value)
|
|
6
7
|
? value
|
|
@@ -46,14 +47,14 @@ function analystProjection(record) {
|
|
|
46
47
|
export async function buildInvestigationCapsule(input) {
|
|
47
48
|
const analystRecords = input.includeAnalystRecords ? analystProjection(input.caseRecord) : null;
|
|
48
49
|
const [evidenceDigest, briefDigest, graphDigest, analystRecordsDigest] = await Promise.all([
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
analystRecords ?
|
|
50
|
+
sha256ArtifactDigestV2(input.lookupEvidence),
|
|
51
|
+
sha256ArtifactDigestV2(input.brief),
|
|
52
|
+
sha256ArtifactDigestV2(input.graph),
|
|
53
|
+
analystRecords ? sha256ArtifactDigestV2(analystRecords) : Promise.resolve(null),
|
|
53
54
|
]);
|
|
54
55
|
const evidenceSchema = safeSchema(input.lookupEvidence.schema);
|
|
55
56
|
const evidenceVersion = safeSchemaVersion(input.lookupEvidence.schemaVersion);
|
|
56
|
-
|
|
57
|
+
const unsigned = {
|
|
57
58
|
schema: INVESTIGATION_CAPSULE_SCHEMA,
|
|
58
59
|
schemaVersion: INVESTIGATION_CAPSULE_VERSION,
|
|
59
60
|
generatedAt: iso(input.generatedAt),
|
|
@@ -70,7 +71,6 @@ export async function buildInvestigationCapsule(input) {
|
|
|
70
71
|
investigationBrief: input.brief,
|
|
71
72
|
graphSnapshot: input.graph,
|
|
72
73
|
analystRecords,
|
|
73
|
-
integrity: { algorithm: 'SHA-256', briefDigest, graphDigest, analystRecordsDigest },
|
|
74
74
|
limitations: [
|
|
75
75
|
'The Lookup evidence file is linked by digest but is not embedded; keep or share that exact file separately when verification is required.',
|
|
76
76
|
'Digest verification can detect changed content but does not establish who created the capsule. No digital signature is applied.',
|
|
@@ -80,12 +80,36 @@ export async function buildInvestigationCapsule(input) {
|
|
|
80
80
|
: 'Analyst decisions, assertions, notes, contacts, actions, and raw source payloads are excluded.',
|
|
81
81
|
],
|
|
82
82
|
};
|
|
83
|
+
return {
|
|
84
|
+
...unsigned,
|
|
85
|
+
integrity: {
|
|
86
|
+
algorithm: 'SHA-256',
|
|
87
|
+
canonicalization: SORTED_JSON_V2,
|
|
88
|
+
scope: 'capsule excluding integrity',
|
|
89
|
+
briefDigest,
|
|
90
|
+
graphDigest,
|
|
91
|
+
analystRecordsDigest,
|
|
92
|
+
digestSha256: await sha256ArtifactDigestV2(unsigned),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
83
95
|
}
|
|
84
96
|
export async function verifyInvestigationCapsule(capsule) {
|
|
97
|
+
const current = capsule.schemaVersion === INVESTIGATION_CAPSULE_VERSION;
|
|
98
|
+
const legacy = capsule.schemaVersion === LEGACY_INVESTIGATION_CAPSULE_VERSION;
|
|
99
|
+
const integrityRecord = capsule.integrity;
|
|
100
|
+
if ((!current && !legacy)
|
|
101
|
+
|| (current && (integrityRecord.canonicalization !== SORTED_JSON_V2
|
|
102
|
+
|| integrityRecord.scope !== 'capsule excluding integrity'))
|
|
103
|
+
|| (legacy && (Object.hasOwn(integrityRecord, 'canonicalization')
|
|
104
|
+
|| Object.hasOwn(integrityRecord, 'scope')
|
|
105
|
+
|| Object.hasOwn(integrityRecord, 'digestSha256')))) {
|
|
106
|
+
return { valid: false, brief: false, graph: false, analystRecords: null, whole: current ? false : null };
|
|
107
|
+
}
|
|
108
|
+
const projectionDigest = current ? sha256ArtifactDigestV2 : sha256ArtifactDigest;
|
|
85
109
|
const [briefDigest, graphDigest, analystRecordsDigest] = await Promise.all([
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
capsule.analystRecords ?
|
|
110
|
+
projectionDigest(capsule.investigationBrief),
|
|
111
|
+
projectionDigest(capsule.graphSnapshot),
|
|
112
|
+
capsule.analystRecords ? projectionDigest(capsule.analystRecords) : Promise.resolve(null),
|
|
89
113
|
]);
|
|
90
114
|
const brief = briefDigest === capsule.integrity.briefDigest;
|
|
91
115
|
const graph = graphDigest === capsule.integrity.graphDigest;
|
|
@@ -94,12 +118,22 @@ export async function verifyInvestigationCapsule(capsule) {
|
|
|
94
118
|
: capsule.integrity.analystRecordsDigest === null
|
|
95
119
|
? null
|
|
96
120
|
: false;
|
|
97
|
-
|
|
121
|
+
let whole = null;
|
|
122
|
+
if (current) {
|
|
123
|
+
const { integrity, ...unsigned } = capsule;
|
|
124
|
+
whole = integrity.algorithm === 'SHA-256'
|
|
125
|
+
&& integrity.canonicalization === SORTED_JSON_V2
|
|
126
|
+
&& integrity.scope === 'capsule excluding integrity'
|
|
127
|
+
&& integrity.digestSha256 === await sha256ArtifactDigestV2(unsigned);
|
|
128
|
+
}
|
|
129
|
+
return { valid: brief && graph && analystRecords !== false && whole !== false, brief, graph, analystRecords, whole };
|
|
98
130
|
}
|
|
99
131
|
export function investigationCapsuleFilename(capsule) {
|
|
100
132
|
const target = capsule.target.value.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 80) || 'lookup';
|
|
101
133
|
return `whoisleuth-investigation-capsule-${target}-${capsule.generatedAt.slice(0, 10)}.json`;
|
|
102
134
|
}
|
|
103
135
|
export function serializeInvestigationCapsule(capsule) {
|
|
104
|
-
return `${
|
|
136
|
+
return `${capsule.schemaVersion === INVESTIGATION_CAPSULE_VERSION
|
|
137
|
+
? canonicalArtifactJsonV2(capsule)
|
|
138
|
+
: canonicalArtifactJson(capsule)}\n`;
|
|
105
139
|
}
|