@slicedearth/whoisleuth-cli 1.43.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 +67 -10
- package/bin/whoisleuth.mjs +22 -9
- 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 +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- 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/errors.mjs +7 -1
- 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/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- 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/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- 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-fixture-scaffold.mjs +4 -3
- 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 +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- 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 +9 -1
- 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-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- 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-asset-graph.js +8 -8
- 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 +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- 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-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- 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-cache.mjs +5 -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/rdap-transport.mjs +6 -6
- 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 +96 -42
- 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 +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -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
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { normalizeDomain } from "./case-model.js";
|
|
2
2
|
import { parse } from 'tldts';
|
|
3
3
|
export const INVESTIGATION_GUIDE_SCHEMA = 'whoisleuth.investigation-recipe';
|
|
4
|
-
export const INVESTIGATION_GUIDE_VERSION =
|
|
4
|
+
export const INVESTIGATION_GUIDE_VERSION = 5;
|
|
5
5
|
export const INVESTIGATION_GUIDE_LEGACY_VERSION = 1;
|
|
6
|
-
export const INVESTIGATION_GUIDE_EXPORT_VERSION =
|
|
7
|
-
export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [2, 3, INVESTIGATION_GUIDE_VERSION];
|
|
6
|
+
export const INVESTIGATION_GUIDE_EXPORT_VERSION = 4;
|
|
7
|
+
export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [2, 3, 4, INVESTIGATION_GUIDE_VERSION];
|
|
8
8
|
export const INVESTIGATION_GUIDE_EXPORT_SCHEMA = 'whoisleuth.investigation-recipe-summary';
|
|
9
9
|
export const MAX_INVESTIGATION_GUIDE_DOMAIN_LENGTH = 253;
|
|
10
10
|
export const MAX_INVESTIGATION_GUIDE_REVIEW_DOMAINS = 25;
|
|
@@ -16,6 +16,7 @@ const CONTROL_RE = /[\x00-\x1f\x7f]/u;
|
|
|
16
16
|
const SAFE_TEMPLATE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
17
17
|
const GUIDE_STATUSES = new Set(['active', 'paused']);
|
|
18
18
|
const GUIDE_OUTCOMES = new Set(['pending', 'complete', 'partial', 'skipped']);
|
|
19
|
+
const PRE_RESPONSE_RECIPE_IDS = new Set(['brand_sweep', 'infrastructure_pivot', 'new_domain_triage']);
|
|
19
20
|
function stage(id, workspace, label, detail, expectedEvidence, requestImpact, prerequisite, completionCriteria, instructions, requiresApproval) {
|
|
20
21
|
return Object.freeze({
|
|
21
22
|
id,
|
|
@@ -91,6 +92,39 @@ export const INVESTIGATION_RECIPES = Object.freeze([
|
|
|
91
92
|
stage('monitor', 'monitor', 'Record disposition', 'Review the bounded domain set carried from Bulk and create or update only the cases that need an analyst decision or follow-up plan.', 'A bounded review queue plus case records with dispositions, notes, evidence history, and optional monitoring intent.', 'Local case editing makes no request. Rescans and hosted-monitor changes remain separate explicit actions.', 'Review source provenance, limitations, and any scoring explanation before deciding.', 'The analyst records the required dispositions or explicitly skips retention; the recipe never decides automatically.', ['Review the domains carried from Bulk and open only the cases that need retention.', 'Record a disposition and any concise evidence-based follow-up for each retained case.', 'Mark the step reviewed when the retained cases reflect your decision.'], false),
|
|
92
93
|
]),
|
|
93
94
|
}),
|
|
95
|
+
Object.freeze({
|
|
96
|
+
id: 'credential_impersonation_response',
|
|
97
|
+
label: 'Credential impersonation response',
|
|
98
|
+
summary: 'Review observed impersonation evidence, confirm the affected brand boundary, and prepare a local response packet from explicitly recorded routes.',
|
|
99
|
+
targetLabel: 'Domain under review',
|
|
100
|
+
stages: Object.freeze([
|
|
101
|
+
stage('lookup', 'lookup', 'Review impersonation evidence', 'Collect separately attributed deep evidence for the domain without treating visual or naming similarity as attribution.', 'Current registry, DNS, certificate, HTTP, page-identity, and source-health observations where supported.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm the target is in scope and do not enter a credential-bearing URL or secret.', 'Observed facts and missing sources are reviewed without making an ownership, intent, or harm conclusion.', ['Run the pre-filled Lookup in Deep mode.', 'Review exact page, redirect, certificate, and registration observations with their source states.', 'Create or update a Case only when the retained observations need reviewed follow-up.'], true),
|
|
102
|
+
stage('brands', 'brands', 'Confirm affected brand boundary', 'Review the saved Brand Profile that defines official and allowlisted references; do not infer an association from similarity.', 'An analyst-selected Brand Profile boundary, or an explicit decision to continue without one.', 'Local-only. Opening Brands makes no analysis request.', 'Know which saved profile, if any, is relevant to the affected party.', 'The relevant profile boundary is reviewed, or the stage is marked partial or skipped without creating an association.', ['Open the saved Brand Profiles list.', 'Review the official references and allowlists for the affected party.', 'Return to the guide without adding a profile association unless you deliberately selected one in Monitor.'], false),
|
|
103
|
+
stage('monitor', 'monitor', 'Prepare reviewed response', 'Open the Case response workspace, record only analyst-selected published recipient routes, and review packet preflight.', 'A Case action with recipient provenance and limitations plus an optional local draft packet that remains unsubmitted.', 'Local Case edits and packet generation make no request. WHOISleuth never sends the packet.', 'Retain supporting facts and reasoning before recording an external route; confirm the route manually.', 'The reviewed action and packet preflight are recorded, or the stage is partial or skipped without implying submission.', ['Open or create the pre-filled Case, then review its evidence and reasoning.', 'Record an analyst-selected published route with its source and limitations; do not claim it is reachable.', 'Open response preflight and prepare a local draft only if its required facts are complete.'], false),
|
|
104
|
+
]),
|
|
105
|
+
}),
|
|
106
|
+
Object.freeze({
|
|
107
|
+
id: 'mail_abuse_response',
|
|
108
|
+
label: 'Mail abuse response',
|
|
109
|
+
summary: 'Review official mail posture and observed domain evidence before preparing a source-qualified, unsent response packet.',
|
|
110
|
+
targetLabel: 'Domain under review',
|
|
111
|
+
stages: Object.freeze([
|
|
112
|
+
stage('brands', 'brands', 'Review official mail posture', 'Review the affected Brand Profile and retained mail posture without treating a missing control as proof of abuse.', 'Analyst-selected official-domain, mail, and Brand Profile context with explicit availability states.', 'Local-only unless the analyst separately starts a Brand posture refresh.', 'Identify the affected profile and distinguish official infrastructure from the domain under review.', 'Relevant retained posture is reviewed, or unavailable context is marked partial rather than absent.', ['Open the saved Brand Profiles list.', 'Review official MX, SPF, DMARC, and approved-domain context where retained.', 'Do not refresh or change a profile unless that separate action is necessary and approved.'], false),
|
|
113
|
+
stage('lookup', 'lookup', 'Review domain and mail evidence', 'Collect current deep evidence for the domain and keep DNS, mail, registration, and page observations separately attributed.', 'Authority-aware availability plus bounded DNS, mail, TLS, HTTP, and page observations where supported.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm the domain is in scope; email content and mailbox data are outside this workflow.', 'Current observations and source failures are reviewed without claiming message origin, sender control, or maliciousness.', ['Run the pre-filled Lookup in Deep mode.', 'Review MX, SPF, DMARC, registration, certificate, HTTP, and page source states.', 'Retain only the evidence needed for a reviewed Case.'], true),
|
|
114
|
+
stage('monitor', 'monitor', 'Prepare mail-abuse response', 'Record a reviewed recipient route and use Case packet preflight without sending or claiming delivery.', 'A bounded Case action and optional local response draft with exact recipient provenance and limitations.', 'Local Case edits and packet generation make no request. WHOISleuth never sends the packet.', 'Confirm the intended recipient and retain the observed facts and limitations that support review.', 'The current action state and packet preflight are recorded, or the stage remains partial without a delivery conclusion.', ['Open or create the pre-filled Case and review its retained evidence.', 'Record only a deliberately selected published route, its source, and known limitations.', 'Use the response preflight to prepare a local draft; submission remains a separate manual action recorded later.'], false),
|
|
115
|
+
]),
|
|
116
|
+
}),
|
|
117
|
+
Object.freeze({
|
|
118
|
+
id: 'domain_control_change_response',
|
|
119
|
+
label: 'Domain-control change response',
|
|
120
|
+
summary: 'Review retained control posture and current authority-aware evidence before documenting a bounded internal or provider response.',
|
|
121
|
+
targetLabel: 'Official domain',
|
|
122
|
+
stages: Object.freeze([
|
|
123
|
+
stage('brands', 'brands', 'Review retained control posture', 'Review the saved domain-control passport, posture matrix, and source limitations before interpreting a change.', 'Retained official-domain control observations with separately attributed registry, DNS, certificate, mail, and web states.', 'Local-only unless the analyst separately starts a posture refresh.', 'Confirm the domain belongs to the reviewed Brand Profile; a saved association is analyst-selected, not inferred.', 'The retained baseline and its completeness are understood, or the stage is partial when required context is unavailable.', ['Open the relevant saved Brand Profile.', 'Review the domain-control passport, portfolio matrix, and observation times.', 'Identify which source actually supports the suspected change and preserve unavailable states.'], false),
|
|
124
|
+
stage('lookup', 'lookup', 'Confirm current domain evidence', 'Collect a deliberate deep Lookup so current authority-aware evidence remains distinct from the retained baseline.', 'Current registry, registrar, DNS, certificate, HTTP, and page observations with explicit source health.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm a fresh collection is necessary and review its request implications.', 'The current result is reviewed against the retained baseline without converting incomplete evidence into removal or compromise.', ['Run the pre-filled Lookup in Deep mode.', 'Compare current observations with the retained control evidence source by source.', 'Record only facts and limitations that need a Case response.'], true),
|
|
125
|
+
stage('monitor', 'monitor', 'Document control-change response', 'Record an internal or provider action and review an optional response packet without automatic enforcement or submission.', 'A bounded Case action, due or follow-up dates, recipient provenance when external, and an optional unsubmitted packet.', 'Local Case edits and packet generation make no request. WHOISleuth never changes DNS, registrar, hosting, mail, or defensive systems.', 'Document supporting evidence and confirm any recipient route or internal owner manually.', 'The reviewed action state is recorded, or the stage is partial without a compromise, delivery, or remediation claim.', ['Open or create the pre-filled Case and retain the relevant comparison facts.', 'Record the internal owner or analyst-selected provider route, source, limitations, and follow-up dates.', 'Review packet preflight when a draft is useful; record submission or resolution only after it actually occurs.'], false),
|
|
126
|
+
]),
|
|
127
|
+
}),
|
|
94
128
|
]);
|
|
95
129
|
function record(value) {
|
|
96
130
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
@@ -239,10 +273,15 @@ export function investigationGuideHref(stageId, domain, recipeId = 'new_domain_t
|
|
|
239
273
|
return `/discover?q=${encodeURIComponent(registrableGuideDomain(normalized))}#discovery-seed`;
|
|
240
274
|
if (stageDefinition.workspace === 'bulk')
|
|
241
275
|
return `/bulk?investigation=${encodeURIComponent(normalized)}#domains`;
|
|
242
|
-
if (stageDefinition.workspace === 'monitor')
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
276
|
+
if (stageDefinition.workspace === 'monitor') {
|
|
277
|
+
const response = typeof recipeId === 'string' && recipeId.endsWith('_response') ? '&response=1' : '';
|
|
278
|
+
return `/monitor?view=cases&investigation=1${response}&domain=${encodeURIComponent(workingDomain)}#case-review-queue`;
|
|
279
|
+
}
|
|
280
|
+
if (stageDefinition.workspace === 'brands') {
|
|
281
|
+
return typeof recipeId === 'string' && recipeId.endsWith('_response')
|
|
282
|
+
? '/brands'
|
|
283
|
+
: `/brands?new=1&domain=${encodeURIComponent(normalized)}#official-domains`;
|
|
284
|
+
}
|
|
246
285
|
return stageDefinition.path;
|
|
247
286
|
}
|
|
248
287
|
export function investigationGuideApprovedHref(guide, stageId) {
|
|
@@ -324,9 +363,10 @@ export function parseInvestigationGuide(value) {
|
|
|
324
363
|
const domain = normalizeInvestigationGuideDomain(input.domain);
|
|
325
364
|
const createdAt = timestamp(input.createdAt);
|
|
326
365
|
const updatedAt = timestamp(input.updatedAt);
|
|
327
|
-
if (!recipe || !domain || !createdAt || !updatedAt
|
|
366
|
+
if (!recipe || !domain || !createdAt || !updatedAt
|
|
367
|
+
|| (input.version !== INVESTIGATION_GUIDE_VERSION && !PRE_RESPONSE_RECIPE_IDS.has(recipe.id)))
|
|
328
368
|
return null;
|
|
329
|
-
const supportsTemplate = input.version === 3 || input.version === INVESTIGATION_GUIDE_VERSION;
|
|
369
|
+
const supportsTemplate = input.version === 3 || input.version === 4 || input.version === INVESTIGATION_GUIDE_VERSION;
|
|
330
370
|
const template = supportsTemplate && input.template !== null && input.template !== undefined
|
|
331
371
|
? normalizeInvestigationGuideTemplateSnapshot(input.template, recipe.id)
|
|
332
372
|
: null;
|
|
@@ -361,7 +401,9 @@ export function parseInvestigationGuide(value) {
|
|
|
361
401
|
outcome: guideOutcome(item?.outcome),
|
|
362
402
|
approvedAt: nullableTimestamp(item?.approvedAt),
|
|
363
403
|
openedAt: nullableTimestamp(item?.openedAt),
|
|
364
|
-
reviewNote: input.version ===
|
|
404
|
+
reviewNote: input.version === 4 || input.version === INVESTIGATION_GUIDE_VERSION
|
|
405
|
+
? boundedReviewNote(item?.reviewNote)
|
|
406
|
+
: null,
|
|
365
407
|
updatedAt: timestamp(item?.updatedAt) || updatedAt,
|
|
366
408
|
};
|
|
367
409
|
}),
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { INVESTIGATION_RECIPES, normalizeInvestigationGuideTemplateSnapshot, } from "./investigation-guide.js";
|
|
2
2
|
export const INVESTIGATION_TEMPLATE_SCHEMA = 'whoisleuth.investigation-templates';
|
|
3
|
-
export const INVESTIGATION_TEMPLATE_VERSION =
|
|
3
|
+
export const INVESTIGATION_TEMPLATE_VERSION = 2;
|
|
4
|
+
export const INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS = [1, INVESTIGATION_TEMPLATE_VERSION];
|
|
4
5
|
export const MAX_INVESTIGATION_TEMPLATES = 20;
|
|
5
6
|
export const MAX_INVESTIGATION_TEMPLATE_STORE_BYTES = 256 * 1024;
|
|
6
7
|
export const MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES = 384 * 1024;
|
|
7
8
|
const CONTROL_RE = /[\x00-\x1f\x7f]/u;
|
|
9
|
+
const LEGACY_RECIPE_IDS = new Set(['brand_sweep', 'infrastructure_pivot', 'new_domain_triage']);
|
|
8
10
|
function record(value) {
|
|
9
11
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
10
12
|
}
|
|
@@ -17,12 +19,12 @@ function timestamp(value) {
|
|
|
17
19
|
function byteLength(value) {
|
|
18
20
|
return new TextEncoder().encode(value).byteLength;
|
|
19
21
|
}
|
|
20
|
-
function template(raw) {
|
|
22
|
+
function template(raw, sourceVersion = INVESTIGATION_TEMPLATE_VERSION) {
|
|
21
23
|
const value = record(raw);
|
|
22
24
|
const snapshot = normalizeInvestigationGuideTemplateSnapshot(value);
|
|
23
25
|
const createdAt = timestamp(value?.createdAt);
|
|
24
26
|
const updatedAt = timestamp(value?.updatedAt);
|
|
25
|
-
return snapshot && createdAt && updatedAt
|
|
27
|
+
return snapshot && createdAt && updatedAt && (sourceVersion !== 1 || LEGACY_RECIPE_IDS.has(snapshot.recipeId))
|
|
26
28
|
? { ...snapshot, createdAt, updatedAt }
|
|
27
29
|
: null;
|
|
28
30
|
}
|
|
@@ -35,11 +37,12 @@ export function normalizeInvestigationTemplateStore(raw) {
|
|
|
35
37
|
&& Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
|
|
36
38
|
throw new Error('This investigation-template collection uses a newer schema and cannot be read safely.');
|
|
37
39
|
}
|
|
40
|
+
const sourceVersion = Array.isArray(raw) ? INVESTIGATION_TEMPLATE_VERSION : Number(value?.version) || INVESTIGATION_TEMPLATE_VERSION;
|
|
38
41
|
const source = Array.isArray(raw) ? raw : Array.isArray(value?.templates) ? value.templates : [];
|
|
39
42
|
const templates = [];
|
|
40
43
|
const seen = new Set();
|
|
41
44
|
for (const candidate of source.slice(0, MAX_INVESTIGATION_TEMPLATES * 2)) {
|
|
42
|
-
const normalized = template(candidate);
|
|
45
|
+
const normalized = template(candidate, sourceVersion);
|
|
43
46
|
if (!normalized || seen.has(normalized.id))
|
|
44
47
|
continue;
|
|
45
48
|
seen.add(normalized.id);
|
|
@@ -115,8 +118,8 @@ function strictImport(raw) {
|
|
|
115
118
|
if (Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
|
|
116
119
|
throw new Error('This investigation-template export uses a newer schema and cannot be imported safely.');
|
|
117
120
|
}
|
|
118
|
-
if (value.version
|
|
119
|
-
throw new Error(`Expected investigation-template schema ${INVESTIGATION_TEMPLATE_VERSION}.`);
|
|
121
|
+
if (!INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS.includes(Number(value.version))) {
|
|
122
|
+
throw new Error(`Expected investigation-template schema 1 or ${INVESTIGATION_TEMPLATE_VERSION}.`);
|
|
120
123
|
}
|
|
121
124
|
return normalizeInvestigationTemplateStore(value).templates;
|
|
122
125
|
}
|
|
@@ -372,7 +372,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
372
372
|
completeness: sourceCompleteness(networkContext),
|
|
373
373
|
limitations: limitations(networkContext.limitations),
|
|
374
374
|
lenses: ['all'],
|
|
375
|
-
href: '#evidence-network
|
|
375
|
+
href: '#evidence-network',
|
|
376
376
|
});
|
|
377
377
|
}
|
|
378
378
|
const networkLabel = text(network.name || network.holder || network.handle, 180);
|
|
@@ -390,7 +390,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
390
390
|
completeness: sourceCompleteness(networkContext),
|
|
391
391
|
limitations: limitations(networkContext.limitations),
|
|
392
392
|
lenses: ['all'],
|
|
393
|
-
href: '#evidence-network
|
|
393
|
+
href: '#evidence-network',
|
|
394
394
|
});
|
|
395
395
|
}
|
|
396
396
|
for (const cidr of textList(network.cidrs, 8)) {
|
|
@@ -407,7 +407,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
407
407
|
completeness: sourceCompleteness(networkContext),
|
|
408
408
|
limitations: limitations(networkContext.limitations),
|
|
409
409
|
lenses: ['all'],
|
|
410
|
-
href: '#evidence-network
|
|
410
|
+
href: '#evidence-network',
|
|
411
411
|
});
|
|
412
412
|
}
|
|
413
413
|
}
|
|
@@ -437,7 +437,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
437
437
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
438
438
|
limitations: limitations(record(input.pageIdentity).limitations),
|
|
439
439
|
lenses: ['identity'],
|
|
440
|
-
href: '#evidence-page
|
|
440
|
+
href: '#evidence-page',
|
|
441
441
|
boundary: trustBoundary(canonicalHost, finalHost || target),
|
|
442
442
|
}, 'Publisher-declared canonical origin', identitySource);
|
|
443
443
|
}
|
|
@@ -451,7 +451,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
451
451
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
452
452
|
limitations: limitations(record(input.pageIdentity).limitations),
|
|
453
453
|
lenses: ['identity'],
|
|
454
|
-
href: '#evidence-page
|
|
454
|
+
href: '#evidence-page',
|
|
455
455
|
boundary: trustBoundary(openGraphHost, finalHost || target),
|
|
456
456
|
}, 'Publisher-declared Open Graph origin', identitySource);
|
|
457
457
|
}
|
|
@@ -466,7 +466,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
466
466
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
467
467
|
limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
|
|
468
468
|
lenses: ['identity'],
|
|
469
|
-
href: '#evidence-page
|
|
469
|
+
href: '#evidence-page',
|
|
470
470
|
boundary: trustBoundary(host, finalHost || target),
|
|
471
471
|
}, 'External form-action origin', identitySource);
|
|
472
472
|
}
|
|
@@ -482,7 +482,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
482
482
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
483
483
|
limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
|
|
484
484
|
lenses: ['identity'],
|
|
485
|
-
href: '#evidence-page
|
|
485
|
+
href: '#evidence-page',
|
|
486
486
|
boundary: trustBoundary(host, finalHost || target),
|
|
487
487
|
}, 'External resource origin', identitySource);
|
|
488
488
|
}
|
|
@@ -498,7 +498,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
498
498
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
499
499
|
limitations: ['A shared identifier is a relationship lead and does not establish common ownership or control.'],
|
|
500
500
|
lenses: ['identity'],
|
|
501
|
-
href: '#evidence-page
|
|
501
|
+
href: '#evidence-page',
|
|
502
502
|
}, text(identifier.type, 80), identitySource);
|
|
503
503
|
}
|
|
504
504
|
}
|